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 

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 

8from django.utils.html import format_html 

9from django.utils.safestring import mark_safe 

10from jutil.model import get_model_field_label_and_value 

11from django.utils.translation import gettext_lazy as _ 

12from django.contrib.admin.models import CHANGE 

13from django.template.response import TemplateResponse 

14from django.contrib.admin.options import get_content_type_for_model 

15from django.contrib.admin.utils import unquote 

16from django.core.exceptions import PermissionDenied 

17from django.utils.text import capfirst 

18from django.utils.encoding import force_text 

19from django.contrib.admin.models import LogEntry 

20 

21 

22def admin_log(instances: Sequence[object], 

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

24 """ 

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

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

27 :param msg: Message to log 

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

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

30 :return: None 

31 """ 

32 # use system user if 'who' is missing 

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

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

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

36 

37 # allow passing individual instance 

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

39 instances = [instances] # type: ignore 

40 

41 # append extra keyword attributes if any 

42 att_str = '' 

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

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

45 v = v.pk 

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

47 if att_str: 

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

49 msg = str(msg) + att_str 

50 

51 for instance in instances: 

52 if instance: 

53 LogEntry.objects.log_action( 

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

55 content_type_id=get_content_type_for_model(instance).pk, 

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

57 object_repr=force_text(instance), 

58 action_flag=CHANGE, 

59 change_message=msg, 

60 ) 

61 

62 

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

64 """ 

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

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

67 :param obj: Object 

68 :param route: Empty for default route 

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

70 :return: URL to admin object change view 

71 """ 

72 if obj is None: 

73 return '' 

74 if not route: 

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

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

77 return base_url + path 

78 

79 

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

81 """ 

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

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

84 :param obj: Object 

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

86 :param route: Empty for default route 

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

88 :return: HTML link marked safe 

89 """ 

90 if obj is None: 

91 return '' 

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

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

94 

95 

96class ModelAdminBase(admin.ModelAdmin): 

97 """ 

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

99 """ 

100 save_on_top = True 

101 max_history_length = 1000 

102 

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

104 """ 

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

106 :return: OrderedDict 

107 """ 

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

109 sorted_actions = OrderedDict() 

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

111 sorted_actions[k] = actions[k] 

112 return sorted_actions 

113 

114 def get_actions(self, request): 

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

116 

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

118 """ 

119 Changelist view which allow key-value arguments. 

120 :param request: HttpRequest 

121 :param extra_context: Extra context dict 

122 :param kwargs: Key-value dict 

123 :return: See changelist_view() 

124 """ 

125 return self.changelist_view(request, extra_context) 

126 

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

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

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

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

131 model = self.model 

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

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

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

135 

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

137 raise PermissionDenied 

138 

139 # Then get the history for this object. 

140 opts = model._meta 

141 app_label = opts.app_label 

142 action_list = LogEntry.objects.filter( 

143 object_id=unquote(object_id), 

144 content_type=get_content_type_for_model(model) 

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

146 

147 context = { 

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

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

150 'action_list': action_list, 

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

152 'object': obj, 

153 'opts': opts, 

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

155 **(extra_context or {}), 

156 } 

157 

158 request.current_app = self.admin_site.name 

159 

160 return TemplateResponse(request, self.object_history_template or [ 

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

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

163 "admin/object_history.html" 

164 ], context) 

165 

166 

167class AdminLogEntryMixin: 

168 """ 

169 Mixin for logging Django admin changes of models. 

170 Call fields_changed() on change events. 

171 """ 

172 

173 def fields_changed(self, field_names: Sequence[str], who: Optional[User], **kw): 

174 fv_str = '' 

175 for k in field_names: 

176 label, value = get_model_field_label_and_value(self, k) 

177 fv_str += '{}={}'.format(label, value) if not fv_str else ', {}={}'.format(label, value) 

178 

179 msg = "{class_name} id={id}: {fv_str}".format( 

180 class_name=self._meta.verbose_name.title(), id=self.id, fv_str=fv_str) # type: ignore 

181 admin_log([who, self], msg, who, **kw) # type: ignore