Coverage for jutil/admin.py : 81%

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, List
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, resolve
8from django.utils.html import format_html
9from django.utils.safestring import mark_safe
10from django.utils.translation import gettext_lazy as _
11from django.contrib.admin.models import CHANGE
12from django.template.response import TemplateResponse
13from django.contrib.admin.options import get_content_type_for_model
14from django.contrib.admin.utils import unquote
15from django.core.exceptions import PermissionDenied
16from django.utils.text import capfirst
17from django.utils.encoding import force_text
18from django.contrib.admin.models import LogEntry
21def admin_log(instances: Sequence[object], msg: str, who: Optional[User] = None, **kw):
22 """
23 Logs an entry to admin logs of model(s).
24 :param instances: Model instance or list of instances (None values are ignored)
25 :param msg: Message to log
26 :param who: Who did the change. If who is None then User with username of settings.DJANGO_SYSTEM_USER (default: 'system') will be used
27 :param kw: Optional key-value attributes to append to message
28 :return: None
29 """
30 # use system user if 'who' is missing
31 if who is None: 31 ↛ 36line 31 didn't jump to line 36, because the condition on line 31 was never false
32 username = settings.DJANGO_SYSTEM_USER if hasattr(settings, "DJANGO_SYSTEM_USER") else "system"
33 who = User.objects.get_or_create(username=username)[0]
35 # allow passing individual instance
36 if not isinstance(instances, list) and not isinstance(instances, tuple):
37 instances = [instances] # type: ignore
39 # append extra keyword attributes if any
40 att_str = ""
41 for k, v in kw.items():
42 if hasattr(v, "pk"): # log only primary key for model instances, not whole str representation
43 v = v.pk
44 att_str += "{}={}".format(k, v) if not att_str else ", {}={}".format(k, v)
45 if att_str:
46 att_str = " [{}]".format(att_str)
47 msg = str(msg) + att_str
49 for instance in instances:
50 if instance: 50 ↛ 49line 50 didn't jump to line 49, because the condition on line 50 was never false
51 LogEntry.objects.log_action(
52 user_id=who.pk if who is not None else None,
53 content_type_id=get_content_type_for_model(instance).pk, # type: ignore
54 object_id=instance.pk, # type: ignore # pytype: disable=attribute-error
55 object_repr=force_text(instance),
56 action_flag=CHANGE,
57 change_message=msg,
58 )
61def admin_log_changed_fields(obj: object, field_names: Sequence[str], who: Optional[User] = None, **kwargs):
62 """
63 Logs changed fields of a model instance to admin log.
64 :param obj: Model instance
65 :param field_names: Field names
66 :param who: Who did the change. If who is None then User with username of settings.DJANGO_SYSTEM_USER (default: 'system') will be used
67 :param kwargs: Optional key-value attributes to append to message
68 :return:
69 """
70 from jutil.model import get_model_field_label_and_value # noqa
72 fv: List[str] = []
73 for k in field_names:
74 label, value = get_model_field_label_and_value(obj, k)
75 fv.append('{}: "{}"'.format(label, value))
76 msg = ", ".join(fv)
77 if "ip" in kwargs: 77 ↛ 78line 77 didn't jump to line 78, because the condition on line 77 was never true
78 msg += " (IP {ip})".format(ip=kwargs.pop("ip"))
79 admin_log([obj], msg, who, **kwargs) # type: ignore
82def admin_obj_url(obj: Optional[object], route: str = "", base_url: str = "") -> str:
83 """
84 Returns admin URL to object. If object is standard model with default route name, the function
85 can deduct the route name as in "admin:<app>_<class-lowercase>_change".
86 :param obj: Object
87 :param route: Empty for default route
88 :param base_url: Base URL if you want absolute URLs, e.g. https://example.com
89 :return: URL to admin object change view
90 """
91 if obj is None:
92 return ""
93 if not route:
94 route = "admin:{}_{}_change".format(obj._meta.app_label, obj._meta.model_name) # type: ignore
95 path = reverse(route, args=[obj.id]) # type: ignore
96 return base_url + path
99def admin_obj_link(obj: Optional[object], label: str = "", route: str = "", base_url: str = "") -> str:
100 """
101 Returns safe-marked admin link to object. If object is standard model with default route name, the function
102 can deduct the route name as in "admin:<app>_<class-lowercase>_change".
103 :param obj: Object
104 :param label: Optional label. If empty uses str(obj)
105 :param route: Empty for default route
106 :param base_url: Base URL if you want absolute URLs, e.g. https://example.com
107 :return: HTML link marked safe
108 """
109 if obj is None:
110 return ""
111 url = mark_safe(admin_obj_url(obj, route, base_url)) # nosec
112 return format_html("<a href='{}'>{}</a>", url, str(obj) if not label else label)
115class ModelAdminBase(admin.ModelAdmin):
116 """
117 ModelAdmin with save-on-top default enabled and customized (length-limited) history view.
118 """
120 save_on_top = True
121 max_history_length = 1000
123 def sort_actions_by_description(self, actions: dict) -> OrderedDict:
124 """
125 :param actions: dict of str: (callable, name, description)
126 :return: OrderedDict
127 """
128 sorted_descriptions = sorted([(k, data[2]) for k, data in actions.items()], key=lambda x: x[1])
129 sorted_actions = OrderedDict()
130 for k, description in sorted_descriptions: # pylint: disable=unused-variable
131 sorted_actions[k] = actions[k]
132 return sorted_actions
134 def get_actions(self, request):
135 return self.sort_actions_by_description(super().get_actions(request))
137 def kw_changelist_view(self, request: HttpRequest, extra_context=None, **kwargs): # pylint: disable=unused-argument
138 """
139 Changelist view which allow key-value arguments.
140 :param request: HttpRequest
141 :param extra_context: Extra context dict
142 :param kwargs: Key-value dict
143 :return: See changelist_view()
144 """
145 return self.changelist_view(request, extra_context)
147 def history_view(self, request, object_id, extra_context=None):
148 "The 'history' admin view for this model."
149 from django.contrib.admin.models import LogEntry # noqa
151 # First check if the user can see this history.
152 model = self.model
153 obj = self.get_object(request, unquote(object_id))
154 if obj is None: 154 ↛ 155line 154 didn't jump to line 155, because the condition on line 154 was never true
155 return self._get_obj_does_not_exist_redirect(request, model._meta, object_id)
157 if not self.has_view_or_change_permission(request, obj): 157 ↛ 158line 157 didn't jump to line 158, because the condition on line 157 was never true
158 raise PermissionDenied
160 # Then get the history for this object.
161 opts = model._meta
162 app_label = opts.app_label
163 action_list = (
164 LogEntry.objects.filter(object_id=unquote(object_id), content_type=get_content_type_for_model(model))
165 .select_related()
166 .order_by("-action_time")[: self.max_history_length]
167 )
169 context = {
170 **self.admin_site.each_context(request),
171 "title": _("Change history: %s") % obj,
172 "action_list": action_list,
173 "module_name": str(capfirst(opts.verbose_name_plural)),
174 "object": obj,
175 "opts": opts,
176 "preserved_filters": self.get_preserved_filters(request),
177 **(extra_context or {}),
178 }
180 request.current_app = self.admin_site.name
182 return TemplateResponse(
183 request,
184 self.object_history_template
185 or [
186 "admin/%s/%s/object_history.html" % (app_label, opts.model_name),
187 "admin/%s/object_history.html" % app_label,
188 "admin/object_history.html",
189 ],
190 context,
191 )
194class InlineModelAdminParentAccessMixin:
195 """
196 Admin mixin for accessing parent objects to be used in InlineModelAdmin derived classes.
197 """
199 OBJECT_PK_KWARGS = ["object_id", "pk", "id"]
201 def get_parent_object(self, request) -> Optional[object]:
202 """
203 Returns the inline admin object's parent object or None if not found.
204 """
205 mgr = self.parent_model.objects # type: ignore
206 resolved = resolve(request.path_info)
207 if resolved.kwargs:
208 for k in self.OBJECT_PK_KWARGS:
209 if k in resolved.kwargs:
210 return mgr.filter(pk=resolved.kwargs[k]).first()
211 if resolved.args:
212 return mgr.filter(pk=resolved.args[0]).first()
213 return None
216class AdminLogEntryMixin:
217 """
218 Model mixin for logging Django admin changes of Models.
219 Call fields_changed() on change events.
220 """
222 def fields_changed(self, field_names: Sequence[str], who: Optional[User] = None, **kwargs):
223 admin_log_changed_fields(self, field_names, who, **kwargs)