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,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
+ ]