Coverage for jutil/admin.py: 75%

# Coverage for jutil/admin.py : 75%

## 147 statements

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

1 import os

2 from collections import OrderedDict

3 from typing import List, Optional, Sequence, TYPE_CHECKING

4 from django.conf import settings

5 from django.conf.urls import url

6 from django.contrib import admin

7 from django.contrib.auth.models import User

8 from django.db.models import Q

9 from django.http import HttpRequest, Http404

10 from django.urls import reverse

11 from django.utils.html import format_html

12 from django.utils.safestring import mark_safe

13 from jutil.model import get_model_field_label_and_value

14 from django.utils.translation import gettext_lazy as _

15 from jutil.responses import FileSystemFileResponse

16 from django.contrib.admin.models import CHANGE

17 from django.template.response import TemplateResponse

18 from django.contrib.admin.options import get_content_type_for_model

19 from django.contrib.admin.utils import unquote

20 from django.core.exceptions import PermissionDenied, ImproperlyConfigured

21 from django.utils.text import capfirst

22 from django.utils.encoding import force_text

23 from django.contrib.admin.models import LogEntry

24

25

26 def 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

67 def 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

84 def 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

100 class 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

171 class 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

188 class 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 ]