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.
- django_admin_inline_controls/__init__.py +1 -0
- django_admin_inline_controls/actions.py +176 -0
- django_admin_inline_controls/apps.py +7 -0
- django_admin_inline_controls/checks.py +212 -0
- django_admin_inline_controls/contrib/__init__.py +4 -0
- django_admin_inline_controls/contrib/filters.py +69 -0
- django_admin_inline_controls/contrib/nested.py +77 -0
- django_admin_inline_controls/contrib/unfold.py +85 -0
- django_admin_inline_controls/controls.py +684 -0
- django_admin_inline_controls/locale/es/LC_MESSAGES/django.mo +0 -0
- django_admin_inline_controls/locale/es/LC_MESSAGES/django.po +232 -0
- django_admin_inline_controls/mixins.py +932 -0
- django_admin_inline_controls/py.typed +0 -0
- django_admin_inline_controls/row_actions.py +173 -0
- django_admin_inline_controls/static/django_admin_inline_controls/css/contrib/unfold.css +111 -0
- django_admin_inline_controls/static/django_admin_inline_controls/css/core.css +294 -0
- django_admin_inline_controls/static/django_admin_inline_controls/js/actions.js +330 -0
- django_admin_inline_controls/static/django_admin_inline_controls/js/contrib/unfold.js +21 -0
- django_admin_inline_controls/static/django_admin_inline_controls/js/core.js +570 -0
- django_admin_inline_controls/static/django_admin_inline_controls/js/save.js +109 -0
- django_admin_inline_controls/templates/django_admin_inline_controls/footer.html +99 -0
- django_admin_inline_controls/templates/django_admin_inline_controls/inline.html +34 -0
- django_admin_inline_controls/templates/django_admin_inline_controls/inline_response.html +14 -0
- django_admin_inline_controls/templates/django_admin_inline_controls/row_actions.html +19 -0
- django_admin_inline_controls/templates/django_admin_inline_controls/tfoot.html +36 -0
- django_admin_inline_controls/templates/django_admin_inline_controls/toolbar.html +93 -0
- django_admin_inline_controls/templatetags/__init__.py +0 -0
- django_admin_inline_controls/templatetags/inline_controls.py +72 -0
- django_admin_inline_controls/types.py +40 -0
- django_admin_inline_controls-0.1.0.dist-info/METADATA +823 -0
- django_admin_inline_controls-0.1.0.dist-info/RECORD +33 -0
- django_admin_inline_controls-0.1.0.dist-info/WHEEL +4 -0
- 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,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,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"]
|