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

Sign up to get free protection for your applications and to get access to all the features.
Files changed (37) hide show
  1. {django_unfold-0.47.0.dist-info → django_unfold-0.49.0.dist-info}/METADATA +1 -1
  2. {django_unfold-0.47.0.dist-info → django_unfold-0.49.0.dist-info}/RECORD +37 -31
  3. unfold/admin.py +17 -412
  4. unfold/dataclasses.py +8 -0
  5. unfold/decorators.py +14 -1
  6. unfold/fields.py +6 -5
  7. unfold/mixins/__init__.py +4 -0
  8. unfold/mixins/action_model_admin.py +329 -0
  9. unfold/mixins/base_model_admin.py +110 -0
  10. unfold/overrides.py +73 -0
  11. unfold/settings.py +2 -0
  12. unfold/sites.py +25 -5
  13. unfold/static/unfold/css/styles.css +1 -1
  14. unfold/static/unfold/fonts/inter/Inter-Bold.woff2 +0 -0
  15. unfold/static/unfold/fonts/inter/Inter-Medium.woff2 +0 -0
  16. unfold/static/unfold/fonts/inter/Inter-Regular.woff2 +0 -0
  17. unfold/static/unfold/fonts/inter/Inter-SemiBold.woff2 +0 -0
  18. unfold/templates/admin/app_index.html +1 -5
  19. unfold/templates/admin/base_site.html +1 -1
  20. unfold/templates/admin/index.html +1 -5
  21. unfold/templates/admin/login.html +1 -1
  22. unfold/templates/admin/search_form.html +4 -2
  23. unfold/templates/admin/submit_line.html +7 -1
  24. unfold/templates/unfold/helpers/account_links.html +1 -1
  25. unfold/templates/unfold/helpers/actions_row.html +14 -4
  26. unfold/templates/unfold/helpers/language_switch.html +1 -1
  27. unfold/templates/unfold/helpers/navigation_header.html +18 -5
  28. unfold/templates/unfold/helpers/site_branding.html +9 -0
  29. unfold/templates/unfold/helpers/site_dropdown.html +19 -0
  30. unfold/templates/unfold/helpers/site_icon.html +20 -10
  31. unfold/templates/unfold/helpers/tab_action.html +35 -2
  32. unfold/templates/unfold/helpers/theme_switch.html +1 -1
  33. unfold/templates/unfold/layouts/base.html +1 -5
  34. unfold/typing.py +2 -1
  35. unfold/widgets.py +2 -0
  36. {django_unfold-0.47.0.dist-info → django_unfold-0.49.0.dist-info}/LICENSE.md +0 -0
  37. {django_unfold-0.47.0.dist-info → django_unfold-0.49.0.dist-info}/WHEEL +0 -0
unfold/decorators.py CHANGED
@@ -17,6 +17,7 @@ def action(
17
17
  description: Optional[str] = None,
18
18
  url_path: Optional[str] = None,
19
19
  attrs: Optional[dict[str, Any]] = None,
20
+ icon: Optional[str] = None,
20
21
  ) -> ActionFunction:
21
22
  def decorator(func: Callable) -> ActionFunction:
22
23
  def inner(
@@ -33,8 +34,14 @@ def action(
33
34
  # Permissions methods have following syntax: has_<some>_permission(self, request, obj=None):
34
35
  # But obj is not examined by default in django admin and it would also require additional
35
36
  # fetch from database, therefore it is not supported yet
36
- if not any(
37
+ has_object_argument = (
38
+ func.__name__ in model_admin.actions_detail
39
+ or func.__name__ in model_admin.actions_submit_line
40
+ )
41
+ if not all(
37
42
  has_permission(request, kwargs.get("object_id"))
43
+ if has_object_argument
44
+ else has_permission(request)
38
45
  for has_permission in permission_checks
39
46
  ):
40
47
  raise PermissionDenied
@@ -42,10 +49,16 @@ def action(
42
49
 
43
50
  if permissions is not None:
44
51
  inner.allowed_permissions = permissions
52
+
45
53
  if description is not None:
46
54
  inner.short_description = description
55
+
47
56
  if url_path is not None:
48
57
  inner.url_path = url_path
58
+
59
+ if icon is not None:
60
+ inner.icon = icon
61
+
49
62
  inner.attrs = attrs or {}
50
63
  return inner
51
64
 
unfold/fields.py CHANGED
@@ -17,17 +17,18 @@ from django.utils.module_loading import import_string
17
17
  from django.utils.safestring import SafeText, mark_safe
18
18
  from django.utils.text import capfirst
19
19
 
20
- from .settings import get_config
21
- from .utils import display_for_field, prettify_json
22
- from .widgets import CHECKBOX_LABEL_CLASSES, LABEL_CLASSES
20
+ from unfold.mixins import BaseModelAdminMixin
21
+ from unfold.settings import get_config
22
+ from unfold.utils import display_for_field, prettify_json
23
+ from unfold.widgets import CHECKBOX_LABEL_CLASSES, LABEL_CLASSES
23
24
 
24
25
 
25
26
  class UnfoldAdminReadonlyField(helpers.AdminReadonlyField):
26
27
  def label_tag(self) -> SafeText:
27
- from .admin import ModelAdmin, ModelAdminMixin
28
+ from .admin import ModelAdmin
28
29
 
29
30
  if not isinstance(self.model_admin, ModelAdmin) and not isinstance(
30
- self.model_admin, ModelAdminMixin
31
+ self.model_admin, BaseModelAdminMixin
31
32
  ):
32
33
  return super().label_tag()
33
34
 
@@ -0,0 +1,4 @@
1
+ from unfold.mixins.action_model_admin import ActionModelAdminMixin
2
+ from unfold.mixins.base_model_admin import BaseModelAdminMixin
3
+
4
+ __all__ = ["BaseModelAdminMixin", "ActionModelAdminMixin"]
@@ -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/settings.py CHANGED
@@ -5,6 +5,8 @@ from django.conf import settings
5
5
  CONFIG_DEFAULTS = {
6
6
  "SITE_TITLE": None,
7
7
  "SITE_HEADER": None,
8
+ "SITE_SUBHEADER": None,
9
+ "SITE_DROPDOWN": None,
8
10
  "SITE_URL": "/",
9
11
  "SITE_ICON": None,
10
12
  "SITE_SYMBOL": None,
unfold/sites.py CHANGED
@@ -1,3 +1,4 @@
1
+ import copy
1
2
  from http import HTTPStatus
2
3
  from typing import Any, Callable, Optional, Union
3
4
  from urllib.parse import parse_qs, urlparse
@@ -13,7 +14,7 @@ from django.utils.functional import lazy
13
14
  from django.utils.module_loading import import_string
14
15
  from django.views.decorators.cache import never_cache
15
16
 
16
- from unfold.dataclasses import Favicon
17
+ from unfold.dataclasses import DropdownItem, Favicon
17
18
 
18
19
  try:
19
20
  from django.contrib.auth.decorators import login_not_required
@@ -70,9 +71,11 @@ class UnfoldAdminSite(AdminSite):
70
71
  "text_input": INPUT_CLASSES,
71
72
  "checkbox": CHECKBOX_CLASSES,
72
73
  },
73
- "site_title": self._get_config("SITE_TITLE", request),
74
- "site_header": self._get_config("SITE_HEADER", request),
75
- "site_url": self._get_config("SITE_URL", 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,
77
+ "site_subheader": self._get_config("SITE_SUBHEADER", request),
78
+ "site_dropdown": self._get_site_dropdown_items("SITE_DROPDOWN", request),
76
79
  "site_logo": self._get_theme_images("SITE_LOGO", request),
77
80
  "site_icon": self._get_theme_images("SITE_ICON", request),
78
81
  "site_symbol": self._get_config("SITE_SYMBOL", request),
@@ -278,7 +281,7 @@ class UnfoldAdminSite(AdminSite):
278
281
  return results
279
282
 
280
283
  def get_tabs_list(self, request: HttpRequest) -> list[dict[str, Any]]:
281
- tabs = self._get_config("TABS", request)
284
+ tabs = copy.deepcopy(self._get_config("TABS", request))
282
285
 
283
286
  if not tabs:
284
287
  return []
@@ -298,6 +301,8 @@ class UnfoldAdminSite(AdminSite):
298
301
  item["active"] = self._get_is_active(
299
302
  request, item.get("link_callback") or item["link"], True
300
303
  )
304
+ else:
305
+ item["active"] = self._get_value(item["active"], request)
301
306
 
302
307
  allowed_items.append(item)
303
308
 
@@ -434,6 +439,21 @@ class UnfoldAdminSite(AdminSite):
434
439
  for item in favicons
435
440
  ]
436
441
 
442
+ def _get_site_dropdown_items(self, key: str, *args) -> list[dict[str, Any]]:
443
+ items = self._get_config(key, *args)
444
+
445
+ if not items:
446
+ return []
447
+
448
+ return [
449
+ DropdownItem(
450
+ title=item.get("title"),
451
+ link=self._get_value(item["link"], *args),
452
+ icon=item.get("icon"),
453
+ )
454
+ for item in items
455
+ ]
456
+
437
457
  def _get_value(
438
458
  self, value: Union[str, Callable, lazy, None], *args: Any
439
459
  ) -> Optional[str]: