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,932 @@
|
|
|
1
|
+
"""Pagination, filtering and ordering for Django admin inlines."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Callable, Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from django import forms
|
|
12
|
+
from django.contrib import messages
|
|
13
|
+
from django.contrib.admin.utils import lookup_spawns_duplicates, model_format_dict
|
|
14
|
+
from django.core.exceptions import FieldDoesNotExist, PermissionDenied, ValidationError
|
|
15
|
+
from django.db import models, router, transaction
|
|
16
|
+
from django.db.models import QuerySet
|
|
17
|
+
from django.db.models.constants import LOOKUP_SEP
|
|
18
|
+
from django.forms import BaseInlineFormSet, Form
|
|
19
|
+
from django.http import (
|
|
20
|
+
Http404,
|
|
21
|
+
HttpRequest,
|
|
22
|
+
HttpResponse,
|
|
23
|
+
HttpResponseBadRequest,
|
|
24
|
+
HttpResponseBase,
|
|
25
|
+
QueryDict,
|
|
26
|
+
)
|
|
27
|
+
from django.template.defaultfilters import floatformat
|
|
28
|
+
from django.template.response import TemplateResponse
|
|
29
|
+
from django.urls import URLPattern, path
|
|
30
|
+
from django.utils.safestring import SafeString, mark_safe
|
|
31
|
+
from django.utils.text import capfirst
|
|
32
|
+
from django.utils.translation import gettext
|
|
33
|
+
from django.utils.translation import gettext_lazy as _
|
|
34
|
+
from django.views.decorators.http import require_POST
|
|
35
|
+
|
|
36
|
+
from django_admin_inline_controls.controls import (
|
|
37
|
+
DEFAULT_SELECTORS,
|
|
38
|
+
FOOTER_FILTERED,
|
|
39
|
+
INFINITE,
|
|
40
|
+
PAGES,
|
|
41
|
+
InlineControls,
|
|
42
|
+
bound_primary_keys,
|
|
43
|
+
)
|
|
44
|
+
from django_admin_inline_controls.types import (
|
|
45
|
+
FilterResult,
|
|
46
|
+
FooterRows,
|
|
47
|
+
InlineActions,
|
|
48
|
+
OrderingFields,
|
|
49
|
+
Selectors,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
LOOKUP_LABELS = {
|
|
53
|
+
"contains": _("contains"),
|
|
54
|
+
"icontains": _("contains"),
|
|
55
|
+
"startswith": _("starts with"),
|
|
56
|
+
"istartswith": _("starts with"),
|
|
57
|
+
"endswith": _("ends with"),
|
|
58
|
+
"iendswith": _("ends with"),
|
|
59
|
+
"gt": _("after"),
|
|
60
|
+
"gte": _("from"),
|
|
61
|
+
"lt": _("before"),
|
|
62
|
+
"lte": _("to"),
|
|
63
|
+
"in": _("in"),
|
|
64
|
+
"isnull": _("is empty"),
|
|
65
|
+
"year": _("year"),
|
|
66
|
+
"month": _("month"),
|
|
67
|
+
"day": _("day"),
|
|
68
|
+
}
|
|
69
|
+
TEXT_LOOKUPS = {
|
|
70
|
+
"contains",
|
|
71
|
+
"icontains",
|
|
72
|
+
"startswith",
|
|
73
|
+
"istartswith",
|
|
74
|
+
"endswith",
|
|
75
|
+
"iendswith",
|
|
76
|
+
"iexact",
|
|
77
|
+
}
|
|
78
|
+
DATE_PART_LOOKUPS = {"year", "month", "day", "week_day", "quarter", "hour", "minute"}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def null_boolean_filter_field(**options: Any) -> forms.NullBooleanField:
|
|
82
|
+
"""Yes / No / any, where "any" is an empty value instead of "Unknown"."""
|
|
83
|
+
choices = [("", "---------"), ("true", _("Yes")), ("false", _("No"))]
|
|
84
|
+
return forms.NullBooleanField(widget=forms.Select(choices=choices), **options)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def resolve_lookup(
|
|
88
|
+
model: type[models.Model], lookup: str
|
|
89
|
+
) -> tuple[models.Field | models.ForeignObjectRel, str | None]:
|
|
90
|
+
"""Split ``"author__name__icontains"`` into the ``name`` field and ``icontains``.
|
|
91
|
+
|
|
92
|
+
Raises ``FieldDoesNotExist`` if the first part is not a field of ``model``.
|
|
93
|
+
"""
|
|
94
|
+
parts = lookup.split(LOOKUP_SEP)
|
|
95
|
+
opts = model._meta
|
|
96
|
+
field: Any = None
|
|
97
|
+
for index, part in enumerate(parts):
|
|
98
|
+
try:
|
|
99
|
+
field = opts.get_field(part)
|
|
100
|
+
except FieldDoesNotExist:
|
|
101
|
+
if field is None:
|
|
102
|
+
raise
|
|
103
|
+
return field, LOOKUP_SEP.join(parts[index:])
|
|
104
|
+
if field.is_relation and field.related_model and index < len(parts) - 1:
|
|
105
|
+
opts = field.related_model._meta
|
|
106
|
+
return field, None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def build_filter_formfield(
|
|
110
|
+
db_field: models.Field | models.ForeignObjectRel,
|
|
111
|
+
lookup_name: str | None,
|
|
112
|
+
label: str,
|
|
113
|
+
) -> forms.Field:
|
|
114
|
+
"""Return an optional form field suitable to filter by ``db_field``."""
|
|
115
|
+
options: dict[str, Any] = {"required": False, "label": label}
|
|
116
|
+
related_model = getattr(db_field, "related_model", None)
|
|
117
|
+
choices = getattr(db_field, "flatchoices", None)
|
|
118
|
+
if lookup_name == "isnull":
|
|
119
|
+
return null_boolean_filter_field(**options)
|
|
120
|
+
if lookup_name in DATE_PART_LOOKUPS:
|
|
121
|
+
return forms.IntegerField(**options)
|
|
122
|
+
if lookup_name == "in":
|
|
123
|
+
if related_model is not None:
|
|
124
|
+
return forms.ModelMultipleChoiceField(
|
|
125
|
+
queryset=related_model._default_manager.all(), **options
|
|
126
|
+
)
|
|
127
|
+
if choices:
|
|
128
|
+
return forms.MultipleChoiceField(choices=choices, **options)
|
|
129
|
+
return forms.CharField(**options)
|
|
130
|
+
if lookup_name in TEXT_LOOKUPS:
|
|
131
|
+
return forms.CharField(**options)
|
|
132
|
+
if related_model is not None:
|
|
133
|
+
return forms.ModelChoiceField(
|
|
134
|
+
queryset=related_model._default_manager.all(), **options
|
|
135
|
+
)
|
|
136
|
+
if choices:
|
|
137
|
+
return forms.ChoiceField(choices=[("", "---------"), *choices], **options)
|
|
138
|
+
if isinstance(db_field, models.BooleanField):
|
|
139
|
+
return null_boolean_filter_field(**options)
|
|
140
|
+
if isinstance(db_field, models.DateTimeField):
|
|
141
|
+
return forms.DateTimeField(
|
|
142
|
+
widget=forms.DateTimeInput(
|
|
143
|
+
attrs={"type": "datetime-local"}, format="%Y-%m-%dT%H:%M"
|
|
144
|
+
),
|
|
145
|
+
**options,
|
|
146
|
+
)
|
|
147
|
+
if isinstance(db_field, models.DateField):
|
|
148
|
+
return forms.DateField(
|
|
149
|
+
widget=forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
|
|
150
|
+
**options,
|
|
151
|
+
)
|
|
152
|
+
formfield = (
|
|
153
|
+
db_field.formfield(**options) if isinstance(db_field, models.Field) else None
|
|
154
|
+
)
|
|
155
|
+
return formfield or forms.CharField(**options)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class _KeepMissing(dict):
|
|
159
|
+
def __missing__(self, key: str) -> str:
|
|
160
|
+
return f"%({key})s"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass(frozen=True)
|
|
164
|
+
class InlineActionSpec:
|
|
165
|
+
name: str
|
|
166
|
+
function: Callable[..., Any]
|
|
167
|
+
description: str
|
|
168
|
+
confirmation: str | None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class InlineControlsFormSetMixin:
|
|
172
|
+
"""Mixed into the inline's formset class by ``InlineControlsMixin``."""
|
|
173
|
+
|
|
174
|
+
inline_controls_inline: InlineControlsMixin | None = None
|
|
175
|
+
inline_controls_request: HttpRequest | None = None
|
|
176
|
+
inline_controls_parent: Any = None
|
|
177
|
+
#: Infinite mode: render this many pages at once (after saving the inline).
|
|
178
|
+
inline_controls_pages_loaded: int | None = None
|
|
179
|
+
|
|
180
|
+
prefix: str
|
|
181
|
+
is_bound: bool
|
|
182
|
+
|
|
183
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
184
|
+
super().__init__(*args, **kwargs)
|
|
185
|
+
inline = self.inline_controls_inline
|
|
186
|
+
request = self.inline_controls_request
|
|
187
|
+
parent = self.inline_controls_parent
|
|
188
|
+
self.inline_controls = (
|
|
189
|
+
InlineControls(
|
|
190
|
+
inline,
|
|
191
|
+
request,
|
|
192
|
+
parent,
|
|
193
|
+
self.prefix,
|
|
194
|
+
pages_loaded=self.inline_controls_pages_loaded,
|
|
195
|
+
)
|
|
196
|
+
if inline is not None
|
|
197
|
+
and request is not None
|
|
198
|
+
and parent is not None
|
|
199
|
+
and parent.pk is not None
|
|
200
|
+
else None
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def get_queryset(self) -> QuerySet:
|
|
204
|
+
queryset = super().get_queryset() # type: ignore[misc]
|
|
205
|
+
if self.inline_controls is None:
|
|
206
|
+
return queryset
|
|
207
|
+
if not hasattr(self, "_inline_controls_queryset"):
|
|
208
|
+
bound_pks = bound_primary_keys(self) if self.is_bound else None
|
|
209
|
+
self._inline_controls_queryset = self.inline_controls.apply(
|
|
210
|
+
queryset, bound_pks
|
|
211
|
+
)
|
|
212
|
+
return self._inline_controls_queryset
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class InlineControlsMixin:
|
|
216
|
+
"""Add pagination, filters and sortable columns to an admin inline.
|
|
217
|
+
|
|
218
|
+
Mix it in before ``TabularInline`` or ``StackedInline``::
|
|
219
|
+
|
|
220
|
+
class BookInline(InlineControlsMixin, admin.TabularInline):
|
|
221
|
+
model = Book
|
|
222
|
+
inline_per_page = 20
|
|
223
|
+
inline_ordering_fields = ["title", "published"]
|
|
224
|
+
inline_filter_fields = ["status", "title__icontains"]
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
#: Rows per page. ``None`` disables pagination.
|
|
228
|
+
inline_per_page: int | None = None
|
|
229
|
+
#: ``"pages"`` renders page links, ``"infinite"`` loads more on scroll.
|
|
230
|
+
inline_pagination: str = PAGES
|
|
231
|
+
#: Sortable columns: field names, or ``{column: expression}`` where the
|
|
232
|
+
#: expression is a field path, an ORM expression or an
|
|
233
|
+
#: ``(ascending, descending)`` pair.
|
|
234
|
+
inline_ordering_fields: OrderingFields = ()
|
|
235
|
+
#: Ordering applied after the user's, before the ``pk`` tie-breaker.
|
|
236
|
+
#: Defaults to the queryset's or the model's ordering.
|
|
237
|
+
inline_default_ordering: Sequence[Any] = ()
|
|
238
|
+
#: Filter lookups (``"status"``, ``"title__icontains"``,
|
|
239
|
+
#: ``"published__gte"``). A form is generated from them unless
|
|
240
|
+
#: ``inline_filter_form`` is set.
|
|
241
|
+
inline_filter_fields: Sequence[str] = ()
|
|
242
|
+
#: Custom filter form. Its fields are applied as lookups, unless
|
|
243
|
+
#: ``inline_filter_fields`` restricts which ones.
|
|
244
|
+
inline_filter_form: type[Form] | None = None
|
|
245
|
+
#: Refresh the inline in place (fetch) instead of reloading the page.
|
|
246
|
+
inline_controls_ajax: bool = True
|
|
247
|
+
#: Show a button that saves only this inline. Requires
|
|
248
|
+
#: ``InlineControlsAdminMixin`` on the parent ``ModelAdmin``.
|
|
249
|
+
inline_save_button: bool = False
|
|
250
|
+
#: Actions for the selected rows, like ``ModelAdmin.actions``: method
|
|
251
|
+
#: names, callables, or ``"delete_selected"``. Requires
|
|
252
|
+
#: ``InlineControlsAdminMixin`` on the parent ``ModelAdmin``.
|
|
253
|
+
inline_bulk_actions: Sequence[str | Callable[..., Any]] = ()
|
|
254
|
+
#: Buttons on each saved row, as in django-inline-actions: method names,
|
|
255
|
+
#: callables, or ``"view"`` / ``"delete"``, gathered from every class of
|
|
256
|
+
#: the inline (``ViewAction``, ``DeleteAction``…); ``None`` removes the
|
|
257
|
+
#: column. An action takes ``(request, obj, parent_obj=None)`` or, like
|
|
258
|
+
#: ``inline_bulk_actions``, ``(request, queryset)`` (a queryset with only
|
|
259
|
+
#: that row). Requires ``InlineControlsAdminMixin`` on the parent
|
|
260
|
+
#: ``ModelAdmin``.
|
|
261
|
+
inline_actions: Sequence[str | Callable[..., Any]] | None = ()
|
|
262
|
+
inline_actions_template = "django_admin_inline_controls/row_actions.html"
|
|
263
|
+
#: Rows of totals/averages/… below the table: ``(label, {column: value})``
|
|
264
|
+
#: pairs, a value being an aggregate (``Sum("pages")``), a
|
|
265
|
+
#: ``callable(queryset)`` or a constant.
|
|
266
|
+
inline_footer_rows: FooterRows = ()
|
|
267
|
+
#: What the footer rows add up: ``"filtered"`` (every row matching the
|
|
268
|
+
#: filters, on every page) or ``"page"`` (the rows shown).
|
|
269
|
+
inline_footer_scope: str = FOOTER_FILTERED
|
|
270
|
+
#: Render the footer rows in the table's <tfoot>, under their columns
|
|
271
|
+
#: (tabular inlines). ``False``, or an inline without a table, shows them
|
|
272
|
+
#: as a summary line in the footer instead.
|
|
273
|
+
inline_footer_tfoot: bool = True
|
|
274
|
+
inline_footer_tfoot_template = "django_admin_inline_controls/tfoot.html"
|
|
275
|
+
#: CSS selectors the JS uses to find its way in the inline's markup,
|
|
276
|
+
#: merged over ``DEFAULT_SELECTORS``: set the keys that differ in your
|
|
277
|
+
#: theme or inline template.
|
|
278
|
+
inline_controls_selectors: Selectors = {}
|
|
279
|
+
#: Wrapper template; it includes the inline's own ``template``, the
|
|
280
|
+
#: toolbar and the footer. Each one can extend the library's and
|
|
281
|
+
#: override its blocks.
|
|
282
|
+
inline_controls_template = "django_admin_inline_controls/inline.html"
|
|
283
|
+
inline_controls_toolbar_template = "django_admin_inline_controls/toolbar.html"
|
|
284
|
+
inline_controls_footer_template = "django_admin_inline_controls/footer.html"
|
|
285
|
+
|
|
286
|
+
# Provided by InlineModelAdmin.
|
|
287
|
+
model: type[models.Model]
|
|
288
|
+
parent_model: type[models.Model]
|
|
289
|
+
admin_site: Any
|
|
290
|
+
template: str
|
|
291
|
+
|
|
292
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
293
|
+
super().__init__(*args, **kwargs)
|
|
294
|
+
self.inline_controls_inner_template = self.template
|
|
295
|
+
self.template = self.inline_controls_template
|
|
296
|
+
|
|
297
|
+
# Hooks -----------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
def get_inline_per_page(self, request: HttpRequest, obj: Any) -> int | None:
|
|
300
|
+
return self.inline_per_page
|
|
301
|
+
|
|
302
|
+
def get_inline_ordering_fields(
|
|
303
|
+
self, request: HttpRequest, obj: Any
|
|
304
|
+
) -> OrderingFields:
|
|
305
|
+
return self.inline_ordering_fields
|
|
306
|
+
|
|
307
|
+
def get_inline_filter_fields(self, request: HttpRequest, obj: Any) -> Sequence[str]:
|
|
308
|
+
return self.inline_filter_fields
|
|
309
|
+
|
|
310
|
+
def get_inline_controls_selectors(
|
|
311
|
+
self, request: HttpRequest, obj: Any
|
|
312
|
+
) -> dict[str, list[str]]:
|
|
313
|
+
"""``DEFAULT_SELECTORS`` with ``inline_controls_selectors`` applied."""
|
|
314
|
+
selectors = {key: list(value) for key, value in DEFAULT_SELECTORS.items()}
|
|
315
|
+
for key, value in self.inline_controls_selectors.items():
|
|
316
|
+
selectors[key] = [value] if isinstance(value, str) else list(value)
|
|
317
|
+
return selectors
|
|
318
|
+
|
|
319
|
+
def get_inline_footer_rows(self, request: HttpRequest, obj: Any) -> FooterRows:
|
|
320
|
+
return self.inline_footer_rows
|
|
321
|
+
|
|
322
|
+
def format_inline_footer_value(self, column: str, value: Any) -> Any:
|
|
323
|
+
"""Text (or safe HTML) of a footer cell. Override for currencies,
|
|
324
|
+
units, localized numbers…; the default is the plain value, floats and
|
|
325
|
+
decimals rounded to 2 decimals at most (``1234.5``)."""
|
|
326
|
+
if value is None:
|
|
327
|
+
return ""
|
|
328
|
+
if isinstance(value, SafeString):
|
|
329
|
+
return value
|
|
330
|
+
if isinstance(value, float | Decimal):
|
|
331
|
+
return floatformat(value, "-2u")
|
|
332
|
+
return str(value)
|
|
333
|
+
|
|
334
|
+
def get_inline_filter_formfield(self, lookup: str) -> forms.Field:
|
|
335
|
+
"""Form field used to filter by ``lookup``. Override to customize one."""
|
|
336
|
+
db_field, lookup_name = resolve_lookup(self.model, lookup)
|
|
337
|
+
label = str(getattr(db_field, "verbose_name", db_field.name)).capitalize()
|
|
338
|
+
if lookup_name in LOOKUP_LABELS:
|
|
339
|
+
label = f"{label} ({LOOKUP_LABELS[lookup_name]})"
|
|
340
|
+
return build_filter_formfield(db_field, lookup_name, label)
|
|
341
|
+
|
|
342
|
+
def get_inline_filter_form_class(
|
|
343
|
+
self, request: HttpRequest, obj: Any
|
|
344
|
+
) -> type[Form] | None:
|
|
345
|
+
if self.inline_filter_form is not None:
|
|
346
|
+
return self.inline_filter_form
|
|
347
|
+
lookups = self.get_inline_filter_fields(request, obj)
|
|
348
|
+
if not lookups:
|
|
349
|
+
return None
|
|
350
|
+
fields = {
|
|
351
|
+
lookup: self.get_inline_filter_formfield(lookup) for lookup in lookups
|
|
352
|
+
}
|
|
353
|
+
return type("InlineFilterForm", (forms.Form,), fields)
|
|
354
|
+
|
|
355
|
+
def get_inline_filter_form_kwargs(
|
|
356
|
+
self, request: HttpRequest, obj: Any
|
|
357
|
+
) -> dict[str, Any]:
|
|
358
|
+
"""Extra keyword arguments for the filter form, e.g. the parent object."""
|
|
359
|
+
return {}
|
|
360
|
+
|
|
361
|
+
def get_inline_filtered_queryset(
|
|
362
|
+
self,
|
|
363
|
+
request: HttpRequest,
|
|
364
|
+
obj: Any,
|
|
365
|
+
queryset: QuerySet,
|
|
366
|
+
data: QueryDict,
|
|
367
|
+
prefix: str,
|
|
368
|
+
) -> FilterResult:
|
|
369
|
+
"""Return the bound filter form (or ``None``) and the filtered queryset."""
|
|
370
|
+
form_class = self.get_inline_filter_form_class(request, obj)
|
|
371
|
+
if form_class is None:
|
|
372
|
+
return None, queryset
|
|
373
|
+
form = form_class(
|
|
374
|
+
data, prefix=prefix, **self.get_inline_filter_form_kwargs(request, obj)
|
|
375
|
+
)
|
|
376
|
+
form.is_valid()
|
|
377
|
+
filters = {
|
|
378
|
+
name: value
|
|
379
|
+
for name, value in form.cleaned_data.items()
|
|
380
|
+
if form[name].data not in (None, "", []) and value is not None
|
|
381
|
+
}
|
|
382
|
+
return form, self.filter_inline_queryset(request, queryset, filters)
|
|
383
|
+
|
|
384
|
+
def filter_inline_queryset(
|
|
385
|
+
self, request: HttpRequest, queryset: QuerySet, filters: dict[str, Any]
|
|
386
|
+
) -> QuerySet:
|
|
387
|
+
"""Apply the cleaned, non-empty filter values to ``queryset``.
|
|
388
|
+
|
|
389
|
+
Override to handle form fields that are not plain lookups::
|
|
390
|
+
|
|
391
|
+
def filter_inline_queryset(self, request, queryset, filters):
|
|
392
|
+
if search := filters.pop("q", None):
|
|
393
|
+
queryset = queryset.filter(title__icontains=search)
|
|
394
|
+
return super().filter_inline_queryset(request, queryset, filters)
|
|
395
|
+
"""
|
|
396
|
+
allowed = set(self.inline_filter_fields) or set(filters)
|
|
397
|
+
lookups = {name: value for name, value in filters.items() if name in allowed}
|
|
398
|
+
queryset = queryset.filter(**lookups)
|
|
399
|
+
if any(lookup_spawns_duplicates(self.model._meta, name) for name in lookups):
|
|
400
|
+
queryset = queryset.distinct()
|
|
401
|
+
return queryset
|
|
402
|
+
|
|
403
|
+
# Actions ---------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
def _resolve_inline_bulk_action(
|
|
406
|
+
self, action: str | Callable[..., Any]
|
|
407
|
+
) -> tuple[str, Callable[..., Any]]:
|
|
408
|
+
from django_admin_inline_controls.actions import BUILTIN_BULK_ACTIONS
|
|
409
|
+
|
|
410
|
+
if callable(action):
|
|
411
|
+
return action.__name__, action
|
|
412
|
+
if not isinstance(action, str):
|
|
413
|
+
raise AttributeError(action)
|
|
414
|
+
if hasattr(type(self), action):
|
|
415
|
+
return action, getattr(type(self), action)
|
|
416
|
+
if action in BUILTIN_BULK_ACTIONS:
|
|
417
|
+
return action, BUILTIN_BULK_ACTIONS[action]
|
|
418
|
+
raise AttributeError(action)
|
|
419
|
+
|
|
420
|
+
def get_inline_bulk_actions(self, request: HttpRequest, obj: Any) -> InlineActions:
|
|
421
|
+
"""Actions available to this user, keyed by name."""
|
|
422
|
+
actions: InlineActions = {}
|
|
423
|
+
# Unknown placeholders (``%(count)s``) are left for the JS to fill.
|
|
424
|
+
names = _KeepMissing(model_format_dict(self.model._meta))
|
|
425
|
+
for action in self.inline_bulk_actions:
|
|
426
|
+
name, func = self._resolve_inline_bulk_action(action)
|
|
427
|
+
permissions = getattr(func, "allowed_permissions", ())
|
|
428
|
+
if not all(
|
|
429
|
+
getattr(self, f"has_{permission}_permission")(request, obj)
|
|
430
|
+
for permission in permissions
|
|
431
|
+
):
|
|
432
|
+
continue
|
|
433
|
+
description = getattr(
|
|
434
|
+
func, "short_description", capfirst(name.replace("_", " "))
|
|
435
|
+
)
|
|
436
|
+
confirmation = getattr(func, "confirmation", None)
|
|
437
|
+
actions[name] = InlineActionSpec(
|
|
438
|
+
name=name,
|
|
439
|
+
function=func,
|
|
440
|
+
description=str(description) % names,
|
|
441
|
+
confirmation=None
|
|
442
|
+
if confirmation is None
|
|
443
|
+
else str(confirmation) % names,
|
|
444
|
+
)
|
|
445
|
+
return actions
|
|
446
|
+
|
|
447
|
+
# Row actions -----------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
def _resolve_inline_action(
|
|
450
|
+
self, action: str | Callable[..., Any]
|
|
451
|
+
) -> tuple[str, Callable[..., Any]]:
|
|
452
|
+
from django_admin_inline_controls.row_actions import BUILTIN_ROW_ACTIONS
|
|
453
|
+
|
|
454
|
+
if callable(action):
|
|
455
|
+
return action.__name__, action
|
|
456
|
+
if not isinstance(action, str):
|
|
457
|
+
raise AttributeError(action)
|
|
458
|
+
if hasattr(type(self), action):
|
|
459
|
+
return action, getattr(type(self), action)
|
|
460
|
+
if action in BUILTIN_ROW_ACTIONS:
|
|
461
|
+
return action, BUILTIN_ROW_ACTIONS[action]
|
|
462
|
+
raise AttributeError(action)
|
|
463
|
+
|
|
464
|
+
def _declared_inline_actions(self) -> list[str | Callable[..., Any]]:
|
|
465
|
+
"""``inline_actions`` of every class of the inline, bases first."""
|
|
466
|
+
if self.inline_actions is None:
|
|
467
|
+
return []
|
|
468
|
+
actions: list[str | Callable[..., Any]] = []
|
|
469
|
+
for klass in reversed(type(self).__mro__):
|
|
470
|
+
for action in getattr(klass, "inline_actions", None) or ():
|
|
471
|
+
if action not in actions:
|
|
472
|
+
actions.append(action)
|
|
473
|
+
return actions
|
|
474
|
+
|
|
475
|
+
@property
|
|
476
|
+
def has_inline_actions(self) -> bool:
|
|
477
|
+
"""Whether the rows get action buttons: actions are listed, or
|
|
478
|
+
``get_inline_actions()`` adds them (``DeleteAction``)."""
|
|
479
|
+
if self.inline_actions is None:
|
|
480
|
+
return False
|
|
481
|
+
overridden = (
|
|
482
|
+
type(self).get_inline_actions is not InlineControlsMixin.get_inline_actions
|
|
483
|
+
)
|
|
484
|
+
return overridden or bool(self._declared_inline_actions())
|
|
485
|
+
|
|
486
|
+
def get_inline_actions(
|
|
487
|
+
self, request: HttpRequest | None, obj: Any = None
|
|
488
|
+
) -> list[str | Callable[..., Any]]:
|
|
489
|
+
"""The actions of the row ``obj``, as in django-inline-actions.
|
|
490
|
+
Override to offer some only on some rows (e.g. "Unpublish" on the
|
|
491
|
+
published ones); the parent object is
|
|
492
|
+
``request.inline_controls_parent``."""
|
|
493
|
+
return self._declared_inline_actions()
|
|
494
|
+
|
|
495
|
+
def get_inline_action_specs(
|
|
496
|
+
self, request: HttpRequest | None, parent: Any, obj: Any
|
|
497
|
+
) -> dict[str, Any]:
|
|
498
|
+
"""The row actions of ``obj`` this user may run, keyed by name."""
|
|
499
|
+
from django_admin_inline_controls.row_actions import row_action_spec
|
|
500
|
+
|
|
501
|
+
if request is None or obj is None or obj.pk is None:
|
|
502
|
+
return {}
|
|
503
|
+
request.inline_controls_parent = parent # type: ignore[attr-defined]
|
|
504
|
+
specs = {}
|
|
505
|
+
for action in self.get_inline_actions(request, obj):
|
|
506
|
+
name, func = self._resolve_inline_action(action)
|
|
507
|
+
permissions = getattr(func, "allowed_permissions", ())
|
|
508
|
+
if not all(
|
|
509
|
+
getattr(self, f"has_{permission}_permission")(request, parent)
|
|
510
|
+
for permission in permissions
|
|
511
|
+
):
|
|
512
|
+
continue
|
|
513
|
+
spec = row_action_spec(self, name, func, obj)
|
|
514
|
+
if getattr(func, "inline_actions_link", False) and spec.url is None:
|
|
515
|
+
continue # its model has no change view here
|
|
516
|
+
specs[name] = spec
|
|
517
|
+
return specs
|
|
518
|
+
|
|
519
|
+
@property
|
|
520
|
+
def inline_actions_column(self) -> str:
|
|
521
|
+
return "render_inline_actions"
|
|
522
|
+
|
|
523
|
+
def render_inline_actions(self, obj: Any = None) -> SafeString:
|
|
524
|
+
"""The row actions' buttons: a read-only column of the inline."""
|
|
525
|
+
from django.template.loader import render_to_string
|
|
526
|
+
|
|
527
|
+
from django_admin_inline_controls.row_actions import get_render_context
|
|
528
|
+
|
|
529
|
+
request, parent = get_render_context(self)
|
|
530
|
+
actions = self.get_inline_action_specs(request, parent, obj)
|
|
531
|
+
if not actions:
|
|
532
|
+
return mark_safe("")
|
|
533
|
+
return mark_safe(
|
|
534
|
+
render_to_string(
|
|
535
|
+
self.inline_actions_template,
|
|
536
|
+
{"actions": list(actions.values()), "obj": obj, "inline": self},
|
|
537
|
+
)
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
render_inline_actions.short_description = _("Actions") # type: ignore[attr-defined]
|
|
541
|
+
|
|
542
|
+
def _with_row_actions(self, obj: Any) -> bool:
|
|
543
|
+
return self.has_inline_actions and obj is not None and obj.pk is not None
|
|
544
|
+
|
|
545
|
+
def get_readonly_fields(self, request: HttpRequest, obj: Any = None) -> Any:
|
|
546
|
+
fields = super().get_readonly_fields(request, obj) # type: ignore[misc]
|
|
547
|
+
column = self.inline_actions_column
|
|
548
|
+
if self._with_row_actions(obj) and column not in fields:
|
|
549
|
+
fields = [*fields, column]
|
|
550
|
+
return fields
|
|
551
|
+
|
|
552
|
+
def get_fields(self, request: HttpRequest, obj: Any = None) -> Any:
|
|
553
|
+
fields = super().get_fields(request, obj) # type: ignore[misc]
|
|
554
|
+
column = self.inline_actions_column
|
|
555
|
+
if self._with_row_actions(obj) and column not in fields:
|
|
556
|
+
fields = [*fields, column]
|
|
557
|
+
return fields
|
|
558
|
+
|
|
559
|
+
def get_fieldsets(self, request: HttpRequest, obj: Any = None) -> Any:
|
|
560
|
+
fieldsets = list(super().get_fieldsets(request, obj)) # type: ignore[misc]
|
|
561
|
+
column = self.inline_actions_column
|
|
562
|
+
if self._with_row_actions(obj) and fieldsets:
|
|
563
|
+
name, options = fieldsets[-1]
|
|
564
|
+
fields = list(options.get("fields") or ())
|
|
565
|
+
if column not in fields:
|
|
566
|
+
fieldsets[-1] = (name, {**options, "fields": [*fields, column]})
|
|
567
|
+
return fieldsets
|
|
568
|
+
|
|
569
|
+
def message_user(
|
|
570
|
+
self,
|
|
571
|
+
request: HttpRequest,
|
|
572
|
+
message: str,
|
|
573
|
+
level: int = messages.INFO,
|
|
574
|
+
extra_tags: str = "",
|
|
575
|
+
fail_silently: bool = False,
|
|
576
|
+
) -> None:
|
|
577
|
+
"""Like ``ModelAdmin.message_user()``, for use in inline actions."""
|
|
578
|
+
messages.add_message(
|
|
579
|
+
request, level, message, extra_tags=extra_tags, fail_silently=fail_silently
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
# InlineModelAdmin ------------------------------------------------------
|
|
583
|
+
|
|
584
|
+
def get_formset(
|
|
585
|
+
self, request: HttpRequest, obj: Any = None, **kwargs: Any
|
|
586
|
+
) -> type[BaseInlineFormSet[Any, Any, Any]]:
|
|
587
|
+
from django_admin_inline_controls.row_actions import set_render_context
|
|
588
|
+
|
|
589
|
+
if self.has_inline_actions:
|
|
590
|
+
set_render_context(self, request, obj)
|
|
591
|
+
formset_class = super().get_formset(request, obj, **kwargs) # type: ignore[misc]
|
|
592
|
+
return type(
|
|
593
|
+
formset_class.__name__,
|
|
594
|
+
(InlineControlsFormSetMixin, formset_class),
|
|
595
|
+
{
|
|
596
|
+
"inline_controls_inline": self,
|
|
597
|
+
"inline_controls_request": request,
|
|
598
|
+
"inline_controls_parent": obj,
|
|
599
|
+
},
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
@property
|
|
603
|
+
def media(self) -> forms.Media:
|
|
604
|
+
# The save button's and the actions' scripts load only when used;
|
|
605
|
+
# always in this order, so several inlines' media merge cleanly.
|
|
606
|
+
js = ["django_admin_inline_controls/js/core.js"]
|
|
607
|
+
if self.inline_save_button:
|
|
608
|
+
js.append("django_admin_inline_controls/js/save.js")
|
|
609
|
+
if self.inline_bulk_actions or self.has_inline_actions:
|
|
610
|
+
js.append("django_admin_inline_controls/js/actions.js")
|
|
611
|
+
return super().media + forms.Media( # type: ignore[misc]
|
|
612
|
+
js=js,
|
|
613
|
+
css={"all": ["django_admin_inline_controls/css/core.css"]},
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
def check(self, **kwargs: Any) -> list[Any]:
|
|
617
|
+
from django_admin_inline_controls.checks import check_inline_controls
|
|
618
|
+
|
|
619
|
+
return [*super().check(**kwargs), *check_inline_controls(self)] # type: ignore[misc]
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
class InlineControlsAdminMixin:
|
|
623
|
+
"""Parent ``ModelAdmin`` side of ``inline_save_button`` and ``inline_bulk_actions``.
|
|
624
|
+
|
|
625
|
+
Adds the endpoints that save a single inline and run inline actions,
|
|
626
|
+
both returning the inline re-rendered::
|
|
627
|
+
|
|
628
|
+
@admin.register(Author)
|
|
629
|
+
class AuthorAdmin(InlineControlsAdminMixin, admin.ModelAdmin):
|
|
630
|
+
inlines = [BookInline]
|
|
631
|
+
"""
|
|
632
|
+
|
|
633
|
+
inline_controls_response_template = (
|
|
634
|
+
"django_admin_inline_controls/inline_response.html"
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
# Provided by ModelAdmin.
|
|
638
|
+
model: type[models.Model]
|
|
639
|
+
admin_site: Any
|
|
640
|
+
opts: Any
|
|
641
|
+
|
|
642
|
+
def get_urls(self) -> list[URLPattern]:
|
|
643
|
+
opts = self.model._meta
|
|
644
|
+
name = f"{opts.app_label}_{opts.model_name}_inline_controls"
|
|
645
|
+
return [
|
|
646
|
+
path(
|
|
647
|
+
"<path:object_id>/inline-controls/<str:prefix>/save/",
|
|
648
|
+
self.admin_site.admin_view(self.inline_controls_save_view),
|
|
649
|
+
name=f"{name}_save",
|
|
650
|
+
),
|
|
651
|
+
path(
|
|
652
|
+
"<path:object_id>/inline-controls/<str:prefix>/action/",
|
|
653
|
+
self.admin_site.admin_view(self.inline_controls_action_view),
|
|
654
|
+
name=f"{name}_action",
|
|
655
|
+
),
|
|
656
|
+
*super().get_urls(), # type: ignore[misc]
|
|
657
|
+
]
|
|
658
|
+
|
|
659
|
+
def _inline_controls_formset_class(
|
|
660
|
+
self,
|
|
661
|
+
request: HttpRequest,
|
|
662
|
+
obj: models.Model,
|
|
663
|
+
prefix: str,
|
|
664
|
+
enabled: Callable[[Any], bool],
|
|
665
|
+
) -> tuple[Any, InlineControlsMixin]:
|
|
666
|
+
"""Find the formset class and inline with ``prefix``, like the change
|
|
667
|
+
view does (a repeated default prefix gets ``-2``, ``-3``...)."""
|
|
668
|
+
seen: dict[str, int] = {}
|
|
669
|
+
for formset_class, inline in self.get_formsets_with_inlines(request, obj): # type: ignore[attr-defined]
|
|
670
|
+
default = formset_class.get_default_prefix()
|
|
671
|
+
seen[default] = seen.get(default, 0) + 1
|
|
672
|
+
current = (
|
|
673
|
+
default
|
|
674
|
+
if seen[default] == 1 and default
|
|
675
|
+
else (f"{default}-{seen[default]}")
|
|
676
|
+
)
|
|
677
|
+
if current == prefix:
|
|
678
|
+
if not enabled(inline):
|
|
679
|
+
break
|
|
680
|
+
return formset_class, inline
|
|
681
|
+
raise Http404(f"No such inline: {prefix!r}.")
|
|
682
|
+
|
|
683
|
+
def _inline_controls_object(self, request: HttpRequest, object_id: str) -> Any:
|
|
684
|
+
request.current_app = self.admin_site.name
|
|
685
|
+
obj = self.get_object(request, object_id) # type: ignore[attr-defined]
|
|
686
|
+
if obj is None:
|
|
687
|
+
raise Http404
|
|
688
|
+
return obj
|
|
689
|
+
|
|
690
|
+
def _inline_controls_response(
|
|
691
|
+
self,
|
|
692
|
+
request: HttpRequest,
|
|
693
|
+
obj: Any,
|
|
694
|
+
inline: InlineControlsMixin,
|
|
695
|
+
formset: Any,
|
|
696
|
+
prefix: str,
|
|
697
|
+
status: str,
|
|
698
|
+
message: str,
|
|
699
|
+
) -> HttpResponse:
|
|
700
|
+
inline_admin_formsets = self.get_inline_formsets( # type: ignore[attr-defined]
|
|
701
|
+
request, [formset], [inline], obj
|
|
702
|
+
)
|
|
703
|
+
return TemplateResponse(
|
|
704
|
+
request,
|
|
705
|
+
self.inline_controls_response_template,
|
|
706
|
+
{
|
|
707
|
+
"inline_admin_formsets": inline_admin_formsets,
|
|
708
|
+
"prefix": prefix,
|
|
709
|
+
"status": status,
|
|
710
|
+
"message": message,
|
|
711
|
+
"opts": self.opts,
|
|
712
|
+
"original": obj,
|
|
713
|
+
"change": True,
|
|
714
|
+
"is_popup": False,
|
|
715
|
+
},
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
def _inline_controls_fresh_formset(
|
|
719
|
+
self,
|
|
720
|
+
request: HttpRequest,
|
|
721
|
+
obj: Any,
|
|
722
|
+
inline: InlineControlsMixin,
|
|
723
|
+
formset_class: Any,
|
|
724
|
+
prefix: str,
|
|
725
|
+
loaded: int,
|
|
726
|
+
) -> Any:
|
|
727
|
+
"""The inline as stored now; in infinite mode, as many pages as were
|
|
728
|
+
loaded (``loaded`` rows)."""
|
|
729
|
+
per_page = inline.get_inline_per_page(request, obj)
|
|
730
|
+
if inline.inline_pagination == INFINITE and per_page:
|
|
731
|
+
formset_class.inline_controls_pages_loaded = max(
|
|
732
|
+
1, math.ceil(loaded / per_page)
|
|
733
|
+
)
|
|
734
|
+
return formset_class(
|
|
735
|
+
instance=obj,
|
|
736
|
+
prefix=prefix,
|
|
737
|
+
queryset=inline.get_queryset(request), # type: ignore[attr-defined]
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
# Save ------------------------------------------------------------------
|
|
741
|
+
|
|
742
|
+
def inline_controls_save_view(
|
|
743
|
+
self, request: HttpRequest, object_id: str, prefix: str
|
|
744
|
+
) -> HttpResponse:
|
|
745
|
+
return require_POST(self._inline_controls_save)(request, object_id, prefix)
|
|
746
|
+
|
|
747
|
+
def _inline_controls_save(
|
|
748
|
+
self, request: HttpRequest, object_id: str, prefix: str
|
|
749
|
+
) -> HttpResponse:
|
|
750
|
+
obj = self._inline_controls_object(request, object_id)
|
|
751
|
+
if not self.has_change_permission(request, obj): # type: ignore[attr-defined]
|
|
752
|
+
raise PermissionDenied
|
|
753
|
+
formset_class, inline = self._inline_controls_formset_class(
|
|
754
|
+
request, obj, prefix, lambda inline: inline.inline_save_button
|
|
755
|
+
)
|
|
756
|
+
if not (
|
|
757
|
+
inline.has_change_permission(request, obj) # type: ignore[attr-defined]
|
|
758
|
+
or inline.has_add_permission(request, obj) # type: ignore[attr-defined]
|
|
759
|
+
or inline.has_delete_permission(request, obj) # type: ignore[attr-defined]
|
|
760
|
+
):
|
|
761
|
+
raise PermissionDenied
|
|
762
|
+
|
|
763
|
+
params = self.get_formset_kwargs(request, obj, inline, prefix) # type: ignore[attr-defined]
|
|
764
|
+
formset = formset_class(**params)
|
|
765
|
+
if not inline.has_change_permission(request, obj): # type: ignore[attr-defined]
|
|
766
|
+
# As in the change view: view-only rows aren't in the POST data,
|
|
767
|
+
# so skip their validation unless they were marked for deletion.
|
|
768
|
+
can_delete = inline.has_delete_permission(request, obj) # type: ignore[attr-defined]
|
|
769
|
+
for index, form in enumerate(formset.initial_forms):
|
|
770
|
+
deleted = f"{formset.prefix}-{index}-DELETE" in request.POST
|
|
771
|
+
if not (can_delete and deleted):
|
|
772
|
+
form._errors = {}
|
|
773
|
+
form.cleaned_data = form.initial
|
|
774
|
+
|
|
775
|
+
# save_formset() and construct_change_message() expect the parent's
|
|
776
|
+
# form; only the inline was submitted, so it is an unchanged one.
|
|
777
|
+
form = self.get_form(request, obj, change=True)(instance=obj) # type: ignore[attr-defined]
|
|
778
|
+
form.changed_data = []
|
|
779
|
+
|
|
780
|
+
if not formset.is_valid():
|
|
781
|
+
return self._inline_controls_response(
|
|
782
|
+
request,
|
|
783
|
+
obj,
|
|
784
|
+
inline,
|
|
785
|
+
formset,
|
|
786
|
+
prefix,
|
|
787
|
+
"invalid",
|
|
788
|
+
gettext("Please correct the errors below."),
|
|
789
|
+
)
|
|
790
|
+
with transaction.atomic(using=router.db_for_write(self.model)):
|
|
791
|
+
self.save_formset(request, form, formset, change=True) # type: ignore[attr-defined]
|
|
792
|
+
message = self.construct_change_message(request, form, [formset]) # type: ignore[attr-defined]
|
|
793
|
+
if message:
|
|
794
|
+
self.log_change(request, obj, message) # type: ignore[attr-defined]
|
|
795
|
+
fresh = self._inline_controls_fresh_formset(
|
|
796
|
+
request, obj, inline, formset_class, prefix, formset.initial_form_count()
|
|
797
|
+
)
|
|
798
|
+
return self._inline_controls_response(
|
|
799
|
+
request, obj, inline, fresh, prefix, "saved", gettext("Saved.")
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
# Actions ---------------------------------------------------------------
|
|
803
|
+
|
|
804
|
+
def inline_controls_action_view(
|
|
805
|
+
self, request: HttpRequest, object_id: str, prefix: str
|
|
806
|
+
) -> HttpResponseBase:
|
|
807
|
+
return require_POST(self._inline_controls_action)(request, object_id, prefix)
|
|
808
|
+
|
|
809
|
+
def _inline_controls_action(
|
|
810
|
+
self, request: HttpRequest, object_id: str, prefix: str
|
|
811
|
+
) -> HttpResponseBase:
|
|
812
|
+
obj = self._inline_controls_object(request, object_id)
|
|
813
|
+
if not self.has_view_or_change_permission(request, obj): # type: ignore[attr-defined]
|
|
814
|
+
raise PermissionDenied
|
|
815
|
+
formset_class, inline = self._inline_controls_formset_class(
|
|
816
|
+
request,
|
|
817
|
+
obj,
|
|
818
|
+
prefix,
|
|
819
|
+
lambda inline: bool(
|
|
820
|
+
inline.inline_bulk_actions or inline.has_inline_actions
|
|
821
|
+
),
|
|
822
|
+
)
|
|
823
|
+
row_action = request.POST.get("row_action")
|
|
824
|
+
|
|
825
|
+
try:
|
|
826
|
+
loaded = int(request.POST.get("_inline_controls_loaded") or 0)
|
|
827
|
+
except ValueError:
|
|
828
|
+
loaded = 0
|
|
829
|
+
# The current filters come from the query string, as for the page.
|
|
830
|
+
formset = formset_class(
|
|
831
|
+
instance=obj,
|
|
832
|
+
prefix=prefix,
|
|
833
|
+
queryset=inline.get_queryset(request), # type: ignore[attr-defined]
|
|
834
|
+
)
|
|
835
|
+
formset.get_queryset()
|
|
836
|
+
queryset = formset.inline_controls.filtered_queryset
|
|
837
|
+
if row_action is not None:
|
|
838
|
+
return self._inline_controls_row_action(
|
|
839
|
+
request,
|
|
840
|
+
obj,
|
|
841
|
+
inline,
|
|
842
|
+
formset_class,
|
|
843
|
+
prefix,
|
|
844
|
+
loaded,
|
|
845
|
+
row_action,
|
|
846
|
+
queryset,
|
|
847
|
+
)
|
|
848
|
+
action = inline.get_inline_bulk_actions(request, obj).get(
|
|
849
|
+
request.POST.get("action", "")
|
|
850
|
+
)
|
|
851
|
+
if action is None:
|
|
852
|
+
return HttpResponseBadRequest("Unknown or forbidden action.")
|
|
853
|
+
if request.POST.get("select_across") != "1":
|
|
854
|
+
pk_field = queryset.model._meta.pk
|
|
855
|
+
pks = []
|
|
856
|
+
for raw in request.POST.getlist("_selected_action"):
|
|
857
|
+
try:
|
|
858
|
+
pks.append(pk_field.to_python(raw))
|
|
859
|
+
except ValidationError:
|
|
860
|
+
continue
|
|
861
|
+
queryset = queryset.filter(pk__in=pks)
|
|
862
|
+
|
|
863
|
+
if not queryset.exists():
|
|
864
|
+
status, text = (
|
|
865
|
+
"error",
|
|
866
|
+
gettext(
|
|
867
|
+
"Items must be selected in order to perform actions on them. "
|
|
868
|
+
"No items have been changed."
|
|
869
|
+
),
|
|
870
|
+
)
|
|
871
|
+
else:
|
|
872
|
+
request.inline_controls_parent = obj # type: ignore[attr-defined]
|
|
873
|
+
response = action.function(inline, request, queryset)
|
|
874
|
+
if isinstance(response, HttpResponseBase):
|
|
875
|
+
return response
|
|
876
|
+
sent = list(messages.get_messages(request))
|
|
877
|
+
failed = any(message.level >= messages.ERROR for message in sent)
|
|
878
|
+
status = "error" if failed else "done"
|
|
879
|
+
text = " ".join(str(message) for message in sent) or gettext("Done.")
|
|
880
|
+
|
|
881
|
+
fresh = self._inline_controls_fresh_formset(
|
|
882
|
+
request, obj, inline, formset_class, prefix, loaded
|
|
883
|
+
)
|
|
884
|
+
return self._inline_controls_response(
|
|
885
|
+
request, obj, inline, fresh, prefix, status, text
|
|
886
|
+
)
|
|
887
|
+
|
|
888
|
+
def _inline_controls_row_action(
|
|
889
|
+
self,
|
|
890
|
+
request: HttpRequest,
|
|
891
|
+
obj: Any,
|
|
892
|
+
inline: InlineControlsMixin,
|
|
893
|
+
formset_class: Any,
|
|
894
|
+
prefix: str,
|
|
895
|
+
loaded: int,
|
|
896
|
+
name: str,
|
|
897
|
+
queryset: QuerySet,
|
|
898
|
+
) -> HttpResponseBase:
|
|
899
|
+
from django_admin_inline_controls.row_actions import run_row_action
|
|
900
|
+
|
|
901
|
+
pk_field = queryset.model._meta.pk
|
|
902
|
+
try:
|
|
903
|
+
pk = pk_field.to_python(request.POST.get("_selected_action"))
|
|
904
|
+
except ValidationError:
|
|
905
|
+
pk = None
|
|
906
|
+
row = queryset.filter(pk=pk).first() if pk is not None else None
|
|
907
|
+
action = inline.get_inline_action_specs(request, obj, row).get(name)
|
|
908
|
+
if action is None:
|
|
909
|
+
return HttpResponseBadRequest("Unknown or forbidden action.")
|
|
910
|
+
response = run_row_action(inline, request, action, row, obj)
|
|
911
|
+
if isinstance(response, HttpResponseBase):
|
|
912
|
+
return response
|
|
913
|
+
sent = list(messages.get_messages(request))
|
|
914
|
+
failed = any(message.level >= messages.ERROR for message in sent)
|
|
915
|
+
status = "error" if failed else "done"
|
|
916
|
+
text = " ".join(str(message) for message in sent) or gettext("Done.")
|
|
917
|
+
fresh = self._inline_controls_fresh_formset(
|
|
918
|
+
request, obj, inline, formset_class, prefix, loaded
|
|
919
|
+
)
|
|
920
|
+
return self._inline_controls_response(
|
|
921
|
+
request, obj, inline, fresh, prefix, status, text
|
|
922
|
+
)
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
__all__ = [
|
|
926
|
+
"INFINITE",
|
|
927
|
+
"PAGES",
|
|
928
|
+
"InlineActionSpec",
|
|
929
|
+
"InlineControlsAdminMixin",
|
|
930
|
+
"InlineControlsFormSetMixin",
|
|
931
|
+
"InlineControlsMixin",
|
|
932
|
+
]
|