django-unfold 0.46.0__py3-none-any.whl → 0.48.0__py3-none-any.whl
Sign up to get free protection for your applications and to get access to all the features.
- {django_unfold-0.46.0.dist-info → django_unfold-0.48.0.dist-info}/METADATA +5 -6
- {django_unfold-0.46.0.dist-info → django_unfold-0.48.0.dist-info}/RECORD +52 -43
- {django_unfold-0.46.0.dist-info → django_unfold-0.48.0.dist-info}/WHEEL +1 -1
- unfold/admin.py +15 -16
- unfold/checks.py +4 -4
- unfold/components.py +5 -5
- unfold/contrib/filters/admin/__init__.py +43 -0
- unfold/contrib/filters/admin/autocomplete_filters.py +16 -0
- unfold/contrib/filters/admin/datetime_filters.py +212 -0
- unfold/contrib/filters/admin/dropdown_filters.py +100 -0
- unfold/contrib/filters/admin/mixins.py +146 -0
- unfold/contrib/filters/admin/numeric_filters.py +196 -0
- unfold/contrib/filters/admin/text_filters.py +65 -0
- unfold/contrib/filters/admin.py +32 -32
- unfold/contrib/filters/forms.py +68 -17
- unfold/contrib/forms/widgets.py +9 -9
- unfold/contrib/inlines/checks.py +2 -4
- unfold/contrib/simple_history/templates/simple_history/object_history.html +17 -1
- unfold/contrib/simple_history/templates/simple_history/object_history_list.html +1 -1
- unfold/dataclasses.py +9 -2
- unfold/decorators.py +4 -3
- unfold/settings.py +4 -2
- unfold/sites.py +176 -140
- unfold/static/unfold/css/styles.css +1 -1
- unfold/static/unfold/js/app.js +2 -2
- unfold/templates/admin/app_index.html +1 -5
- unfold/templates/admin/base_site.html +1 -1
- unfold/templates/admin/filter.html +1 -1
- unfold/templates/admin/index.html +1 -5
- unfold/templates/admin/login.html +1 -1
- unfold/templates/admin/search_form.html +4 -2
- unfold/templates/unfold/helpers/account_links.html +1 -1
- unfold/templates/unfold/helpers/actions_row.html +1 -1
- unfold/templates/unfold/helpers/change_list_filter.html +2 -2
- unfold/templates/unfold/helpers/change_list_filter_actions.html +1 -1
- unfold/templates/unfold/helpers/header_back_button.html +2 -2
- unfold/templates/unfold/helpers/language_switch.html +1 -1
- unfold/templates/unfold/helpers/navigation_header.html +15 -5
- unfold/templates/unfold/helpers/site_branding.html +9 -0
- unfold/templates/unfold/helpers/site_dropdown.html +19 -0
- unfold/templates/unfold/helpers/site_icon.html +10 -2
- unfold/templates/unfold/helpers/tab_list.html +7 -1
- unfold/templates/unfold/helpers/theme_switch.html +1 -1
- unfold/templates/unfold/layouts/base.html +1 -5
- unfold/templates/unfold/layouts/skeleton.html +1 -1
- unfold/templatetags/unfold.py +55 -22
- unfold/templatetags/unfold_list.py +2 -2
- unfold/typing.py +5 -4
- unfold/utils.py +3 -2
- unfold/views.py +2 -2
- unfold/widgets.py +27 -27
- {django_unfold-0.46.0.dist-info → django_unfold-0.48.0.dist-info}/LICENSE.md +0 -0
@@ -0,0 +1,212 @@
|
|
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.core.validators import EMPTY_VALUES
|
7
|
+
from django.db.models import Model, QuerySet
|
8
|
+
from django.db.models.fields import (
|
9
|
+
DateField,
|
10
|
+
DateTimeField,
|
11
|
+
Field,
|
12
|
+
)
|
13
|
+
from django.forms import ValidationError
|
14
|
+
from django.http import HttpRequest
|
15
|
+
|
16
|
+
from unfold.contrib.filters.forms import (
|
17
|
+
RangeDateForm,
|
18
|
+
RangeDateTimeForm,
|
19
|
+
)
|
20
|
+
from unfold.utils import parse_date_str, parse_datetime_str
|
21
|
+
|
22
|
+
|
23
|
+
class RangeDateFilter(admin.FieldListFilter):
|
24
|
+
request = None
|
25
|
+
parameter_name = None
|
26
|
+
form_class = RangeDateForm
|
27
|
+
template = "unfold/filters/filters_date_range.html"
|
28
|
+
|
29
|
+
def __init__(
|
30
|
+
self,
|
31
|
+
field: Field,
|
32
|
+
request: HttpRequest,
|
33
|
+
params: dict[str, str],
|
34
|
+
model: type[Model],
|
35
|
+
model_admin: ModelAdmin,
|
36
|
+
field_path: str,
|
37
|
+
) -> None:
|
38
|
+
super().__init__(field, request, params, model, model_admin, field_path)
|
39
|
+
if not isinstance(field, DateField):
|
40
|
+
raise TypeError(
|
41
|
+
f"Class {type(self.field)} is not supported for {self.__class__.__name__}."
|
42
|
+
)
|
43
|
+
|
44
|
+
self.request = request
|
45
|
+
if self.parameter_name is None:
|
46
|
+
self.parameter_name = self.field_path
|
47
|
+
|
48
|
+
if self.parameter_name + "_from" in params:
|
49
|
+
value = params.pop(self.field_path + "_from")
|
50
|
+
value = value[0] if isinstance(value, list) else value
|
51
|
+
|
52
|
+
if value not in EMPTY_VALUES:
|
53
|
+
self.used_parameters[self.field_path + "_from"] = value
|
54
|
+
|
55
|
+
if self.parameter_name + "_to" in params:
|
56
|
+
value = params.pop(self.field_path + "_to")
|
57
|
+
value = value[0] if isinstance(value, list) else value
|
58
|
+
|
59
|
+
if value not in EMPTY_VALUES:
|
60
|
+
self.used_parameters[self.field_path + "_to"] = value
|
61
|
+
|
62
|
+
def queryset(self, request: HttpRequest, queryset: QuerySet) -> QuerySet:
|
63
|
+
filters = {}
|
64
|
+
|
65
|
+
value_from = self.used_parameters.get(self.parameter_name + "_from")
|
66
|
+
if value_from not in EMPTY_VALUES:
|
67
|
+
filters.update({self.parameter_name + "__gte": parse_date_str(value_from)})
|
68
|
+
|
69
|
+
value_to = self.used_parameters.get(self.parameter_name + "_to")
|
70
|
+
if value_to not in EMPTY_VALUES:
|
71
|
+
filters.update({self.parameter_name + "__lte": parse_date_str(value_to)})
|
72
|
+
|
73
|
+
try:
|
74
|
+
return queryset.filter(**filters)
|
75
|
+
except (ValueError, ValidationError):
|
76
|
+
return None
|
77
|
+
|
78
|
+
def expected_parameters(self) -> list[str]:
|
79
|
+
return [
|
80
|
+
f"{self.parameter_name}_from",
|
81
|
+
f"{self.parameter_name}_to",
|
82
|
+
]
|
83
|
+
|
84
|
+
def choices(self, changelist: ChangeList) -> tuple[dict[str, Any], ...]:
|
85
|
+
return (
|
86
|
+
{
|
87
|
+
"request": self.request,
|
88
|
+
"parameter_name": self.parameter_name,
|
89
|
+
"form": self.form_class(
|
90
|
+
name=self.parameter_name,
|
91
|
+
data={
|
92
|
+
self.parameter_name + "_from": self.used_parameters.get(
|
93
|
+
self.parameter_name + "_from", None
|
94
|
+
),
|
95
|
+
self.parameter_name + "_to": self.used_parameters.get(
|
96
|
+
self.parameter_name + "_to", None
|
97
|
+
),
|
98
|
+
},
|
99
|
+
),
|
100
|
+
},
|
101
|
+
)
|
102
|
+
|
103
|
+
|
104
|
+
class RangeDateTimeFilter(admin.FieldListFilter):
|
105
|
+
request = None
|
106
|
+
parameter_name = None
|
107
|
+
template = "unfold/filters/filters_datetime_range.html"
|
108
|
+
form_class = RangeDateTimeForm
|
109
|
+
|
110
|
+
def __init__(
|
111
|
+
self,
|
112
|
+
field: Field,
|
113
|
+
request: HttpRequest,
|
114
|
+
params: dict[str, str],
|
115
|
+
model: type[Model],
|
116
|
+
model_admin: ModelAdmin,
|
117
|
+
field_path: str,
|
118
|
+
) -> None:
|
119
|
+
super().__init__(field, request, params, model, model_admin, field_path)
|
120
|
+
if not isinstance(field, DateTimeField):
|
121
|
+
raise TypeError(
|
122
|
+
f"Class {type(self.field)} is not supported for {self.__class__.__name__}."
|
123
|
+
)
|
124
|
+
|
125
|
+
self.request = request
|
126
|
+
if self.parameter_name is None:
|
127
|
+
self.parameter_name = self.field_path
|
128
|
+
|
129
|
+
if self.parameter_name + "_from_0" in params:
|
130
|
+
value = params.pop(self.field_path + "_from_0")
|
131
|
+
value = value[0] if isinstance(value, list) else value
|
132
|
+
self.used_parameters[self.field_path + "_from_0"] = value
|
133
|
+
|
134
|
+
if self.parameter_name + "_from_1" in params:
|
135
|
+
value = params.pop(self.field_path + "_from_1")
|
136
|
+
value = value[0] if isinstance(value, list) else value
|
137
|
+
self.used_parameters[self.field_path + "_from_1"] = value
|
138
|
+
|
139
|
+
if self.parameter_name + "_to_0" in params:
|
140
|
+
value = params.pop(self.field_path + "_to_0")
|
141
|
+
value = value[0] if isinstance(value, list) else value
|
142
|
+
self.used_parameters[self.field_path + "_to_0"] = value
|
143
|
+
|
144
|
+
if self.parameter_name + "_to_1" in params:
|
145
|
+
value = params.pop(self.field_path + "_to_1")
|
146
|
+
value = value[0] if isinstance(value, list) else value
|
147
|
+
self.used_parameters[self.field_path + "_to_1"] = value
|
148
|
+
|
149
|
+
def expected_parameters(self) -> list[str]:
|
150
|
+
return [
|
151
|
+
f"{self.parameter_name}_from_0",
|
152
|
+
f"{self.parameter_name}_from_1",
|
153
|
+
f"{self.parameter_name}_to_0",
|
154
|
+
f"{self.parameter_name}_to_1",
|
155
|
+
]
|
156
|
+
|
157
|
+
def queryset(self, request: HttpRequest, queryset: QuerySet) -> QuerySet:
|
158
|
+
filters = {}
|
159
|
+
|
160
|
+
date_value_from = self.used_parameters.get(self.parameter_name + "_from_0")
|
161
|
+
time_value_from = self.used_parameters.get(self.parameter_name + "_from_1")
|
162
|
+
|
163
|
+
date_value_to = self.used_parameters.get(self.parameter_name + "_to_0")
|
164
|
+
time_value_to = self.used_parameters.get(self.parameter_name + "_to_1")
|
165
|
+
|
166
|
+
if date_value_from not in EMPTY_VALUES and time_value_from not in EMPTY_VALUES:
|
167
|
+
filters.update(
|
168
|
+
{
|
169
|
+
f"{self.parameter_name}__gte": parse_datetime_str(
|
170
|
+
f"{date_value_from} {time_value_from}"
|
171
|
+
),
|
172
|
+
}
|
173
|
+
)
|
174
|
+
|
175
|
+
if date_value_to not in EMPTY_VALUES and time_value_to not in EMPTY_VALUES:
|
176
|
+
filters.update(
|
177
|
+
{
|
178
|
+
f"{self.parameter_name}__lte": parse_datetime_str(
|
179
|
+
f"{date_value_to} {time_value_to}"
|
180
|
+
),
|
181
|
+
}
|
182
|
+
)
|
183
|
+
|
184
|
+
try:
|
185
|
+
return queryset.filter(**filters)
|
186
|
+
except (ValueError, ValidationError):
|
187
|
+
return None
|
188
|
+
|
189
|
+
def choices(self, changelist: ChangeList) -> tuple[dict[str, Any], ...]:
|
190
|
+
return (
|
191
|
+
{
|
192
|
+
"request": self.request,
|
193
|
+
"parameter_name": self.parameter_name,
|
194
|
+
"form": self.form_class(
|
195
|
+
name=self.parameter_name,
|
196
|
+
data={
|
197
|
+
self.parameter_name + "_from_0": self.used_parameters.get(
|
198
|
+
self.parameter_name + "_from_0"
|
199
|
+
),
|
200
|
+
self.parameter_name + "_from_1": self.used_parameters.get(
|
201
|
+
self.parameter_name + "_from_1"
|
202
|
+
),
|
203
|
+
self.parameter_name + "_to_0": self.used_parameters.get(
|
204
|
+
self.parameter_name + "_to_0"
|
205
|
+
),
|
206
|
+
self.parameter_name + "_to_1": self.used_parameters.get(
|
207
|
+
self.parameter_name + "_to_1"
|
208
|
+
),
|
209
|
+
},
|
210
|
+
),
|
211
|
+
},
|
212
|
+
)
|
@@ -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")
|