Coverage for jutil/admin.py: 54%

145 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-10-07 16:40 -0500

1import json 

2from collections import OrderedDict 

3from typing import Optional, Sequence, List, Dict, Any 

4from django.conf import settings 

5from django.contrib import admin 

6from django.contrib.auth import get_user_model 

7from django.contrib.auth.models import User 

8from django.core.serializers.json import DjangoJSONEncoder 

9from django.db.models import QuerySet 

10from django.http import HttpRequest 

11from django.urls import reverse, resolve 

12from django.utils import translation 

13from django.utils.html import format_html 

14from django.utils.safestring import mark_safe 

15from django.utils.translation import gettext_lazy as _ 

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 

21from django.utils.text import capfirst 

22from django.utils.encoding import force_str 

23from django.contrib.admin.models import LogEntry 

24 

25 

26def get_admin_log(instance: object) -> QuerySet: 

27 """ 

28 Returns admin log (LogEntry QuerySet) of the object. 

29 """ 

30 return LogEntry.objects.filter( 

31 content_type_id=get_content_type_for_model(instance).pk, # type: ignore 

32 object_id=instance.pk, # type: ignore # pytype: disable=attribute-error 

33 ) 

34 

35 

36def admin_log(instances: Sequence[object], msg: str, who: Optional[User] = None, action_flag: int = CHANGE, **kwargs): 

37 """ 

38 Logs an entry to admin logs of model(s). 

39 :param instances: Model instance or list of instances (None values are ignored) 

40 :param msg: Message to log 

41 :param who: Who did the change. If who is None then User with username of settings.DJANGO_SYSTEM_USER (default: 'system') will be used 

42 :param action_flag: ADDITION / CHANGE / DELETION action flag. Default CHANGED. 

43 :param kwargs: Optional key-value attributes to append to message 

44 :return: None 

45 """ 

46 # use system user if 'who' is missing 

47 if who is None: 47 ↛ 52line 47 didn't jump to line 52, because the condition on line 47 was never false

48 username = settings.DJANGO_SYSTEM_USER if hasattr(settings, "DJANGO_SYSTEM_USER") else "system" # type: ignore 

49 who = get_user_model().objects.get_or_create(username=username)[0] 

50 

51 # allow passing individual instance 

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

53 instances = [instances] # type: ignore 

54 

55 # append extra context if any 

56 extra_context: List[str] = [] 

57 for k, v in kwargs.items(): 

58 extra_context.append(str(k) + "=" + str(v)) 

59 if extra_context: 

60 msg += " | " + ", ".join(extra_context) 

61 

62 for instance in instances: 

63 if instance: 63 ↛ 62line 63 didn't jump to line 62, because the condition on line 63 was never false

64 LogEntry.objects.log_action( 

65 user_id=who.pk if who is not None else None, 

66 content_type_id=get_content_type_for_model(instance).pk, # type: ignore 

67 object_id=instance.pk, # type: ignore # pytype: disable=attribute-error 

68 object_repr=force_str(instance), 

69 action_flag=action_flag, 

70 change_message=msg, 

71 ) 

72 

73 

74def admin_obj_serialize_fields(obj: object, field_names: Sequence[str], cls=DjangoJSONEncoder, max_serialized_field_length: Optional[int] = None) -> str: 

75 """ 

76 JSON serializes (changed) fields of a model instance for logging purposes. 

77 Referenced objects with primary key (pk) attribute are formatted using only that field as value. 

78 :param obj: Model instance 

79 :param field_names: List of field names to store 

80 :param cls: Serialization class. Default DjangoJSONEncoder. 

81 :param max_serialized_field_length: Optional maximum length for individual serialized str value. Longer fields are cut with terminating [...] 

82 :return: str 

83 """ 

84 out: Dict[str, Any] = {} 

85 for k in field_names: 

86 val = getattr(obj, k) if hasattr(obj, k) else None 

87 if hasattr(val, "pk"): 

88 val = val.pk 

89 if max_serialized_field_length is not None and isinstance(val, str) and len(val) > max_serialized_field_length: 

90 val = val[:max_serialized_field_length] + " [...]" 

91 out[k] = val 

92 return json.dumps(out, cls=cls) 

93 

94 

95def admin_construct_change_message_ex(request, form, formsets, add, cls=DjangoJSONEncoder, max_serialized_field_length: int = 1000): # noqa 

96 from ipware import get_client_ip # type: ignore # noqa 

97 from django.contrib.admin.utils import _get_changed_field_labels_from_form # type: ignore # noqa 

98 from jutil.model import get_model_field_names # noqa 

99 

100 changed_data = form.changed_data 

101 with translation.override(None): 

102 changed_field_labels = _get_changed_field_labels_from_form(form, changed_data) 

103 

104 ip = get_client_ip(request)[0] 

105 instance = form.instance if hasattr(form, "instance") and form.instance is not None else None 

106 values_str = admin_obj_serialize_fields(form.instance, changed_data, cls, max_serialized_field_length) if instance is not None else "" 

107 values = json.loads(values_str) if values_str else {} 

108 change_message = [] 

109 if add: 

110 change_message.append({"added": {"values": values, "ip": ip}}) 

111 elif form.changed_data: 

112 change_message.append({"changed": {"fields": changed_field_labels, "values": values, "ip": ip}}) 

113 if formsets: 

114 with translation.override(None): 

115 for formset in formsets: 

116 for added_object in formset.new_objects: 

117 values = json.loads(admin_obj_serialize_fields(added_object, get_model_field_names(added_object), cls, max_serialized_field_length)) 

118 change_message.append( 

119 { 

120 "added": { 

121 "name": str(added_object._meta.verbose_name), 

122 "object": str(added_object), 

123 "values": values, 

124 "ip": ip, 

125 } 

126 } 

127 ) 

128 for changed_object, changed_fields in formset.changed_objects: 

129 values = json.loads(admin_obj_serialize_fields(changed_object, changed_fields, cls, max_serialized_field_length)) 

130 change_message.append( 

131 { 

132 "changed": { 

133 "name": str(changed_object._meta.verbose_name), 

134 "object": str(changed_object), 

135 "fields": _get_changed_field_labels_from_form(formset.forms[0], changed_fields), 

136 "values": values, 

137 "ip": ip, 

138 } 

139 } 

140 ) 

141 for deleted_object in formset.deleted_objects: 

142 change_message.append( 

143 { 

144 "deleted": { 

145 "name": str(deleted_object._meta.verbose_name), 

146 "object": str(deleted_object), 

147 "ip": ip, 

148 } 

149 } 

150 ) 

151 return change_message 

152 

153 

154def admin_obj_url(obj: Optional[object], route: str = "", base_url: str = "") -> str: 

155 """ 

156 Returns admin URL to object. If object is standard model with default route name, the function 

157 can deduct the route name as in "admin:<app>_<class-lowercase>_change". 

158 :param obj: Object 

159 :param route: Empty for default route 

160 :param base_url: Base URL if you want absolute URLs, e.g. https://example.com 

161 :return: URL to admin object change view 

162 """ 

163 if obj is None: 

164 return "" 

165 if not route: 

166 route = "admin:{}_{}_change".format(obj._meta.app_label, obj._meta.model_name) # type: ignore 

167 path = reverse(route, args=[obj.id]) # type: ignore 

168 return base_url + path 

169 

170 

171def admin_obj_link(obj: Optional[object], label: str = "", route: str = "", base_url: str = "") -> str: 

172 """ 

173 Returns safe-marked admin link to object. If object is standard model with default route name, the function 

174 can deduct the route name as in "admin:<app>_<class-lowercase>_change". 

175 :param obj: Object 

176 :param label: Optional label. If empty uses str(obj) 

177 :param route: Empty for default route 

178 :param base_url: Base URL if you want absolute URLs, e.g. https://example.com 

179 :return: HTML link marked safe 

180 """ 

181 if obj is None: 

182 return "" 

183 url = mark_safe(admin_obj_url(obj, route, base_url)) # nosec 

184 return format_html("<a href='{}'>{}</a>", url, str(obj) if not label else label) 

185 

186 

187class ModelAdminBase(admin.ModelAdmin): 

188 """ 

189 ModelAdmin with some customizations: 

190 * Customized change message which logs changed values and user IP as well (can be disabled by extended_log=False) 

191 * Length-limited latest-first history view (customizable by max_history_length) 

192 * Actions sorted alphabetically by localized description 

193 * Additional fill_extra_context() method which can be used to share common extra context for add_view(), change_view() and changelist_view() 

194 * Save-on-top enabled by default (save_on_top=True) 

195 """ 

196 

197 save_on_top = True 

198 extended_log = True 

199 max_history_length = 1000 

200 serialization_cls = DjangoJSONEncoder 

201 max_serialized_field_length = 1000 

202 

203 def construct_change_message(self, request, form, formsets, add=False): 

204 if self.extended_log: 

205 return admin_construct_change_message_ex(request, form, formsets, add, self.serialization_cls, self.max_serialized_field_length) 

206 return super().construct_change_message(request, form, formsets, add) 

207 

208 def sort_actions_by_description(self, actions: dict) -> OrderedDict: 

209 """ 

210 :param actions: dict of str: (callable, name, description) 

211 :return: OrderedDict 

212 """ 

213 sorted_descriptions = sorted([(k, data[2]) for k, data in actions.items()], key=lambda x: x[1]) 

214 sorted_actions = OrderedDict() 

215 for k, description in sorted_descriptions: # pylint: disable=unused-variable 

216 sorted_actions[k] = actions[k] 

217 return sorted_actions 

218 

219 def get_actions(self, request): 

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

221 

222 def fill_extra_context(self, request: HttpRequest, extra_context: Optional[Dict[str, Any]]): # pylint: disable=unused-argument 

223 """ 

224 Function called by customized add_view(), change_view() and kw_changelist_view() 

225 to supplement extra_context dictionary by custom variables. 

226 """ 

227 return extra_context 

228 

229 def add_view(self, request: HttpRequest, form_url="", extra_context=None): 

230 """ 

231 Custom add_view() which calls fill_extra_context(). 

232 """ 

233 return super().add_view(request, form_url, self.fill_extra_context(request, extra_context)) 

234 

235 def change_view(self, request: HttpRequest, object_id, form_url="", extra_context=None): 

236 """ 

237 Custom change_view() which calls fill_extra_context(). 

238 """ 

239 return super().change_view(request, object_id, form_url, self.fill_extra_context(request, extra_context)) 

240 

241 def changelist_view(self, request, extra_context=None): 

242 return super().changelist_view(request, self.fill_extra_context(request, extra_context)) 

243 

244 def kw_changelist_view(self, request: HttpRequest, extra_context=None, **kwargs): # pylint: disable=unused-argument 

245 """ 

246 Changelist view which allow key-value arguments and calls fill_extra_context(). 

247 :param request: HttpRequest 

248 :param extra_context: Extra context dict 

249 :param kwargs: Key-value dict 

250 :return: See changelist_view() 

251 """ 

252 extra_context = extra_context or {} 

253 extra_context.update(kwargs) 

254 return self.changelist_view(request, self.fill_extra_context(request, extra_context)) 

255 

256 def history_view(self, request, object_id, extra_context=None): 

257 from django.contrib.admin.models import LogEntry # noqa 

258 

259 # First check if the user can see this history. 

260 model = self.model 

261 obj = self.get_object(request, unquote(object_id)) 

262 if obj is None: 262 ↛ 263line 262 didn't jump to line 263, because the condition on line 262 was never true

263 return self._get_obj_does_not_exist_redirect(request, model._meta, object_id) # noqa 

264 

265 if not self.has_view_or_change_permission(request, obj): 265 ↛ 266line 265 didn't jump to line 266, because the condition on line 265 was never true

266 raise PermissionDenied 

267 

268 # Then get the history for this object. 

269 opts = model._meta # noqa 

270 app_label = opts.app_label 

271 action_list = ( 

272 LogEntry.objects.filter( 

273 object_id=unquote(object_id), 

274 content_type=get_content_type_for_model(model), 

275 ) 

276 .select_related() 

277 .order_by("-action_time") 

278 )[: self.max_history_length] 

279 

280 context = { 

281 **self.admin_site.each_context(request), 

282 "title": _("Change history: %s") % obj, 

283 "subtitle": None, 

284 "action_list": action_list, 

285 "module_name": str(capfirst(opts.verbose_name_plural)), 

286 "object": obj, 

287 "opts": opts, 

288 "preserved_filters": self.get_preserved_filters(request), 

289 **(extra_context or {}), 

290 } 

291 

292 request.current_app = self.admin_site.name 

293 

294 return TemplateResponse( 

295 request, 

296 self.object_history_template 

297 or [ 

298 "admin/%s/%s/object_history.html" % (app_label, opts.model_name), 

299 "admin/%s/object_history.html" % app_label, 

300 "jutil/admin/object_history.html", 

301 ], 

302 context, 

303 ) 

304 

305 

306class InlineModelAdminParentAccessMixin: 

307 """ 

308 Admin mixin for accessing parent objects to be used in InlineModelAdmin derived classes. 

309 """ 

310 

311 OBJECT_PK_KWARGS = ["object_id", "pk", "id"] 

312 

313 def get_parent_object(self, request) -> Optional[object]: 

314 """ 

315 Returns the inline admin object's parent object or None if not found. 

316 """ 

317 mgr = self.parent_model.objects # type: ignore 

318 resolved = resolve(request.path_info) 

319 if resolved.kwargs: 

320 for k in self.OBJECT_PK_KWARGS: 

321 if k in resolved.kwargs: 

322 return mgr.filter(pk=resolved.kwargs[k]).first() 

323 if resolved.args: 

324 return mgr.filter(pk=resolved.args[0]).first() 

325 return None