Hide keyboard shortcuts

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 

24 

25 

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] 

40 

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 

44 

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 

54 

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 ) 

65 

66 

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 

82 

83 

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) 

98 

99 

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 

106 

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 

117 

118 def get_actions(self, request): 

119 return self.sort_actions_by_description(super().get_actions(request)) 

120 

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) 

130 

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) 

139 

140 if not self.has_view_or_change_permission(request, obj): 

141 raise PermissionDenied 

142 

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] 

150 

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 } 

161 

162 request.current_app = self.admin_site.name 

163 

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) 

169 

170 

171class AdminLogEntryMixin: 

172 """ 

173 Mixin for logging Django admin changes of models. 

174 Call fields_changed() on change events. 

175 """ 

176 

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) 

182 

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 

186 

187 

188class AdminFileDownloadMixin: 

189 """ 

190 Model Admin mixin for downloading uploaded files. Checks object permission before allowing download. 

191 

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 

202 

203 if TYPE_CHECKING: 

204 def get_queryset(self, request): 

205 pass 

206 

207 def get_object(self, request, object_id, from_field=None): 

208 pass 

209 

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) 

222 

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] 

230 

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)) 

258 

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 

264 

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)) 

268 

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) 

275 

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() 

281 

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 ]