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.
- {django_unfold-0.67.0.dist-info → django_unfold-0.69.0.dist-info}/METADATA +33 -41
- {django_unfold-0.67.0.dist-info → django_unfold-0.69.0.dist-info}/RECORD +54 -47
- unfold/admin.py +46 -15
- unfold/components.py +2 -2
- unfold/contrib/filters/admin/choice_filters.py +13 -1
- unfold/contrib/filters/admin/mixins.py +3 -3
- unfold/contrib/filters/admin/numeric_filters.py +6 -6
- unfold/contrib/forms/widgets.py +5 -5
- unfold/contrib/inlines/admin.py +3 -3
- unfold/contrib/inlines/forms.py +5 -4
- unfold/dataclasses.py +13 -13
- unfold/datasets.py +90 -0
- unfold/decorators.py +19 -19
- unfold/fields.py +3 -5
- unfold/forms.py +41 -22
- unfold/mixins/__init__.py +2 -1
- unfold/mixins/action_model_admin.py +11 -10
- unfold/mixins/base_model_admin.py +6 -6
- unfold/mixins/dataset_model_admin.py +62 -0
- unfold/settings.py +1 -0
- unfold/sites.py +19 -18
- unfold/static/admin/js/actions.js +246 -0
- unfold/static/unfold/css/styles.css +2 -2
- unfold/static/unfold/fonts/material-symbols/Material-Symbols-Outlined.woff2 +0 -0
- unfold/static/unfold/js/app.js +3 -1
- unfold/styles.css +21 -16
- unfold/templates/admin/actions.html +2 -2
- unfold/templates/admin/change_form.html +10 -2
- unfold/templates/admin/change_list.html +1 -1
- unfold/templates/admin/change_list_results.html +10 -62
- unfold/templates/admin/dataset_actions.html +50 -0
- unfold/templates/admin/edit_inline/stacked.html +2 -8
- unfold/templates/admin/edit_inline/tabular.html +1 -7
- unfold/templates/admin/includes/fieldset.html +1 -3
- unfold/templates/admin/search_form.html +6 -4
- unfold/templates/registration/password_change_done.html +3 -4
- unfold/templates/registration/password_change_form.html +10 -6
- unfold/templates/unfold/helpers/change_list_actions.html +1 -1
- unfold/templates/unfold/helpers/change_list_headers.html +65 -0
- unfold/templates/unfold/helpers/dataset.html +31 -0
- unfold/templates/unfold/helpers/edit_inline/tabular_field.html +1 -1
- unfold/templates/unfold/helpers/empty_results.html +6 -4
- unfold/templates/unfold/helpers/field_readonly_value_file.html +1 -1
- unfold/templates/unfold/helpers/fieldsets_tabs.html +9 -11
- unfold/templates/unfold/helpers/inline_heading.html +11 -0
- unfold/templates/unfold/helpers/tab_items.html +9 -1
- unfold/templatetags/unfold.py +64 -82
- unfold/templatetags/unfold_list.py +76 -8
- unfold/typing.py +5 -6
- unfold/utils.py +9 -9
- unfold/views.py +15 -1
- unfold/widgets.py +31 -31
- {django_unfold-0.67.0.dist-info → django_unfold-0.69.0.dist-info}/WHEEL +0 -0
- {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]:
|
|
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 %}
|
unfold/templatetags/unfold.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import json
|
|
2
2
|
from collections.abc import Iterable, Mapping
|
|
3
|
-
from typing import Any
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
333
|
-
changelist:
|
|
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],
|
|
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
|
|
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,
|
|
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,
|
|
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:
|
|
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:
|
|
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
|
-
{
|
|
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(
|
|
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,
|
|
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:
|
|
16
|
+
icon: str | None = None
|
|
17
17
|
|
|
18
18
|
def __call__(self, *args, **kwargs):
|
|
19
19
|
pass
|
|
20
20
|
|
|
21
21
|
|
|
22
|
-
FieldsetsType =
|
|
23
|
-
list[tuple[
|
|
24
|
-
|
|
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
|
|
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,
|
|
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,
|
|
101
|
+
elif isinstance(value, int | decimal.Decimal | float):
|
|
102
102
|
return formats.number_format(value)
|
|
103
|
-
elif isinstance(value,
|
|
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,
|
|
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,
|
|
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) ->
|
|
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) ->
|
|
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) ->
|
|
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
|