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,11 @@
1
+ <h2 class="bg-base-100 flex flex-row font-semibold h-[38px] items-center mb-4 px-3 rounded-default text-important dark:bg-white/[.02] {% if inline_admin_formset.opts.tab %}hidden{% endif %} {% if clickable or inline_admin_formset.is_collapsible %}cursor-pointer{% endif %}" {% if inline_admin_formset %}id="{{ inline_admin_formset.formset.prefix }}-heading"{% endif %}>
2
+ {% if inline_admin_formset %}
3
+ {% if inline_admin_formset.formset.max_num == 1 %}
4
+ {{ inline_admin_formset.opts.verbose_name|capfirst }}
5
+ {% else %}
6
+ {{ inline_admin_formset.opts.verbose_name_plural|capfirst }}
7
+ {% endif %}
8
+ {% else %}
9
+ {{ title|capfirst }}
10
+ {% endif %}
11
+ </h2>
@@ -1,7 +1,7 @@
1
1
  {% load i18n %}
2
2
 
3
3
  {% if inlines_list or tabs_list %}
4
- <nav class="bg-base-100 flex flex-col font-medium gap-1 p-1 rounded-default w-full dark:border-base-700 md:flex-row md:w-auto dark:bg-white/[.04] *:flex *:flex-row *:font-medium *:items-center *:px-2.5 *:py-[5px] *:rounded-default *:transition-colors *:hover:bg-base-700/[.04] *:dark:hover:bg-white/[.04] [&>.active]:bg-white [&>.active]:shadow-xs [&>.active]:text-font-important-light [&>.active]:hover:bg-white [&>.active]:dark:bg-base-900 [&>.active]:dark:hover:bg-base-900 [&>.active]:dark:text-font-important-dark">
4
+ <nav class="bg-base-100 flex flex-col font-medium gap-1 p-1 rounded-default text-important w-full dark:border-base-700 md:flex-row md:w-auto dark:bg-white/[.04] *:flex *:flex-row *:font-medium *:items-center *:px-2.5 *:py-[5px] *:rounded-default *:transition-colors *:hover:bg-base-700/[.04] *:dark:hover:bg-white/[.04] [&>.active]:bg-white [&>.active]:shadow-xs [&>.active]:hover:bg-white [&>.active]:dark:bg-base-900 [&>.active]:dark:hover:bg-base-900">
5
5
  {% for item in tabs_list %}
6
6
  {% if item.has_permission %}
7
7
  <a href="{% if item.link_callback %}{{ item.link_callback }}{% else %}{{ item.link }}{% endif %}{% if item.inline %}#{{ item.inline|slugify }}{% endif %}" class="{% if item.active and not item.inline %}active{% endif %}" {% if item.inline %}x-on:click="activeTab = '{{ item.inline|slugify }}'" x-bind:class="{'active': activeTab == '{{ item.inline|slugify }}'}"{% endif %}>
@@ -24,6 +24,14 @@
24
24
  {% endif %}
25
25
  </a>
26
26
  {% endfor %}
27
+
28
+ {% for dataset in datasets_list %}
29
+ {% if dataset.tab %}
30
+ <a href="#dataset-{{ dataset.model_name }}" x-on:click="activeTab = 'dataset-{{ dataset.model_name }}'" x-bind:class="{'active': activeTab == 'dataset-{{ dataset.model_name }}'}">
31
+ {{ dataset.model_verbose_name|capfirst }}
32
+ </a>
33
+ {% endif %}
34
+ {% endfor %}
27
35
  {% endif %}
28
36
  </nav>
29
37
  {% endif %}
@@ -1,6 +1,6 @@
1
1
  import json
2
2
  from collections.abc import Iterable, Mapping
3
- from typing import Any, Optional, Union
3
+ from typing import Any
4
4
 
5
5
  from django import template
6
6
  from django.contrib.admin.helpers import AdminForm, Fieldset
@@ -27,7 +27,7 @@ register = Library()
27
27
 
28
28
 
29
29
  def _get_tabs_list(
30
- context: RequestContext, page: str, opts: Optional[Options] = None
30
+ context: RequestContext, page: str, opts: Options | None = None
31
31
  ) -> list:
32
32
  tabs_list = []
33
33
  page_id = None
@@ -63,9 +63,9 @@ def _get_tabs_list(
63
63
 
64
64
 
65
65
  @register.simple_tag(name="tab_list", takes_context=True)
66
- def tab_list(context: RequestContext, page: str, opts: Optional[Options] = None) -> str:
66
+ def tab_list(context: RequestContext, page: str, opts: Options | None = None) -> str:
67
67
  inlines_list = []
68
-
68
+ datasets_list = []
69
69
  data = {
70
70
  "nav_global": context.get("nav_global"),
71
71
  "actions_detail": context.get("actions_detail"),
@@ -85,6 +85,13 @@ def tab_list(context: RequestContext, page: str, opts: Optional[Options] = None)
85
85
  if len(inlines_list) > 0:
86
86
  data["inlines_list"] = inlines_list
87
87
 
88
+ for dataset in context.get("datasets", []):
89
+ if dataset and hasattr(dataset, "tab"):
90
+ datasets_list.append(dataset)
91
+
92
+ if len(datasets_list) > 0:
93
+ data["datasets_list"] = datasets_list
94
+
88
95
  return render_to_string(
89
96
  "unfold/helpers/tab_list.html",
90
97
  request=context["request"],
@@ -144,82 +151,12 @@ def tabs(adminform: AdminForm) -> list[Fieldset]:
144
151
  return result
145
152
 
146
153
 
147
- class CaptureNode(Node):
148
- def __init__(self, nodelist: NodeList, varname: str, silent: bool) -> None:
149
- self.nodelist = nodelist
150
- self.varname = varname
151
- self.silent = silent
152
-
153
- def render(self, context: dict[str, Any]) -> Union[str, SafeText]:
154
- output = self.nodelist.render(context)
155
- context[self.varname] = output
156
- if self.silent:
157
- return ""
158
- else:
159
- return output
160
-
161
-
162
- @register.tag(name="capture")
163
- def do_capture(parser: Parser, token: Token) -> CaptureNode:
164
- """
165
- Capture the contents of a tag output.
166
- Usage:
167
- .. code-block:: html+django
168
- {% capture %}..{% endcapture %} # output in {{ capture }}
169
- {% capture silent %}..{% endcapture %} # output in {{ capture }} only
170
- {% capture as varname %}..{% endcapture %} # output in {{ varname }}
171
- {% capture as varname silent %}..{% endcapture %} # output in {{ varname }} only
172
- For example:
173
- .. code-block:: html+django
174
- {# Allow templates to override the page title/description #}
175
- <meta name="description" content="{% capture as meta_description %}
176
- {% block meta-description %}{% endblock %}{% endcapture %}" />
177
- <title>{% capture as meta_title %}{% block meta-title %}Untitled{% endblock %}{% endcapture %}</title>
178
- {# copy the values to the Social Media meta tags #}
179
- <meta property="og:description" content="{% block og-description %}{{ meta_description }}{% endblock %}" />
180
- <meta name="twitter:title" content="{% block twitter-title %}{{ meta_title }}{% endblock %}" />
181
- """
182
- bits = token.split_contents()
183
-
184
- # tokens
185
- t_as = "as"
186
- t_silent = "silent"
187
- var = "capture"
188
- silent = False
189
-
190
- num_bits = len(bits)
191
- if len(bits) > 4:
192
- raise TemplateSyntaxError(
193
- "'capture' node supports '[as variable] [silent]' parameters."
194
- )
195
- elif num_bits == 4:
196
- t_name, t_as, var, t_silent = bits
197
- silent = True
198
- elif num_bits == 3:
199
- t_name, t_as, var = bits
200
- elif num_bits == 2:
201
- t_name, t_silent = bits
202
- silent = True
203
- else:
204
- var = "capture"
205
- silent = False
206
-
207
- if t_silent != "silent" or t_as != "as":
208
- raise TemplateSyntaxError(
209
- "'capture' node expects 'as variable' or 'silent' syntax."
210
- )
211
-
212
- nodelist = parser.parse(("endcapture",))
213
- parser.delete_first_token()
214
- return CaptureNode(nodelist, var, silent)
215
-
216
-
217
154
  class RenderComponentNode(template.Node):
218
155
  def __init__(
219
156
  self,
220
157
  template_name: str,
221
158
  nodelist: NodeList,
222
- extra_context: Optional[dict] = None,
159
+ extra_context: dict | None = None,
223
160
  include_context: bool = False,
224
161
  *args,
225
162
  **kwargs,
@@ -308,7 +245,7 @@ def do_component(parser: Parser, token: Token) -> str:
308
245
 
309
246
 
310
247
  @register.filter
311
- def add_css_class(field: Field, classes: Union[list, tuple]) -> Field:
248
+ def add_css_class(field: Field, classes: list | tuple) -> Field:
312
249
  if type(classes) in (list, tuple):
313
250
  classes = " ".join(classes)
314
251
 
@@ -329,8 +266,8 @@ def preserve_changelist_filters(context: Context) -> dict[str, dict[str, str]]:
329
266
  """
330
267
  Generate hidden input fields to preserve filters for POST forms.
331
268
  """
332
- request: Optional[HttpRequest] = context.get("request")
333
- changelist: Optional[ChangeList] = context.get("cl")
269
+ request: HttpRequest | None = context.get("request")
270
+ changelist: ChangeList | None = context.get("cl")
334
271
 
335
272
  if not request or not changelist:
336
273
  return {"params": {}}
@@ -348,7 +285,7 @@ def preserve_changelist_filters(context: Context) -> dict[str, dict[str, str]]:
348
285
  @register.simple_tag(takes_context=True)
349
286
  def element_classes(context: Context, key: str) -> str:
350
287
  if key in context.get("element_classes", {}):
351
- if isinstance(context["element_classes"][key], (list, tuple)):
288
+ if isinstance(context["element_classes"][key], list | tuple):
352
289
  return " ".join(context["element_classes"][key])
353
290
 
354
291
  return context["element_classes"][key]
@@ -585,9 +522,7 @@ def infinite_paginator_url(cl, i):
585
522
 
586
523
 
587
524
  @register.simple_tag
588
- def elided_page_range(
589
- paginator: Paginator, number: int
590
- ) -> Optional[list[Union[int, str]]]:
525
+ def elided_page_range(paginator: Paginator, number: int) -> list[int | str] | None:
591
526
  if not paginator or not number:
592
527
  return None
593
528
 
@@ -794,3 +729,50 @@ def has_nested_tables(table: dict) -> bool:
794
729
  return any(
795
730
  isinstance(row, dict) and "table" in row for row in table.get("rows", [])
796
731
  )
732
+
733
+
734
+ class RenderCaptureNode(Node):
735
+ def __init__(self, nodelist: NodeList, variable_name: str, silent: bool) -> None:
736
+ self.nodelist = nodelist
737
+ self.variable_name = variable_name
738
+ self.silent = silent
739
+
740
+ def render(self, context: dict[str, Any]) -> str | SafeText:
741
+ content = self.nodelist.render(context)
742
+
743
+ if not self.silent:
744
+ return content
745
+
746
+ context.update(
747
+ {
748
+ self.variable_name: content,
749
+ }
750
+ )
751
+
752
+ return ""
753
+
754
+
755
+ @register.tag(name="capture")
756
+ def do_capture(parser: Parser, token: Token) -> RenderCaptureNode:
757
+ parts = token.split_contents()
758
+ variable_name = ""
759
+ silent = False
760
+
761
+ if len(parts) > 4:
762
+ raise TemplateSyntaxError("Too many arguments for 'capture' tag.")
763
+
764
+ if len(parts) >= 3:
765
+ if parts[1] != "as":
766
+ raise TemplateSyntaxError("'as' is required for 'capture' tag.")
767
+
768
+ variable_name = parts[2]
769
+
770
+ if len(parts) == 4:
771
+ if parts[3] != "silent":
772
+ raise TemplateSyntaxError("'silent' is required for 'capture' tag.")
773
+
774
+ silent = True
775
+
776
+ nodelist = parser.parse(("endcapture",))
777
+ parser.delete_first_token()
778
+ return RenderCaptureNode(nodelist, variable_name, silent)
@@ -1,18 +1,21 @@
1
1
  import datetime
2
2
  from collections.abc import Generator
3
- from typing import Any, Optional, Union
3
+ from typing import Any
4
4
 
5
5
  from django.contrib.admin.templatetags.admin_list import (
6
6
  ResultList,
7
7
  _coerce_field_name,
8
+ admin_actions,
8
9
  result_hidden_fields,
9
10
  )
10
11
  from django.contrib.admin.templatetags.admin_urls import add_preserved_filters
11
12
  from django.contrib.admin.templatetags.base import InclusionAdminNode
12
13
  from django.contrib.admin.utils import label_for_field, lookup_field
13
14
  from django.contrib.admin.views.main import (
15
+ IS_POPUP_VAR,
14
16
  ORDER_VAR,
15
17
  PAGE_VAR,
18
+ SEARCH_VAR,
16
19
  ChangeList,
17
20
  )
18
21
  from django.core.exceptions import ObjectDoesNotExist
@@ -34,8 +37,15 @@ from unfold.utils import (
34
37
  display_for_label,
35
38
  display_for_value,
36
39
  )
40
+ from unfold.views import DatasetChangeList
37
41
  from unfold.widgets import UnfoldBooleanWidget
38
42
 
43
+ try:
44
+ from django.contrib.admin.views.main import IS_FACETS_VAR
45
+ except ImportError:
46
+ # TODO: remove once django 4.x is not supported
47
+ IS_FACETS_VAR = None
48
+
39
49
  register = Library()
40
50
 
41
51
  LINK_CLASSES = [
@@ -99,6 +109,9 @@ def result_headers(cl):
99
109
  Generate the list column headers.
100
110
  """
101
111
  ordering_field_columns = cl.get_ordering_field_columns()
112
+ ordering_field = getattr(cl.model_admin, "ordering_field", None)
113
+ hide_ordering_field = getattr(cl.model_admin, "hide_ordering_field", False)
114
+
102
115
  for i, field_name in enumerate(cl.list_display):
103
116
  text, attr = label_for_field(
104
117
  field_name, cl.model, model_admin=cl.model_admin, return_attr=True
@@ -114,6 +127,7 @@ def result_headers(cl):
114
127
  "text": UnfoldBooleanWidget(
115
128
  {
116
129
  "id": "action-toggle",
130
+ "class": "action-toggle",
117
131
  "aria-label": _(
118
132
  "Select all objects on this page for an action"
119
133
  ),
@@ -145,6 +159,10 @@ def result_headers(cl):
145
159
  order_type = ""
146
160
  new_order_type = "asc"
147
161
  sort_priority = 0
162
+
163
+ if ordering_field and field_name == ordering_field and hide_ordering_field:
164
+ th_classes.append("!hidden")
165
+
148
166
  # Is it currently being sorted on?
149
167
  is_sorted = i in ordering_field_columns
150
168
  if is_sorted:
@@ -209,6 +227,9 @@ def items_for_result(
209
227
 
210
228
  for field_index, field_name in enumerate(cl.list_display):
211
229
  empty_value_display = cl.model_admin.get_empty_value_display()
230
+ ordering_field = getattr(cl.model_admin, "ordering_field", None)
231
+ hide_ordering_field = getattr(cl.model_admin, "hide_ordering_field", False)
232
+
212
233
  row_classes = [
213
234
  f"field-{_coerce_field_name(field_name, field_index)}",
214
235
  *ROW_CLASSES,
@@ -241,7 +262,7 @@ def items_for_result(
241
262
  else:
242
263
  result_repr = display_for_value(value, empty_value_display, boolean)
243
264
 
244
- if isinstance(value, (datetime.date, datetime.time)):
265
+ if isinstance(value, datetime.date | datetime.time):
245
266
  row_classes.append("nowrap")
246
267
  else:
247
268
  if isinstance(f.remote_field, models.ManyToOneRel):
@@ -253,7 +274,7 @@ def items_for_result(
253
274
  else:
254
275
  result_repr = display_for_field(value, f, empty_value_display)
255
276
  if isinstance(
256
- f, (models.DateField, models.TimeField, models.ForeignKey)
277
+ f, models.DateField | models.TimeField | models.ForeignKey
257
278
  ):
258
279
  row_classes.append("nowrap")
259
280
 
@@ -321,6 +342,9 @@ def items_for_result(
321
342
  if bf.errors:
322
343
  row_classes += ["group", "errors"]
323
344
 
345
+ if ordering_field and field_name == ordering_field and hide_ordering_field:
346
+ row_classes.append("!hidden")
347
+
324
348
  row_class = mark_safe(f' class="{" ".join(row_classes)}"')
325
349
 
326
350
  if field_index != 0:
@@ -346,7 +370,7 @@ class UnfoldResultList(ResultList):
346
370
  def __init__(
347
371
  self,
348
372
  instance: Model,
349
- form: Optional[Form],
373
+ form: Form | None,
350
374
  *items: Any,
351
375
  ) -> None:
352
376
  self.instance = instance
@@ -395,23 +419,67 @@ def result_list_tag(parser: Parser, token: Token) -> InclusionAdminNode:
395
419
 
396
420
 
397
421
  @register.simple_tag
398
- def paginator_number(cl: ChangeList, i: Union[str, int]) -> Union[str, SafeText]:
422
+ def paginator_number(cl: ChangeList, i: str | int) -> str | SafeText:
399
423
  """
400
424
  Generate an individual page index link in a paginated list.
401
425
  """
402
426
  if i == cl.paginator.ELLIPSIS:
403
427
  return render_to_string(
404
428
  "unfold/helpers/pagination_ellipsis.html",
405
- {"ellipsis": cl.paginator.ELLIPSIS},
429
+ {
430
+ "ellipsis": cl.paginator.ELLIPSIS,
431
+ },
406
432
  )
407
433
  elif i == cl.page_num:
408
434
  return render_to_string(
409
435
  "unfold/helpers/pagination_current_item.html", {"number": i}
410
436
  )
411
437
  else:
438
+ page_param = PAGE_VAR
439
+
440
+ if isinstance(cl, DatasetChangeList):
441
+ page_param = f"{cl.model._meta.model_name}-p"
442
+
412
443
  return format_html(
413
- '<a href="{}"{}>{}</a> ',
414
- cl.get_query_string({PAGE_VAR: i}),
444
+ '<a href="{}"{} x-data x-on:click.prevent="window.location.href = $el.href + window.location.hash">{}</a> ',
445
+ cl.get_query_string(
446
+ {
447
+ page_param: i,
448
+ }
449
+ ),
415
450
  mark_safe(' class="end"' if i == cl.paginator.num_pages else ""),
416
451
  i,
417
452
  )
453
+
454
+
455
+ def unfold_search_form(cl):
456
+ model_name = cl.model_admin.model._meta.model_name
457
+
458
+ return {
459
+ "cl": cl,
460
+ "show_result_count": cl.result_count != cl.full_result_count,
461
+ "search_var": f"{model_name}-{SEARCH_VAR}",
462
+ "is_popup_var": IS_POPUP_VAR,
463
+ "is_facets_var": IS_FACETS_VAR,
464
+ }
465
+
466
+
467
+ @register.tag(name="unfold_search_form")
468
+ def unfold_search_form_tag(parser, token):
469
+ return InclusionAdminNode(
470
+ parser,
471
+ token,
472
+ func=unfold_search_form,
473
+ template_name="search_form.html",
474
+ takes_context=False,
475
+ )
476
+
477
+
478
+ @register.tag(name="unfold_admin_actions")
479
+ def unfold_admin_actions_tag(parser, token):
480
+ return InclusionAdminNode(
481
+ parser,
482
+ token,
483
+ func=admin_actions,
484
+ template_name="dataset_actions.html",
485
+ )
unfold/typing.py CHANGED
@@ -1,5 +1,5 @@
1
1
  from collections.abc import Iterable
2
- from typing import Any, Optional, Protocol, Union
2
+ from typing import Any, Protocol
3
3
 
4
4
 
5
5
  class ActionFunction(Protocol):
@@ -13,13 +13,12 @@ class ActionFunction(Protocol):
13
13
  short_description: str
14
14
  url_path: str
15
15
  attrs: dict[str, Any]
16
- icon: Optional[str] = None
16
+ icon: str | None = None
17
17
 
18
18
  def __call__(self, *args, **kwargs):
19
19
  pass
20
20
 
21
21
 
22
- FieldsetsType = Union[
23
- list[tuple[Union[str, None], dict[str, Any]]],
24
- tuple[tuple[Union[str, None], dict[str, Any]]],
25
- ]
22
+ FieldsetsType = (
23
+ list[tuple[str | None, dict[str, Any]]] | tuple[tuple[str | None, dict[str, Any]]]
24
+ )
unfold/utils.py CHANGED
@@ -2,7 +2,7 @@ import datetime
2
2
  import decimal
3
3
  import json
4
4
  from collections.abc import Iterable
5
- from typing import Any, Optional
5
+ from typing import Any
6
6
 
7
7
  from django.conf import settings
8
8
  from django.db import models
@@ -94,13 +94,13 @@ def display_for_value(
94
94
  return str(value)
95
95
  elif isinstance(value, datetime.datetime):
96
96
  return formats.localize(timezone.template_localtime(value))
97
- elif isinstance(value, (datetime.date, datetime.time)):
97
+ elif isinstance(value, datetime.date | datetime.time):
98
98
  return formats.localize(value)
99
99
  elif Money is not None and isinstance(value, Money):
100
100
  return str(value)
101
- elif isinstance(value, (int, decimal.Decimal, float)):
101
+ elif isinstance(value, int | decimal.Decimal | float):
102
102
  return formats.number_format(value)
103
- elif isinstance(value, (list, tuple)):
103
+ elif isinstance(value, list | tuple):
104
104
  return ", ".join(str(v) for v in value)
105
105
  else:
106
106
  return str(value)
@@ -121,13 +121,13 @@ def display_for_field(value: Any, field: Any, empty_value_display: str) -> str:
121
121
  return empty_value_display
122
122
  elif isinstance(field, models.DateTimeField):
123
123
  return formats.localize(timezone.template_localtime(value))
124
- elif isinstance(field, (models.DateField, models.TimeField)):
124
+ elif isinstance(field, models.DateField | models.TimeField):
125
125
  return formats.localize(value)
126
126
  elif MoneyField is not None and isinstance(field, MoneyField):
127
127
  return str(value)
128
128
  elif isinstance(field, models.DecimalField):
129
129
  return formats.number_format(value, field.decimal_places)
130
- elif isinstance(field, (models.IntegerField, models.FloatField)):
130
+ elif isinstance(field, models.IntegerField | models.FloatField):
131
131
  return formats.number_format(value)
132
132
  elif isinstance(field, models.FileField) and value:
133
133
  return format_html('<a href="{}">{}</a>', value.url, value)
@@ -140,7 +140,7 @@ def display_for_field(value: Any, field: Any, empty_value_display: str) -> str:
140
140
  return display_for_value(value, empty_value_display)
141
141
 
142
142
 
143
- def prettify_json(data: Any, encoder: Any) -> Optional[str]:
143
+ def prettify_json(data: Any, encoder: Any) -> str | None:
144
144
  try:
145
145
  from pygments import highlight
146
146
  from pygments.formatters import HtmlFormatter
@@ -165,7 +165,7 @@ def prettify_json(data: Any, encoder: Any) -> Optional[str]:
165
165
  )
166
166
 
167
167
 
168
- def parse_date_str(value: str) -> Optional[datetime.date]:
168
+ def parse_date_str(value: str) -> datetime.date | None:
169
169
  for format in settings.DATE_INPUT_FORMATS:
170
170
  try:
171
171
  return datetime.datetime.strptime(value, format).date()
@@ -173,7 +173,7 @@ def parse_date_str(value: str) -> Optional[datetime.date]:
173
173
  continue
174
174
 
175
175
 
176
- def parse_datetime_str(value: str) -> Optional[datetime.datetime]:
176
+ def parse_datetime_str(value: str) -> datetime.datetime | None:
177
177
  for format in settings.DATETIME_INPUT_FORMATS:
178
178
  try:
179
179
  return datetime.datetime.strptime(value, format)
unfold/views.py CHANGED
@@ -1,15 +1,18 @@
1
1
  from typing import Any
2
2
 
3
3
  import django
4
+ from django.contrib.admin.views import main
4
5
  from django.contrib.admin.views.main import ERROR_FLAG, PAGE_VAR
5
6
  from django.contrib.admin.views.main import ChangeList as BaseChangeList
6
7
  from django.contrib.auth.mixins import PermissionRequiredMixin
8
+ from django.http import HttpRequest
7
9
 
8
10
  from unfold.exceptions import UnfoldException
11
+ from unfold.forms import DatasetChangeListSearchForm
9
12
 
10
13
 
11
14
  class ChangeList(BaseChangeList):
12
- def __init__(self, request, *args, **kwargs):
15
+ def __init__(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None:
13
16
  super().__init__(request, *args, **kwargs)
14
17
 
15
18
  if django.VERSION < (5, 0):
@@ -18,6 +21,17 @@ class ChangeList(BaseChangeList):
18
21
  self.filter_params.pop(ERROR_FLAG, None)
19
22
 
20
23
 
24
+ class DatasetChangeList(ChangeList):
25
+ is_dataset = True
26
+ search_form_class = DatasetChangeListSearchForm
27
+
28
+ def __init__(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None:
29
+ # Monkeypatch SEARCH_VAR and PAGE_VAR for custom datasets
30
+ main.SEARCH_VAR = f"{kwargs.get('model')._meta.model_name}-q"
31
+ main.PAGE_VAR = f"{kwargs.get('model')._meta.model_name}-p"
32
+ super().__init__(request, *args, **kwargs)
33
+
34
+
21
35
  class UnfoldModelAdminViewMixin(PermissionRequiredMixin):
22
36
  """
23
37
  Prepares views to be displayed in admin