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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,329 @@
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
+ # `actions_submit_line` is a list of actions that are displayed in the submit line they
72
+ # are displayed as form buttons
73
+ actions_submit_line = self.get_actions_submit_line(request, object_id)
74
+
75
+ # `actions_detail` may contain custom structure with dropdowns so it is needed
76
+ # to use `_get_actions_navigation` to build the final structure for the template
77
+ actions_detail = self._get_actions_navigation(
78
+ self.actions_detail,
79
+ self.get_actions_detail(request, object_id),
80
+ object_id,
81
+ )
82
+
83
+ extra_context.update(
84
+ {
85
+ "actions_submit_line": actions_submit_line,
86
+ "actions_detail": actions_detail,
87
+ }
88
+ )
89
+
90
+ return super().changeform_view(request, object_id, form_url, extra_context)
91
+
92
+ def save_model(
93
+ self, request: HttpRequest, obj: Model, form: Form, change: Any
94
+ ) -> None:
95
+ """
96
+ When saving object, run all appropriate actions from `actions_submit_line`
97
+ """
98
+ super().save_model(request, obj, form, change)
99
+
100
+ # After saving object, check if any button from `actions_submit_line` was pressed
101
+ # and call the corresponding method
102
+ for action in self.get_actions_submit_line(request, obj.pk):
103
+ if action.action_name not in request.POST:
104
+ continue
105
+
106
+ action.method(request, obj)
107
+
108
+ def get_unfold_action(self, action: str) -> UnfoldAction:
109
+ """
110
+ Converts action name into UnfoldAction object.
111
+ """
112
+ method = self._get_instance_method(action)
113
+
114
+ return UnfoldAction(
115
+ action_name=f"{self.model._meta.app_label}_{self.model._meta.model_name}_{action}",
116
+ method=method,
117
+ description=self._get_action_description(method, action),
118
+ path=getattr(method, "url_path", action),
119
+ attrs=method.attrs if hasattr(method, "attrs") else None,
120
+ icon=method.icon if hasattr(method, "icon") else None,
121
+ )
122
+
123
+ def get_actions_list(self, request: HttpRequest) -> list[UnfoldAction]:
124
+ """
125
+ Filters `actions_list` by permissions and returns list of UnfoldAction objects.
126
+ """
127
+ return self._filter_unfold_actions_by_permissions(
128
+ request, self._get_base_actions_list()
129
+ )
130
+
131
+ def get_actions_detail(
132
+ self, request: HttpRequest, object_id: int
133
+ ) -> list[UnfoldAction]:
134
+ """
135
+ Filters `actions_detail` by permissions and returns list of UnfoldAction objects.
136
+ """
137
+ return self._filter_unfold_actions_by_permissions(
138
+ request, self._get_base_actions_detail(), object_id
139
+ )
140
+
141
+ def get_actions_row(self, request: HttpRequest) -> list[UnfoldAction]:
142
+ """
143
+ Filters `actions_row` by permissions and returns list of UnfoldAction objects.
144
+ """
145
+ return self._filter_unfold_actions_by_permissions(
146
+ request, self._get_base_actions_row()
147
+ )
148
+
149
+ def get_actions_submit_line(
150
+ self, request: HttpRequest, object_id: int
151
+ ) -> list[UnfoldAction]:
152
+ """
153
+ Filters `actions_submit_line` by permissions and returns list of UnfoldAction objects.
154
+ """
155
+ return self._filter_unfold_actions_by_permissions(
156
+ request, self._get_base_actions_submit_line(), object_id
157
+ )
158
+
159
+ def _extract_action_names(self, actions: list[Union[str, dict]]) -> list[str]:
160
+ """
161
+ Gets the list of only actions names from the actions structure provided in ModelAdmin
162
+ """
163
+ results = []
164
+
165
+ for action in actions or []:
166
+ if isinstance(action, dict) and "items" in action:
167
+ results.extend(action["items"])
168
+ else:
169
+ results.append(action)
170
+
171
+ return results
172
+
173
+ def _get_base_actions_list(self) -> list[UnfoldAction]:
174
+ """
175
+ Returns list of UnfoldAction objects for `actions_list`.
176
+ """
177
+ return [
178
+ self.get_unfold_action(action)
179
+ for action in self._extract_action_names(self.actions_list)
180
+ ]
181
+
182
+ def _get_base_actions_detail(self) -> list[UnfoldAction]:
183
+ """
184
+ Returns list of UnfoldAction objects for `actions_detail`.
185
+ """
186
+ return [
187
+ self.get_unfold_action(action)
188
+ for action in self._extract_action_names(self.actions_detail) or []
189
+ ]
190
+
191
+ def _get_base_actions_row(self) -> list[UnfoldAction]:
192
+ """
193
+ Returns list of UnfoldAction objects for `actions_row`.
194
+ """
195
+ return [
196
+ self.get_unfold_action(action)
197
+ for action in self._extract_action_names(self.actions_row) or []
198
+ ]
199
+
200
+ def _get_base_actions_submit_line(self) -> list[UnfoldAction]:
201
+ """
202
+ Returns list of UnfoldAction objects for `actions_submit_line`.
203
+ """
204
+ return [
205
+ self.get_unfold_action(action)
206
+ for action in self._extract_action_names(self.actions_submit_line) or []
207
+ ]
208
+
209
+ def _get_instance_method(self, method_name: str) -> Callable:
210
+ """
211
+ Searches for method on self instance based on method_name and returns it if it exists.
212
+ If it does not exist or is not callable, it raises UnfoldException
213
+ """
214
+ try:
215
+ method = getattr(self, method_name)
216
+ except AttributeError as e:
217
+ raise UnfoldException(
218
+ f"Method {method_name} specified does not exist on current object"
219
+ ) from e
220
+
221
+ if not callable(method):
222
+ raise UnfoldException(f"{method_name} is not callable")
223
+
224
+ return method
225
+
226
+ def _get_actions_navigation(
227
+ self,
228
+ provided_actions: list[Union[str, dict]],
229
+ allowed_actions: list[UnfoldAction],
230
+ object_id: Optional[str] = None,
231
+ ) -> list[Union[str, dict]]:
232
+ """
233
+ Builds navigation structure for the actions which is going to be provided to the template.
234
+ """
235
+ navigation = []
236
+
237
+ def get_action_by_name(name: str) -> UnfoldAction:
238
+ """
239
+ Searches for an action in allowed_actions by its name.
240
+ """
241
+ for action in allowed_actions:
242
+ full_action_name = (
243
+ f"{self.model._meta.app_label}_{self.model._meta.model_name}_{name}"
244
+ )
245
+
246
+ if action.action_name == full_action_name:
247
+ return action
248
+
249
+ def get_action_path(action: UnfoldAction) -> str:
250
+ """
251
+ Returns the URL path for an action.
252
+ """
253
+ path_name = f"{self.admin_site.name}:{action.action_name}"
254
+
255
+ if object_id:
256
+ return reverse(path_name, args=(object_id,))
257
+
258
+ return reverse(path_name)
259
+
260
+ def get_action_attrs(action: UnfoldAction) -> dict:
261
+ """
262
+ Returns the attributes for an action which will be used in the template.
263
+ """
264
+ return {
265
+ "title": action.description,
266
+ "icon": action.icon,
267
+ "attrs": action.method.attrs,
268
+ "path": get_action_path(action),
269
+ }
270
+
271
+ def build_dropdown(nav_item: dict) -> Optional[dict]:
272
+ """
273
+ Builds a dropdown structure for the action.
274
+ """
275
+ dropdown = {
276
+ "title": nav_item["title"],
277
+ "icon": nav_item.get("icon"),
278
+ "items": [],
279
+ }
280
+
281
+ for child in nav_item["items"]:
282
+ if action := get_action_by_name(child):
283
+ dropdown["items"].append(get_action_attrs(action))
284
+
285
+ if len(dropdown["items"]) > 0:
286
+ return dropdown
287
+
288
+ for nav_item in provided_actions:
289
+ if isinstance(nav_item, str):
290
+ if action := get_action_by_name(nav_item):
291
+ navigation.append(get_action_attrs(action))
292
+ elif isinstance(nav_item, dict):
293
+ if dropdown := build_dropdown(nav_item):
294
+ navigation.append(dropdown)
295
+
296
+ return navigation
297
+
298
+ def _filter_unfold_actions_by_permissions(
299
+ self,
300
+ request: HttpRequest,
301
+ actions: list[UnfoldAction],
302
+ object_id: Optional[Union[int, str]] = None,
303
+ ) -> list[UnfoldAction]:
304
+ """
305
+ Filters out actions that the user doesn't have access to.
306
+ """
307
+ filtered_actions = []
308
+
309
+ for action in actions:
310
+ if not hasattr(action.method, "allowed_permissions"):
311
+ filtered_actions.append(action)
312
+ continue
313
+
314
+ permission_checks = (
315
+ getattr(self, f"has_{permission}_permission")
316
+ for permission in action.method.allowed_permissions
317
+ )
318
+
319
+ if object_id:
320
+ if all(
321
+ has_permission(request, object_id)
322
+ for has_permission in permission_checks
323
+ ):
324
+ filtered_actions.append(action)
325
+ else:
326
+ if all(has_permission(request) for has_permission in permission_checks):
327
+ filtered_actions.append(action)
328
+
329
+ 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),