django-unfold 0.58.0__py3-none-any.whl → 0.60.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.
Files changed (60) hide show
  1. {django_unfold-0.58.0.dist-info → django_unfold-0.60.0.dist-info}/METADATA +3 -2
  2. {django_unfold-0.58.0.dist-info → django_unfold-0.60.0.dist-info}/RECORD +60 -50
  3. unfold/admin.py +45 -13
  4. unfold/checks.py +24 -2
  5. unfold/contrib/filters/templates/unfold/filters/filters_numeric_slider.html +2 -2
  6. unfold/contrib/inlines/admin.py +11 -6
  7. unfold/contrib/inlines/forms.py +3 -1
  8. unfold/contrib/location_field/__init__.py +0 -0
  9. unfold/contrib/location_field/apps.py +6 -0
  10. unfold/contrib/location_field/templates/location_field/map_widget.html +5 -0
  11. unfold/decorators.py +27 -13
  12. unfold/fields.py +18 -20
  13. unfold/forms.py +99 -3
  14. unfold/layout.py +16 -0
  15. unfold/mixins/action_model_admin.py +22 -14
  16. unfold/mixins/base_model_admin.py +15 -1
  17. unfold/paginator.py +12 -1
  18. unfold/settings.py +8 -1
  19. unfold/sites.py +27 -29
  20. unfold/static/admin/js/admin/RelatedObjectLookups.js +9 -3
  21. unfold/static/unfold/css/styles.css +1 -1
  22. unfold/styles.css +12 -16
  23. unfold/templates/admin/app_list.html +4 -1
  24. unfold/templates/admin/base.html +1 -1
  25. unfold/templates/admin/change_form.html +2 -1
  26. unfold/templates/admin/edit_inline/stacked.html +12 -8
  27. unfold/templates/admin/edit_inline/tabular.html +2 -0
  28. unfold/templates/admin/includes/fieldset.html +9 -3
  29. unfold/templates/admin/login.html +45 -88
  30. unfold/templates/admin/pagination.html +1 -1
  31. unfold/templates/unfold/components/button.html +10 -1
  32. unfold/templates/unfold/helpers/account_links.html +14 -6
  33. unfold/templates/unfold/helpers/app_list.html +5 -2
  34. unfold/templates/unfold/helpers/app_list_default.html +2 -1
  35. unfold/templates/unfold/helpers/change_list_actions.html +2 -2
  36. unfold/templates/unfold/helpers/change_list_filter_actions.html +1 -1
  37. unfold/templates/unfold/helpers/edit_inline/tabular_heading.html +1 -1
  38. unfold/templates/unfold/helpers/empty_results.html +2 -2
  39. unfold/templates/unfold/helpers/field.html +5 -3
  40. unfold/templates/unfold/helpers/header.html +1 -1
  41. unfold/templates/unfold/helpers/language_form.html +10 -0
  42. unfold/templates/unfold/helpers/language_switch.html +17 -19
  43. unfold/templates/unfold/helpers/navigation.html +1 -1
  44. unfold/templates/unfold/helpers/pagination_infinite.html +3 -3
  45. unfold/templates/unfold/helpers/pagination_inline.html +28 -0
  46. unfold/templates/unfold/helpers/theme_switch.html +29 -27
  47. unfold/templates/unfold/helpers/unauthenticated_header.html +15 -0
  48. unfold/templates/unfold/helpers/unauthenticated_title.html +11 -0
  49. unfold/templates/unfold/helpers/userlinks.html +2 -6
  50. unfold/templates/unfold/helpers/welcomemsg.html +9 -7
  51. unfold/templates/unfold/layouts/unauthenticated.html +37 -0
  52. unfold/templates/unfold/widgets/select.html +1 -1
  53. unfold/templates/unfold/widgets/text.html +28 -0
  54. unfold/templates/unfold_crispy/layout/fieldset.html +3 -1
  55. unfold/templates/unfold_crispy/layout/fieldset_subheader.html +3 -0
  56. unfold/templatetags/unfold.py +37 -2
  57. unfold/utils.py +2 -2
  58. unfold/widgets.py +49 -3
  59. {django_unfold-0.58.0.dist-info → django_unfold-0.60.0.dist-info}/LICENSE.md +0 -0
  60. {django_unfold-0.58.0.dist-info → django_unfold-0.60.0.dist-info}/WHEEL +0 -0
unfold/fields.py CHANGED
@@ -17,33 +17,24 @@ 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 unfold.mixins import BaseModelAdminMixin
21
20
  from unfold.settings import get_config
22
21
  from unfold.utils import display_for_field, prettify_json
23
- from unfold.widgets import CHECKBOX_LABEL_CLASSES, LABEL_CLASSES
22
+ from unfold.widgets import (
23
+ CHECKBOX_LABEL_CLASSES,
24
+ INPUT_CLASSES,
25
+ LABEL_CLASSES,
26
+ )
24
27
 
25
28
 
26
29
  class UnfoldAdminReadonlyField(helpers.AdminReadonlyField):
27
30
  def label_tag(self) -> SafeText:
28
- from .admin import ModelAdmin
29
-
30
- if not isinstance(self.model_admin, ModelAdmin) and not isinstance(
31
- self.model_admin, BaseModelAdminMixin
32
- ):
33
- return super().label_tag()
34
-
35
31
  attrs = {
36
32
  "class": " ".join(LABEL_CLASSES + ["mb-2"]),
37
33
  }
38
34
 
39
35
  label = self.field["label"]
40
36
 
41
- return format_html(
42
- "<label{}>{}{}</label>",
43
- flatatt(attrs),
44
- capfirst(label),
45
- self.form.label_suffix,
46
- )
37
+ return format_html("<label{}>{}</label>", flatatt(attrs), capfirst(label))
47
38
 
48
39
  def is_json(self) -> bool:
49
40
  field, obj, model_admin = (
@@ -140,7 +131,7 @@ class UnfoldAdminReadonlyField(helpers.AdminReadonlyField):
140
131
  ):
141
132
  result_repr = self.get_admin_url(f.remote_field, value)
142
133
  elif isinstance(f, models.JSONField):
143
- formatted_output = prettify_json(value)
134
+ formatted_output = prettify_json(value, f.encoder)
144
135
 
145
136
  if formatted_output:
146
137
  return formatted_output
@@ -174,12 +165,19 @@ class UnfoldAdminReadonlyField(helpers.AdminReadonlyField):
174
165
 
175
166
 
176
167
  class UnfoldAdminField(helpers.AdminField):
168
+ def __init__(self, *args, **kwargs):
169
+ super().__init__(*args, **kwargs)
170
+
171
+ try:
172
+ from location_field.widgets import LocationWidget
173
+
174
+ if isinstance(self.field.field.widget, LocationWidget):
175
+ self.field.field.widget.attrs["class"] = " ".join(INPUT_CLASSES)
176
+ except ImportError:
177
+ pass
178
+
177
179
  def label_tag(self) -> SafeText:
178
180
  classes = []
179
- if not self.field.field.widget.__class__.__name__.startswith(
180
- "Unfold"
181
- ) and not self.field.field.widget.template_name.startswith("unfold"):
182
- return super().label_tag()
183
181
 
184
182
  # TODO load config from current AdminSite (override Fieldline.__iter__ method)
185
183
  for lang, flag in get_config()["EXTENSIONS"]["modeltranslation"][
unfold/forms.py CHANGED
@@ -1,3 +1,4 @@
1
+ from collections.abc import Generator
1
2
  from typing import Optional
2
3
 
3
4
  from django import forms
@@ -11,14 +12,23 @@ from django.contrib.auth.forms import (
11
12
  AdminPasswordChangeForm as BaseAdminPasswordChangeForm,
12
13
  )
13
14
  from django.contrib.auth.models import User
15
+ from django.contrib.contenttypes.forms import BaseGenericInlineFormSet
16
+ from django.core.paginator import Page, Paginator
17
+ from django.db.models import QuerySet
18
+ from django.forms import BaseInlineFormSet
19
+ from django.http import HttpRequest
20
+
21
+ from unfold.fields import UnfoldAdminField, UnfoldAdminReadonlyField
14
22
 
15
23
  try:
16
24
  from django.contrib.auth.forms import AdminUserCreationForm as BaseUserCreationForm
17
25
  except ImportError:
18
26
  from django.contrib.auth.forms import UserCreationForm as BaseUserCreationForm
27
+ from django.contrib.admin.helpers import AdminForm as BaseAdminForm
28
+ from django.contrib.admin.helpers import Fieldline as BaseFieldline
29
+ from django.contrib.admin.helpers import Fieldset as BaseFieldset
19
30
  from django.contrib.auth.forms import ReadOnlyPasswordHashWidget
20
31
  from django.contrib.auth.forms import UserChangeForm as BaseUserChangeForm
21
- from django.http import HttpRequest
22
32
  from django.utils.safestring import mark_safe
23
33
  from django.utils.translation import gettext_lazy as _
24
34
 
@@ -43,15 +53,17 @@ class ActionForm(forms.Form):
43
53
  "class": " ".join(
44
54
  [
45
55
  "appearance-none",
46
- "bg-white/20",
56
+ "!bg-white/20",
47
57
  "font-medium",
48
58
  "grow",
49
59
  "px-3",
50
60
  "py-2",
51
61
  "pr-8",
52
62
  "rounded-default",
53
- "text-white",
63
+ "!text-white",
54
64
  "truncate",
65
+ "!outline-primary-400",
66
+ "dark:!outline-primary-700",
55
67
  "*:text-base-700",
56
68
  "lg:w-72",
57
69
  ]
@@ -144,3 +156,87 @@ class AdminOwnPasswordChangeForm(BaseAdminOwnPasswordChangeForm):
144
156
  self.fields["old_password"].widget.attrs["class"] = " ".join(INPUT_CLASSES)
145
157
  self.fields["new_password1"].widget.attrs["class"] = " ".join(INPUT_CLASSES)
146
158
  self.fields["new_password2"].widget.attrs["class"] = " ".join(INPUT_CLASSES)
159
+
160
+
161
+ class AdminForm(BaseAdminForm):
162
+ def __iter__(self) -> Generator["Fieldset", None, None]:
163
+ for name, options in self.fieldsets:
164
+ yield Fieldset(
165
+ self.form,
166
+ name,
167
+ readonly_fields=self.readonly_fields,
168
+ model_admin=self.model_admin,
169
+ **options,
170
+ )
171
+
172
+
173
+ class Fieldset(BaseFieldset):
174
+ def __iter__(self) -> Generator["Fieldline", None, None]:
175
+ for field in self.fields:
176
+ yield Fieldline(
177
+ self.form, field, self.readonly_fields, model_admin=self.model_admin
178
+ )
179
+
180
+
181
+ class Fieldline(BaseFieldline):
182
+ def __iter__(
183
+ self,
184
+ ) -> Generator["UnfoldAdminReadonlyField | UnfoldAdminField", None, None]:
185
+ for i, field in enumerate(self.fields):
186
+ if field in self.readonly_fields:
187
+ yield UnfoldAdminReadonlyField(
188
+ self.form, field, is_first=(i == 0), model_admin=self.model_admin
189
+ )
190
+ else:
191
+ yield UnfoldAdminField(self.form, field, is_first=(i == 0))
192
+
193
+
194
+ class PaginationFormSetMixin:
195
+ queryset: Optional[QuerySet] = None
196
+ request: Optional[HttpRequest] = None
197
+ per_page: Optional[int] = None
198
+
199
+ def __init__(
200
+ self,
201
+ request: Optional[HttpRequest] = None,
202
+ per_page: Optional[int] = None,
203
+ *args,
204
+ **kwargs,
205
+ ):
206
+ self.request = request
207
+ self.per_page = per_page
208
+
209
+ super().__init__(*args, **kwargs)
210
+
211
+ if self.per_page:
212
+ self.paginator = Paginator(self.queryset, self.per_page)
213
+ self.page = self.get_page(self.paginator, self.get_page_num())
214
+ self._queryset = self.page.object_list
215
+
216
+ def get_pagination_key(self) -> str:
217
+ return f"{self.prefix}-page"
218
+
219
+ def get_page_num(self) -> int:
220
+ page = self.request.GET.get(self.get_pagination_key())
221
+ if page and page.isnumeric() and page > "0":
222
+ return int(page)
223
+
224
+ page = self.request.POST.get(self.get_pagination_key())
225
+ if page and page.isnumeric() and page > "0":
226
+ return int(page)
227
+
228
+ return 1
229
+
230
+ def get_page(self, paginator: Paginator, page: int) -> Page:
231
+ if page <= paginator.num_pages:
232
+ return paginator.page(page)
233
+
234
+ return paginator.page(1)
235
+
236
+
237
+ class PaginationInlineFormSet(PaginationFormSetMixin, BaseInlineFormSet):
238
+ pass
239
+
240
+
241
+ class PaginationGenericInlineFormSet(PaginationFormSetMixin, BaseGenericInlineFormSet):
242
+ pass
unfold/layout.py CHANGED
@@ -25,6 +25,22 @@ class Button(ButtonClassesMixin, BaseInput):
25
25
  input_type = "button"
26
26
 
27
27
 
28
+ class FieldsetSubheader(LayoutObject):
29
+ template = "unfold_crispy/layout/fieldset_subheader.html"
30
+
31
+ def __init__(self, title=None, *args, **kwargs):
32
+ self.title = title
33
+ super().__init__(*args, **kwargs)
34
+
35
+ def render(self, form, context, template_pack=TEMPLATE_PACK, **kwargs):
36
+ return render_to_string(
37
+ self.template,
38
+ {
39
+ "title": self.title,
40
+ },
41
+ )
42
+
43
+
28
44
  class Hr(LayoutObject):
29
45
  template = "unfold_crispy/layout/hr.html"
30
46
 
@@ -316,19 +316,27 @@ class ActionModelAdminMixin:
316
316
  filtered_actions.append(action)
317
317
  continue
318
318
 
319
- permission_checks = (
320
- getattr(self, f"has_{permission}_permission")
321
- for permission in action.method.allowed_permissions
322
- )
323
-
324
- if object_id:
325
- if all(
326
- has_permission(request, object_id)
327
- for has_permission in permission_checks
328
- ):
329
- filtered_actions.append(action)
330
- else:
331
- if all(has_permission(request) for has_permission in permission_checks):
332
- filtered_actions.append(action)
319
+ permission_rules = []
320
+
321
+ for permission in action.method.allowed_permissions:
322
+ if "." in permission:
323
+ permission_rules.append(permission)
324
+ else:
325
+ permission_rules.append(
326
+ getattr(self, f"has_{permission}_permission")
327
+ )
328
+
329
+ permission_checks = []
330
+
331
+ for permission_rule in permission_rules:
332
+ if isinstance(permission_rule, str) and "." in permission_rule:
333
+ permission_checks.append(request.user.has_perm(permission_rule))
334
+ elif object_id:
335
+ permission_checks.append(permission_rule(request, object_id))
336
+ else:
337
+ permission_checks.append(permission_rule(request))
338
+
339
+ if all(permission_checks):
340
+ filtered_actions.append(action)
333
341
 
334
342
  return filtered_actions
@@ -1,6 +1,7 @@
1
1
  import copy
2
- from typing import Optional
2
+ from typing import Any, Optional
3
3
 
4
+ from django.contrib.admin import helpers
4
5
  from django.contrib.admin.sites import AdminSite
5
6
  from django.contrib.admin.widgets import RelatedFieldWidgetWrapper
6
7
  from django.db import models
@@ -27,6 +28,19 @@ class BaseModelAdminMixin:
27
28
 
28
29
  super().__init__(model, admin_site)
29
30
 
31
+ def changeform_view(
32
+ self,
33
+ request: HttpRequest,
34
+ object_id: Optional[str] = None,
35
+ form_url: str = "",
36
+ extra_context: Optional[dict[str, bool]] = None,
37
+ ) -> Any:
38
+ from unfold.forms import AdminForm, Fieldline
39
+
40
+ helpers.AdminForm = AdminForm
41
+ helpers.Fieldline = Fieldline
42
+ return super().changeform_view(request, object_id, form_url, extra_context)
43
+
30
44
  def formfield_for_choice_field(
31
45
  self, db_field: Field, request: HttpRequest, **kwargs
32
46
  ) -> TypedChoiceField:
unfold/paginator.py CHANGED
@@ -1,10 +1,21 @@
1
- from django.core.paginator import Paginator
1
+ from django.core.paginator import Page, Paginator
2
2
  from django.utils.functional import cached_property
3
3
 
4
4
 
5
+ class InfinitePage(Page):
6
+ def has_next(self):
7
+ if len(self.object_list) == 0:
8
+ return False
9
+
10
+ return self.number < self.paginator.num_pages
11
+
12
+
5
13
  class InfinitePaginator(Paginator):
6
14
  template_name = "unfold/helpers/pagination_infinite.html"
7
15
 
8
16
  @cached_property
9
17
  def count(self):
10
18
  return 9_999_999_999
19
+
20
+ def _get_page(self, *args, **kwargs):
21
+ return InfinitePage(*args, **kwargs)
unfold/settings.py CHANGED
@@ -57,10 +57,17 @@ CONFIG_DEFAULTS = {
57
57
  "ENVIRONMENT_TITLE_PREFIX": None,
58
58
  "STYLES": [],
59
59
  "SCRIPTS": [],
60
+ "ACCOUNT": {
61
+ "navigation": [],
62
+ },
63
+ "LANGUAGES": {
64
+ "action": None,
65
+ "navigation": [],
66
+ },
60
67
  "SIDEBAR": {
61
68
  "show_search": False,
62
69
  "show_all_applications": False,
63
- "navigation": {},
70
+ "navigation": [],
64
71
  },
65
72
  "TABS": [],
66
73
  "LOGIN": {
unfold/sites.py CHANGED
@@ -4,15 +4,12 @@ from typing import Any, Callable, Optional, Union
4
4
  from urllib.parse import parse_qs, urlparse
5
5
 
6
6
  from django.contrib.admin import AdminSite
7
- from django.contrib.auth import REDIRECT_FIELD_NAME
8
7
  from django.core.validators import EMPTY_VALUES
9
8
  from django.http import HttpRequest, HttpResponse
10
9
  from django.template.response import TemplateResponse
11
10
  from django.urls import URLPattern, path, reverse, reverse_lazy
12
- from django.utils.decorators import method_decorator
13
11
  from django.utils.functional import lazy
14
12
  from django.utils.module_loading import import_string
15
- from django.views.decorators.cache import never_cache
16
13
 
17
14
  from unfold.dataclasses import DropdownItem, Favicon
18
15
 
@@ -91,6 +88,9 @@ class UnfoldAdminSite(AdminSite):
91
88
  "site_icon": self._get_theme_images("SITE_ICON", request),
92
89
  "site_symbol": self._get_config("SITE_SYMBOL", request),
93
90
  "site_favicons": self._get_favicons("SITE_FAVICONS", request),
91
+ "login_image": self._get_value(
92
+ get_config(self.settings_name)["LOGIN"].get("image"), request
93
+ ),
94
94
  "show_history": self._get_config("SHOW_HISTORY", request),
95
95
  "show_view_on_site": self._get_config("SHOW_VIEW_ON_SITE", request),
96
96
  "show_languages": self._get_config("SHOW_LANGUAGES", request),
@@ -102,6 +102,13 @@ class UnfoldAdminSite(AdminSite):
102
102
  "environment_title_prefix": self._get_config(
103
103
  "ENVIRONMENT_TITLE_PREFIX", request
104
104
  ),
105
+ "languages_list": self._get_value(
106
+ self._get_config("LANGUAGES", request).get("navigation"), request
107
+ ),
108
+ "languages_action": self._get_value(
109
+ self._get_config("LANGUAGES", request).get("action"), request
110
+ ),
111
+ "account_links": self._get_account_links(request),
105
112
  "tab_list": self.get_tabs_list(request),
106
113
  "styles": self._get_list("STYLES", request),
107
114
  "scripts": self._get_list("SCRIPTS", request),
@@ -191,32 +198,6 @@ class UnfoldAdminSite(AdminSite):
191
198
  },
192
199
  )
193
200
 
194
- @method_decorator(never_cache)
195
- @login_not_required
196
- def login(
197
- self, request: HttpRequest, extra_context: Optional[dict[str, Any]] = None
198
- ) -> HttpResponse:
199
- extra_context = {} if extra_context is None else extra_context
200
- image = self._get_value(
201
- get_config(self.settings_name)["LOGIN"].get("image"), request
202
- )
203
-
204
- redirect_field_name = self._get_value(
205
- get_config(self.settings_name)["LOGIN"].get("redirect_after"), request
206
- )
207
-
208
- if image not in EMPTY_VALUES:
209
- extra_context.update(
210
- {
211
- "image": image,
212
- }
213
- )
214
-
215
- if redirect_field_name not in EMPTY_VALUES:
216
- extra_context.update({REDIRECT_FIELD_NAME: redirect_field_name})
217
-
218
- return super().login(request, extra_context)
219
-
220
201
  def password_change(
221
202
  self, request: HttpRequest, extra_context: Optional[dict[str, Any]] = None
222
203
  ) -> HttpResponse:
@@ -296,6 +277,23 @@ class UnfoldAdminSite(AdminSite):
296
277
 
297
278
  return allowed_items
298
279
 
280
+ def _get_account_links(self, request: HttpRequest) -> list[dict[str, Any]]:
281
+ links = []
282
+
283
+ navigation = self._get_value(
284
+ get_config(self.settings_name)["ACCOUNT"].get("navigation"), request
285
+ )
286
+
287
+ for item in navigation:
288
+ links.append(
289
+ {
290
+ "title": self._get_value(item["title"], request),
291
+ "link": self._get_value(item["link"], request),
292
+ }
293
+ )
294
+
295
+ return links
296
+
299
297
  def get_tabs_list(self, request: HttpRequest) -> list[dict[str, Any]]:
300
298
  tabs = copy.deepcopy(self._get_config("TABS", request))
301
299
 
@@ -74,9 +74,15 @@
74
74
 
75
75
  function updateRelatedObjectLinks(triggeringLink) {
76
76
  const $this = $(triggeringLink);
77
- const siblings = $this.nextAll(
78
- ".view-related, .change-related, .delete-related"
79
- );
77
+ // !CHANGED from original
78
+ // const siblings = $this.nextAll(
79
+ // ".view-related, .change-related, .delete-related"
80
+ // );
81
+
82
+ const siblings = $this
83
+ .closest(".related-widget-wrapper")
84
+ .find(".view-related, .change-related, .delete-related");
85
+
80
86
  if (!siblings.length) {
81
87
  return;
82
88
  }