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

22 msg: str, who: Optional[User] = None, **kw): 

23 """ 

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

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

26 :param msg: Message to log 

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

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

29 :return: None 

30 """ 

31 # use system user if 'who' is missing 

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

33 username = settings.DJANGO_SYSTEM_USER if hasattr(settings, 'DJANGO_SYSTEM_USER') else 'system' 

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

35 

36 # allow passing individual instance 

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

38 instances = [instances] # type: ignore 

39 

40 # append extra keyword attributes if any 

41 att_str = '' 

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

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

44 v = v.pk 

45 att_str += '{}={}'.format(k, v) if not att_str else ', {}={}'.format(k, v) 

46 if att_str: 

47 att_str = ' [{}]'.format(att_str) 

48 msg = str(msg) + att_str 

49 

50 for instance in instances: 

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

52 LogEntry.objects.log_action( 

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

54 content_type_id=get_content_type_for_model(instance).pk, 

55 object_id=instance.pk, # pytype: disable=attribute-error 

56 object_repr=force_text(instance), 

57 action_flag=CHANGE, 

58 change_message=msg, 

59 ) 

60 

61 

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

63 """ 

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

65 :param obj: Model instance 

66 :param field_names: Field names 

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

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

69 :return: 

70 """ 

71 from jutil.model import get_model_field_label_and_value # noqa 

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 save_on_top = True 

120 max_history_length = 1000 

121 

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

123 """ 

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

125 :return: OrderedDict 

126 """ 

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

128 sorted_actions = OrderedDict() 

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

130 sorted_actions[k] = actions[k] 

131 return sorted_actions 

132 

133 def get_actions(self, request): 

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

135 

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

137 """ 

138 Changelist view which allow key-value arguments. 

139 :param request: HttpRequest 

140 :param extra_context: Extra context dict 

141 :param kwargs: Key-value dict 

142 :return: See changelist_view() 

143 """ 

144 return self.changelist_view(request, extra_context) 

145 

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

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

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

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

150 model = self.model 

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

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

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

154 

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

156 raise PermissionDenied 

157 

158 # Then get the history for this object. 

159 opts = model._meta 

160 app_label = opts.app_label 

161 action_list = LogEntry.objects.filter( 

162 object_id=unquote(object_id), 

163 content_type=get_content_type_for_model(model) 

164 ).select_related().order_by('-action_time')[:self.max_history_length] 

165 

166 context = { 

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

168 'title': _('Change history: %s') % obj, 

169 'action_list': action_list, 

170 'module_name': str(capfirst(opts.verbose_name_plural)), 

171 'object': obj, 

172 'opts': opts, 

173 'preserved_filters': self.get_preserved_filters(request), 

174 **(extra_context or {}), 

175 } 

176 

177 request.current_app = self.admin_site.name 

178 

179 return TemplateResponse(request, self.object_history_template or [ 

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

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

182 "admin/object_history.html" 

183 ], context) 

184 

185 

186class InlineModelAdminParentAccessMixin: 

187 """ 

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

189 """ 

190 OBJECT_PK_KWARGS = ['object_id', 'pk', 'id'] 

191 

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

193 """ 

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

195 """ 

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

197 resolved = resolve(request.path_info) 

198 if resolved.kwargs: 

199 for k in self.OBJECT_PK_KWARGS: 

200 if k in resolved.kwargs: 

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

202 if resolved.args: 

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

204 return None 

205 

206 

207class AdminLogEntryMixin: 

208 """ 

209 Model mixin for logging Django admin changes of Models. 

210 Call fields_changed() on change events. 

211 """ 

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

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