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

1from collections import OrderedDict 

2from typing import Optional, Sequence, List 

3from django.conf import settings 

4from django.contrib import admin 

5from django.contrib.auth.models import User 

6from django.http import HttpRequest 

7from django.urls import reverse, resolve 

8from django.utils.html import format_html 

9from django.utils.safestring import mark_safe 

10from django.utils.translation import gettext_lazy as _ 

11from django.contrib.admin.models import CHANGE 

12from django.template.response import TemplateResponse 

13from django.contrib.admin.options import get_content_type_for_model 

14from django.contrib.admin.utils import unquote 

15from django.core.exceptions import PermissionDenied 

16from django.utils.text import capfirst 

17from django.utils.encoding import force_text 

18from django.contrib.admin.models import LogEntry 

19 

20 

21def admin_log(instances: Sequence[object], msg: str, who: Optional[User] = None, **kw): 

22 """ 

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

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

25 :param msg: Message to log 

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

27 :param kw: Optional key-value attributes to append to message 

28 :return: None 

29 """ 

30 # use system user if 'who' is missing 

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

32 username = settings.DJANGO_SYSTEM_USER if hasattr(settings, "DJANGO_SYSTEM_USER") else "system" 

33 who = User.objects.get_or_create(username=username)[0] 

34 

35 # allow passing individual instance 

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

37 instances = [instances] # type: ignore 

38 

39 # append extra keyword attributes if any 

40 att_str = "" 

41 for k, v in kw.items(): 

42 if hasattr(v, "pk"): # log only primary key for model instances, not whole str representation 

43 v = v.pk 

44 att_str += "{}={}".format(k, v) if not att_str else ", {}={}".format(k, v) 

45 if att_str: 

46 att_str = " [{}]".format(att_str) 

47 msg = str(msg) + att_str 

48 

49 for instance in instances: 

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

51 LogEntry.objects.log_action( 

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

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

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

55 object_repr=force_text(instance), 

56 action_flag=CHANGE, 

57 change_message=msg, 

58 ) 

59 

60 

61def admin_log_changed_fields(obj: object, field_names: Sequence[str], who: Optional[User] = None, **kwargs): 

62 """ 

63 Logs changed fields of a model instance to admin log. 

64 :param obj: Model instance 

65 :param field_names: Field names 

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

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

68 :return: 

69 """ 

70 from jutil.model import get_model_field_label_and_value # noqa 

71 

72 fv: List[str] = [] 

73 for k in field_names: 

74 label, value = get_model_field_label_and_value(obj, k) 

75 fv.append('{}: "{}"'.format(label, value)) 

76 msg = ", ".join(fv) 

77 if "ip" in kwargs: 77 ↛ 78line 77 didn't jump to line 78, because the condition on line 77 was never true

78 msg += " (IP {ip})".format(ip=kwargs.pop("ip")) 

79 admin_log([obj], msg, who, **kwargs) # type: ignore 

80 

81 

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

83 """ 

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

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

86 :param obj: Object 

87 :param route: Empty for default route 

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

89 :return: URL to admin object change view 

90 """ 

91 if obj is None: 

92 return "" 

93 if not route: 

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

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

96 return base_url + path 

97 

98 

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

100 """ 

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

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

103 :param obj: Object 

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

105 :param route: Empty for default route 

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

107 :return: HTML link marked safe 

108 """ 

109 if obj is None: 

110 return "" 

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

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

113 

114 

115class ModelAdminBase(admin.ModelAdmin): 

116 """ 

117 ModelAdmin with save-on-top default enabled and customized (length-limited) history view. 

118 """ 

119 

120 save_on_top = True 

121 max_history_length = 1000 

122 

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

124 """ 

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

126 :return: OrderedDict 

127 """ 

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

129 sorted_actions = OrderedDict() 

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

131 sorted_actions[k] = actions[k] 

132 return sorted_actions 

133 

134 def get_actions(self, request): 

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

136 

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

138 """ 

139 Changelist view which allow key-value arguments. 

140 :param request: HttpRequest 

141 :param extra_context: Extra context dict 

142 :param kwargs: Key-value dict 

143 :return: See changelist_view() 

144 """ 

145 return self.changelist_view(request, extra_context) 

146 

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

148 "The 'history' admin view for this model." 

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

150 

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

152 model = self.model 

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

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

155 return self._get_obj_does_not_exist_redirect(request, model._meta, object_id) 

156 

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

158 raise PermissionDenied 

159 

160 # Then get the history for this object. 

161 opts = model._meta 

162 app_label = opts.app_label 

163 action_list = ( 

164 LogEntry.objects.filter(object_id=unquote(object_id), content_type=get_content_type_for_model(model)) 

165 .select_related() 

166 .order_by("-action_time")[: self.max_history_length] 

167 ) 

168 

169 context = { 

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

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

172 "action_list": action_list, 

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

174 "object": obj, 

175 "opts": opts, 

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

177 **(extra_context or {}), 

178 } 

179 

180 request.current_app = self.admin_site.name 

181 

182 return TemplateResponse( 

183 request, 

184 self.object_history_template 

185 or [ 

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

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

188 "admin/object_history.html", 

189 ], 

190 context, 

191 ) 

192 

193 

194class InlineModelAdminParentAccessMixin: 

195 """ 

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

197 """ 

198 

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

200 

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

202 """ 

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

204 """ 

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

206 resolved = resolve(request.path_info) 

207 if resolved.kwargs: 

208 for k in self.OBJECT_PK_KWARGS: 

209 if k in resolved.kwargs: 

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

211 if resolved.args: 

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

213 return None 

214 

215 

216class AdminLogEntryMixin: 

217 """ 

218 Model mixin for logging Django admin changes of Models. 

219 Call fields_changed() on change events. 

220 """ 

221 

222 def fields_changed(self, field_names: Sequence[str], who: Optional[User] = None, **kwargs): 

223 admin_log_changed_fields(self, field_names, who, **kwargs)