Coverage for jutil/admin.py: 29%

# Coverage for jutil/admin.py : 29%

## 147 statements 51 run 96 missing 3 excluded 8 partial

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, Union, Type

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, Model

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

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

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

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:

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

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:

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 ]