django-unfold 0.48.0__py3-none-any.whl → 0.49.1__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,330 @@
1
+ from typing import Any, Callable, Optional, Union
2
+
3
+ from django.db.models import Model
4
+ from django.forms import Form
5
+ from django.http import HttpRequest
6
+ from django.template.response import TemplateResponse
7
+ from django.urls import reverse
8
+
9
+ from unfold.dataclasses import UnfoldAction
10
+ from unfold.exceptions import UnfoldException
11
+
12
+
13
+ class ActionModelAdminMixin:
14
+ """
15
+ Adds support for various ModelAdmin actions (list, detail, row, submit line)
16
+ """
17
+
18
+ actions_list = () # Displayed in changelist at the top
19
+ actions_row = () # Displayed in changelist for each row in the table
20
+ actions_detail = () # Displayed in changeform at the top
21
+ actions_submit_line = () # Displayed in changeform in the submit line (form buttons)
22
+
23
+ def changelist_view(
24
+ self, request: HttpRequest, extra_context: Optional[dict[str, str]] = None
25
+ ) -> TemplateResponse:
26
+ """
27
+ Changelist contains `actions_list` and `actions_row` custom actions. In case of `actions_row` they
28
+ are displayed in the each row of the table.
29
+ """
30
+ extra_context = extra_context or {}
31
+
32
+ actions_row = [
33
+ {
34
+ "title": action.description,
35
+ "icon": action.icon,
36
+ "attrs": action.method.attrs,
37
+ # This is just a path name as string and in template is used in {% url %} tag
38
+ # with custom instance pk value
39
+ "raw_path": f"{self.admin_site.name}:{action.action_name}",
40
+ }
41
+ for action in self.get_actions_row(request)
42
+ ]
43
+
44
+ # `actions_list` may contain custom structure with dropdowns so it is needed
45
+ # to use `_get_actions_navigation` to build the final structure for the template
46
+ actions_list = self._get_actions_navigation(
47
+ self.actions_list, self.get_actions_list(request)
48
+ )
49
+
50
+ extra_context.update(
51
+ {
52
+ "actions_list": actions_list,
53
+ "actions_row": actions_row,
54
+ }
55
+ )
56
+
57
+ return super().changelist_view(request, extra_context)
58
+
59
+ def changeform_view(
60
+ self,
61
+ request: HttpRequest,
62
+ object_id: Optional[str] = None,
63
+ form_url: str = "",
64
+ extra_context: Optional[dict[str, bool]] = None,
65
+ ) -> Any:
66
+ """
67
+ Changeform contains `actions_submit_line` and `actions_detail` custom actions.
68
+ """
69
+ extra_context = extra_context or {}
70
+
71
+ if object_id:
72
+ # `actions_submit_line` is a list of actions that are displayed in the submit line they
73
+ # are displayed as form buttons
74
+ actions_submit_line = self.get_actions_submit_line(request, object_id)
75
+
76
+ # `actions_detail` may contain custom structure with dropdowns so it is needed
77
+ # to use `_get_actions_navigation` to build the final structure for the template
78
+ actions_detail = self._get_actions_navigation(
79
+ self.actions_detail,
80
+ self.get_actions_detail(request, object_id),
81
+ object_id,
82
+ )
83
+
84
+ extra_context.update(
85
+ {
86
+ "actions_submit_line": actions_submit_line,
87
+ "actions_detail": actions_detail,
88
+ }
89
+ )
90
+
91
+ return super().changeform_view(request, object_id, form_url, extra_context)
92
+
93
+ def save_model(
94
+ self, request: HttpRequest, obj: Model, form: Form, change: Any
95
+ ) -> None:
96
+ """
97
+ When saving object, run all appropriate actions from `actions_submit_line`
98
+ """
99
+ super().save_model(request, obj, form, change)
100
+
101
+ # After saving object, check if any button from `actions_submit_line` was pressed
102
+ # and call the corresponding method
103
+ for action in self.get_actions_submit_line(request, obj.pk):
104
+ if action.action_name not in request.POST:
105
+ continue
106
+
107
+ action.method(request, obj)
108
+
109
+ def get_unfold_action(self, action: str) -> UnfoldAction:
110
+ """
111
+ Converts action name into UnfoldAction object.
112
+ """
113
+ method = self._get_instance_method(action)
114
+
115
+ return UnfoldAction(
116
+ action_name=f"{self.model._meta.app_label}_{self.model._meta.model_name}_{action}",
117
+ method=method,
118
+ description=self._get_action_description(method, action),
119
+ path=getattr(method, "url_path", action),
120
+ attrs=method.attrs if hasattr(method, "attrs") else None,
121
+ icon=method.icon if hasattr(method, "icon") else None,
122
+ )
123
+
124
+ def get_actions_list(self, request: HttpRequest) -> list[UnfoldAction]:
125
+ """
126
+ Filters `actions_list` by permissions and returns list of UnfoldAction objects.
127
+ """
128
+ return self._filter_unfold_actions_by_permissions(
129
+ request, self._get_base_actions_list()
130
+ )
131
+
132
+ def get_actions_detail(
133
+ self, request: HttpRequest, object_id: int
134
+ ) -> list[UnfoldAction]:
135
+ """
136
+ Filters `actions_detail` by permissions and returns list of UnfoldAction objects.
137
+ """
138
+ return self._filter_unfold_actions_by_permissions(
139
+ request, self._get_base_actions_detail(), object_id
140
+ )
141
+
142
+ def get_actions_row(self, request: HttpRequest) -> list[UnfoldAction]:
143
+ """
144
+ Filters `actions_row` by permissions and returns list of UnfoldAction objects.
145
+ """
146
+ return self._filter_unfold_actions_by_permissions(
147
+ request, self._get_base_actions_row()
148
+ )
149
+
150
+ def get_actions_submit_line(
151
+ self, request: HttpRequest, object_id: int
152
+ ) -> list[UnfoldAction]:
153
+ """
154
+ Filters `actions_submit_line` by permissions and returns list of UnfoldAction objects.
155
+ """
156
+ return self._filter_unfold_actions_by_permissions(
157
+ request, self._get_base_actions_submit_line(), object_id
158
+ )
159
+
160
+ def _extract_action_names(self, actions: list[Union[str, dict]]) -> list[str]:
161
+ """
162
+ Gets the list of only actions names from the actions structure provided in ModelAdmin
163
+ """
164
+ results = []
165
+
166
+ for action in actions or []:
167
+ if isinstance(action, dict) and "items" in action:
168
+ results.extend(action["items"])
169
+ else:
170
+ results.append(action)
171
+
172
+ return results
173
+
174
+ def _get_base_actions_list(self) -> list[UnfoldAction]:
175
+ """
176
+ Returns list of UnfoldAction objects for `actions_list`.
177
+ """
178
+ return [
179
+ self.get_unfold_action(action)
180
+ for action in self._extract_action_names(self.actions_list)
181
+ ]
182
+
183
+ def _get_base_actions_detail(self) -> list[UnfoldAction]:
184
+ """
185
+ Returns list of UnfoldAction objects for `actions_detail`.
186
+ """
187
+ return [
188
+ self.get_unfold_action(action)
189
+ for action in self._extract_action_names(self.actions_detail) or []
190
+ ]
191
+
192
+ def _get_base_actions_row(self) -> list[UnfoldAction]:
193
+ """
194
+ Returns list of UnfoldAction objects for `actions_row`.
195
+ """
196
+ return [
197
+ self.get_unfold_action(action)
198
+ for action in self._extract_action_names(self.actions_row) or []
199
+ ]
200
+
201
+ def _get_base_actions_submit_line(self) -> list[UnfoldAction]:
202
+ """
203
+ Returns list of UnfoldAction objects for `actions_submit_line`.
204
+ """
205
+ return [
206
+ self.get_unfold_action(action)
207
+ for action in self._extract_action_names(self.actions_submit_line) or []
208
+ ]
209
+
210
+ def _get_instance_method(self, method_name: str) -> Callable:
211
+ """
212
+ Searches for method on self instance based on method_name and returns it if it exists.
213
+ If it does not exist or is not callable, it raises UnfoldException
214
+ """
215
+ try:
216
+ method = getattr(self, method_name)
217
+ except AttributeError as e:
218
+ raise UnfoldException(
219
+ f"Method {method_name} specified does not exist on current object"
220
+ ) from e
221
+
222
+ if not callable(method):
223
+ raise UnfoldException(f"{method_name} is not callable")
224
+
225
+ return method
226
+
227
+ def _get_actions_navigation(
228
+ self,
229
+ provided_actions: list[Union[str, dict]],
230
+ allowed_actions: list[UnfoldAction],
231
+ object_id: Optional[str] = None,
232
+ ) -> list[Union[str, dict]]:
233
+ """
234
+ Builds navigation structure for the actions which is going to be provided to the template.
235
+ """
236
+ navigation = []
237
+
238
+ def get_action_by_name(name: str) -> UnfoldAction:
239
+ """
240
+ Searches for an action in allowed_actions by its name.
241
+ """
242
+ for action in allowed_actions:
243
+ full_action_name = (
244
+ f"{self.model._meta.app_label}_{self.model._meta.model_name}_{name}"
245
+ )
246
+
247
+ if action.action_name == full_action_name:
248
+ return action
249
+
250
+ def get_action_path(action: UnfoldAction) -> str:
251
+ """
252
+ Returns the URL path for an action.
253
+ """
254
+ path_name = f"{self.admin_site.name}:{action.action_name}"
255
+
256
+ if object_id:
257
+ return reverse(path_name, args=(object_id,))
258
+
259
+ return reverse(path_name)
260
+
261
+ def get_action_attrs(action: UnfoldAction) -> dict:
262
+ """
263
+ Returns the attributes for an action which will be used in the template.
264
+ """
265
+ return {
266
+ "title": action.description,
267
+ "icon": action.icon,
268
+ "attrs": action.method.attrs,
269
+ "path": get_action_path(action),
270
+ }
271
+
272
+ def build_dropdown(nav_item: dict) -> Optional[dict]:
273
+ """
274
+ Builds a dropdown structure for the action.
275
+ """
276
+ dropdown = {
277
+ "title": nav_item["title"],
278
+ "icon": nav_item.get("icon"),
279
+ "items": [],
280
+ }
281
+
282
+ for child in nav_item["items"]:
283
+ if action := get_action_by_name(child):
284
+ dropdown["items"].append(get_action_attrs(action))
285
+
286
+ if len(dropdown["items"]) > 0:
287
+ return dropdown
288
+
289
+ for nav_item in provided_actions:
290
+ if isinstance(nav_item, str):
291
+ if action := get_action_by_name(nav_item):
292
+ navigation.append(get_action_attrs(action))
293
+ elif isinstance(nav_item, dict):
294
+ if dropdown := build_dropdown(nav_item):
295
+ navigation.append(dropdown)
296
+
297
+ return navigation
298
+
299
+ def _filter_unfold_actions_by_permissions(
300
+ self,
301
+ request: HttpRequest,
302
+ actions: list[UnfoldAction],
303
+ object_id: Optional[Union[int, str]] = None,
304
+ ) -> list[UnfoldAction]:
305
+ """
306
+ Filters out actions that the user doesn't have access to.
307
+ """
308
+ filtered_actions = []
309
+
310
+ for action in actions:
311
+ if not hasattr(action.method, "allowed_permissions"):
312
+ filtered_actions.append(action)
313
+ continue
314
+
315
+ permission_checks = (
316
+ getattr(self, f"has_{permission}_permission")
317
+ for permission in action.method.allowed_permissions
318
+ )
319
+
320
+ if object_id:
321
+ if all(
322
+ has_permission(request, object_id)
323
+ for has_permission in permission_checks
324
+ ):
325
+ filtered_actions.append(action)
326
+ else:
327
+ if all(has_permission(request) for has_permission in permission_checks):
328
+ filtered_actions.append(action)
329
+
330
+ return filtered_actions
@@ -0,0 +1,110 @@
1
+ import copy
2
+ from typing import Optional
3
+
4
+ from django.contrib.admin.sites import AdminSite
5
+ from django.contrib.admin.widgets import RelatedFieldWidgetWrapper
6
+ from django.db import models
7
+ from django.db.models.fields import Field
8
+ from django.db.models.fields.related import ForeignKey, ManyToManyField
9
+ from django.forms.fields import TypedChoiceField
10
+ from django.forms.models import ModelChoiceField, ModelMultipleChoiceField
11
+ from django.forms.widgets import SelectMultiple
12
+ from django.http import HttpRequest
13
+ from django.utils.translation import gettext_lazy as _
14
+
15
+ from unfold import widgets
16
+ from unfold.overrides import FORMFIELD_OVERRIDES
17
+
18
+
19
+ class BaseModelAdminMixin:
20
+ def __init__(self, model: models.Model, admin_site: AdminSite) -> None:
21
+ overrides = copy.deepcopy(FORMFIELD_OVERRIDES)
22
+
23
+ for k, v in self.formfield_overrides.items():
24
+ overrides.setdefault(k, {}).update(v)
25
+
26
+ self.formfield_overrides = overrides
27
+
28
+ super().__init__(model, admin_site)
29
+
30
+ def formfield_for_choice_field(
31
+ self, db_field: Field, request: HttpRequest, **kwargs
32
+ ) -> TypedChoiceField:
33
+ if "widget" not in kwargs:
34
+ if db_field.name in self.radio_fields:
35
+ kwargs["widget"] = widgets.UnfoldAdminRadioSelectWidget(
36
+ radio_style=self.radio_fields[db_field.name]
37
+ )
38
+ else:
39
+ kwargs["widget"] = widgets.UnfoldAdminSelectWidget()
40
+
41
+ if "choices" not in kwargs:
42
+ kwargs["choices"] = db_field.get_choices(
43
+ include_blank=db_field.blank, blank_choice=[("", _("Select value"))]
44
+ )
45
+
46
+ return super().formfield_for_choice_field(db_field, request, **kwargs)
47
+
48
+ def formfield_for_foreignkey(
49
+ self, db_field: ForeignKey, request: HttpRequest, **kwargs
50
+ ) -> Optional[ModelChoiceField]:
51
+ db = kwargs.get("using")
52
+
53
+ # Overrides widgets for all related fields
54
+ if "widget" not in kwargs:
55
+ if db_field.name in self.raw_id_fields:
56
+ kwargs["widget"] = widgets.UnfoldForeignKeyRawIdWidget(
57
+ db_field.remote_field, self.admin_site, using=db
58
+ )
59
+ elif (
60
+ db_field.name not in self.get_autocomplete_fields(request)
61
+ and db_field.name not in self.radio_fields
62
+ ):
63
+ kwargs["widget"] = widgets.UnfoldAdminSelectWidget()
64
+ kwargs["empty_label"] = _("Select value")
65
+
66
+ return super().formfield_for_foreignkey(db_field, request, **kwargs)
67
+
68
+ def formfield_for_manytomany(
69
+ self,
70
+ db_field: ManyToManyField,
71
+ request: HttpRequest,
72
+ **kwargs,
73
+ ) -> ModelMultipleChoiceField:
74
+ if "widget" not in kwargs:
75
+ if db_field.name in self.raw_id_fields:
76
+ kwargs["widget"] = widgets.UnfoldAdminTextInputWidget()
77
+
78
+ form_field = super().formfield_for_manytomany(db_field, request, **kwargs)
79
+
80
+ # If M2M uses intermediary model, form_field will be None
81
+ if not form_field:
82
+ return None
83
+
84
+ if isinstance(form_field.widget, SelectMultiple):
85
+ form_field.widget.attrs["class"] = " ".join(widgets.SELECT_CLASSES)
86
+
87
+ return form_field
88
+
89
+ def formfield_for_nullboolean_field(
90
+ self, db_field: Field, request: HttpRequest, **kwargs
91
+ ) -> Optional[Field]:
92
+ if "widget" not in kwargs:
93
+ kwargs["widget"] = widgets.UnfoldAdminNullBooleanSelectWidget()
94
+
95
+ return db_field.formfield(**kwargs)
96
+
97
+ def formfield_for_dbfield(
98
+ self, db_field: Field, request: HttpRequest, **kwargs
99
+ ) -> Optional[Field]:
100
+ if isinstance(db_field, models.BooleanField) and db_field.null is True:
101
+ return self.formfield_for_nullboolean_field(db_field, request, **kwargs)
102
+
103
+ formfield = super().formfield_for_dbfield(db_field, request, **kwargs)
104
+
105
+ if formfield and isinstance(formfield.widget, RelatedFieldWidgetWrapper):
106
+ formfield.widget.template_name = (
107
+ "unfold/widgets/related_widget_wrapper.html"
108
+ )
109
+
110
+ return formfield
unfold/overrides.py ADDED
@@ -0,0 +1,73 @@
1
+ import copy
2
+
3
+ from django import forms
4
+ from django.db import models
5
+
6
+ from unfold import widgets
7
+
8
+ FORMFIELD_OVERRIDES = {
9
+ models.DateTimeField: {
10
+ "form_class": forms.SplitDateTimeField,
11
+ "widget": widgets.UnfoldAdminSplitDateTimeWidget,
12
+ },
13
+ models.DateField: {"widget": widgets.UnfoldAdminSingleDateWidget},
14
+ models.TimeField: {"widget": widgets.UnfoldAdminSingleTimeWidget},
15
+ models.EmailField: {"widget": widgets.UnfoldAdminEmailInputWidget},
16
+ models.CharField: {"widget": widgets.UnfoldAdminTextInputWidget},
17
+ models.URLField: {"widget": widgets.UnfoldAdminURLInputWidget},
18
+ models.GenericIPAddressField: {"widget": widgets.UnfoldAdminTextInputWidget},
19
+ models.UUIDField: {"widget": widgets.UnfoldAdminUUIDInputWidget},
20
+ models.TextField: {"widget": widgets.UnfoldAdminTextareaWidget},
21
+ models.NullBooleanField: {"widget": widgets.UnfoldAdminNullBooleanSelectWidget},
22
+ models.BooleanField: {"widget": widgets.UnfoldBooleanSwitchWidget},
23
+ models.IntegerField: {"widget": widgets.UnfoldAdminIntegerFieldWidget},
24
+ models.BigIntegerField: {"widget": widgets.UnfoldAdminBigIntegerFieldWidget},
25
+ models.DecimalField: {"widget": widgets.UnfoldAdminDecimalFieldWidget},
26
+ models.FloatField: {"widget": widgets.UnfoldAdminDecimalFieldWidget},
27
+ models.FileField: {"widget": widgets.UnfoldAdminFileFieldWidget},
28
+ models.ImageField: {"widget": widgets.UnfoldAdminImageFieldWidget},
29
+ models.JSONField: {"widget": widgets.UnfoldAdminTextareaWidget},
30
+ models.DurationField: {"widget": widgets.UnfoldAdminTextInputWidget},
31
+ }
32
+
33
+ ######################################################################
34
+ # Postgres
35
+ ######################################################################
36
+ try:
37
+ from django.contrib.postgres.fields import ArrayField, IntegerRangeField
38
+ from django.contrib.postgres.search import SearchVectorField
39
+
40
+ FORMFIELD_OVERRIDES.update(
41
+ {
42
+ ArrayField: {"widget": widgets.UnfoldAdminTextareaWidget},
43
+ SearchVectorField: {"widget": widgets.UnfoldAdminTextareaWidget},
44
+ IntegerRangeField: {"widget": widgets.UnfoldAdminIntegerRangeWidget},
45
+ }
46
+ )
47
+ except ImportError:
48
+ pass
49
+
50
+ ######################################################################
51
+ # Django Money
52
+ ######################################################################
53
+ try:
54
+ from djmoney.models.fields import MoneyField
55
+
56
+ FORMFIELD_OVERRIDES.update(
57
+ {
58
+ MoneyField: {"widget": widgets.UnfoldAdminMoneyWidget},
59
+ }
60
+ )
61
+ except ImportError:
62
+ pass
63
+
64
+ ######################################################################
65
+ # Inlines
66
+ ######################################################################
67
+ FORMFIELD_OVERRIDES_INLINE = copy.deepcopy(FORMFIELD_OVERRIDES)
68
+
69
+ FORMFIELD_OVERRIDES_INLINE.update(
70
+ {
71
+ models.ImageField: {"widget": widgets.UnfoldAdminImageSmallFieldWidget},
72
+ }
73
+ )
unfold/sites.py CHANGED
@@ -71,11 +71,11 @@ class UnfoldAdminSite(AdminSite):
71
71
  "text_input": INPUT_CLASSES,
72
72
  "checkbox": CHECKBOX_CLASSES,
73
73
  },
74
- "site_title": self._get_config("SITE_TITLE", request),
75
- "site_header": self._get_config("SITE_HEADER", request),
74
+ "site_title": self._get_config("SITE_TITLE", request) or self.site_title,
75
+ "site_header": self._get_config("SITE_HEADER", request) or self.site_header,
76
+ "site_url": self._get_config("SITE_URL", request) or self.site_url,
76
77
  "site_subheader": self._get_config("SITE_SUBHEADER", request),
77
78
  "site_dropdown": self._get_site_dropdown_items("SITE_DROPDOWN", request),
78
- "site_url": self._get_config("SITE_URL", request),
79
79
  "site_logo": self._get_theme_images("SITE_LOGO", request),
80
80
  "site_icon": self._get_theme_images("SITE_ICON", request),
81
81
  "site_symbol": self._get_config("SITE_SYMBOL", request),