django-unfold 0.49.1__py3-none-any.whl → 0.51.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 (36) hide show
  1. django_unfold-0.51.0.dist-info/METADATA +81 -0
  2. {django_unfold-0.49.1.dist-info → django_unfold-0.51.0.dist-info}/RECORD +34 -30
  3. {django_unfold-0.49.1.dist-info → django_unfold-0.51.0.dist-info}/WHEEL +1 -1
  4. unfold/admin.py +4 -0
  5. unfold/contrib/filters/admin/dropdown_filters.py +1 -0
  6. unfold/contrib/filters/admin/mixins.py +10 -4
  7. unfold/contrib/filters/forms.py +2 -2
  8. unfold/dataclasses.py +3 -0
  9. unfold/decorators.py +8 -0
  10. unfold/enums.py +10 -0
  11. unfold/mixins/action_model_admin.py +4 -0
  12. unfold/sections.py +82 -0
  13. unfold/settings.py +1 -0
  14. unfold/sites.py +3 -0
  15. unfold/static/admin/js/inlines.js +18 -5
  16. unfold/static/unfold/css/styles.css +1 -1
  17. unfold/static/unfold/js/select2.init.js +29 -0
  18. unfold/styles.css +1 -1
  19. unfold/templates/admin/change_list.html +16 -16
  20. unfold/templates/admin/change_list_results.html +38 -40
  21. unfold/templates/admin/filter.html +2 -2
  22. unfold/templates/unfold/components/card.html +1 -1
  23. unfold/templates/unfold/components/table.html +42 -26
  24. unfold/templates/unfold/helpers/change_list_filter_actions.html +1 -1
  25. unfold/templates/unfold/helpers/empty_results.html +35 -0
  26. unfold/templates/unfold/helpers/tab_action.html +24 -19
  27. unfold/templates/unfold/helpers/tab_actions.html +19 -0
  28. unfold/templates/unfold/helpers/tab_items.html +47 -0
  29. unfold/templates/unfold/helpers/tab_list.html +3 -66
  30. unfold/templates/unfold/layouts/skeleton.html +1 -2
  31. unfold/templatetags/unfold.py +81 -2
  32. unfold/templatetags/unfold_list.py +10 -10
  33. unfold/views.py +14 -1
  34. django_unfold-0.49.1.dist-info/METADATA +0 -75
  35. unfold/contrib/filters/admin.py +0 -619
  36. {django_unfold-0.49.1.dist-info → django_unfold-0.51.0.dist-info}/LICENSE.md +0 -0
@@ -1,619 +0,0 @@
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
- DateField,
11
- DateTimeField,
12
- DecimalField,
13
- Field,
14
- FloatField,
15
- IntegerField,
16
- )
17
- from django.forms import ValidationError
18
- from django.http import HttpRequest
19
- from django.utils.translation import gettext_lazy as _
20
-
21
- from unfold.utils import parse_date_str, parse_datetime_str
22
-
23
- from .forms import (
24
- DropdownForm,
25
- RangeDateForm,
26
- RangeDateTimeForm,
27
- RangeNumericForm,
28
- SearchForm,
29
- SingleNumericForm,
30
- SliderNumericForm,
31
- )
32
-
33
-
34
- class ValueMixin:
35
- def value(self) -> Optional[str]:
36
- return (
37
- self.lookup_val[0]
38
- if self.lookup_val not in EMPTY_VALUES
39
- and isinstance(self.lookup_val, list)
40
- and len(self.lookup_val) > 0
41
- else self.lookup_val
42
- )
43
-
44
-
45
- class MultiValueMixin:
46
- def value(self) -> Optional[list[str]]:
47
- return (
48
- self.lookup_val
49
- if self.lookup_val not in EMPTY_VALUES
50
- and isinstance(self.lookup_val, list)
51
- and len(self.lookup_val) > 0
52
- else self.lookup_val
53
- )
54
-
55
-
56
- class DropdownMixin:
57
- template = "unfold/filters/filters_field.html"
58
- form_class = DropdownForm
59
- all_option = ["", _("All")]
60
-
61
- def queryset(self, request: HttpRequest, queryset: QuerySet) -> QuerySet:
62
- if self.value() not in EMPTY_VALUES:
63
- return super().queryset(request, queryset)
64
-
65
- return queryset
66
-
67
-
68
- class TextFilter(admin.SimpleListFilter):
69
- template = "unfold/filters/filters_field.html"
70
- form_class = SearchForm
71
-
72
- def has_output(self) -> bool:
73
- return True
74
-
75
- def lookups(self, request: HttpRequest, model_admin: ModelAdmin) -> tuple:
76
- return ()
77
-
78
- def choices(self, changelist: ChangeList) -> tuple[dict[str, Any], ...]:
79
- return (
80
- {
81
- "form": self.form_class(
82
- name=self.parameter_name,
83
- label=_("By %(filter_title)s") % {"filter_title": self.title},
84
- data={self.parameter_name: self.value()},
85
- ),
86
- },
87
- )
88
-
89
-
90
- class FieldTextFilter(ValueMixin, admin.FieldListFilter):
91
- template = "unfold/filters/filters_field.html"
92
- form_class = SearchForm
93
-
94
- def __init__(self, field, request, params, model, model_admin, field_path):
95
- self.lookup_kwarg = f"{field_path}__icontains"
96
- self.lookup_val = params.get(self.lookup_kwarg)
97
- super().__init__(field, request, params, model, model_admin, field_path)
98
-
99
- def expected_parameters(self) -> list[str]:
100
- return [self.lookup_kwarg]
101
-
102
- def choices(self, changelist: ChangeList) -> tuple[dict[str, Any], ...]:
103
- return (
104
- {
105
- "form": self.form_class(
106
- label=_("By %(filter_title)s") % {"filter_title": self.title},
107
- name=self.lookup_kwarg,
108
- data={self.lookup_kwarg: self.value()},
109
- ),
110
- },
111
- )
112
-
113
-
114
- class DropdownFilter(admin.SimpleListFilter):
115
- template = "unfold/filters/filters_field.html"
116
- form_class = DropdownForm
117
- all_option = ["", _("All")]
118
-
119
- def choices(self, changelist: ChangeList) -> tuple[dict[str, Any], ...]:
120
- return (
121
- {
122
- "form": self.form_class(
123
- label=_("By %(filter_title)s") % {"filter_title": self.title},
124
- name=self.parameter_name,
125
- choices=[self.all_option, *self.lookup_choices],
126
- data={self.parameter_name: self.value()},
127
- multiple=self.multiple if hasattr(self, "multiple") else False,
128
- ),
129
- },
130
- )
131
-
132
-
133
- class MultipleDropdownFilter(DropdownFilter):
134
- multiple = True
135
-
136
- def __init__(self, request, params, model, model_admin):
137
- self.request = request
138
- super().__init__(request, params, model, model_admin)
139
-
140
- def value(self):
141
- return self.request.GET.getlist(self.parameter_name)
142
-
143
-
144
- class ChoicesDropdownFilter(ValueMixin, DropdownMixin, admin.ChoicesFieldListFilter):
145
- def choices(self, changelist: ChangeList):
146
- choices = [self.all_option, *self.field.flatchoices]
147
-
148
- yield {
149
- "form": self.form_class(
150
- label=_("By %(filter_title)s") % {"filter_title": self.title},
151
- name=self.lookup_kwarg,
152
- choices=choices,
153
- data={self.lookup_kwarg: self.value()},
154
- multiple=self.multiple if hasattr(self, "multiple") else False,
155
- ),
156
- }
157
-
158
-
159
- class MultipleChoicesDropdownFilter(MultiValueMixin, ChoicesDropdownFilter):
160
- multiple = True
161
-
162
-
163
- class RelatedDropdownFilter(ValueMixin, DropdownMixin, admin.RelatedFieldListFilter):
164
- def choices(self, changelist: ChangeList):
165
- yield {
166
- "form": self.form_class(
167
- label=_("By %(filter_title)s") % {"filter_title": self.title},
168
- name=self.lookup_kwarg,
169
- choices=[self.all_option, *self.lookup_choices],
170
- data={self.lookup_kwarg: self.value()},
171
- multiple=self.multiple if hasattr(self, "multiple") else False,
172
- ),
173
- }
174
-
175
-
176
- class MultipleRelatedDropdownFilter(MultiValueMixin, RelatedDropdownFilter):
177
- multiple = True
178
-
179
-
180
- class SingleNumericFilter(admin.FieldListFilter):
181
- request = None
182
- parameter_name = None
183
- template = "unfold/filters/filters_numeric_single.html"
184
-
185
- def __init__(
186
- self,
187
- field: Field,
188
- request: HttpRequest,
189
- params: dict[str, str],
190
- model: type[Model],
191
- model_admin: ModelAdmin,
192
- field_path: str,
193
- ) -> None:
194
- super().__init__(field, request, params, model, model_admin, field_path)
195
-
196
- if not isinstance(field, (DecimalField, IntegerField, FloatField, AutoField)):
197
- raise TypeError(
198
- f"Class {type(self.field)} is not supported for {self.__class__.__name__}."
199
- )
200
-
201
- self.request = request
202
-
203
- if self.parameter_name is None:
204
- self.parameter_name = self.field_path
205
-
206
- if self.parameter_name in params:
207
- value = params.pop(self.parameter_name)
208
- value = value[0] if isinstance(value, list) else value
209
-
210
- if value not in EMPTY_VALUES:
211
- self.used_parameters[self.parameter_name] = value
212
-
213
- def queryset(
214
- self, request: HttpRequest, queryset: QuerySet[Any]
215
- ) -> Optional[QuerySet]:
216
- if self.value():
217
- try:
218
- return queryset.filter(**{self.parameter_name: self.value()})
219
- except (ValueError, ValidationError):
220
- return None
221
-
222
- def value(self) -> Any:
223
- return self.used_parameters.get(self.parameter_name, None)
224
-
225
- def expected_parameters(self) -> list[Optional[str]]:
226
- return [self.parameter_name]
227
-
228
- def choices(self, changelist: ChangeList) -> tuple[dict[str, Any], ...]:
229
- return (
230
- {
231
- "request": self.request,
232
- "parameter_name": self.parameter_name,
233
- "form": SingleNumericForm(
234
- name=self.parameter_name, data={self.parameter_name: self.value()}
235
- ),
236
- },
237
- )
238
-
239
-
240
- class RangeNumericMixin:
241
- request = None
242
- parameter_name = None
243
- template = "unfold/filters/filters_numeric_range.html"
244
-
245
- def init_used_parameters(self, params: dict[str, Any]) -> None:
246
- if self.parameter_name + "_from" in params:
247
- value = params.pop(self.parameter_name + "_from")
248
-
249
- self.used_parameters[self.parameter_name + "_from"] = (
250
- value[0] if isinstance(value, list) else value
251
- )
252
-
253
- if self.parameter_name + "_to" in params:
254
- value = params.pop(self.parameter_name + "_to")
255
- self.used_parameters[self.parameter_name + "_to"] = (
256
- value[0] if isinstance(value, list) else value
257
- )
258
-
259
- def queryset(self, request: HttpRequest, queryset: QuerySet) -> QuerySet:
260
- filters = {}
261
-
262
- value_from = self.used_parameters.get(self.parameter_name + "_from", None)
263
- if value_from is not None and value_from != "":
264
- filters.update(
265
- {
266
- self.parameter_name + "__gte": self.used_parameters.get(
267
- self.parameter_name + "_from", None
268
- ),
269
- }
270
- )
271
-
272
- value_to = self.used_parameters.get(self.parameter_name + "_to", None)
273
- if value_to is not None and value_to != "":
274
- filters.update(
275
- {
276
- self.parameter_name + "__lte": self.used_parameters.get(
277
- self.parameter_name + "_to", None
278
- ),
279
- }
280
- )
281
-
282
- try:
283
- return queryset.filter(**filters)
284
- except (ValueError, ValidationError):
285
- return None
286
-
287
- def expected_parameters(self) -> list[str]:
288
- return [
289
- f"{self.parameter_name}_from",
290
- f"{self.parameter_name}_to",
291
- ]
292
-
293
- def choices(self, changelist: ChangeList) -> tuple[dict[str, Any], ...]:
294
- return (
295
- {
296
- "request": self.request,
297
- "parameter_name": self.parameter_name,
298
- "form": RangeNumericForm(
299
- name=self.parameter_name,
300
- data={
301
- self.parameter_name + "_from": self.used_parameters.get(
302
- self.parameter_name + "_from", None
303
- ),
304
- self.parameter_name + "_to": self.used_parameters.get(
305
- self.parameter_name + "_to", None
306
- ),
307
- },
308
- ),
309
- },
310
- )
311
-
312
-
313
- class RangeNumericListFilter(RangeNumericMixin, admin.SimpleListFilter):
314
- def __init__(
315
- self,
316
- request: HttpRequest,
317
- params: dict[str, str],
318
- model: type[Model],
319
- model_admin: ModelAdmin,
320
- ) -> None:
321
- super().__init__(request, params, model, model_admin)
322
- if not self.parameter_name:
323
- raise ValueError("Parameter name cannot be None")
324
-
325
- self.request = request
326
- self.init_used_parameters(params)
327
-
328
- def lookups(
329
- self, request: HttpRequest, model_admin: ModelAdmin
330
- ) -> tuple[tuple[str, str], ...]:
331
- return (("dummy", "dummy"),)
332
-
333
-
334
- class RangeNumericFilter(RangeNumericMixin, admin.FieldListFilter):
335
- def __init__(
336
- self,
337
- field: Field,
338
- request: HttpRequest,
339
- params: dict[str, str],
340
- model: type[Model],
341
- model_admin: ModelAdmin,
342
- field_path: str,
343
- ) -> None:
344
- super().__init__(field, request, params, model, model_admin, field_path)
345
- if not isinstance(field, (DecimalField, IntegerField, FloatField, AutoField)):
346
- raise TypeError(
347
- f"Class {type(self.field)} is not supported for {self.__class__.__name__}."
348
- )
349
-
350
- self.request = request
351
- if self.parameter_name is None:
352
- self.parameter_name = self.field_path
353
-
354
- self.init_used_parameters(params)
355
-
356
-
357
- class SliderNumericFilter(RangeNumericFilter):
358
- MAX_DECIMALS = 7
359
- STEP = None
360
-
361
- template = "unfold/filters/filters_numeric_slider.html"
362
- field = None
363
- form_class = SliderNumericForm
364
-
365
- def __init__(
366
- self,
367
- field: Field,
368
- request: HttpRequest,
369
- params: dict[str, str],
370
- model: type[Model],
371
- model_admin: ModelAdmin,
372
- field_path: str,
373
- ) -> None:
374
- super().__init__(field, request, params, model, model_admin, field_path)
375
-
376
- self.field = field
377
- self.q = model_admin.get_queryset(request)
378
-
379
- def choices(self, changelist: ChangeList) -> tuple[dict[str, Any], ...]:
380
- total = self.q.all().count()
381
- min_value = self.q.all().aggregate(min=Min(self.parameter_name)).get("min", 0)
382
-
383
- if total > 1:
384
- max_value = (
385
- self.q.all().aggregate(max=Max(self.parameter_name)).get("max", 0)
386
- )
387
- else:
388
- max_value = None
389
-
390
- if isinstance(self.field, (FloatField, DecimalField)):
391
- decimals = self.MAX_DECIMALS
392
- step = self.STEP if self.STEP else self._get_min_step(self.MAX_DECIMALS)
393
- else:
394
- decimals = 0
395
- step = self.STEP if self.STEP else 1
396
-
397
- return (
398
- {
399
- "decimals": decimals,
400
- "step": step,
401
- "parameter_name": self.parameter_name,
402
- "request": self.request,
403
- "min": min_value,
404
- "max": max_value,
405
- "value_from": self.used_parameters.get(
406
- self.parameter_name + "_from", min_value
407
- ),
408
- "value_to": self.used_parameters.get(
409
- self.parameter_name + "_to", max_value
410
- ),
411
- "form": self.form_class(
412
- name=self.parameter_name,
413
- data={
414
- self.parameter_name + "_from": self.used_parameters.get(
415
- self.parameter_name + "_from", min_value
416
- ),
417
- self.parameter_name + "_to": self.used_parameters.get(
418
- self.parameter_name + "_to", max_value
419
- ),
420
- },
421
- ),
422
- },
423
- )
424
-
425
- def _get_min_step(self, precision: int) -> float:
426
- result_format = f"{{:.{precision - 1}f}}"
427
- return float(result_format.format(0) + "1")
428
-
429
-
430
- class RangeDateFilter(admin.FieldListFilter):
431
- request = None
432
- parameter_name = None
433
- form_class = RangeDateForm
434
- template = "unfold/filters/filters_date_range.html"
435
-
436
- def __init__(
437
- self,
438
- field: Field,
439
- request: HttpRequest,
440
- params: dict[str, str],
441
- model: type[Model],
442
- model_admin: ModelAdmin,
443
- field_path: str,
444
- ) -> None:
445
- super().__init__(field, request, params, model, model_admin, field_path)
446
- if not isinstance(field, DateField):
447
- raise TypeError(
448
- f"Class {type(self.field)} is not supported for {self.__class__.__name__}."
449
- )
450
-
451
- self.request = request
452
- if self.parameter_name is None:
453
- self.parameter_name = self.field_path
454
-
455
- if self.parameter_name + "_from" in params:
456
- value = params.pop(self.field_path + "_from")
457
- value = value[0] if isinstance(value, list) else value
458
-
459
- if value not in EMPTY_VALUES:
460
- self.used_parameters[self.field_path + "_from"] = value
461
-
462
- if self.parameter_name + "_to" in params:
463
- value = params.pop(self.field_path + "_to")
464
- value = value[0] if isinstance(value, list) else value
465
-
466
- if value not in EMPTY_VALUES:
467
- self.used_parameters[self.field_path + "_to"] = value
468
-
469
- def queryset(self, request: HttpRequest, queryset: QuerySet) -> QuerySet:
470
- filters = {}
471
-
472
- value_from = self.used_parameters.get(self.parameter_name + "_from")
473
- if value_from not in EMPTY_VALUES:
474
- filters.update({self.parameter_name + "__gte": parse_date_str(value_from)})
475
-
476
- value_to = self.used_parameters.get(self.parameter_name + "_to")
477
- if value_to not in EMPTY_VALUES:
478
- filters.update({self.parameter_name + "__lte": parse_date_str(value_to)})
479
-
480
- try:
481
- return queryset.filter(**filters)
482
- except (ValueError, ValidationError):
483
- return None
484
-
485
- def expected_parameters(self) -> list[str]:
486
- return [
487
- f"{self.parameter_name}_from",
488
- f"{self.parameter_name}_to",
489
- ]
490
-
491
- def choices(self, changelist: ChangeList) -> tuple[dict[str, Any], ...]:
492
- return (
493
- {
494
- "request": self.request,
495
- "parameter_name": self.parameter_name,
496
- "form": self.form_class(
497
- name=self.parameter_name,
498
- data={
499
- self.parameter_name + "_from": self.used_parameters.get(
500
- self.parameter_name + "_from", None
501
- ),
502
- self.parameter_name + "_to": self.used_parameters.get(
503
- self.parameter_name + "_to", None
504
- ),
505
- },
506
- ),
507
- },
508
- )
509
-
510
-
511
- class RangeDateTimeFilter(admin.FieldListFilter):
512
- request = None
513
- parameter_name = None
514
- template = "unfold/filters/filters_datetime_range.html"
515
- form_class = RangeDateTimeForm
516
-
517
- def __init__(
518
- self,
519
- field: Field,
520
- request: HttpRequest,
521
- params: dict[str, str],
522
- model: type[Model],
523
- model_admin: ModelAdmin,
524
- field_path: str,
525
- ) -> None:
526
- super().__init__(field, request, params, model, model_admin, field_path)
527
- if not isinstance(field, DateTimeField):
528
- raise TypeError(
529
- f"Class {type(self.field)} is not supported for {self.__class__.__name__}."
530
- )
531
-
532
- self.request = request
533
- if self.parameter_name is None:
534
- self.parameter_name = self.field_path
535
-
536
- if self.parameter_name + "_from_0" in params:
537
- value = params.pop(self.field_path + "_from_0")
538
- value = value[0] if isinstance(value, list) else value
539
- self.used_parameters[self.field_path + "_from_0"] = value
540
-
541
- if self.parameter_name + "_from_1" in params:
542
- value = params.pop(self.field_path + "_from_1")
543
- value = value[0] if isinstance(value, list) else value
544
- self.used_parameters[self.field_path + "_from_1"] = value
545
-
546
- if self.parameter_name + "_to_0" in params:
547
- value = params.pop(self.field_path + "_to_0")
548
- value = value[0] if isinstance(value, list) else value
549
- self.used_parameters[self.field_path + "_to_0"] = value
550
-
551
- if self.parameter_name + "_to_1" in params:
552
- value = params.pop(self.field_path + "_to_1")
553
- value = value[0] if isinstance(value, list) else value
554
- self.used_parameters[self.field_path + "_to_1"] = value
555
-
556
- def expected_parameters(self) -> list[str]:
557
- return [
558
- f"{self.parameter_name}_from_0",
559
- f"{self.parameter_name}_from_1",
560
- f"{self.parameter_name}_to_0",
561
- f"{self.parameter_name}_to_1",
562
- ]
563
-
564
- def queryset(self, request: HttpRequest, queryset: QuerySet) -> QuerySet:
565
- filters = {}
566
-
567
- date_value_from = self.used_parameters.get(self.parameter_name + "_from_0")
568
- time_value_from = self.used_parameters.get(self.parameter_name + "_from_1")
569
-
570
- date_value_to = self.used_parameters.get(self.parameter_name + "_to_0")
571
- time_value_to = self.used_parameters.get(self.parameter_name + "_to_1")
572
-
573
- if date_value_from not in EMPTY_VALUES and time_value_from not in EMPTY_VALUES:
574
- filters.update(
575
- {
576
- f"{self.parameter_name}__gte": parse_datetime_str(
577
- f"{date_value_from} {time_value_from}"
578
- ),
579
- }
580
- )
581
-
582
- if date_value_to not in EMPTY_VALUES and time_value_to not in EMPTY_VALUES:
583
- filters.update(
584
- {
585
- f"{self.parameter_name}__lte": parse_datetime_str(
586
- f"{date_value_to} {time_value_to}"
587
- ),
588
- }
589
- )
590
-
591
- try:
592
- return queryset.filter(**filters)
593
- except (ValueError, ValidationError):
594
- return None
595
-
596
- def choices(self, changelist: ChangeList) -> tuple[dict[str, Any], ...]:
597
- return (
598
- {
599
- "request": self.request,
600
- "parameter_name": self.parameter_name,
601
- "form": self.form_class(
602
- name=self.parameter_name,
603
- data={
604
- self.parameter_name + "_from_0": self.used_parameters.get(
605
- self.parameter_name + "_from_0"
606
- ),
607
- self.parameter_name + "_from_1": self.used_parameters.get(
608
- self.parameter_name + "_from_1"
609
- ),
610
- self.parameter_name + "_to_0": self.used_parameters.get(
611
- self.parameter_name + "_to_0"
612
- ),
613
- self.parameter_name + "_to_1": self.used_parameters.get(
614
- self.parameter_name + "_to_1"
615
- ),
616
- },
617
- ),
618
- },
619
- )