Coverage for jutil/admin.py : 75%

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
1import os
2from collections import OrderedDict
3from typing import List, Optional, Sequence, TYPE_CHECKING
4from django.conf import settings
5from django.conf.urls import url
6from django.contrib import admin
7from django.contrib.auth.models import User
8from django.db.models import Q
9from django.http import HttpRequest, Http404
10from django.urls import reverse
11from django.utils.html import format_html
12from django.utils.safestring import mark_safe
13from jutil.model import get_model_field_label_and_value
14from django.utils.translation import gettext_lazy as _
15from jutil.responses import FileSystemFileResponse
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, ImproperlyConfigured
21from django.utils.text import capfirst
22from django.utils.encoding import force_text
23from django.contrib.admin.models import LogEntry
26def admin_log(instances: Sequence[object],
27 msg: str, who: Optional[User] = None, **kw):
28 """
29 Logs an entry to admin logs of model(s).
30 :param instances: Model instance or list of instances (None values are ignored)
31 :param msg: Message to log
32 :param who: Who did the change. If who is None then User with username of settings.DJANGO_SYSTEM_USER (default: 'system') will be used
33 :param kw: Optional key-value attributes to append to message
34 :return: None
35 """
36 # use system user if 'who' is missing
37 if who is None: 37 ↛ 42line 37 didn't jump to line 42, because the condition on line 37 was never false
38 username = settings.DJANGO_SYSTEM_USER if hasattr(settings, 'DJANGO_SYSTEM_USER') else 'system'
39 who = User.objects.get_or_create(username=username)[0]
41 # allow passing individual instance
42 if not isinstance(instances, list) and not isinstance(instances, tuple):
43 instances = [instances] # type: ignore
45 # append extra keyword attributes if any
46 att_str = ''
47 for k, v in kw.items():
48 if hasattr(v, 'pk'): # log only primary key for model instances, not whole str representation
49 v = v.pk
50 att_str += '{}={}'.format(k, v) if not att_str else ', {}={}'.format(k, v)
51 if att_str:
52 att_str = ' [{}]'.format(att_str)
53 msg = str(msg) + att_str
55 for instance in instances:
56 if instance:
57 LogEntry.objects.log_action(
58 user_id=who.pk if who is not None else None,
59 content_type_id=get_content_type_for_model(instance).pk,
60 object_id=instance.pk, # pytype: disable=attribute-error
61 object_repr=force_text(instance),
62 action_flag=CHANGE,
63 change_message=msg,
64 )
67def admin_obj_url(obj: Optional[object], route: str = '', base_url: str = '') -> str:
68 """
69 Returns admin URL to object. If object is standard model with default route name, the function
70 can deduct the route name as in "admin:<app>_<class-lowercase>_change".
71 :param obj: Object
72 :param route: Empty for default route
73 :param base_url: Base URL if you want absolute URLs, e.g. https://example.com
74 :return: URL to admin object change view
75 """
76 if obj is None:
77 return ''
78 if not route:
79 route = 'admin:{}_{}_change'.format(obj._meta.app_label, obj._meta.model_name) # type: ignore
80 path = reverse(route, args=[obj.id]) # type: ignore
81 return base_url + path
84def admin_obj_link(obj: Optional[object], label: str = '', route: str = '', base_url: str = '') -> str:
85 """
86 Returns safe-marked admin link to object. If object is standard model with default route name, the function
87 can deduct the route name as in "admin:<app>_<class-lowercase>_change".
88 :param obj: Object
89 :param label: Optional label. If empty uses str(obj)
90 :param route: Empty for default route
91 :param base_url: Base URL if you want absolute URLs, e.g. https://example.com
92 :return: HTML link marked safe
93 """
94 if obj is None:
95 return ''
96 url = mark_safe(admin_obj_url(obj, route, base_url)) # nosec
97 return format_html("<a href='{}'>{}</a>", url, str(obj) if not label else label)
100class ModelAdminBase(admin.ModelAdmin):
101 """
102 ModelAdmin with save-on-top default enabled and customized (length-limited) history view.
103 """
104 save_on_top = True
105 max_history_length = 1000
107 def sort_actions_by_description(self, actions: dict) -> OrderedDict:
108 """
109 :param actions: dict of str: (callable, name, description)
110 :return: OrderedDict
111 """
112 sorted_descriptions = sorted([(k, data[2]) for k, data in actions.items()], key=lambda x: x[1])
113 sorted_actions = OrderedDict()
114 for k, description in sorted_descriptions: # pylint: disable=unused-variable
115 sorted_actions[k] = actions[k]
116 return sorted_actions
118 def get_actions(self, request):
119 return self.sort_actions_by_description(super().get_actions(request))
121 def kw_changelist_view(self, request: HttpRequest, extra_context=None, **kwargs): # pylint: disable=unused-argument
122 """
123 Changelist view which allow key-value arguments.
124 :param request: HttpRequest
125 :param extra_context: Extra context dict
126 :param kwargs: Key-value dict
127 :return: See changelist_view()
128 """
129 return self.changelist_view(request, extra_context)
131 def history_view(self, request, object_id, extra_context=None):
132 "The 'history' admin view for this model."
133 from django.contrib.admin.models import LogEntry # noqa
134 # First check if the user can see this history.
135 model = self.model
136 obj = self.get_object(request, unquote(object_id))
137 if obj is None: 137 ↛ 138line 137 didn't jump to line 138, because the condition on line 137 was never true
138 return self._get_obj_does_not_exist_redirect(request, model._meta, object_id)
140 if not self.has_view_or_change_permission(request, obj): 140 ↛ 141line 140 didn't jump to line 141, because the condition on line 140 was never true
141 raise PermissionDenied
143 # Then get the history for this object.
144 opts = model._meta
145 app_label = opts.app_label
146 action_list = LogEntry.objects.filter(
147 object_id=unquote(object_id),
148 content_type=get_content_type_for_model(model)
149 ).select_related().order_by('action_time')[:self.max_history_length]
151 context = {
152 **self.admin_site.each_context(request),
153 'title': _('Change history: %s') % obj,
154 'action_list': action_list,
155 'module_name': str(capfirst(opts.verbose_name_plural)),
156 'object': obj,
157 'opts': opts,
158 'preserved_filters': self.get_preserved_filters(request),
159 **(extra_context or {}),
160 }
162 request.current_app = self.admin_site.name
164 return TemplateResponse(request, self.object_history_template or [
165 "admin/%s/%s/object_history.html" % (app_label, opts.model_name),
166 "admin/%s/object_history.html" % app_label,
167 "admin/object_history.html"
168 ], context)
171class AdminLogEntryMixin:
172 """
173 Mixin for logging Django admin changes of models.
174 Call fields_changed() on change events.
175 """
177 def fields_changed(self, field_names: Sequence[str], who: Optional[User], **kw):
178 fv_str = ''
179 for k in field_names:
180 label, value = get_model_field_label_and_value(self, k)
181 fv_str += '{}={}'.format(label, value) if not fv_str else ', {}={}'.format(label, value)
183 msg = "{class_name} id={id}: {fv_str}".format(
184 class_name=self._meta.verbose_name.title(), id=self.id, fv_str=fv_str) # type: ignore
185 admin_log([who, self], msg, who, **kw) # type: ignore
188class AdminFileDownloadMixin:
189 """
190 Model Admin mixin for downloading uploaded files. Checks object permission before allowing download.
192 You can control access to file downloads using three different ways:
193 1) Set is_staff_to_download=False to allow also those users who don't have is_staff flag set to download files.
194 2) Set is_authenticated_to_download=False to allow also non-logged in users to download files.
195 3) Define get_queryset(request) for the admin class. This is most fine-grained access method.
196 """
197 upload_to = 'uploads'
198 file_field = 'file'
199 file_fields: Sequence[str] = []
200 is_staff_to_download = True
201 is_authenticated_to_download = True
203 if TYPE_CHECKING: 203 ↛ 204line 203 didn't jump to line 204, because the condition on line 203 was never true
204 def get_queryset(self, request):
205 pass
207 def get_object(self, request, object_id, from_field=None):
208 pass
210 def get_file_fields(self) -> List[str]:
211 if self.file_fields and self.file_field:
212 raise ImproperlyConfigured('AdminFileDownloadMixin cannot have both file_fields and '
213 'file_field set ({})'.format(self.__class__))
214 out = set()
215 for f in self.file_fields or [self.file_field]:
216 if f: 216 ↛ 215line 216 didn't jump to line 215, because the condition on line 216 was never false
217 out.add(f)
218 if not out:
219 raise ImproperlyConfigured('AdminFileDownloadMixin must have either file_fields or '
220 'file_field set ({})'.format(self.__class__))
221 return list(out)
223 @property
224 def single_file_field(self) -> str:
225 out = self.get_file_fields()
226 if len(out) != 1:
227 raise ImproperlyConfigured('AdminFileDownloadMixin has multiple file fields, '
228 'you need to specify field explicitly ({})'.format(self.__class__))
229 return out[0]
231 def get_object_by_filename(self, request, filename):
232 """
233 Returns owner object by filename (to be downloaded).
234 This can be used to implement custom permission checks.
235 :param request: HttpRequest
236 :param filename: File name of the downloaded object.
237 :return: owner object
238 """
239 user = request.user
240 if self.is_authenticated_to_download and not user.is_authenticated:
241 raise Http404(_('File {} not found').format(filename))
242 if self.is_staff_to_download and (not user.is_authenticated or not user.is_staff):
243 raise Http404(_('File {} not found').format(filename))
244 query = None
245 for k in self.get_file_fields():
246 query_params = {k: filename}
247 if query is None:
248 query = Q(**query_params)
249 else:
250 query = query | Q(**query_params)
251 objs = self.get_queryset(request).filter(query) # pytype: disable=attribute-error
252 for obj in objs:
253 try:
254 return self.get_object(request, obj.id) # pytype: disable=attribute-error
255 except Exception: # nosec
256 pass
257 raise Http404(_('File {} not found').format(filename))
259 def get_download_url(self, obj, file_field: str = '') -> str:
260 obj_id = obj.pk
261 filename = getattr(obj, self.single_file_field if not file_field else file_field).name
262 info = self.model._meta.app_label, self.model._meta.model_name # type: ignore
263 return reverse('admin:{}_{}_change'.format(*info), args=(str(obj_id),)) + filename
265 def get_download_link(self, obj, file_field: str = '', label: str = '') -> str:
266 label = str(label or getattr(obj, self.single_file_field if not file_field else file_field))
267 return mark_safe(format_html('<a href="{}">{}</a>', self.get_download_url(obj, file_field), label))
269 def file_download_view(self, request, filename, form_url='', extra_context=None): # pylint: disable=unused-argument
270 full_path = os.path.join(settings.MEDIA_ROOT, filename)
271 obj = self.get_object_by_filename(request, filename)
272 if not obj: 272 ↛ 273line 272 didn't jump to line 273, because the condition on line 272 was never true
273 raise Http404(_('File {} not found').format(filename))
274 return FileSystemFileResponse(full_path)
276 def get_download_urls(self):
277 """
278 Use like this:
279 def get_urls(self):
280 return self.get_download_urls() + super().get_urls()
282 Returns: File download URLs for this model.
283 """
284 info = self.model._meta.app_label, self.model._meta.model_name # type: ignore # pytype: disable=attribute-error
285 return [
286 url(r'^\d+/change/(' + self.upload_to + '/.+)/$', self.file_download_view, name='%s_%s_file_download' % info),
287 url(r'^(' + self.upload_to + '/.+)/$', self.file_download_view, name='%s_%s_file_download_changelist' % info),
288 ]