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 

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 

24 

25 

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] 

40 

41 # allow passing individual instance 

42 if not isinstance(instances, list) and not isinstance(instances, tuple): 

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 

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: 

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 ) 

65 

66 

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 

82 

83 

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) 

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

139 

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 

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

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

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

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 ]