Coverage for jutil/admin.py: 54%
145 statements
« prev ^ index » next coverage.py v6.5.0, created at 2022-10-07 16:40 -0500
« prev ^ index » next coverage.py v6.5.0, created at 2022-10-07 16:40 -0500
1import json
2from collections import OrderedDict
3from typing import Optional, Sequence, List, Dict, Any
4from django.conf import settings
5from django.contrib import admin
6from django.contrib.auth import get_user_model
7from django.contrib.auth.models import User
8from django.core.serializers.json import DjangoJSONEncoder
9from django.db.models import QuerySet
10from django.http import HttpRequest
11from django.urls import reverse, resolve
12from django.utils import translation
13from django.utils.html import format_html
14from django.utils.safestring import mark_safe
15from django.utils.translation import gettext_lazy as _
16from django.contrib.admin.models import CHANGE
17from django.template.response import TemplateResponse
18from django.contrib.admin.options import get_content_type_for_model
19from django.contrib.admin.utils import unquote
20from django.core.exceptions import PermissionDenied
21from django.utils.text import capfirst
22from django.utils.encoding import force_str
23from django.contrib.admin.models import LogEntry
26def get_admin_log(instance: object) -> QuerySet:
27 """
28 Returns admin log (LogEntry QuerySet) of the object.
29 """
30 return LogEntry.objects.filter(
31 content_type_id=get_content_type_for_model(instance).pk, # type: ignore
32 object_id=instance.pk, # type: ignore # pytype: disable=attribute-error
33 )
36def admin_log(instances: Sequence[object], msg: str, who: Optional[User] = None, action_flag: int = CHANGE, **kwargs):
37 """
38 Logs an entry to admin logs of model(s).
39 :param instances: Model instance or list of instances (None values are ignored)
40 :param msg: Message to log
41 :param who: Who did the change. If who is None then User with username of settings.DJANGO_SYSTEM_USER (default: 'system') will be used
42 :param action_flag: ADDITION / CHANGE / DELETION action flag. Default CHANGED.
43 :param kwargs: Optional key-value attributes to append to message
44 :return: None
45 """
46 # use system user if 'who' is missing
47 if who is None: 47 ↛ 52line 47 didn't jump to line 52, because the condition on line 47 was never false
48 username = settings.DJANGO_SYSTEM_USER if hasattr(settings, "DJANGO_SYSTEM_USER") else "system" # type: ignore
49 who = get_user_model().objects.get_or_create(username=username)[0]
51 # allow passing individual instance
52 if not isinstance(instances, list) and not isinstance(instances, tuple):
53 instances = [instances] # type: ignore
55 # append extra context if any
56 extra_context: List[str] = []
57 for k, v in kwargs.items():
58 extra_context.append(str(k) + "=" + str(v))
59 if extra_context:
60 msg += " | " + ", ".join(extra_context)
62 for instance in instances:
63 if instance: 63 ↛ 62line 63 didn't jump to line 62, because the condition on line 63 was never false
64 LogEntry.objects.log_action(
65 user_id=who.pk if who is not None else None,
66 content_type_id=get_content_type_for_model(instance).pk, # type: ignore
67 object_id=instance.pk, # type: ignore # pytype: disable=attribute-error
68 object_repr=force_str(instance),
69 action_flag=action_flag,
70 change_message=msg,
71 )
74def admin_obj_serialize_fields(obj: object, field_names: Sequence[str], cls=DjangoJSONEncoder, max_serialized_field_length: Optional[int] = None) -> str:
75 """
76 JSON serializes (changed) fields of a model instance for logging purposes.
77 Referenced objects with primary key (pk) attribute are formatted using only that field as value.
78 :param obj: Model instance
79 :param field_names: List of field names to store
80 :param cls: Serialization class. Default DjangoJSONEncoder.
81 :param max_serialized_field_length: Optional maximum length for individual serialized str value. Longer fields are cut with terminating [...]
82 :return: str
83 """
84 out: Dict[str, Any] = {}
85 for k in field_names:
86 val = getattr(obj, k) if hasattr(obj, k) else None
87 if hasattr(val, "pk"):
88 val = val.pk
89 if max_serialized_field_length is not None and isinstance(val, str) and len(val) > max_serialized_field_length:
90 val = val[:max_serialized_field_length] + " [...]"
91 out[k] = val
92 return json.dumps(out, cls=cls)
95def admin_construct_change_message_ex(request, form, formsets, add, cls=DjangoJSONEncoder, max_serialized_field_length: int = 1000): # noqa
96 from ipware import get_client_ip # type: ignore # noqa
97 from django.contrib.admin.utils import _get_changed_field_labels_from_form # type: ignore # noqa
98 from jutil.model import get_model_field_names # noqa
100 changed_data = form.changed_data
101 with translation.override(None):
102 changed_field_labels = _get_changed_field_labels_from_form(form, changed_data)
104 ip = get_client_ip(request)[0]
105 instance = form.instance if hasattr(form, "instance") and form.instance is not None else None
106 values_str = admin_obj_serialize_fields(form.instance, changed_data, cls, max_serialized_field_length) if instance is not None else ""
107 values = json.loads(values_str) if values_str else {}
108 change_message = []
109 if add:
110 change_message.append({"added": {"values": values, "ip": ip}})
111 elif form.changed_data:
112 change_message.append({"changed": {"fields": changed_field_labels, "values": values, "ip": ip}})
113 if formsets:
114 with translation.override(None):
115 for formset in formsets:
116 for added_object in formset.new_objects:
117 values = json.loads(admin_obj_serialize_fields(added_object, get_model_field_names(added_object), cls, max_serialized_field_length))
118 change_message.append(
119 {
120 "added": {
121 "name": str(added_object._meta.verbose_name),
122 "object": str(added_object),
123 "values": values,
124 "ip": ip,
125 }
126 }
127 )
128 for changed_object, changed_fields in formset.changed_objects:
129 values = json.loads(admin_obj_serialize_fields(changed_object, changed_fields, cls, max_serialized_field_length))
130 change_message.append(
131 {
132 "changed": {
133 "name": str(changed_object._meta.verbose_name),
134 "object": str(changed_object),
135 "fields": _get_changed_field_labels_from_form(formset.forms[0], changed_fields),
136 "values": values,
137 "ip": ip,
138 }
139 }
140 )
141 for deleted_object in formset.deleted_objects:
142 change_message.append(
143 {
144 "deleted": {
145 "name": str(deleted_object._meta.verbose_name),
146 "object": str(deleted_object),
147 "ip": ip,
148 }
149 }
150 )
151 return change_message
154def admin_obj_url(obj: Optional[object], route: str = "", base_url: str = "") -> str:
155 """
156 Returns admin URL to object. If object is standard model with default route name, the function
157 can deduct the route name as in "admin:<app>_<class-lowercase>_change".
158 :param obj: Object
159 :param route: Empty for default route
160 :param base_url: Base URL if you want absolute URLs, e.g. https://example.com
161 :return: URL to admin object change view
162 """
163 if obj is None:
164 return ""
165 if not route:
166 route = "admin:{}_{}_change".format(obj._meta.app_label, obj._meta.model_name) # type: ignore
167 path = reverse(route, args=[obj.id]) # type: ignore
168 return base_url + path
171def admin_obj_link(obj: Optional[object], label: str = "", route: str = "", base_url: str = "") -> str:
172 """
173 Returns safe-marked admin link to object. If object is standard model with default route name, the function
174 can deduct the route name as in "admin:<app>_<class-lowercase>_change".
175 :param obj: Object
176 :param label: Optional label. If empty uses str(obj)
177 :param route: Empty for default route
178 :param base_url: Base URL if you want absolute URLs, e.g. https://example.com
179 :return: HTML link marked safe
180 """
181 if obj is None:
182 return ""
183 url = mark_safe(admin_obj_url(obj, route, base_url)) # nosec
184 return format_html("<a href='{}'>{}</a>", url, str(obj) if not label else label)
187class ModelAdminBase(admin.ModelAdmin):
188 """
189 ModelAdmin with some customizations:
190 * Customized change message which logs changed values and user IP as well (can be disabled by extended_log=False)
191 * Length-limited latest-first history view (customizable by max_history_length)
192 * Actions sorted alphabetically by localized description
193 * Additional fill_extra_context() method which can be used to share common extra context for add_view(), change_view() and changelist_view()
194 * Save-on-top enabled by default (save_on_top=True)
195 """
197 save_on_top = True
198 extended_log = True
199 max_history_length = 1000
200 serialization_cls = DjangoJSONEncoder
201 max_serialized_field_length = 1000
203 def construct_change_message(self, request, form, formsets, add=False):
204 if self.extended_log:
205 return admin_construct_change_message_ex(request, form, formsets, add, self.serialization_cls, self.max_serialized_field_length)
206 return super().construct_change_message(request, form, formsets, add)
208 def sort_actions_by_description(self, actions: dict) -> OrderedDict:
209 """
210 :param actions: dict of str: (callable, name, description)
211 :return: OrderedDict
212 """
213 sorted_descriptions = sorted([(k, data[2]) for k, data in actions.items()], key=lambda x: x[1])
214 sorted_actions = OrderedDict()
215 for k, description in sorted_descriptions: # pylint: disable=unused-variable
216 sorted_actions[k] = actions[k]
217 return sorted_actions
219 def get_actions(self, request):
220 return self.sort_actions_by_description(super().get_actions(request))
222 def fill_extra_context(self, request: HttpRequest, extra_context: Optional[Dict[str, Any]]): # pylint: disable=unused-argument
223 """
224 Function called by customized add_view(), change_view() and kw_changelist_view()
225 to supplement extra_context dictionary by custom variables.
226 """
227 return extra_context
229 def add_view(self, request: HttpRequest, form_url="", extra_context=None):
230 """
231 Custom add_view() which calls fill_extra_context().
232 """
233 return super().add_view(request, form_url, self.fill_extra_context(request, extra_context))
235 def change_view(self, request: HttpRequest, object_id, form_url="", extra_context=None):
236 """
237 Custom change_view() which calls fill_extra_context().
238 """
239 return super().change_view(request, object_id, form_url, self.fill_extra_context(request, extra_context))
241 def changelist_view(self, request, extra_context=None):
242 return super().changelist_view(request, self.fill_extra_context(request, extra_context))
244 def kw_changelist_view(self, request: HttpRequest, extra_context=None, **kwargs): # pylint: disable=unused-argument
245 """
246 Changelist view which allow key-value arguments and calls fill_extra_context().
247 :param request: HttpRequest
248 :param extra_context: Extra context dict
249 :param kwargs: Key-value dict
250 :return: See changelist_view()
251 """
252 extra_context = extra_context or {}
253 extra_context.update(kwargs)
254 return self.changelist_view(request, self.fill_extra_context(request, extra_context))
256 def history_view(self, request, object_id, extra_context=None):
257 from django.contrib.admin.models import LogEntry # noqa
259 # First check if the user can see this history.
260 model = self.model
261 obj = self.get_object(request, unquote(object_id))
262 if obj is None: 262 ↛ 263line 262 didn't jump to line 263, because the condition on line 262 was never true
263 return self._get_obj_does_not_exist_redirect(request, model._meta, object_id) # noqa
265 if not self.has_view_or_change_permission(request, obj): 265 ↛ 266line 265 didn't jump to line 266, because the condition on line 265 was never true
266 raise PermissionDenied
268 # Then get the history for this object.
269 opts = model._meta # noqa
270 app_label = opts.app_label
271 action_list = (
272 LogEntry.objects.filter(
273 object_id=unquote(object_id),
274 content_type=get_content_type_for_model(model),
275 )
276 .select_related()
277 .order_by("-action_time")
278 )[: self.max_history_length]
280 context = {
281 **self.admin_site.each_context(request),
282 "title": _("Change history: %s") % obj,
283 "subtitle": None,
284 "action_list": action_list,
285 "module_name": str(capfirst(opts.verbose_name_plural)),
286 "object": obj,
287 "opts": opts,
288 "preserved_filters": self.get_preserved_filters(request),
289 **(extra_context or {}),
290 }
292 request.current_app = self.admin_site.name
294 return TemplateResponse(
295 request,
296 self.object_history_template
297 or [
298 "admin/%s/%s/object_history.html" % (app_label, opts.model_name),
299 "admin/%s/object_history.html" % app_label,
300 "jutil/admin/object_history.html",
301 ],
302 context,
303 )
306class InlineModelAdminParentAccessMixin:
307 """
308 Admin mixin for accessing parent objects to be used in InlineModelAdmin derived classes.
309 """
311 OBJECT_PK_KWARGS = ["object_id", "pk", "id"]
313 def get_parent_object(self, request) -> Optional[object]:
314 """
315 Returns the inline admin object's parent object or None if not found.
316 """
317 mgr = self.parent_model.objects # type: ignore
318 resolved = resolve(request.path_info)
319 if resolved.kwargs:
320 for k in self.OBJECT_PK_KWARGS:
321 if k in resolved.kwargs:
322 return mgr.filter(pk=resolved.kwargs[k]).first()
323 if resolved.args:
324 return mgr.filter(pk=resolved.args[0]).first()
325 return None