django-unfold 0.46.0__py3-none-any.whl → 0.47.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 (38) hide show
  1. {django_unfold-0.46.0.dist-info → django_unfold-0.47.0.dist-info}/METADATA +5 -6
  2. {django_unfold-0.46.0.dist-info → django_unfold-0.47.0.dist-info}/RECORD +38 -31
  3. {django_unfold-0.46.0.dist-info → django_unfold-0.47.0.dist-info}/WHEEL +1 -1
  4. unfold/admin.py +15 -16
  5. unfold/checks.py +4 -4
  6. unfold/components.py +5 -5
  7. unfold/contrib/filters/admin/__init__.py +43 -0
  8. unfold/contrib/filters/admin/autocomplete_filters.py +16 -0
  9. unfold/contrib/filters/admin/datetime_filters.py +212 -0
  10. unfold/contrib/filters/admin/dropdown_filters.py +100 -0
  11. unfold/contrib/filters/admin/mixins.py +146 -0
  12. unfold/contrib/filters/admin/numeric_filters.py +196 -0
  13. unfold/contrib/filters/admin/text_filters.py +65 -0
  14. unfold/contrib/filters/admin.py +32 -32
  15. unfold/contrib/filters/forms.py +68 -17
  16. unfold/contrib/forms/widgets.py +9 -9
  17. unfold/contrib/inlines/checks.py +2 -4
  18. unfold/contrib/simple_history/templates/simple_history/object_history.html +17 -1
  19. unfold/contrib/simple_history/templates/simple_history/object_history_list.html +1 -1
  20. unfold/dataclasses.py +2 -2
  21. unfold/decorators.py +4 -3
  22. unfold/settings.py +2 -2
  23. unfold/sites.py +156 -140
  24. unfold/static/unfold/css/styles.css +1 -1
  25. unfold/static/unfold/js/app.js +2 -2
  26. unfold/templates/admin/filter.html +1 -1
  27. unfold/templates/unfold/helpers/change_list_filter.html +2 -2
  28. unfold/templates/unfold/helpers/change_list_filter_actions.html +1 -1
  29. unfold/templates/unfold/helpers/header_back_button.html +2 -2
  30. unfold/templates/unfold/helpers/tab_list.html +7 -1
  31. unfold/templates/unfold/layouts/skeleton.html +1 -1
  32. unfold/templatetags/unfold.py +55 -22
  33. unfold/templatetags/unfold_list.py +2 -2
  34. unfold/typing.py +5 -4
  35. unfold/utils.py +3 -2
  36. unfold/views.py +2 -2
  37. unfold/widgets.py +27 -27
  38. {django_unfold-0.46.0.dist-info → django_unfold-0.47.0.dist-info}/LICENSE.md +0 -0
@@ -0,0 +1,100 @@
1
+ from collections.abc import Generator
2
+ from typing import Any
3
+
4
+ from django.contrib import admin
5
+ from django.contrib.admin.views.main import ChangeList
6
+ from django.db.models import Field, Model
7
+ from django.http import HttpRequest
8
+ from django.utils.translation import gettext_lazy as _
9
+
10
+ from unfold.admin import ModelAdmin
11
+ from unfold.contrib.filters.admin.mixins import (
12
+ DropdownMixin,
13
+ MultiValueMixin,
14
+ ValueMixin,
15
+ )
16
+ from unfold.contrib.filters.forms import DropdownForm
17
+
18
+
19
+ class DropdownFilter(admin.SimpleListFilter):
20
+ template = "unfold/filters/filters_field.html"
21
+ form_class = DropdownForm
22
+ all_option = ["", _("All")]
23
+
24
+ def choices(self, changelist: ChangeList) -> tuple[dict[str, Any], ...]:
25
+ return (
26
+ {
27
+ "form": self.form_class(
28
+ label=_("By %(filter_title)s") % {"filter_title": self.title},
29
+ name=self.parameter_name,
30
+ choices=[self.all_option, *self.lookup_choices],
31
+ data={self.parameter_name: self.value()},
32
+ multiple=self.multiple if hasattr(self, "multiple") else False,
33
+ ),
34
+ },
35
+ )
36
+
37
+
38
+ class MultipleDropdownFilter(DropdownFilter):
39
+ multiple = True
40
+
41
+ def __init__(
42
+ self,
43
+ request: HttpRequest,
44
+ params: dict[str, Any],
45
+ model: type[Model],
46
+ model_admin: ModelAdmin,
47
+ ) -> None:
48
+ self.request = request
49
+ super().__init__(request, params, model, model_admin)
50
+
51
+ def value(self) -> list[Any]:
52
+ return self.request.GET.getlist(self.parameter_name)
53
+
54
+
55
+ class ChoicesDropdownFilter(ValueMixin, DropdownMixin, admin.ChoicesFieldListFilter):
56
+ def choices(self, changelist: ChangeList) -> Generator[dict[str, Any], None, None]:
57
+ choices = [self.all_option, *self.field.flatchoices]
58
+
59
+ yield {
60
+ "form": self.form_class(
61
+ label=_("By %(filter_title)s") % {"filter_title": self.title},
62
+ name=self.lookup_kwarg,
63
+ choices=choices,
64
+ data={self.lookup_kwarg: self.value()},
65
+ multiple=self.multiple if hasattr(self, "multiple") else False,
66
+ ),
67
+ }
68
+
69
+
70
+ class MultipleChoicesDropdownFilter(MultiValueMixin, ChoicesDropdownFilter):
71
+ multiple = True
72
+
73
+
74
+ class RelatedDropdownFilter(ValueMixin, DropdownMixin, admin.RelatedFieldListFilter):
75
+ def __init__(
76
+ self,
77
+ field: Field,
78
+ request: HttpRequest,
79
+ params: dict[str, str],
80
+ model: type[Model],
81
+ model_admin: ModelAdmin,
82
+ field_path: str,
83
+ ) -> None:
84
+ super().__init__(field, request, params, model, model_admin, field_path)
85
+ self.model_admin = model_admin
86
+
87
+ def choices(self, changelist: ChangeList) -> Generator[dict[str, Any], None, None]:
88
+ yield {
89
+ "form": self.form_class(
90
+ label=_("By %(filter_title)s") % {"filter_title": self.title},
91
+ name=self.lookup_kwarg,
92
+ choices=[self.all_option, *self.lookup_choices],
93
+ data={self.lookup_kwarg: self.value()},
94
+ multiple=self.multiple if hasattr(self, "multiple") else False,
95
+ ),
96
+ }
97
+
98
+
99
+ class MultipleRelatedDropdownFilter(MultiValueMixin, RelatedDropdownFilter):
100
+ multiple = True
@@ -0,0 +1,146 @@
1
+ from collections.abc import Generator
2
+ from typing import Any, Optional
3
+
4
+ from django.contrib.admin.views.main import ChangeList
5
+ from django.core.validators import EMPTY_VALUES
6
+ from django.db.models import QuerySet
7
+ from django.forms import ValidationError
8
+ from django.http import HttpRequest
9
+ from django.utils.translation import gettext_lazy as _
10
+
11
+ from unfold.contrib.filters.forms import (
12
+ AutocompleteDropdownForm,
13
+ DropdownForm,
14
+ RangeNumericForm,
15
+ )
16
+
17
+
18
+ class ValueMixin:
19
+ def value(self) -> Optional[str]:
20
+ return (
21
+ self.lookup_val[0]
22
+ if self.lookup_val not in EMPTY_VALUES
23
+ and isinstance(self.lookup_val, list)
24
+ and len(self.lookup_val) > 0
25
+ else self.lookup_val
26
+ )
27
+
28
+
29
+ class MultiValueMixin:
30
+ def value(self) -> Optional[list[str]]:
31
+ return (
32
+ self.lookup_val
33
+ if self.lookup_val not in EMPTY_VALUES
34
+ and isinstance(self.lookup_val, list)
35
+ and len(self.lookup_val) > 0
36
+ else self.lookup_val
37
+ )
38
+
39
+
40
+ class DropdownMixin:
41
+ template = "unfold/filters/filters_field.html"
42
+ form_class = DropdownForm
43
+ all_option = ["", _("All")]
44
+
45
+ def queryset(self, request: HttpRequest, queryset: QuerySet) -> QuerySet:
46
+ if self.value() not in EMPTY_VALUES:
47
+ return super().queryset(request, queryset)
48
+
49
+ return queryset
50
+
51
+
52
+ class RangeNumericMixin:
53
+ request = None
54
+ parameter_name = None
55
+ template = "unfold/filters/filters_numeric_range.html"
56
+
57
+ def init_used_parameters(self, params: dict[str, Any]) -> None:
58
+ if self.parameter_name + "_from" in params:
59
+ value = params.pop(self.parameter_name + "_from")
60
+
61
+ self.used_parameters[self.parameter_name + "_from"] = (
62
+ value[0] if isinstance(value, list) else value
63
+ )
64
+
65
+ if self.parameter_name + "_to" in params:
66
+ value = params.pop(self.parameter_name + "_to")
67
+ self.used_parameters[self.parameter_name + "_to"] = (
68
+ value[0] if isinstance(value, list) else value
69
+ )
70
+
71
+ def queryset(self, request: HttpRequest, queryset: QuerySet) -> QuerySet:
72
+ filters = {}
73
+
74
+ value_from = self.used_parameters.get(self.parameter_name + "_from", None)
75
+ if value_from is not None and value_from != "":
76
+ filters.update(
77
+ {
78
+ self.parameter_name + "__gte": self.used_parameters.get(
79
+ self.parameter_name + "_from", None
80
+ ),
81
+ }
82
+ )
83
+
84
+ value_to = self.used_parameters.get(self.parameter_name + "_to", None)
85
+ if value_to is not None and value_to != "":
86
+ filters.update(
87
+ {
88
+ self.parameter_name + "__lte": self.used_parameters.get(
89
+ self.parameter_name + "_to", None
90
+ ),
91
+ }
92
+ )
93
+
94
+ try:
95
+ return queryset.filter(**filters)
96
+ except (ValueError, ValidationError):
97
+ return None
98
+
99
+ def expected_parameters(self) -> list[str]:
100
+ return [
101
+ f"{self.parameter_name}_from",
102
+ f"{self.parameter_name}_to",
103
+ ]
104
+
105
+ def choices(self, changelist: ChangeList) -> tuple[dict[str, Any], ...]:
106
+ return (
107
+ {
108
+ "request": self.request,
109
+ "parameter_name": self.parameter_name,
110
+ "form": RangeNumericForm(
111
+ name=self.parameter_name,
112
+ data={
113
+ self.parameter_name + "_from": self.used_parameters.get(
114
+ self.parameter_name + "_from", None
115
+ ),
116
+ self.parameter_name + "_to": self.used_parameters.get(
117
+ self.parameter_name + "_to", None
118
+ ),
119
+ },
120
+ ),
121
+ },
122
+ )
123
+
124
+
125
+ class AutocompleteMixin:
126
+ def __init__(self, *args, **kwargs) -> None:
127
+ super().__init__(*args, **kwargs)
128
+
129
+ if "request" in kwargs:
130
+ self.request = kwargs["request"]
131
+
132
+ def choices(
133
+ self, changelist: ChangeList
134
+ ) -> Generator[dict[str, AutocompleteDropdownForm], None, None]:
135
+ yield {
136
+ "form": self.form_class(
137
+ request=self.request,
138
+ label=_("By %(filter_title)s") % {"filter_title": self.title},
139
+ name=self.lookup_kwarg,
140
+ choices=(),
141
+ field=self.field,
142
+ model_admin=self.model_admin,
143
+ data={self.lookup_kwarg: self.value()},
144
+ multiple=self.multiple if hasattr(self, "multiple") else False,
145
+ ),
146
+ }
@@ -0,0 +1,196 @@
1
+ from typing import Any, Optional
2
+
3
+ from django.contrib import admin
4
+ from django.contrib.admin.options import ModelAdmin
5
+ from django.contrib.admin.views.main import ChangeList
6
+ from django.core.validators import EMPTY_VALUES
7
+ from django.db.models import Max, Min, Model, QuerySet
8
+ from django.db.models.fields import (
9
+ AutoField,
10
+ DecimalField,
11
+ Field,
12
+ FloatField,
13
+ IntegerField,
14
+ )
15
+ from django.forms import ValidationError
16
+ from django.http import HttpRequest
17
+
18
+ from unfold.contrib.filters.admin.mixins import RangeNumericMixin
19
+ from unfold.contrib.filters.forms import SingleNumericForm, SliderNumericForm
20
+
21
+
22
+ class SingleNumericFilter(admin.FieldListFilter):
23
+ request = None
24
+ parameter_name = None
25
+ template = "unfold/filters/filters_numeric_single.html"
26
+
27
+ def __init__(
28
+ self,
29
+ field: Field,
30
+ request: HttpRequest,
31
+ params: dict[str, str],
32
+ model: type[Model],
33
+ model_admin: ModelAdmin,
34
+ field_path: str,
35
+ ) -> None:
36
+ super().__init__(field, request, params, model, model_admin, field_path)
37
+
38
+ if not isinstance(field, (DecimalField, IntegerField, FloatField, AutoField)):
39
+ raise TypeError(
40
+ f"Class {type(self.field)} is not supported for {self.__class__.__name__}."
41
+ )
42
+
43
+ self.request = request
44
+
45
+ if self.parameter_name is None:
46
+ self.parameter_name = self.field_path
47
+
48
+ if self.parameter_name in params:
49
+ value = params.pop(self.parameter_name)
50
+ value = value[0] if isinstance(value, list) else value
51
+
52
+ if value not in EMPTY_VALUES:
53
+ self.used_parameters[self.parameter_name] = value
54
+
55
+ def queryset(
56
+ self, request: HttpRequest, queryset: QuerySet[Any]
57
+ ) -> Optional[QuerySet]:
58
+ if self.value():
59
+ try:
60
+ return queryset.filter(**{self.parameter_name: self.value()})
61
+ except (ValueError, ValidationError):
62
+ return None
63
+
64
+ def value(self) -> Any:
65
+ return self.used_parameters.get(self.parameter_name, None)
66
+
67
+ def expected_parameters(self) -> list[Optional[str]]:
68
+ return [self.parameter_name]
69
+
70
+ def choices(self, changelist: ChangeList) -> tuple[dict[str, Any], ...]:
71
+ return (
72
+ {
73
+ "request": self.request,
74
+ "parameter_name": self.parameter_name,
75
+ "form": SingleNumericForm(
76
+ name=self.parameter_name, data={self.parameter_name: self.value()}
77
+ ),
78
+ },
79
+ )
80
+
81
+
82
+ class RangeNumericListFilter(RangeNumericMixin, admin.SimpleListFilter):
83
+ def __init__(
84
+ self,
85
+ request: HttpRequest,
86
+ params: dict[str, str],
87
+ model: type[Model],
88
+ model_admin: ModelAdmin,
89
+ ) -> None:
90
+ super().__init__(request, params, model, model_admin)
91
+ if not self.parameter_name:
92
+ raise ValueError("Parameter name cannot be None")
93
+
94
+ self.request = request
95
+ self.init_used_parameters(params)
96
+
97
+ def lookups(
98
+ self, request: HttpRequest, model_admin: ModelAdmin
99
+ ) -> tuple[tuple[str, str], ...]:
100
+ return (("dummy", "dummy"),)
101
+
102
+
103
+ class RangeNumericFilter(RangeNumericMixin, admin.FieldListFilter):
104
+ def __init__(
105
+ self,
106
+ field: Field,
107
+ request: HttpRequest,
108
+ params: dict[str, str],
109
+ model: type[Model],
110
+ model_admin: ModelAdmin,
111
+ field_path: str,
112
+ ) -> None:
113
+ super().__init__(field, request, params, model, model_admin, field_path)
114
+ if not isinstance(field, (DecimalField, IntegerField, FloatField, AutoField)):
115
+ raise TypeError(
116
+ f"Class {type(self.field)} is not supported for {self.__class__.__name__}."
117
+ )
118
+
119
+ self.request = request
120
+ if self.parameter_name is None:
121
+ self.parameter_name = self.field_path
122
+
123
+ self.init_used_parameters(params)
124
+
125
+
126
+ class SliderNumericFilter(RangeNumericFilter):
127
+ MAX_DECIMALS = 7
128
+ STEP = None
129
+
130
+ template = "unfold/filters/filters_numeric_slider.html"
131
+ field = None
132
+ form_class = SliderNumericForm
133
+
134
+ def __init__(
135
+ self,
136
+ field: Field,
137
+ request: HttpRequest,
138
+ params: dict[str, str],
139
+ model: type[Model],
140
+ model_admin: ModelAdmin,
141
+ field_path: str,
142
+ ) -> None:
143
+ super().__init__(field, request, params, model, model_admin, field_path)
144
+
145
+ self.field = field
146
+ self.q = model_admin.get_queryset(request)
147
+
148
+ def choices(self, changelist: ChangeList) -> tuple[dict[str, Any], ...]:
149
+ total = self.q.all().count()
150
+ min_value = self.q.all().aggregate(min=Min(self.parameter_name)).get("min", 0)
151
+
152
+ if total > 1:
153
+ max_value = (
154
+ self.q.all().aggregate(max=Max(self.parameter_name)).get("max", 0)
155
+ )
156
+ else:
157
+ max_value = None
158
+
159
+ if isinstance(self.field, (FloatField, DecimalField)):
160
+ decimals = self.MAX_DECIMALS
161
+ step = self.STEP if self.STEP else self._get_min_step(self.MAX_DECIMALS)
162
+ else:
163
+ decimals = 0
164
+ step = self.STEP if self.STEP else 1
165
+
166
+ return (
167
+ {
168
+ "decimals": decimals,
169
+ "step": step,
170
+ "parameter_name": self.parameter_name,
171
+ "request": self.request,
172
+ "min": min_value,
173
+ "max": max_value,
174
+ "value_from": self.used_parameters.get(
175
+ self.parameter_name + "_from", min_value
176
+ ),
177
+ "value_to": self.used_parameters.get(
178
+ self.parameter_name + "_to", max_value
179
+ ),
180
+ "form": self.form_class(
181
+ name=self.parameter_name,
182
+ data={
183
+ self.parameter_name + "_from": self.used_parameters.get(
184
+ self.parameter_name + "_from", min_value
185
+ ),
186
+ self.parameter_name + "_to": self.used_parameters.get(
187
+ self.parameter_name + "_to", max_value
188
+ ),
189
+ },
190
+ ),
191
+ },
192
+ )
193
+
194
+ def _get_min_step(self, precision: int) -> float:
195
+ result_format = f"{{:.{precision - 1}f}}"
196
+ return float(result_format.format(0) + "1")
@@ -0,0 +1,65 @@
1
+ from typing import Any
2
+
3
+ from django.contrib import admin
4
+ from django.contrib.admin.options import ModelAdmin
5
+ from django.contrib.admin.views.main import ChangeList
6
+ from django.db.models import Field, Model
7
+ from django.http import HttpRequest
8
+ from django.utils.translation import gettext_lazy as _
9
+
10
+ from unfold.contrib.filters.admin.mixins import ValueMixin
11
+ from unfold.contrib.filters.forms import SearchForm
12
+
13
+
14
+ class TextFilter(admin.SimpleListFilter):
15
+ template = "unfold/filters/filters_field.html"
16
+ form_class = SearchForm
17
+
18
+ def has_output(self) -> bool:
19
+ return True
20
+
21
+ def lookups(self, request: HttpRequest, model_admin: ModelAdmin) -> tuple:
22
+ return ()
23
+
24
+ def choices(self, changelist: ChangeList) -> tuple[dict[str, Any], ...]:
25
+ return (
26
+ {
27
+ "form": self.form_class(
28
+ name=self.parameter_name,
29
+ label=_("By %(filter_title)s") % {"filter_title": self.title},
30
+ data={self.parameter_name: self.value()},
31
+ ),
32
+ },
33
+ )
34
+
35
+
36
+ class FieldTextFilter(ValueMixin, admin.FieldListFilter):
37
+ template = "unfold/filters/filters_field.html"
38
+ form_class = SearchForm
39
+
40
+ def __init__(
41
+ self,
42
+ field: Field,
43
+ request: HttpRequest,
44
+ params: dict[str, str],
45
+ model: type[Model],
46
+ model_admin: ModelAdmin,
47
+ field_path: str,
48
+ ) -> None:
49
+ self.lookup_kwarg = f"{field_path}__icontains"
50
+ self.lookup_val = params.get(self.lookup_kwarg)
51
+ super().__init__(field, request, params, model, model_admin, field_path)
52
+
53
+ def expected_parameters(self) -> list[str]:
54
+ return [self.lookup_kwarg]
55
+
56
+ def choices(self, changelist: ChangeList) -> tuple[dict[str, Any], ...]:
57
+ return (
58
+ {
59
+ "form": self.form_class(
60
+ label=_("By %(filter_title)s") % {"filter_title": self.title},
61
+ name=self.lookup_kwarg,
62
+ data={self.lookup_kwarg: self.value()},
63
+ ),
64
+ },
65
+ )