django-unfold 0.67.0__py3-none-any.whl → 0.69.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 (54) hide show
  1. {django_unfold-0.67.0.dist-info → django_unfold-0.69.0.dist-info}/METADATA +33 -41
  2. {django_unfold-0.67.0.dist-info → django_unfold-0.69.0.dist-info}/RECORD +54 -47
  3. unfold/admin.py +46 -15
  4. unfold/components.py +2 -2
  5. unfold/contrib/filters/admin/choice_filters.py +13 -1
  6. unfold/contrib/filters/admin/mixins.py +3 -3
  7. unfold/contrib/filters/admin/numeric_filters.py +6 -6
  8. unfold/contrib/forms/widgets.py +5 -5
  9. unfold/contrib/inlines/admin.py +3 -3
  10. unfold/contrib/inlines/forms.py +5 -4
  11. unfold/dataclasses.py +13 -13
  12. unfold/datasets.py +90 -0
  13. unfold/decorators.py +19 -19
  14. unfold/fields.py +3 -5
  15. unfold/forms.py +41 -22
  16. unfold/mixins/__init__.py +2 -1
  17. unfold/mixins/action_model_admin.py +11 -10
  18. unfold/mixins/base_model_admin.py +6 -6
  19. unfold/mixins/dataset_model_admin.py +62 -0
  20. unfold/settings.py +1 -0
  21. unfold/sites.py +19 -18
  22. unfold/static/admin/js/actions.js +246 -0
  23. unfold/static/unfold/css/styles.css +2 -2
  24. unfold/static/unfold/fonts/material-symbols/Material-Symbols-Outlined.woff2 +0 -0
  25. unfold/static/unfold/js/app.js +3 -1
  26. unfold/styles.css +21 -16
  27. unfold/templates/admin/actions.html +2 -2
  28. unfold/templates/admin/change_form.html +10 -2
  29. unfold/templates/admin/change_list.html +1 -1
  30. unfold/templates/admin/change_list_results.html +10 -62
  31. unfold/templates/admin/dataset_actions.html +50 -0
  32. unfold/templates/admin/edit_inline/stacked.html +2 -8
  33. unfold/templates/admin/edit_inline/tabular.html +1 -7
  34. unfold/templates/admin/includes/fieldset.html +1 -3
  35. unfold/templates/admin/search_form.html +6 -4
  36. unfold/templates/registration/password_change_done.html +3 -4
  37. unfold/templates/registration/password_change_form.html +10 -6
  38. unfold/templates/unfold/helpers/change_list_actions.html +1 -1
  39. unfold/templates/unfold/helpers/change_list_headers.html +65 -0
  40. unfold/templates/unfold/helpers/dataset.html +31 -0
  41. unfold/templates/unfold/helpers/edit_inline/tabular_field.html +1 -1
  42. unfold/templates/unfold/helpers/empty_results.html +6 -4
  43. unfold/templates/unfold/helpers/field_readonly_value_file.html +1 -1
  44. unfold/templates/unfold/helpers/fieldsets_tabs.html +9 -11
  45. unfold/templates/unfold/helpers/inline_heading.html +11 -0
  46. unfold/templates/unfold/helpers/tab_items.html +9 -1
  47. unfold/templatetags/unfold.py +64 -82
  48. unfold/templatetags/unfold_list.py +76 -8
  49. unfold/typing.py +5 -6
  50. unfold/utils.py +9 -9
  51. unfold/views.py +15 -1
  52. unfold/widgets.py +31 -31
  53. {django_unfold-0.67.0.dist-info → django_unfold-0.69.0.dist-info}/WHEEL +0 -0
  54. {django_unfold-0.67.0.dist-info → django_unfold-0.69.0.dist-info}/licenses/LICENSE.md +0 -0
@@ -0,0 +1,62 @@
1
+ from typing import Any
2
+
3
+ from django.contrib.admin import helpers
4
+ from django.contrib.admin.views import main
5
+ from django.contrib.admin.views.main import IGNORED_PARAMS
6
+ from django.http import HttpRequest
7
+ from django.template.response import TemplateResponse
8
+
9
+
10
+ class DatasetModelAdminMixin:
11
+ def changeform_view(
12
+ self,
13
+ request: HttpRequest,
14
+ object_id: str | None = None,
15
+ form_url: str = "",
16
+ extra_context: dict[str, Any] | None = None,
17
+ ) -> TemplateResponse:
18
+ self.request = request
19
+ extra_context = extra_context or {}
20
+ datasets = self.get_changeform_datasets(request)
21
+
22
+ # Monkeypatch IGNORED_PARAMS to add dataset page and search arguments into ignored params
23
+ ignored_params = []
24
+ for dataset in datasets:
25
+ ignored_params.append(f"{dataset.model._meta.model_name}-q")
26
+ ignored_params.append(f"{dataset.model._meta.model_name}-p")
27
+
28
+ main.IGNORED_PARAMS = (*IGNORED_PARAMS, *ignored_params)
29
+
30
+ rendered_datasets = []
31
+ for dataset in datasets:
32
+ rendered_datasets.append(
33
+ dataset(
34
+ request=request,
35
+ extra_context={
36
+ "object": object_id,
37
+ },
38
+ )
39
+ )
40
+
41
+ extra_context["datasets"] = rendered_datasets
42
+ selected = request.POST.getlist(helpers.ACTION_CHECKBOX_NAME)
43
+
44
+ if (
45
+ request.method == "POST"
46
+ and selected
47
+ and "dataset" in request.POST
48
+ and helpers.ACTION_CHECKBOX_NAME in request.POST
49
+ ):
50
+ dataset = None
51
+ for item in rendered_datasets:
52
+ if item.id == request.POST["dataset"]:
53
+ dataset = item
54
+
55
+ response = dataset.model_admin_instance.response_action(
56
+ request, queryset=dataset.cl.get_queryset(request)
57
+ )
58
+
59
+ if response:
60
+ return response
61
+
62
+ return super().changeform_view(request, object_id, form_url, extra_context)
unfold/settings.py CHANGED
@@ -79,6 +79,7 @@ CONFIG_DEFAULTS = {
79
79
  "LOGIN": {
80
80
  "image": None,
81
81
  "redirect_after": None,
82
+ "form": None,
82
83
  },
83
84
  "EXTENSIONS": {"modeltranslation": {"flags": {}}},
84
85
  }
unfold/sites.py CHANGED
@@ -1,7 +1,8 @@
1
1
  import copy
2
2
  import time
3
+ from collections.abc import Callable
3
4
  from http import HTTPStatus
4
- from typing import Any, Callable, Optional, Union
5
+ from typing import Any
5
6
  from urllib.parse import parse_qs, urlparse
6
7
 
7
8
  from django.contrib.admin import AdminSite
@@ -46,7 +47,11 @@ class UnfoldAdminSite(AdminSite):
46
47
 
47
48
  super().__init__(name)
48
49
 
49
- if self.login_form is None:
50
+ custom_login_form = get_config(self.settings_name)["LOGIN"]["form"]
51
+
52
+ if custom_login_form is not None:
53
+ self.login_form = import_string(custom_login_form)
54
+ elif self.login_form is None:
50
55
  self.login_form = AuthenticationForm
51
56
 
52
57
  def get_urls(self) -> list[URLPattern]:
@@ -141,7 +146,7 @@ class UnfoldAdminSite(AdminSite):
141
146
  return context
142
147
 
143
148
  def index(
144
- self, request: HttpRequest, extra_context: Optional[dict[str, Any]] = None
149
+ self, request: HttpRequest, extra_context: dict[str, Any] | None = None
145
150
  ) -> TemplateResponse:
146
151
  app_list = self.get_app_list(request)
147
152
 
@@ -166,7 +171,7 @@ class UnfoldAdminSite(AdminSite):
166
171
  )
167
172
 
168
173
  def toggle_sidebar(
169
- self, request: HttpRequest, extra_context: Optional[dict[str, Any]] = None
174
+ self, request: HttpRequest, extra_context: dict[str, Any] | None = None
170
175
  ) -> HttpResponse:
171
176
  if "toggle_sidebar" not in request.session:
172
177
  request.session["toggle_sidebar"] = True
@@ -214,14 +219,14 @@ class UnfoldAdminSite(AdminSite):
214
219
  request: HttpRequest,
215
220
  app_list: list[dict[str, Any]],
216
221
  search_term: str,
217
- allowed_models: Optional[list[str]] = None,
222
+ allowed_models: list[str] | None = None,
218
223
  ) -> list[SearchResult]:
219
224
  results = []
220
225
 
221
226
  for app in app_list:
222
227
  for model in app["models"]:
223
228
  # Skip models which are not allowed
224
- if isinstance(allowed_models, (list, tuple)):
229
+ if isinstance(allowed_models, list | tuple):
225
230
  if model["model"]._meta.label.lower() not in [
226
231
  m.lower() for m in allowed_models
227
232
  ]:
@@ -263,7 +268,7 @@ class UnfoldAdminSite(AdminSite):
263
268
  return results
264
269
 
265
270
  def search(
266
- self, request: HttpRequest, extra_context: Optional[dict[str, Any]] = None
271
+ self, request: HttpRequest, extra_context: dict[str, Any] | None = None
267
272
  ) -> TemplateResponse:
268
273
  start_time = time.time()
269
274
 
@@ -302,10 +307,10 @@ class UnfoldAdminSite(AdminSite):
302
307
  self._get_config("COMMAND", request).get("search_models"), request
303
308
  )
304
309
 
305
- if search_models is True or isinstance(search_models, (list, tuple)):
310
+ if search_models is True or isinstance(search_models, list | tuple):
306
311
  allowed_models = (
307
312
  search_models
308
- if isinstance(search_models, (list, tuple))
313
+ if isinstance(search_models, list | tuple)
309
314
  else None
310
315
  )
311
316
 
@@ -340,7 +345,7 @@ class UnfoldAdminSite(AdminSite):
340
345
  )
341
346
 
342
347
  def password_change(
343
- self, request: HttpRequest, extra_context: Optional[dict[str, Any]] = None
348
+ self, request: HttpRequest, extra_context: dict[str, Any] | None = None
344
349
  ) -> HttpResponse:
345
350
  from django.contrib.auth.views import PasswordChangeView
346
351
 
@@ -462,7 +467,7 @@ class UnfoldAdminSite(AdminSite):
462
467
  return tabs
463
468
 
464
469
  def _call_permission_callback(
465
- self, callback: Union[str, Callable, None], request: HttpRequest
470
+ self, callback: str | Callable | None, request: HttpRequest
466
471
  ) -> bool:
467
472
  if callback is None:
468
473
  return True
@@ -490,7 +495,7 @@ class UnfoldAdminSite(AdminSite):
490
495
  return target
491
496
 
492
497
  def _get_is_active(
493
- self, request: HttpRequest, link: Union[str, Callable], is_tab: bool = False
498
+ self, request: HttpRequest, link: str | Callable, is_tab: bool = False
494
499
  ) -> bool:
495
500
  if not isinstance(link, str):
496
501
  link = str(link)
@@ -545,9 +550,7 @@ class UnfoldAdminSite(AdminSite):
545
550
  if key in config and config[key]:
546
551
  return self._get_value(config[key], *args)
547
552
 
548
- def _get_theme_images(
549
- self, key: str, *args: Any
550
- ) -> Union[dict[str, str], str, None]:
553
+ def _get_theme_images(self, key: str, *args: Any) -> dict[str, str] | str | None:
551
554
  images = self._get_config(key, *args)
552
555
 
553
556
  if isinstance(images, dict):
@@ -613,9 +616,7 @@ class UnfoldAdminSite(AdminSite):
613
616
  for item in items
614
617
  ]
615
618
 
616
- def _get_value(
617
- self, value: Union[str, Callable, lazy, None], *args: Any
618
- ) -> Optional[str]:
619
+ def _get_value(self, value: str | Callable | None, *args: Any) -> str | None:
619
620
  if value is None:
620
621
  return None
621
622
 
@@ -0,0 +1,246 @@
1
+ /*global gettext, interpolate, ngettext, Actions*/
2
+ "use strict";
3
+ {
4
+ function show(options, selector) {
5
+ options.parent.querySelectorAll(selector).forEach(function (el) {
6
+ el.classList.remove("hidden");
7
+ });
8
+ }
9
+
10
+ function hide(options, selector) {
11
+ options.parent.querySelectorAll(selector).forEach(function (el) {
12
+ el.classList.add("hidden");
13
+ });
14
+ }
15
+
16
+ function showQuestion(options) {
17
+ hide(options, options.acrossClears);
18
+ show(options, options.acrossQuestions);
19
+ hide(options, options.allContainer);
20
+ }
21
+
22
+ function showClear(options) {
23
+ show(options, options.acrossClears);
24
+ hide(options, options.acrossQuestions);
25
+ options.parent
26
+ .querySelector(options.actionContainer)
27
+ .classList.remove(options.selectedClass);
28
+ show(options, options.allContainer);
29
+ hide(options, options.counterContainer);
30
+ }
31
+
32
+ function reset(options) {
33
+ hide(options, options.acrossClears);
34
+ hide(options, options.acrossQuestions);
35
+ hide(options, options.allContainer);
36
+ show(options, options.counterContainer);
37
+ }
38
+
39
+ function clearAcross(options) {
40
+ reset(options);
41
+ const acrossInputs = options.parent.querySelectorAll(options.acrossInput);
42
+ acrossInputs.forEach(function (acrossInput) {
43
+ acrossInput.value = 0;
44
+ acrossInput.dispatchEvent(new Event("input"));
45
+ });
46
+ options.parent
47
+ .querySelector(options.actionContainer)
48
+ .classList.remove(options.selectedClass);
49
+ }
50
+
51
+ function checker(actionCheckboxes, options, checked) {
52
+ if (checked) {
53
+ showQuestion(options);
54
+ } else {
55
+ reset(options);
56
+ }
57
+ actionCheckboxes.forEach(function (el) {
58
+ el.checked = checked;
59
+ el.closest("tr").classList.toggle(options.selectedClass, checked);
60
+ });
61
+ }
62
+
63
+ function updateCounter(actionCheckboxes, options) {
64
+ const sel = Array.from(actionCheckboxes).filter(function (el) {
65
+ return el.checked;
66
+ }).length;
67
+ const counter = options.parent.querySelector(options.counterContainer);
68
+ // data-actions-icnt is defined in the generated HTML
69
+ // and contains the total amount of objects in the queryset
70
+ const actions_icnt = Number(counter.dataset.actionsIcnt);
71
+ counter.textContent = interpolate(
72
+ ngettext(
73
+ "%(sel)s of %(cnt)s selected",
74
+ "%(sel)s of %(cnt)s selected",
75
+ sel
76
+ ),
77
+ {
78
+ sel: sel,
79
+ cnt: actions_icnt,
80
+ },
81
+ true
82
+ );
83
+ const allToggle = options.parent.querySelector(".action-toggle");
84
+ allToggle.checked = sel === actionCheckboxes.length;
85
+ if (allToggle.checked) {
86
+ showQuestion(options);
87
+ } else {
88
+ clearAcross(options);
89
+ }
90
+ }
91
+
92
+ const defaults = {
93
+ actionContainer: "div.actions",
94
+ counterContainer: "span.action-counter",
95
+ allContainer: "div.actions span.all",
96
+ acrossInput: "div.actions input.select-across",
97
+ acrossQuestions: "div.actions span.question",
98
+ acrossClears: "div.actions span.clear",
99
+ allToggleId: "action-toggle",
100
+ selectedClass: "selected",
101
+ };
102
+
103
+ window.Actions = function (actionCheckboxes, options) {
104
+ options = Object.assign({}, defaults, options);
105
+ let list_editable_changed = false;
106
+ let lastChecked = null;
107
+ let shiftPressed = false;
108
+
109
+ document.addEventListener("keydown", (event) => {
110
+ shiftPressed = event.shiftKey;
111
+ });
112
+
113
+ document.addEventListener("keyup", (event) => {
114
+ shiftPressed = event.shiftKey;
115
+ });
116
+
117
+ const allToggle = options.parent.querySelector(".action-toggle");
118
+ allToggle.addEventListener("click", function (event) {
119
+ checker(actionCheckboxes, options, this.checked);
120
+ updateCounter(actionCheckboxes, options);
121
+ });
122
+
123
+ options.parent
124
+ .querySelectorAll(options.acrossQuestions + " a")
125
+ .forEach(function (el) {
126
+ el.addEventListener("click", function (event) {
127
+ event.preventDefault();
128
+ const acrossInputs = options.parent.querySelectorAll(
129
+ options.acrossInput
130
+ );
131
+ acrossInputs.forEach(function (acrossInput) {
132
+ acrossInput.value = 1;
133
+ acrossInput.dispatchEvent(new Event("input"));
134
+ });
135
+ showClear(options);
136
+ });
137
+ });
138
+
139
+ options.parent
140
+ .querySelectorAll(options.acrossClears + " a")
141
+ .forEach(function (el) {
142
+ el.addEventListener("click", function (event) {
143
+ event.preventDefault();
144
+ options.parent.querySelector(".action-toggle").checked = false;
145
+ clearAcross(options);
146
+ checker(actionCheckboxes, options, false);
147
+ updateCounter(actionCheckboxes, options);
148
+ });
149
+ });
150
+
151
+ function affectedCheckboxes(target, withModifier) {
152
+ const multiSelect = lastChecked && withModifier && lastChecked !== target;
153
+ if (!multiSelect) {
154
+ return [target];
155
+ }
156
+ const checkboxes = Array.from(actionCheckboxes);
157
+ const targetIndex = checkboxes.findIndex((el) => el === target);
158
+ const lastCheckedIndex = checkboxes.findIndex((el) => el === lastChecked);
159
+ const startIndex = Math.min(targetIndex, lastCheckedIndex);
160
+ const endIndex = Math.max(targetIndex, lastCheckedIndex);
161
+ const filtered = checkboxes.filter(
162
+ (el, index) => startIndex <= index && index <= endIndex
163
+ );
164
+ return filtered;
165
+ }
166
+
167
+ const resultList = options.parent.querySelector(".result-list").tBodies;
168
+ Array.from(resultList).forEach(function (el) {
169
+ el.addEventListener("change", function (event) {
170
+ const target = event.target;
171
+ if (target.classList.contains("action-select")) {
172
+ const checkboxes = affectedCheckboxes(target, shiftPressed);
173
+ checker(checkboxes, options, target.checked);
174
+ updateCounter(actionCheckboxes, options);
175
+ lastChecked = target;
176
+ } else {
177
+ list_editable_changed = true;
178
+ }
179
+ });
180
+ });
181
+
182
+ options.parent
183
+ .querySelector("button[name=index]")
184
+ .addEventListener("click", function (event) {
185
+ if (list_editable_changed) {
186
+ const confirmed = confirm(
187
+ gettext(
188
+ "You have unsaved changes on individual editable fields. If you run an action, your unsaved changes will be lost."
189
+ )
190
+ );
191
+ if (!confirmed) {
192
+ event.preventDefault();
193
+ }
194
+ }
195
+ });
196
+
197
+ const el = options.parent.querySelector("input[name=_save]");
198
+
199
+ // The button does not exist if no fields are editable.
200
+ if (el) {
201
+ el.addEventListener("click", function (event) {
202
+ if (document.querySelector("[name=action]").value) {
203
+ const text = list_editable_changed
204
+ ? gettext(
205
+ "You have selected an action, but you haven’t saved your changes to individual fields yet. Please click OK to save. You’ll need to re-run the action."
206
+ )
207
+ : gettext(
208
+ "You have selected an action, and you haven’t made any changes on individual fields. You’re probably looking for the Go button rather than the Save button."
209
+ );
210
+ if (!confirm(text)) {
211
+ event.preventDefault();
212
+ }
213
+ }
214
+ });
215
+ }
216
+
217
+ // Sync counter when navigating to the page, such as through the back
218
+ // button.
219
+ window.addEventListener("pageshow", (event) =>
220
+ updateCounter(actionCheckboxes, options)
221
+ );
222
+ };
223
+
224
+ // Call function fn when the DOM is loaded and ready. If it is already
225
+ // loaded, call the function now.
226
+ // http://youmightnotneedjquery.com/#ready
227
+ function ready(fn) {
228
+ if (document.readyState !== "loading") {
229
+ fn();
230
+ } else {
231
+ document.addEventListener("DOMContentLoaded", fn);
232
+ }
233
+ }
234
+
235
+ ready(function () {
236
+ document.querySelectorAll(".result-list-wrapper").forEach(function (el) {
237
+ const actionsEls = el.querySelectorAll("tr input.action-select");
238
+
239
+ if (actionsEls.length > 0) {
240
+ Actions(actionsEls, {
241
+ parent: el,
242
+ });
243
+ }
244
+ });
245
+ });
246
+ }