django-unfold 0.46.0__py3-none-any.whl → 0.47.0__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
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
+ )