Coverage for jutil/admin.py: 95%

# Coverage for jutil/admin.py : 95%

## 83 statements

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 from collections import OrderedDict

2 from typing import Optional, Sequence

3 from django.conf import settings

4 from django.contrib import admin

5 from django.contrib.auth.models import User

6 from django.http import HttpRequest

7 from django.urls import reverse

8 from django.utils.html import format_html

9 from django.utils.safestring import mark_safe

10 from jutil.model import get_model_field_label_and_value

11 from django.utils.translation import gettext_lazy as _

12 from django.contrib.admin.models import CHANGE

13 from django.template.response import TemplateResponse

14 from django.contrib.admin.options import get_content_type_for_model

15 from django.contrib.admin.utils import unquote

16 from django.core.exceptions import PermissionDenied

17 from django.utils.text import capfirst

18 from django.utils.encoding import force_text

19 from django.contrib.admin.models import LogEntry

20

21

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

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

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

96 class 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

167 class 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