admirarchy/utils.py
from copy import copy
from typing import Type, Optional, Dict, Tuple
from django.conf import settings
from django.contrib.admin.options import ModelAdmin
from django.contrib.admin.views.main import ChangeList
from django.core.exceptions import FieldDoesNotExist
from django.db import models
from django.db.models import Model, QuerySet
from django.http import HttpRequest
from django.utils.encoding import force_str
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from .exceptions import AdmirarchyConfigurationError
class HierarchicalModelAdmin(ModelAdmin):
"""Customized Model admin handling hierarchies navigation."""
hierarchy: 'Hierarchy' = None
change_list_template = 'admin/admirarchy/change_list.html'
_current_changelist = None
def get_changelist(self, request: HttpRequest, **kwargs) -> Type['HierarchicalChangeList']:
"""Returns an appropriate ChangeList for ModelAdmin.
Initializes hierarchies handling.
:param request:
:param kwargs:
"""
Hierarchy.init_hierarchy(self)
return HierarchicalChangeList
def change_view(self, *args, **kwargs):
"""Renders detailed model edit page."""
Hierarchy.init_hierarchy(self)
self.hierarchy.hook_change_view(self, args, kwargs)
return super(HierarchicalModelAdmin, self).change_view(*args, **kwargs)
def action_checkbox(self, obj: Model):
"""Renders checkboxes.
Disable checkbox for parent item navigation link.
"""
if getattr(obj, Hierarchy.UPPER_LEVEL_MODEL_ATTR, False):
return ''
return super(HierarchicalModelAdmin, self).action_checkbox(obj)
def hierarchy_nav(self, obj: Model) -> str:
"""Renders hierarchy navigation elements (folders)."""
result_repr = '' # For items without children.
ch_count = getattr(obj, Hierarchy.CHILD_COUNT_MODEL_ATTR, 0)
is_parent_link = getattr(obj, Hierarchy.UPPER_LEVEL_MODEL_ATTR, False)
if is_parent_link or ch_count: # For items with children and parent links.
icon = 'icon icon-folder'
title = _('Objects inside: %s') % ch_count
if is_parent_link:
icon = 'icon icon-folder-up'
title = _('Upper level')
url = './'
if obj.pk:
url = f'?{Hierarchy.PARENT_ID_QS_PARAM}={obj.pk}'
changelist = self._current_changelist
if changelist.is_popup:
qs_get = copy(changelist._request.GET)
try:
del qs_get[Hierarchy.PARENT_ID_QS_PARAM]
except KeyError:
pass
qs_get = qs_get.urlencode()
url = f'{url}&{qs_get}' if '?' in url else f'{url}?{qs_get}'
result_repr = format_html('<a href="{0}" class="{1}" title="{2}"></a>', url, icon, force_str(title))
return result_repr
hierarchy_nav.short_description = ''
class HierarchicalChangeList(ChangeList):
"""Customized ChangeList used by HierarchicalModelAdmin to handle hierarchies."""
def __init__(self, request, model, list_display, list_display_links,
list_filter, date_hierarchy, search_fields,
list_select_related, list_per_page, list_max_show_all,
list_editable, model_admin, *args):
"""Adds hierarchy navigation column if necessary.
:param args:
"""
model_admin._current_changelist = self
self._hierarchy = model_admin.hierarchy
self._request = request
if not isinstance(self._hierarchy, NoHierarchy):
list_display = [self._hierarchy.NAV_FIELD_MARKER] + list(list_display)
super(HierarchicalChangeList, self).__init__(
request, model, list_display, list_display_links,
list_filter, date_hierarchy, search_fields,
list_select_related, list_per_page, list_max_show_all,
list_editable, model_admin, *args)
def get_queryset(self, request: HttpRequest) -> QuerySet:
"""Constructs a query set.
:param request:
"""
hierarchy = self._hierarchy
hierarchy.hook_get_queryset(self, request)
qs = super(HierarchicalChangeList, self).get_queryset(request)
qs = hierarchy.hook_filter_queryset(self, qs)
return qs
def get_results(self, request: HttpRequest):
"""Gets query set results.
:param request:
"""
super(HierarchicalChangeList, self).get_results(request)
self._hierarchy.hook_get_results(self)
def check_field_exists(self, field_name: str):
"""Implements field exists check for debugging purposes.
:param field_name:
"""
if not settings.DEBUG:
return
try:
self.lookup_opts.get_field(field_name)
except FieldDoesNotExist as e:
raise AdmirarchyConfigurationError(e)
########################################################
class Hierarchy:
"""Base hierarchy class. Hierarchy classes must inherit from it."""
PARENT_ID_QS_PARAM = 'pid' # Parent ID query string parameter.
CHILD_COUNT_MODEL_ATTR = 'child_count' # Attribute given to every model.
UPPER_LEVEL_MODEL_ATTR = 'dummy' # This attribute indicated the model is just a dummy upper level link.
NAV_FIELD_MARKER = 'hierarchy_nav'
@classmethod
def init_hierarchy(cls, model_admin: HierarchicalModelAdmin):
"""Initializes model admin with hierarchy data."""
hierarchy = getattr(model_admin, 'hierarchy')
if hierarchy:
if not isinstance(hierarchy, Hierarchy):
hierarchy = AdjacencyList() # For `True` and etc. TODO heuristics maybe.
else:
hierarchy = NoHierarchy()
model_admin.hierarchy = hierarchy
@classmethod
def get_pid_from_request(cls, changelist: 'HierarchicalChangeList', request: HttpRequest) -> Optional[str]:
"""Gets parent ID from query string.
:param changelist:
:param request:
"""
qs_param = cls.PARENT_ID_QS_PARAM
val = request.GET.get(qs_param, False)
pid = val or None
changelist.params.pop(qs_param, None)
return pid
def hook_change_view(self, model_admin: HierarchicalModelAdmin, view_args: Tuple, view_kwargs: Dict):
"""Triggered by `ModelAdmin.change_view()`."""
def hook_get_results(self, changelist: 'HierarchicalChangeList'):
"""Triggered by `ChangeList.get_results()`."""
def hook_get_queryset(self, changelist: 'HierarchicalChangeList', request: HttpRequest):
"""Triggered by `ChangeList.get_queryset()`."""
def hook_filter_queryset(self, changelist: 'HierarchicalChangeList', query_set: QuerySet) -> QuerySet:
"""Triggered by `ChangeList.get_queryset()`."""
return query_set
class NoHierarchy(Hierarchy):
"""Dummy (disabled) hierarchy class."""
class AdjacencyList(Hierarchy):
def __init__(self, parent_id_field: str = 'parent'):
self.pid = None
self.pid_field = parent_id_field
self.pid_field_real = f'{parent_id_field}_id'
def hook_change_view(self, model_admin: HierarchicalModelAdmin, view_args: Tuple, view_kwargs: Dict):
"""Triggered by `ModelAdmin.change_view()`.
Replaces parent item dropdown list with a lookup dialog.
"""
# TODO start from an appropriate tree level when in parent lookup popup
model_admin.raw_id_fields += (self.pid_field,)
def hook_get_queryset(self, changelist: 'HierarchicalChangeList', request: HttpRequest):
"""Triggered by `ChangeList.get_queryset()`."""
pid_field = self.pid_field
changelist.check_field_exists(pid_field)
pid = self.get_pid_from_request(changelist, request)
self.pid = pid
if changelist.query:
# Do not restrict search to current sub.
return
changelist.params[pid_field] = pid
def hook_filter_queryset(self, changelist: 'HierarchicalChangeList', query_set: QuerySet) -> QuerySet:
"""Triggered by `ChangeList.get_queryset()`."""
if self.pid is None:
changelist.params.pop(self.pid_field, None)
return query_set
def hook_get_results(self, changelist: 'HierarchicalChangeList'):
"""Triggered by `ChangeList.get_results()`."""
result_list = list(changelist.result_list)
if self.pid:
# Render to upper level link.
parent = changelist.model.objects.get(pk=self.pid)
parent = changelist.model(pk=getattr(parent, self.pid_field_real, None))
setattr(parent, self.UPPER_LEVEL_MODEL_ATTR, True)
result_list = [parent] + result_list
# Get children stats.
kwargs_filter = {f'{self.pid_field}__in': result_list}
stats_qs = changelist.model.objects.filter(
**kwargs_filter).values_list(self.pid_field).annotate(cnt=models.Count(self.pid_field))
stats = {item[0]: item[1] for item in stats_qs}
for item in result_list:
if hasattr(item, self.CHILD_COUNT_MODEL_ATTR):
continue
try:
setattr(item, self.CHILD_COUNT_MODEL_ATTR, stats[item.pk])
except KeyError:
setattr(item, self.CHILD_COUNT_MODEL_ATTR, 0)
changelist.result_list = result_list
class NestedSet(Hierarchy):
def __init__(
self,
left_field: str = 'lft',
right_field: str = 'rgt',
level_field: str = 'level',
root_level: int = 0
):
self.pid = None
self.parent = None
self.left_field = left_field
self.right_field = right_field
self.level_field = level_field
self.root_level = root_level
def get_range_clause(self, obj: Model) -> Tuple[int, int]:
return getattr(obj, self.left_field), getattr(obj, self.right_field)
def get_immediate_children_filter(self, obj: Model) -> Dict:
flt = {
f'{self.left_field}__range': self.get_range_clause(obj),
self.level_field: getattr(obj, self.level_field) + 1
}
return flt
def hook_get_queryset(self, changelist: 'HierarchicalChangeList', request: HttpRequest):
"""Triggered by `ChangeList.get_queryset()`."""
changelist.check_field_exists(self.left_field)
changelist.check_field_exists(self.right_field)
pid = self.get_pid_from_request(changelist, request)
self.pid = pid
# Get parent item first.
qs = changelist.root_queryset
if changelist.query:
# Do not restrict search to current sub.
return
if pid:
self.parent = qs.get(pk=pid)
changelist.params.update(self.get_immediate_children_filter(self.parent))
else:
changelist.params[self.level_field] = self.root_level
self.parent = qs.get(**{
key: val for key, val in changelist.params.items()
if not key.startswith('_') and key != 'q'
})
def hook_get_results(self, changelist: 'HierarchicalChangeList'):
"""Triggered by `ChangeList.get_results()`."""
# Poor NestedSet guys they've punished themselves once chosen that approach,
# and now we punish them again with all those DB hits.
result_list = list(changelist.result_list)
left = self.left_field
right = self.right_field
# Get children stats.
filter_kwargs = {f'{left}': models.F(f'{right}') - 1} # Leaf nodes only.
filter_kwargs.update(self.get_immediate_children_filter(self.parent))
stats_qs = changelist.result_list.filter(**filter_kwargs).values_list('id')
leafs = [item[0] for item in stats_qs]
for result in result_list:
if result.pk in leafs:
setattr(result, self.CHILD_COUNT_MODEL_ATTR, 0)
else:
# Too much pain to get real stats, so that'll suffice.
setattr(result, self.CHILD_COUNT_MODEL_ATTR, '>1')
if self.pid:
# Render to upper level link.
parent = self.parent
filter_kwargs = {
f'{left}__lt': getattr(parent, left),
f'{right}__gt': getattr(parent, right),
}
try:
grandparent_id = changelist.model.objects.filter(
**filter_kwargs
).order_by(f'-{left}')[0].pk
except IndexError:
grandparent_id = None
if grandparent_id != parent.pk:
parent = changelist.model(pk=grandparent_id)
setattr(parent, self.UPPER_LEVEL_MODEL_ATTR, True)
result_list = [parent] + result_list
changelist.result_list = result_list