Coverage for jutil/admin.py: 81%

# Coverage for jutil/admin.py : 81%

## 99 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, List

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

8 from django.utils.html import format_html

9 from django.utils.safestring import mark_safe

10 from django.utils.translation import gettext_lazy as _

11 from django.contrib.admin.models import CHANGE

12 from django.template.response import TemplateResponse

13 from django.contrib.admin.options import get_content_type_for_model

14 from django.contrib.admin.utils import unquote

15 from django.core.exceptions import PermissionDenied

16 from django.utils.text import capfirst

17 from django.utils.encoding import force_text

18 from django.contrib.admin.models import LogEntry

19

20

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

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

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

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

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

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

216 class 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)