Coverage for jutil/admin.py : 29%

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, Union, Type
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, Model
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[Optional[Union[Type[Model], Model]]],
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): 42 ↛ 43line 42 didn't jump to line 43, because the condition on line 42 was never true
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 48 ↛ 49line 48 didn't jump to line 49, because the condition on line 48 was never true
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: 55 ↛ exitline 55 didn't return from function 'admin_log', because the loop on line 55 didn't complete
56 if instance: 56 ↛ 55line 56 didn't jump to line 55, because the condition on line 56 was never false
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[Union[Type[Model], Model]], 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[Union[Type[Model], Model]], 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: 94 ↛ 96line 94 didn't jump to line 96, because the condition on line 94 was never false
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:
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):
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:
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:
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 ↛ 273, 272 ↛ 2742 missed branches: 1) line 272 didn't jump to line 273, because the condition on line 272 was never true, 2) line 272 didn't jump to line 274, because the condition on line 272 was never false
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 ]