django-admin-inline-controls 0.1.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 (33) hide show
  1. django_admin_inline_controls/__init__.py +1 -0
  2. django_admin_inline_controls/actions.py +176 -0
  3. django_admin_inline_controls/apps.py +7 -0
  4. django_admin_inline_controls/checks.py +212 -0
  5. django_admin_inline_controls/contrib/__init__.py +4 -0
  6. django_admin_inline_controls/contrib/filters.py +69 -0
  7. django_admin_inline_controls/contrib/nested.py +77 -0
  8. django_admin_inline_controls/contrib/unfold.py +85 -0
  9. django_admin_inline_controls/controls.py +684 -0
  10. django_admin_inline_controls/locale/es/LC_MESSAGES/django.mo +0 -0
  11. django_admin_inline_controls/locale/es/LC_MESSAGES/django.po +232 -0
  12. django_admin_inline_controls/mixins.py +932 -0
  13. django_admin_inline_controls/py.typed +0 -0
  14. django_admin_inline_controls/row_actions.py +173 -0
  15. django_admin_inline_controls/static/django_admin_inline_controls/css/contrib/unfold.css +111 -0
  16. django_admin_inline_controls/static/django_admin_inline_controls/css/core.css +294 -0
  17. django_admin_inline_controls/static/django_admin_inline_controls/js/actions.js +330 -0
  18. django_admin_inline_controls/static/django_admin_inline_controls/js/contrib/unfold.js +21 -0
  19. django_admin_inline_controls/static/django_admin_inline_controls/js/core.js +570 -0
  20. django_admin_inline_controls/static/django_admin_inline_controls/js/save.js +109 -0
  21. django_admin_inline_controls/templates/django_admin_inline_controls/footer.html +99 -0
  22. django_admin_inline_controls/templates/django_admin_inline_controls/inline.html +34 -0
  23. django_admin_inline_controls/templates/django_admin_inline_controls/inline_response.html +14 -0
  24. django_admin_inline_controls/templates/django_admin_inline_controls/row_actions.html +19 -0
  25. django_admin_inline_controls/templates/django_admin_inline_controls/tfoot.html +36 -0
  26. django_admin_inline_controls/templates/django_admin_inline_controls/toolbar.html +93 -0
  27. django_admin_inline_controls/templatetags/__init__.py +0 -0
  28. django_admin_inline_controls/templatetags/inline_controls.py +72 -0
  29. django_admin_inline_controls/types.py +40 -0
  30. django_admin_inline_controls-0.1.0.dist-info/METADATA +823 -0
  31. django_admin_inline_controls-0.1.0.dist-info/RECORD +33 -0
  32. django_admin_inline_controls-0.1.0.dist-info/WHEEL +4 -0
  33. django_admin_inline_controls-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,176 @@
1
+ """Inline actions: like the changelist's, for the rows of one inline.
2
+
3
+ An action is called as ``action(inline, request, queryset)``, where
4
+ ``queryset`` holds the selected rows (always children of the parent object
5
+ being edited) and ``request.inline_controls_parent`` is that parent. Return
6
+ ``None`` to re-render the inline with the messages sent through
7
+ ``inline.message_user()``, or an ``HttpResponse`` (a file download or a
8
+ redirect).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Callable, Sequence
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ from django.contrib import admin, messages
17
+ from django.db import router, transaction
18
+ from django.db.models import ProtectedError, QuerySet
19
+ from django.http import HttpRequest
20
+ from django.utils.translation import gettext_lazy as _
21
+ from django.utils.translation import ngettext
22
+
23
+ if TYPE_CHECKING:
24
+ from django.utils.functional import _StrOrPromise as StrOrPromise
25
+
26
+ InlineAction = Callable[[Any, HttpRequest, QuerySet], Any]
27
+
28
+
29
+ def inline_action(
30
+ function: InlineAction | None = None,
31
+ *,
32
+ permissions: Sequence[str] | None = None,
33
+ description: StrOrPromise | None = None,
34
+ confirmation: StrOrPromise | None = None,
35
+ ) -> Any:
36
+ """``@admin.action`` plus an optional confirmation prompt::
37
+
38
+ @inline_action(
39
+ description="Mark selected %(verbose_name_plural)s as published",
40
+ confirmation="Publish the selected %(verbose_name_plural)s?",
41
+ )
42
+ def publish(inline, request, queryset):
43
+ queryset.update(status="published")
44
+
45
+ ``description`` and ``confirmation`` may use ``%(verbose_name)s`` and
46
+ ``%(verbose_name_plural)s``; ``confirmation`` also ``%(count)s``, the
47
+ number of rows the action will run on. Plain ``@admin.action`` functions
48
+ work too.
49
+ """
50
+
51
+ def decorator(func: InlineAction) -> InlineAction:
52
+ func = admin.action(permissions=permissions, description=description)(func)
53
+ if confirmation is not None:
54
+ func.confirmation = confirmation # type: ignore[attr-defined]
55
+ return func
56
+
57
+ return decorator if function is None else decorator(function)
58
+
59
+
60
+ @inline_action(
61
+ permissions=["delete"],
62
+ description=_("Delete selected %(verbose_name_plural)s"),
63
+ confirmation=_(
64
+ "Delete %(count)s selected %(verbose_name_plural)s? This cannot be undone."
65
+ ),
66
+ )
67
+ def delete_selected(inline: Any, request: HttpRequest, queryset: QuerySet) -> None:
68
+ """Delete the selected rows one by one (so ``delete()`` overrides and
69
+ signals run, as when they're deleted from the change form) and record
70
+ it in the parent's history."""
71
+ objects = list(queryset)
72
+ opts = queryset.model._meta
73
+ parent = getattr(request, "inline_controls_parent", None)
74
+ try:
75
+ with transaction.atomic(using=router.db_for_write(queryset.model)):
76
+ for obj in objects:
77
+ obj.delete()
78
+ except ProtectedError as error:
79
+ inline.message_user(
80
+ request,
81
+ _("Cannot delete: %(objects)s are referenced by protected objects.")
82
+ % {"objects": ", ".join(str(obj) for obj in error.protected_objects)},
83
+ messages.ERROR,
84
+ )
85
+ return None
86
+ parent_admin = parent and inline.admin_site._registry.get(type(parent))
87
+ if parent_admin is not None and objects:
88
+ parent_admin.log_change(
89
+ request,
90
+ parent,
91
+ [
92
+ {"deleted": {"name": str(opts.verbose_name), "object": str(obj)}}
93
+ for obj in objects
94
+ ],
95
+ )
96
+ inline.message_user(
97
+ request,
98
+ ngettext(
99
+ "Deleted %(count)d %(name)s.",
100
+ "Deleted %(count)d %(name)s.",
101
+ len(objects),
102
+ )
103
+ % {
104
+ "count": len(objects),
105
+ "name": opts.verbose_name
106
+ if len(objects) == 1
107
+ else opts.verbose_name_plural,
108
+ },
109
+ messages.SUCCESS,
110
+ )
111
+ return None
112
+
113
+
114
+ #: Actions that can be referenced by name in ``inline_bulk_actions``.
115
+ BUILTIN_BULK_ACTIONS: dict[str, InlineAction] = {"delete_selected": delete_selected}
116
+
117
+
118
+ # Row actions as django-inline-actions' mixins ---------------------------------
119
+
120
+
121
+ class ViewAction:
122
+ """A "View" link on each row, to its change view::
123
+
124
+ class BookInline(ViewAction, InlineControlsMixin, admin.TabularInline):
125
+ ...
126
+ """
127
+
128
+ inline_actions: list[str | InlineAction] | None = ["view_action"]
129
+
130
+ @inline_action(permissions=["view"], description=_("View"))
131
+ def view_action(
132
+ self, request: HttpRequest, obj: Any, parent_obj: Any = None
133
+ ) -> Any:
134
+ from django_admin_inline_controls.row_actions import view
135
+
136
+ return view(self, request, obj, parent_obj)
137
+
138
+ view_action.inline_actions_link = True
139
+
140
+
141
+ class DeleteAction:
142
+ """A "Delete" button on each row (with the delete permission)::
143
+
144
+ class BookInline(DeleteAction, InlineControlsMixin, admin.TabularInline):
145
+ ...
146
+ """
147
+
148
+ def get_inline_actions(self, request: HttpRequest | None, obj: Any = None) -> Any:
149
+ actions = list(super().get_inline_actions(request, obj)) # type: ignore[misc]
150
+ if "delete_action" not in actions:
151
+ actions.append("delete_action")
152
+ return actions
153
+
154
+ @inline_action(
155
+ permissions=["delete"],
156
+ description=_("Delete"),
157
+ confirmation=_("Delete “%(object)s”? This cannot be undone."),
158
+ )
159
+ def delete_action(self, request: HttpRequest, queryset: QuerySet) -> None:
160
+ return delete_selected(self, request, queryset)
161
+
162
+
163
+ class DefaultActionsMixin(ViewAction, DeleteAction):
164
+ """``ViewAction`` and ``DeleteAction``."""
165
+
166
+ inline_actions: list[str | InlineAction] | None = []
167
+
168
+
169
+ __all__ = [
170
+ "BUILTIN_BULK_ACTIONS",
171
+ "DefaultActionsMixin",
172
+ "DeleteAction",
173
+ "ViewAction",
174
+ "delete_selected",
175
+ "inline_action",
176
+ ]
@@ -0,0 +1,7 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class DjangoAdminInlineControlsConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "django_admin_inline_controls"
7
+ label = "django_admin_inline_controls"
@@ -0,0 +1,212 @@
1
+ """System checks for ``InlineControlsMixin`` options."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from django.contrib.admin.utils import label_for_field
9
+ from django.core import checks
10
+ from django.core.exceptions import FieldDoesNotExist
11
+
12
+ from django_admin_inline_controls.controls import (
13
+ DEFAULT_SELECTORS,
14
+ FOOTER_FILTERED,
15
+ FOOTER_PAGE,
16
+ INFINITE,
17
+ PAGES,
18
+ )
19
+
20
+ if TYPE_CHECKING:
21
+ from django_admin_inline_controls.mixins import InlineControlsMixin
22
+
23
+
24
+ def check_inline_controls(inline: InlineControlsMixin) -> list[checks.CheckMessage]:
25
+ from django_admin_inline_controls.mixins import (
26
+ InlineControlsAdminMixin,
27
+ resolve_lookup,
28
+ )
29
+
30
+ errors: list[checks.CheckMessage] = []
31
+ name = type(inline).__qualname__
32
+
33
+ def error(msg: str, id: str) -> None:
34
+ errors.append(checks.Error(msg, obj=type(inline), id=id))
35
+
36
+ if inline.inline_pagination not in (PAGES, INFINITE):
37
+ error(
38
+ f"The value of '{name}.inline_pagination' must be '{PAGES}' or "
39
+ f"'{INFINITE}'.",
40
+ "admin_inline_controls.E001",
41
+ )
42
+
43
+ per_page: Any = inline.inline_per_page
44
+ if per_page is not None and (
45
+ not isinstance(per_page, int) or isinstance(per_page, bool) or per_page < 1
46
+ ):
47
+ error(
48
+ f"The value of '{name}.inline_per_page' must be a positive integer "
49
+ "or None.",
50
+ "admin_inline_controls.E002",
51
+ )
52
+
53
+ if inline.inline_pagination == INFINITE and per_page is None:
54
+ error(
55
+ f"'{name}.inline_pagination = \"{INFINITE}\"' requires 'inline_per_page'.",
56
+ "admin_inline_controls.E003",
57
+ )
58
+
59
+ if isinstance(inline.inline_filter_fields, str):
60
+ error(
61
+ f"The value of '{name}.inline_filter_fields' must be a list or tuple.",
62
+ "admin_inline_controls.E004",
63
+ )
64
+ else:
65
+ for lookup in inline.inline_filter_fields:
66
+ try:
67
+ resolve_lookup(inline.model, lookup)
68
+ except FieldDoesNotExist:
69
+ error(
70
+ f"'{name}.inline_filter_fields' refers to '{lookup}', which "
71
+ f"is not a field of '{inline.model._meta.label}'.",
72
+ "admin_inline_controls.E005",
73
+ )
74
+
75
+ ordering_fields: Any = inline.inline_ordering_fields
76
+ if isinstance(ordering_fields, str) or not isinstance(
77
+ ordering_fields, list | tuple | Mapping
78
+ ):
79
+ error(
80
+ f"The value of '{name}.inline_ordering_fields' must be a list, tuple "
81
+ "or dict.",
82
+ "admin_inline_controls.E006",
83
+ )
84
+ else:
85
+ for column in ordering_fields:
86
+ if not isinstance(column, str) or column.startswith("-") or "," in column:
87
+ error(
88
+ f"'{name}.inline_ordering_fields' contains {column!r}; column "
89
+ "names must be strings without a leading '-' or commas.",
90
+ "admin_inline_controls.E007",
91
+ )
92
+
93
+ footer_rows: Any = inline.inline_footer_rows
94
+ if isinstance(footer_rows, str) or not isinstance(footer_rows, list | tuple):
95
+ error(
96
+ f"The value of '{name}.inline_footer_rows' must be a list of "
97
+ "(label, {column: value}) pairs.",
98
+ "admin_inline_controls.E013",
99
+ )
100
+ else:
101
+ for index, row in enumerate(footer_rows):
102
+ if not (
103
+ isinstance(row, list | tuple)
104
+ and len(row) == 2
105
+ and isinstance(row[1], Mapping)
106
+ ):
107
+ error(
108
+ f"'{name}.inline_footer_rows[{index}]' must be a "
109
+ "(label, {column: value}) pair.",
110
+ "admin_inline_controls.E013",
111
+ )
112
+ continue
113
+ for column in row[1]:
114
+ try:
115
+ label_for_field(column, inline.model, inline) # type: ignore[call-overload]
116
+ except AttributeError:
117
+ error(
118
+ f"'{name}.inline_footer_rows[{index}]' refers to "
119
+ f"'{column}', which is not a field of "
120
+ f"'{inline.model._meta.label}' or of the inline.",
121
+ "admin_inline_controls.E014",
122
+ )
123
+
124
+ if not isinstance(inline.inline_footer_tfoot, bool):
125
+ error(
126
+ f"The value of '{name}.inline_footer_tfoot' must be True or False.",
127
+ "admin_inline_controls.E017",
128
+ )
129
+
130
+ if inline.inline_footer_scope not in (FOOTER_FILTERED, FOOTER_PAGE):
131
+ error(
132
+ f"The value of '{name}.inline_footer_scope' must be "
133
+ f"'{FOOTER_FILTERED}' or '{FOOTER_PAGE}'.",
134
+ "admin_inline_controls.E015",
135
+ )
136
+ elif inline.inline_footer_scope == FOOTER_PAGE and (
137
+ inline.inline_pagination == INFINITE
138
+ ):
139
+ error(
140
+ f"'{name}.inline_footer_scope = \"{FOOTER_PAGE}\"' can't be used with "
141
+ "infinite scroll (the shown rows change as more are loaded).",
142
+ "admin_inline_controls.E016",
143
+ )
144
+
145
+ selectors: Any = inline.inline_controls_selectors
146
+ if not isinstance(selectors, Mapping):
147
+ error(
148
+ f"The value of '{name}.inline_controls_selectors' must be a dict.",
149
+ "admin_inline_controls.E011",
150
+ )
151
+ else:
152
+ for key, value in selectors.items():
153
+ values = [value] if isinstance(value, str) else value
154
+ if key not in DEFAULT_SELECTORS or not (
155
+ isinstance(values, list | tuple)
156
+ and values
157
+ and all(isinstance(v, str) and v for v in values)
158
+ ):
159
+ error(
160
+ f"'{name}.inline_controls_selectors' has an invalid entry "
161
+ f"{key!r}: keys must be one of {sorted(DEFAULT_SELECTORS)} "
162
+ "and values a selector or a non-empty list of selectors.",
163
+ "admin_inline_controls.E012",
164
+ )
165
+
166
+ parent_admin = inline.admin_site._registry.get(inline.parent_model)
167
+ missing_admin_mixin = parent_admin is not None and not isinstance(
168
+ parent_admin, InlineControlsAdminMixin
169
+ )
170
+ for option, enabled, error_id in (
171
+ ("inline_save_button", inline.inline_save_button, "admin_inline_controls.E008"),
172
+ (
173
+ "inline_bulk_actions",
174
+ inline.inline_bulk_actions,
175
+ "admin_inline_controls.E010",
176
+ ),
177
+ ("inline_actions", inline.has_inline_actions, "admin_inline_controls.E019"),
178
+ ):
179
+ if enabled and missing_admin_mixin:
180
+ error(
181
+ f"'{name}.{option}' requires "
182
+ f"'{type(parent_admin).__qualname__}' to inherit from "
183
+ "'InlineControlsAdminMixin'.",
184
+ error_id,
185
+ )
186
+
187
+ for action in inline.inline_bulk_actions:
188
+ try:
189
+ _, func = inline._resolve_inline_bulk_action(action)
190
+ except AttributeError:
191
+ func = None
192
+ if not callable(func):
193
+ error(
194
+ f"'{name}.inline_bulk_actions' contains {action!r}, which is not a "
195
+ f"method of '{name}', a callable, or a built-in action.",
196
+ "admin_inline_controls.E009",
197
+ )
198
+
199
+ for action in inline.inline_actions or ():
200
+ try:
201
+ _, func = inline._resolve_inline_action(action)
202
+ except AttributeError:
203
+ func = None
204
+ if not callable(func):
205
+ error(
206
+ f"'{name}.inline_actions' contains {action!r}, which is not "
207
+ f"a method of '{name}', a callable, or a built-in row action "
208
+ "('view', 'delete').",
209
+ "admin_inline_controls.E018",
210
+ )
211
+
212
+ return errors
@@ -0,0 +1,4 @@
1
+ """Integrations with optional third-party packages.
2
+
3
+ Each submodule requires its matching extra and is only imported on demand.
4
+ """
@@ -0,0 +1,69 @@
1
+ """django-filter integration. Requires the ``filters`` extra."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ try:
8
+ import django_filters
9
+ except ImportError as e:
10
+ raise ImportError(
11
+ "django_admin_inline_controls.contrib.filters requires django-filter. "
12
+ "Install it with: pip install django-admin-inline-controls[filters]"
13
+ ) from e
14
+
15
+ from django.db.models import QuerySet
16
+ from django.http import HttpRequest, QueryDict
17
+
18
+ from django_admin_inline_controls.mixins import InlineControlsMixin
19
+ from django_admin_inline_controls.types import FilterResult
20
+
21
+
22
+ class FilterSetInlineControlsMixin(InlineControlsMixin):
23
+ """Filter the inline with a django-filter ``FilterSet``::
24
+
25
+ class BookInline(FilterSetInlineControlsMixin, admin.TabularInline):
26
+ model = Book
27
+ inline_per_page = 20
28
+ inline_filterset_class = BookFilterSet
29
+
30
+ The filterset receives the request (``self.request`` inside it) and
31
+ whatever ``get_inline_filterset_kwargs()`` returns, e.g. the parent object.
32
+ """
33
+
34
+ inline_filterset_class: type[django_filters.FilterSet] | None = None
35
+
36
+ def get_inline_filterset_class(
37
+ self, request: HttpRequest, obj: Any
38
+ ) -> type[django_filters.FilterSet] | None:
39
+ return self.inline_filterset_class
40
+
41
+ def get_inline_filterset_kwargs(
42
+ self, request: HttpRequest, obj: Any
43
+ ) -> dict[str, Any]:
44
+ return {}
45
+
46
+ def get_inline_filtered_queryset(
47
+ self,
48
+ request: HttpRequest,
49
+ obj: Any,
50
+ queryset: QuerySet,
51
+ data: QueryDict,
52
+ prefix: str,
53
+ ) -> FilterResult:
54
+ filterset_class = self.get_inline_filterset_class(request, obj)
55
+ if filterset_class is None:
56
+ return super().get_inline_filtered_queryset(
57
+ request, obj, queryset, data, prefix
58
+ )
59
+ filterset = filterset_class(
60
+ data,
61
+ queryset=queryset,
62
+ prefix=prefix,
63
+ request=request,
64
+ **self.get_inline_filterset_kwargs(request, obj),
65
+ )
66
+ return filterset.form, filterset.qs
67
+
68
+
69
+ __all__ = ["FilterSetInlineControlsMixin"]
@@ -0,0 +1,77 @@
1
+ """django-nested-admin integration. Requires the ``nested`` extra."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ try:
8
+ import nested_admin # noqa: F401
9
+ except ImportError as e:
10
+ raise ImportError(
11
+ "django_admin_inline_controls.contrib.nested requires django-nested-admin. "
12
+ "Install it with: pip install django-admin-inline-controls[nested]"
13
+ ) from e
14
+
15
+ from django.core import checks
16
+
17
+ from django_admin_inline_controls.controls import INFINITE
18
+ from django_admin_inline_controls.mixins import InlineControlsMixin
19
+
20
+
21
+ class NestedInlineControlsMixin(InlineControlsMixin):
22
+ """``InlineControlsMixin`` for ``nested_admin`` inlines::
23
+
24
+ class BookInline(NestedInlineControlsMixin, nested_admin.NestedTabularInline):
25
+ model = Book
26
+ inline_per_page = 20
27
+
28
+ nested_admin keeps its own client-side formset state, so filters,
29
+ sorting and page links reload the page instead of swapping the inline,
30
+ and infinite scroll, the save-inline button and inline actions are not
31
+ available.
32
+ """
33
+
34
+ inline_controls_ajax = False
35
+
36
+ def check(self, **kwargs: Any) -> list[Any]:
37
+ errors = super().check(**kwargs)
38
+ if self.inline_pagination == INFINITE:
39
+ errors.append(
40
+ checks.Error(
41
+ f"'{type(self).__qualname__}.inline_pagination' cannot be "
42
+ f"'{INFINITE}' on a nested_admin inline.",
43
+ obj=type(self),
44
+ id="admin_inline_controls.E101",
45
+ )
46
+ )
47
+ if self.inline_save_button:
48
+ errors.append(
49
+ checks.Error(
50
+ f"'{type(self).__qualname__}.inline_save_button' is not "
51
+ "supported on a nested_admin inline.",
52
+ obj=type(self),
53
+ id="admin_inline_controls.E102",
54
+ )
55
+ )
56
+ if self.inline_bulk_actions:
57
+ errors.append(
58
+ checks.Error(
59
+ f"'{type(self).__qualname__}.inline_bulk_actions' is not "
60
+ "supported on a nested_admin inline.",
61
+ obj=type(self),
62
+ id="admin_inline_controls.E103",
63
+ )
64
+ )
65
+ if self.has_inline_actions:
66
+ errors.append(
67
+ checks.Error(
68
+ f"'{type(self).__qualname__}.inline_actions' is not "
69
+ "supported on a nested_admin inline.",
70
+ obj=type(self),
71
+ id="admin_inline_controls.E105",
72
+ )
73
+ )
74
+ return errors
75
+
76
+
77
+ __all__ = ["NestedInlineControlsMixin"]
@@ -0,0 +1,85 @@
1
+ """django-unfold integration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ try:
8
+ import unfold # noqa: F401
9
+ except ImportError as e:
10
+ raise ImportError(
11
+ "django_admin_inline_controls.contrib.unfold requires django-unfold. "
12
+ "Install it with: pip install django-admin-inline-controls[unfold]"
13
+ ) from e
14
+
15
+ from django import forms
16
+ from django.core import checks
17
+ from django.http import HttpRequest
18
+
19
+ from django_admin_inline_controls.mixins import InlineControlsMixin
20
+
21
+ #: Where the controls go in Unfold's inline templates; applied under the
22
+ #: inline's own ``inline_controls_selectors``.
23
+ UNFOLD_SELECTORS: dict[str, list[str]] = {
24
+ "container": [
25
+ "[data-inline-type] > fieldset",
26
+ "[data-inline-type] > .inline-related > fieldset",
27
+ ],
28
+ "table_head": ["table thead"],
29
+ # Each form is a <tbody> (tabular) or a <div> (stacked), without an id.
30
+ "form_rows": ['[id="{prefix}-data"] > .form-group'],
31
+ "saved_row": [".original"],
32
+ "row_label": [
33
+ ':scope > tr > td > p[class~="group/title"]',
34
+ ":scope > .form-row > h3 > span",
35
+ ":scope > tr.form-row > td",
36
+ ],
37
+ }
38
+
39
+
40
+ class UnfoldInlineControlsMixin(InlineControlsMixin):
41
+ """``InlineControlsMixin`` for Unfold's inlines::
42
+
43
+ from unfold.admin import TabularInline
44
+
45
+ class BookInline(UnfoldInlineControlsMixin, TabularInline):
46
+ model = Book
47
+ inline_per_page = 20
48
+
49
+ Places the controls in Unfold's markup, styles them with its colors
50
+ (light and dark) and re-binds its "Add another" / delete buttons after an
51
+ inline is refreshed. Paginate with ``inline_per_page``, not Unfold's
52
+ ``per_page``.
53
+ """
54
+
55
+ def get_inline_controls_selectors(
56
+ self, request: HttpRequest, obj: Any
57
+ ) -> dict[str, list[str]]:
58
+ selectors = super().get_inline_controls_selectors(request, obj)
59
+ for key, value in UNFOLD_SELECTORS.items():
60
+ if key not in self.inline_controls_selectors:
61
+ selectors[key] = list(value)
62
+ return selectors
63
+
64
+ @property
65
+ def media(self) -> forms.Media:
66
+ return super().media + forms.Media(
67
+ js=["django_admin_inline_controls/js/contrib/unfold.js"],
68
+ css={"all": ["django_admin_inline_controls/css/contrib/unfold.css"]},
69
+ )
70
+
71
+ def check(self, **kwargs: Any) -> list[Any]:
72
+ errors = super().check(**kwargs)
73
+ if getattr(self, "per_page", None):
74
+ errors.append(
75
+ checks.Error(
76
+ f"'{type(self).__qualname__}.per_page' paginates the inline "
77
+ "a second time; use 'inline_per_page' instead.",
78
+ obj=type(self),
79
+ id="admin_inline_controls.E104",
80
+ )
81
+ )
82
+ return errors
83
+
84
+
85
+ __all__ = ["UNFOLD_SELECTORS", "UnfoldInlineControlsMixin"]