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],
22 msg: str, who: Optional[User] = None, **kw):
23 """
24 Logs an entry to admin logs of model(s).
25 :param instances: Model instance or list of instances (None values are ignored)
26 :param msg: Message to log
27 :param who: Who did the change. If who is None then User with username of settings.DJANGO_SYSTEM_USER (default: 'system') will be used
28 :param kw: Optional key-value attributes to append to message
29 :return: None
30 """
31 # use system user if 'who' is missing
32 if who is None: 32 ↛ 37line 32 didn't jump to line 37, because the condition on line 32 was never false
33 username = settings.DJANGO_SYSTEM_USER if hasattr(settings, 'DJANGO_SYSTEM_USER') else 'system'
34 who = User.objects.get_or_create(username=username)[0]
36 # allow passing individual instance
37 if not isinstance(instances, list) and not isinstance(instances, tuple):
38 instances = [instances] # type: ignore
40 # append extra keyword attributes if any
41 att_str = ''
42 for k, v in kw.items():
43 if hasattr(v, 'pk'): # log only primary key for model instances, not whole str representation
44 v = v.pk
45 att_str += '{}={}'.format(k, v) if not att_str else ', {}={}'.format(k, v)
46 if att_str:
47 att_str = ' [{}]'.format(att_str)
48 msg = str(msg) + att_str
50 for instance in instances:
51 if instance: 51 ↛ 50line 51 didn't jump to line 50, because the condition on line 51 was never false
52 LogEntry.objects.log_action(
53 user_id=who.pk if who is not None else None,
54 content_type_id=get_content_type_for_model(instance).pk,
55 object_id=instance.pk, # pytype: disable=attribute-error
56 object_repr=force_text(instance),
57 action_flag=CHANGE,
58 change_message=msg,
59 )
62def admin_log_changed_fields(obj: object, field_names: Sequence[str], who: Optional[User] = None, **kwargs):
63 """
64 Logs changed fields of a model instance to admin log.
65 :param obj: Model instance
66 :param field_names: Field names
67 :param who: Who did the change. If who is None then User with username of settings.DJANGO_SYSTEM_USER (default: 'system') will be used
68 :param kwargs: Optional key-value attributes to append to message
69 :return:
70 """
71 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 """
119 save_on_top = True
120 max_history_length = 1000
122 def sort_actions_by_description(self, actions: dict) -> OrderedDict:
123 """
124 :param actions: dict of str: (callable, name, description)
125 :return: OrderedDict
126 """
127 sorted_descriptions = sorted([(k, data[2]) for k, data in actions.items()], key=lambda x: x[1])
128 sorted_actions = OrderedDict()
129 for k, description in sorted_descriptions: # pylint: disable=unused-variable
130 sorted_actions[k] = actions[k]
131 return sorted_actions
133 def get_actions(self, request):
134 return self.sort_actions_by_description(super().get_actions(request))
136 def kw_changelist_view(self, request: HttpRequest, extra_context=None, **kwargs): # pylint: disable=unused-argument
137 """
138 Changelist view which allow key-value arguments.
139 :param request: HttpRequest
140 :param extra_context: Extra context dict
141 :param kwargs: Key-value dict
142 :return: See changelist_view()
143 """
144 return self.changelist_view(request, extra_context)
146 def history_view(self, request, object_id, extra_context=None):
147 "The 'history' admin view for this model."
148 from django.contrib.admin.models import LogEntry # noqa
149 # First check if the user can see this history.
150 model = self.model
151 obj = self.get_object(request, unquote(object_id))
152 if obj is None: 152 ↛ 153line 152 didn't jump to line 153, because the condition on line 152 was never true
153 return self._get_obj_does_not_exist_redirect(request, model._meta, object_id)
155 if not self.has_view_or_change_permission(request, obj): 155 ↛ 156line 155 didn't jump to line 156, because the condition on line 155 was never true
156 raise PermissionDenied
158 # Then get the history for this object.
159 opts = model._meta
160 app_label = opts.app_label
161 action_list = LogEntry.objects.filter(
162 object_id=unquote(object_id),
163 content_type=get_content_type_for_model(model)
164 ).select_related().order_by('-action_time')[:self.max_history_length]
166 context = {
167 **self.admin_site.each_context(request),
168 'title': _('Change history: %s') % obj,
169 'action_list': action_list,
170 'module_name': str(capfirst(opts.verbose_name_plural)),
171 'object': obj,
172 'opts': opts,
173 'preserved_filters': self.get_preserved_filters(request),
174 **(extra_context or {}),
175 }
177 request.current_app = self.admin_site.name
179 return TemplateResponse(request, self.object_history_template or [
180 "admin/%s/%s/object_history.html" % (app_label, opts.model_name),
181 "admin/%s/object_history.html" % app_label,
182 "admin/object_history.html"
183 ], context)
186class InlineModelAdminParentAccessMixin:
187 """
188 Admin mixin for accessing parent objects to be used in InlineModelAdmin derived classes.
189 """
190 OBJECT_PK_KWARGS = ['object_id', 'pk', 'id']
192 def get_parent_object(self, request) -> Optional[object]:
193 """
194 Returns the inline admin object's parent object or None if not found.
195 """
196 mgr = self.parent_model.objects # type: ignore
197 resolved = resolve(request.path_info)
198 if resolved.kwargs:
199 for k in self.OBJECT_PK_KWARGS:
200 if k in resolved.kwargs:
201 return mgr.filter(pk=resolved.kwargs[k]).first()
202 if resolved.args:
203 return mgr.filter(pk=resolved.args[0]).first()
204 return None
207class AdminLogEntryMixin:
208 """
209 Model mixin for logging Django admin changes of Models.
210 Call fields_changed() on change events.
211 """
212 def fields_changed(self, field_names: Sequence[str], who: Optional[User] = None, **kwargs):
213 admin_log_changed_fields(self, field_names, who, **kwargs)