Coverage for jutil/admin.py : 95%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1from collections import OrderedDict
2from typing import Optional, Sequence
3from django.conf import settings
4from django.contrib import admin
5from django.contrib.auth.models import User
6from django.http import HttpRequest
7from django.urls import reverse
8from django.utils.html import format_html
9from django.utils.safestring import mark_safe
10from jutil.model import get_model_field_label_and_value
11from django.utils.translation import gettext_lazy as _
12from django.contrib.admin.models import CHANGE
13from django.template.response import TemplateResponse
14from django.contrib.admin.options import get_content_type_for_model
15from django.contrib.admin.utils import unquote
16from django.core.exceptions import PermissionDenied
17from django.utils.text import capfirst
18from django.utils.encoding import force_text
19from django.contrib.admin.models import LogEntry
22def admin_log(instances: Sequence[object],
23 msg: str, who: Optional[User] = None, **kw):
24 """
25 Logs an entry to admin logs of model(s).
26 :param instances: Model instance or list of instances (None values are ignored)
27 :param msg: Message to log
28 :param who: Who did the change. If who is None then User with username of settings.DJANGO_SYSTEM_USER (default: 'system') will be used
29 :param kw: Optional key-value attributes to append to message
30 :return: None
31 """
32 # use system user if 'who' is missing
33 if who is None: 33 ↛ 38line 33 didn't jump to line 38, because the condition on line 33 was never false
34 username = settings.DJANGO_SYSTEM_USER if hasattr(settings, 'DJANGO_SYSTEM_USER') else 'system'
35 who = User.objects.get_or_create(username=username)[0]
37 # allow passing individual instance
38 if not isinstance(instances, list) and not isinstance(instances, tuple):
39 instances = [instances] # type: ignore
41 # append extra keyword attributes if any
42 att_str = ''
43 for k, v in kw.items():
44 if hasattr(v, 'pk'): # log only primary key for model instances, not whole str representation
45 v = v.pk
46 att_str += '{}={}'.format(k, v) if not att_str else ', {}={}'.format(k, v)
47 if att_str:
48 att_str = ' [{}]'.format(att_str)
49 msg = str(msg) + att_str
51 for instance in instances:
52 if instance:
53 LogEntry.objects.log_action(
54 user_id=who.pk if who is not None else None,
55 content_type_id=get_content_type_for_model(instance).pk,
56 object_id=instance.pk, # pytype: disable=attribute-error
57 object_repr=force_text(instance),
58 action_flag=CHANGE,
59 change_message=msg,
60 )
63def admin_obj_url(obj: Optional[object], route: str = '', base_url: str = '') -> str:
64 """
65 Returns admin URL to object. If object is standard model with default route name, the function
66 can deduct the route name as in "admin:<app>_<class-lowercase>_change".
67 :param obj: Object
68 :param route: Empty for default route
69 :param base_url: Base URL if you want absolute URLs, e.g. https://example.com
70 :return: URL to admin object change view
71 """
72 if obj is None:
73 return ''
74 if not route:
75 route = 'admin:{}_{}_change'.format(obj._meta.app_label, obj._meta.model_name) # type: ignore
76 path = reverse(route, args=[obj.id]) # type: ignore
77 return base_url + path
80def admin_obj_link(obj: Optional[object], label: str = '', route: str = '', base_url: str = '') -> str:
81 """
82 Returns safe-marked admin link to object. If object is standard model with default route name, the function
83 can deduct the route name as in "admin:<app>_<class-lowercase>_change".
84 :param obj: Object
85 :param label: Optional label. If empty uses str(obj)
86 :param route: Empty for default route
87 :param base_url: Base URL if you want absolute URLs, e.g. https://example.com
88 :return: HTML link marked safe
89 """
90 if obj is None:
91 return ''
92 url = mark_safe(admin_obj_url(obj, route, base_url)) # nosec
93 return format_html("<a href='{}'>{}</a>", url, str(obj) if not label else label)
96class ModelAdminBase(admin.ModelAdmin):
97 """
98 ModelAdmin with save-on-top default enabled and customized (length-limited) history view.
99 """
100 save_on_top = True
101 max_history_length = 1000
103 def sort_actions_by_description(self, actions: dict) -> OrderedDict:
104 """
105 :param actions: dict of str: (callable, name, description)
106 :return: OrderedDict
107 """
108 sorted_descriptions = sorted([(k, data[2]) for k, data in actions.items()], key=lambda x: x[1])
109 sorted_actions = OrderedDict()
110 for k, description in sorted_descriptions: # pylint: disable=unused-variable
111 sorted_actions[k] = actions[k]
112 return sorted_actions
114 def get_actions(self, request):
115 return self.sort_actions_by_description(super().get_actions(request))
117 def kw_changelist_view(self, request: HttpRequest, extra_context=None, **kwargs): # pylint: disable=unused-argument
118 """
119 Changelist view which allow key-value arguments.
120 :param request: HttpRequest
121 :param extra_context: Extra context dict
122 :param kwargs: Key-value dict
123 :return: See changelist_view()
124 """
125 return self.changelist_view(request, extra_context)
127 def history_view(self, request, object_id, extra_context=None):
128 "The 'history' admin view for this model."
129 from django.contrib.admin.models import LogEntry # noqa
130 # First check if the user can see this history.
131 model = self.model
132 obj = self.get_object(request, unquote(object_id))
133 if obj is None: 133 ↛ 134line 133 didn't jump to line 134, because the condition on line 133 was never true
134 return self._get_obj_does_not_exist_redirect(request, model._meta, object_id)
136 if not self.has_view_or_change_permission(request, obj): 136 ↛ 137line 136 didn't jump to line 137, because the condition on line 136 was never true
137 raise PermissionDenied
139 # Then get the history for this object.
140 opts = model._meta
141 app_label = opts.app_label
142 action_list = LogEntry.objects.filter(
143 object_id=unquote(object_id),
144 content_type=get_content_type_for_model(model)
145 ).select_related().order_by('-action_time')[:self.max_history_length]
147 context = {
148 **self.admin_site.each_context(request),
149 'title': _('Change history: %s') % obj,
150 'action_list': action_list,
151 'module_name': str(capfirst(opts.verbose_name_plural)),
152 'object': obj,
153 'opts': opts,
154 'preserved_filters': self.get_preserved_filters(request),
155 **(extra_context or {}),
156 }
158 request.current_app = self.admin_site.name
160 return TemplateResponse(request, self.object_history_template or [
161 "admin/%s/%s/object_history.html" % (app_label, opts.model_name),
162 "admin/%s/object_history.html" % app_label,
163 "admin/object_history.html"
164 ], context)
167class AdminLogEntryMixin:
168 """
169 Mixin for logging Django admin changes of models.
170 Call fields_changed() on change events.
171 """
173 def fields_changed(self, field_names: Sequence[str], who: Optional[User], **kw):
174 fv_str = ''
175 for k in field_names:
176 label, value = get_model_field_label_and_value(self, k)
177 fv_str += '{}={}'.format(label, value) if not fv_str else ', {}={}'.format(label, value)
179 msg = "{class_name} id={id}: {fv_str}".format(
180 class_name=self._meta.verbose_name.title(), id=self.id, fv_str=fv_str) # type: ignore
181 admin_log([who, self], msg, who, **kw) # type: ignore