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,684 @@
|
|
|
1
|
+
"""Per-request state of a controlled inline formset.
|
|
2
|
+
|
|
3
|
+
``InlineControls`` reads this inline's query parameters (namespaced by the
|
|
4
|
+
formset prefix so several inlines on one page never clash), then filters,
|
|
5
|
+
orders and paginates the formset queryset and exposes everything the
|
|
6
|
+
templates and the JS need.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from decimal import Decimal
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
from django.contrib.admin.utils import label_for_field, quote
|
|
18
|
+
from django.core.exceptions import ValidationError
|
|
19
|
+
from django.core.paginator import Page, Paginator
|
|
20
|
+
from django.db.models import QuerySet
|
|
21
|
+
from django.db.models.expressions import OrderBy
|
|
22
|
+
from django.forms import Form
|
|
23
|
+
from django.http import HttpRequest, QueryDict
|
|
24
|
+
from django.urls import NoReverseMatch, reverse
|
|
25
|
+
from django.utils.functional import cached_property
|
|
26
|
+
from django.utils.text import capfirst
|
|
27
|
+
from django.utils.translation import gettext
|
|
28
|
+
|
|
29
|
+
from django_admin_inline_controls.types import Ordering, OrderingFields, OrderingValue
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from django_admin_inline_controls.mixins import InlineControlsMixin
|
|
33
|
+
|
|
34
|
+
PAGES = "pages"
|
|
35
|
+
INFINITE = "infinite"
|
|
36
|
+
FOOTER_FILTERED = "filtered"
|
|
37
|
+
FOOTER_PAGE = "page"
|
|
38
|
+
|
|
39
|
+
#: Where the JS finds things in the admin's inline markup. Override per inline
|
|
40
|
+
#: with ``inline_controls_selectors`` for themes or inline templates with a
|
|
41
|
+
#: different structure. Each value is one selector or a list tried in order;
|
|
42
|
+
#: ``{name}`` (a column), ``{group}`` (``#<prefix>-group``) and ``{prefix}``
|
|
43
|
+
#: are filled in.
|
|
44
|
+
DEFAULT_SELECTORS: dict[str, list[str]] = {
|
|
45
|
+
# Element the toolbar and footer are moved into, inside the inline.
|
|
46
|
+
"container": [".inline-group fieldset"],
|
|
47
|
+
# Inside the container: the toolbar goes right after it (or its <summary>).
|
|
48
|
+
"heading": ["h2"],
|
|
49
|
+
# Inside the container: the footer is appended here, else to the container.
|
|
50
|
+
"footer_parent": [":scope > details"],
|
|
51
|
+
# Tabular header row holding the sortable column headers.
|
|
52
|
+
"table_head": [".inline-group table thead"],
|
|
53
|
+
# Header of a sortable column, inside the table head.
|
|
54
|
+
"column_header": ["th.column-{name}"],
|
|
55
|
+
# Inside the inline group: each form's container (``{prefix}``: the
|
|
56
|
+
# formset's). Its index comes from its id (``<prefix>-<n>``) or, if it
|
|
57
|
+
# has none, from its fields' names.
|
|
58
|
+
"form_rows": ["[id]"],
|
|
59
|
+
# A form row of a saved object (not a new one) matches this.
|
|
60
|
+
"saved_row": [".has_original"],
|
|
61
|
+
# Inside a saved row: where its action checkbox goes.
|
|
62
|
+
"row_label": [":scope > td.original > p", ":scope > h3", ":scope > td.original"],
|
|
63
|
+
# jQuery selectors of the rows Django's inlines.js manages (re-run after
|
|
64
|
+
# an inline is refreshed in place).
|
|
65
|
+
"tabular_rows": ["{group} .tabular.inline-related tbody:first > tr.form-row"],
|
|
66
|
+
"stacked_rows": ["{group} .inline-related"],
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class OrderingColumn:
|
|
72
|
+
"""A sortable column as rendered in the inline header or toolbar."""
|
|
73
|
+
|
|
74
|
+
name: str
|
|
75
|
+
label: str
|
|
76
|
+
direction: str # "ascending", "descending" or ""
|
|
77
|
+
priority: int # 1-based position in the current ordering, 0 if unsorted
|
|
78
|
+
toggle_url: str
|
|
79
|
+
remove_url: str
|
|
80
|
+
|
|
81
|
+
def as_json(self) -> dict[str, Any]:
|
|
82
|
+
return {
|
|
83
|
+
"name": self.name,
|
|
84
|
+
"label": self.label,
|
|
85
|
+
"direction": self.direction,
|
|
86
|
+
"priority": self.priority,
|
|
87
|
+
"toggleUrl": self.toggle_url,
|
|
88
|
+
"removeUrl": self.remove_url,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class FooterCell:
|
|
94
|
+
column: str
|
|
95
|
+
column_label: str
|
|
96
|
+
#: Formatted for display by ``format_inline_footer_value()``.
|
|
97
|
+
value: str
|
|
98
|
+
#: Machine-readable value (``"1234.5"``) for the page's JS, or ``""`` when
|
|
99
|
+
#: it isn't a number.
|
|
100
|
+
raw: str = ""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class FooterRow:
|
|
105
|
+
"""A row of totals/averages/… below the inline's table."""
|
|
106
|
+
|
|
107
|
+
label: str
|
|
108
|
+
cells: list[FooterCell]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class TfootCell:
|
|
113
|
+
"""One cell of a footer row in the <tfoot>, under its column."""
|
|
114
|
+
|
|
115
|
+
#: The value shown here, or ``None`` for an empty cell.
|
|
116
|
+
cell: FooterCell | None = None
|
|
117
|
+
#: ``"<row index>:<column>"``, as ``data-footer-key``.
|
|
118
|
+
key: str = ""
|
|
119
|
+
#: The row's label goes in this cell, before the value (when the first
|
|
120
|
+
#: value is in the first column, there is no empty column for it).
|
|
121
|
+
label_here: bool = False
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True)
|
|
125
|
+
class TfootRow:
|
|
126
|
+
index: int
|
|
127
|
+
label: str
|
|
128
|
+
#: Columns the label spans at the start of the row; ``0`` when it goes
|
|
129
|
+
#: into the first value's cell instead.
|
|
130
|
+
label_colspan: int
|
|
131
|
+
cells: list[TfootCell]
|
|
132
|
+
#: Empty columns after the last value, up to the table's width.
|
|
133
|
+
trailing: int = 0
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True)
|
|
137
|
+
class PageLink:
|
|
138
|
+
number: int | str
|
|
139
|
+
url: str | None
|
|
140
|
+
current: bool = False
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def raw_number(value: Any) -> str:
|
|
144
|
+
"""A footer value as plain digits for JS (``"1234.5"``), ``""`` if not a
|
|
145
|
+
number."""
|
|
146
|
+
if isinstance(value, bool) or not isinstance(value, int | float | Decimal):
|
|
147
|
+
return ""
|
|
148
|
+
return format(value, "f") if isinstance(value, Decimal) else repr(value)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def normalize_ordering_fields(
|
|
152
|
+
ordering_fields: OrderingFields,
|
|
153
|
+
) -> dict[str, OrderingValue]:
|
|
154
|
+
"""Return ``{column: expression}`` from a sequence or mapping."""
|
|
155
|
+
if isinstance(ordering_fields, Mapping):
|
|
156
|
+
return dict(ordering_fields)
|
|
157
|
+
return {name: name for name in ordering_fields}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def ordering_expression(value: OrderingValue, descending: bool) -> Any:
|
|
161
|
+
"""Build the ``order_by()`` argument for a column in one direction.
|
|
162
|
+
|
|
163
|
+
``value`` may be a field path (``"author__name"``), an expression
|
|
164
|
+
(``Lower("name")``, ``F("date").asc(nulls_last=True)``) or an explicit
|
|
165
|
+
``(ascending, descending)`` pair for full control over both directions.
|
|
166
|
+
"""
|
|
167
|
+
if isinstance(value, tuple):
|
|
168
|
+
return value[1] if descending else value[0]
|
|
169
|
+
if isinstance(value, str):
|
|
170
|
+
path = value.removeprefix("-")
|
|
171
|
+
flipped = value.startswith("-") != descending
|
|
172
|
+
return f"-{path}" if flipped else path
|
|
173
|
+
if isinstance(value, OrderBy):
|
|
174
|
+
if not descending:
|
|
175
|
+
return value
|
|
176
|
+
reversed_value = value.copy()
|
|
177
|
+
reversed_value.reverse_ordering()
|
|
178
|
+
return reversed_value
|
|
179
|
+
return value.desc() if descending else value.asc()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class InlineControls:
|
|
183
|
+
"""Filtering, ordering and pagination state for one inline formset."""
|
|
184
|
+
|
|
185
|
+
def __init__(
|
|
186
|
+
self,
|
|
187
|
+
inline: InlineControlsMixin,
|
|
188
|
+
request: HttpRequest,
|
|
189
|
+
parent: Any,
|
|
190
|
+
prefix: str,
|
|
191
|
+
pages_loaded: int | None = None,
|
|
192
|
+
) -> None:
|
|
193
|
+
self.inline = inline
|
|
194
|
+
self.request = request
|
|
195
|
+
self.parent = parent
|
|
196
|
+
self.prefix = prefix
|
|
197
|
+
self.params: QueryDict = request.GET
|
|
198
|
+
self.mode = inline.inline_pagination
|
|
199
|
+
self.per_page = inline.get_inline_per_page(request, parent)
|
|
200
|
+
self.ordering_fields = normalize_ordering_fields(
|
|
201
|
+
inline.get_inline_ordering_fields(request, parent)
|
|
202
|
+
)
|
|
203
|
+
self.ordering = self._parse_ordering()
|
|
204
|
+
self.filter_form: Form | None = None
|
|
205
|
+
self.paginator: Paginator | None = None
|
|
206
|
+
self.page: Page | None = None
|
|
207
|
+
self.loaded_count: int | None = None
|
|
208
|
+
self.pages_loaded = pages_loaded
|
|
209
|
+
self.selectors = inline.get_inline_controls_selectors(request, parent)
|
|
210
|
+
#: Set when the footer rows were rendered inside the inline's table.
|
|
211
|
+
self.tfoot_rendered = False
|
|
212
|
+
|
|
213
|
+
# Query parameter names -------------------------------------------------
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def page_param(self) -> str:
|
|
217
|
+
return f"{self.prefix}-page"
|
|
218
|
+
|
|
219
|
+
@property
|
|
220
|
+
def ordering_param(self) -> str:
|
|
221
|
+
return f"{self.prefix}-o"
|
|
222
|
+
|
|
223
|
+
@property
|
|
224
|
+
def filter_prefix(self) -> str:
|
|
225
|
+
return f"{self.prefix}-f"
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def filter_form_id(self) -> str:
|
|
229
|
+
"""Id of a form element that is never rendered.
|
|
230
|
+
|
|
231
|
+
Filter widgets point their ``form`` attribute at it, which detaches
|
|
232
|
+
them from the admin change form: they are not submitted on save and
|
|
233
|
+
pressing Enter in them does not save the object.
|
|
234
|
+
"""
|
|
235
|
+
return f"{self.prefix}-inline-controls-filters"
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def container_id(self) -> str:
|
|
239
|
+
return f"{self.prefix}-inline-controls"
|
|
240
|
+
|
|
241
|
+
# Queryset --------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
def apply(self, queryset: QuerySet, bound_pks: list[Any] | None) -> QuerySet:
|
|
244
|
+
"""Filter, order and paginate ``queryset``.
|
|
245
|
+
|
|
246
|
+
On a bound (POST) formset the submitted rows may come from several
|
|
247
|
+
pages (infinite scroll), so instead of slicing the current page the
|
|
248
|
+
queryset is restricted to the submitted primary keys.
|
|
249
|
+
"""
|
|
250
|
+
self.filter_form, queryset = self.inline.get_inline_filtered_queryset(
|
|
251
|
+
self.request, self.parent, queryset, self.params, self.filter_prefix
|
|
252
|
+
)
|
|
253
|
+
if self.filter_form is not None:
|
|
254
|
+
for field in self.filter_form.fields.values():
|
|
255
|
+
field.widget.attrs["form"] = self.filter_form_id
|
|
256
|
+
|
|
257
|
+
queryset = queryset.order_by(*self._order_by(queryset))
|
|
258
|
+
#: Filtered and ordered, before pagination: what actions run on
|
|
259
|
+
#: when "select all" is used.
|
|
260
|
+
self.filtered_queryset = queryset
|
|
261
|
+
|
|
262
|
+
if self.per_page:
|
|
263
|
+
self.paginator = Paginator(queryset, self.per_page)
|
|
264
|
+
self.page = self.paginator.get_page(self.params.get(self.page_param))
|
|
265
|
+
|
|
266
|
+
#: The rows the formset shows: what "page" footer rows add up.
|
|
267
|
+
self.shown_queryset = self._shown(queryset, bound_pks)
|
|
268
|
+
return self.shown_queryset
|
|
269
|
+
|
|
270
|
+
def _shown(self, queryset: QuerySet, bound_pks: list[Any] | None) -> QuerySet:
|
|
271
|
+
if bound_pks is not None:
|
|
272
|
+
if self.mode == INFINITE:
|
|
273
|
+
self.loaded_count = len(bound_pks)
|
|
274
|
+
return queryset.filter(pk__in=bound_pks)
|
|
275
|
+
if self.page is not None and self.page.paginator.count:
|
|
276
|
+
self.loaded_count = self.page.end_index()
|
|
277
|
+
start = self.page.start_index() - 1
|
|
278
|
+
if self.mode == INFINITE and self.pages_loaded and self.per_page:
|
|
279
|
+
start = 0
|
|
280
|
+
self.loaded_count = min(
|
|
281
|
+
self.page.paginator.count, self.pages_loaded * self.per_page
|
|
282
|
+
)
|
|
283
|
+
page_pks = queryset.values_list("pk", flat=True)[start : self.loaded_count]
|
|
284
|
+
return queryset.filter(pk__in=list(page_pks))
|
|
285
|
+
return queryset
|
|
286
|
+
|
|
287
|
+
def _order_by(self, queryset: QuerySet) -> list[Any]:
|
|
288
|
+
order_by = [
|
|
289
|
+
ordering_expression(self.ordering_fields[name], descending)
|
|
290
|
+
for name, descending in self.ordering
|
|
291
|
+
]
|
|
292
|
+
default = list(
|
|
293
|
+
self.inline.inline_default_ordering
|
|
294
|
+
or queryset.query.order_by
|
|
295
|
+
or queryset.model._meta.ordering
|
|
296
|
+
)
|
|
297
|
+
order_by.extend(default)
|
|
298
|
+
# Pagination needs a total order or rows can repeat across pages.
|
|
299
|
+
pk_name = queryset.model._meta.pk.name
|
|
300
|
+
pk_values = {"pk", "-pk", pk_name, f"-{pk_name}"}
|
|
301
|
+
if not any(value in pk_values for value in order_by):
|
|
302
|
+
order_by.append("pk")
|
|
303
|
+
return order_by
|
|
304
|
+
|
|
305
|
+
# Ordering --------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
def _parse_ordering(self) -> Ordering:
|
|
308
|
+
"""Parse ``?<prefix>-o=name,-date`` keeping only declared columns.
|
|
309
|
+
|
|
310
|
+
Unknown names are dropped: ordering by an arbitrary field would let
|
|
311
|
+
anyone infer the values of fields the inline never shows.
|
|
312
|
+
"""
|
|
313
|
+
ordering: Ordering = []
|
|
314
|
+
seen: set[str] = set()
|
|
315
|
+
for token in self.params.get(self.ordering_param, "").split(","):
|
|
316
|
+
name = token.strip().removeprefix("-")
|
|
317
|
+
if name in self.ordering_fields and name not in seen:
|
|
318
|
+
seen.add(name)
|
|
319
|
+
ordering.append((name, token.strip().startswith("-")))
|
|
320
|
+
return ordering
|
|
321
|
+
|
|
322
|
+
def _encode_ordering(self, ordering: Ordering) -> str:
|
|
323
|
+
return ",".join(f"-{name}" if desc else name for name, desc in ordering)
|
|
324
|
+
|
|
325
|
+
@property
|
|
326
|
+
def ordering_columns(self) -> list[OrderingColumn]:
|
|
327
|
+
columns = []
|
|
328
|
+
current = dict(self.ordering)
|
|
329
|
+
positions = {name: index for index, (name, _) in enumerate(self.ordering)}
|
|
330
|
+
for name in self.ordering_fields:
|
|
331
|
+
others = [item for item in self.ordering if item[0] != name]
|
|
332
|
+
if name in current:
|
|
333
|
+
descending = current[name]
|
|
334
|
+
toggled = list(self.ordering)
|
|
335
|
+
toggled[positions[name]] = (name, not descending)
|
|
336
|
+
direction = "descending" if descending else "ascending"
|
|
337
|
+
priority = positions[name] + 1
|
|
338
|
+
else:
|
|
339
|
+
# Like the changelist: a newly sorted column becomes primary.
|
|
340
|
+
toggled = [(name, False), *others]
|
|
341
|
+
direction = ""
|
|
342
|
+
priority = 0
|
|
343
|
+
columns.append(
|
|
344
|
+
OrderingColumn(
|
|
345
|
+
name=name,
|
|
346
|
+
label=self._column_label(name),
|
|
347
|
+
direction=direction,
|
|
348
|
+
priority=priority,
|
|
349
|
+
toggle_url=self._url(
|
|
350
|
+
{self.ordering_param: self._encode_ordering(toggled)},
|
|
351
|
+
reset_page=True,
|
|
352
|
+
),
|
|
353
|
+
remove_url=self._url(
|
|
354
|
+
{self.ordering_param: self._encode_ordering(others)},
|
|
355
|
+
reset_page=True,
|
|
356
|
+
),
|
|
357
|
+
)
|
|
358
|
+
)
|
|
359
|
+
return columns
|
|
360
|
+
|
|
361
|
+
def _column_label(self, name: str) -> str:
|
|
362
|
+
return column_label(self.inline, name)
|
|
363
|
+
|
|
364
|
+
# Pagination ------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
@property
|
|
367
|
+
def is_paginated(self) -> bool:
|
|
368
|
+
return self.paginator is not None and self.paginator.num_pages > 1
|
|
369
|
+
|
|
370
|
+
@property
|
|
371
|
+
def total_count(self) -> int | None:
|
|
372
|
+
return None if self.paginator is None else self.paginator.count
|
|
373
|
+
|
|
374
|
+
@property
|
|
375
|
+
def page_links(self) -> list[PageLink]:
|
|
376
|
+
if self.page is None or self.paginator is None:
|
|
377
|
+
return []
|
|
378
|
+
links = []
|
|
379
|
+
for number in self.paginator.get_elided_page_range(
|
|
380
|
+
self.page.number, on_each_side=2, on_ends=1
|
|
381
|
+
):
|
|
382
|
+
if number == self.paginator.ELLIPSIS:
|
|
383
|
+
links.append(PageLink(number=number, url=None))
|
|
384
|
+
else:
|
|
385
|
+
links.append(
|
|
386
|
+
PageLink(
|
|
387
|
+
number=number,
|
|
388
|
+
url=self._url({self.page_param: str(number)}),
|
|
389
|
+
current=number == self.page.number,
|
|
390
|
+
)
|
|
391
|
+
)
|
|
392
|
+
return links
|
|
393
|
+
|
|
394
|
+
@property
|
|
395
|
+
def next_page_url(self) -> str | None:
|
|
396
|
+
"""URL of the next page to append in infinite mode."""
|
|
397
|
+
if self.page is None or self.paginator is None or not self.per_page:
|
|
398
|
+
return None
|
|
399
|
+
loaded_pages = -(-(self.loaded_count or 0) // self.per_page)
|
|
400
|
+
if loaded_pages >= self.paginator.num_pages:
|
|
401
|
+
return None
|
|
402
|
+
return self._url({self.page_param: str(loaded_pages + 1)})
|
|
403
|
+
|
|
404
|
+
# Filters ---------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
@property
|
|
407
|
+
def filter_param_names(self) -> list[str]:
|
|
408
|
+
if self.filter_form is None:
|
|
409
|
+
return []
|
|
410
|
+
return [self.filter_form.add_prefix(name) for name in self.filter_form.fields]
|
|
411
|
+
|
|
412
|
+
@property
|
|
413
|
+
def is_filtered(self) -> bool:
|
|
414
|
+
return any(self.params.get(name) for name in self.filter_param_names)
|
|
415
|
+
|
|
416
|
+
# Saving ----------------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
def _endpoint(self, kind: str) -> str | None:
|
|
419
|
+
"""URL of an ``InlineControlsAdminMixin`` endpoint, if routed."""
|
|
420
|
+
opts = self.inline.parent_model._meta
|
|
421
|
+
name = f"{opts.app_label}_{opts.model_name}_inline_controls_{kind}"
|
|
422
|
+
try:
|
|
423
|
+
return reverse(
|
|
424
|
+
f"{self.inline.admin_site.name}:{name}",
|
|
425
|
+
args=[quote(self.parent.pk), self.prefix],
|
|
426
|
+
)
|
|
427
|
+
except NoReverseMatch:
|
|
428
|
+
return None
|
|
429
|
+
|
|
430
|
+
@cached_property
|
|
431
|
+
def save_url(self) -> str | None:
|
|
432
|
+
"""Endpoint that saves only this inline, if enabled and routed."""
|
|
433
|
+
return self._endpoint("save") if self.inline.inline_save_button else None
|
|
434
|
+
|
|
435
|
+
# Actions ---------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
@cached_property
|
|
438
|
+
def bulk_actions(self) -> list[Any]:
|
|
439
|
+
if not self.inline.inline_bulk_actions or self._endpoint("action") is None:
|
|
440
|
+
return []
|
|
441
|
+
return list(
|
|
442
|
+
self.inline.get_inline_bulk_actions(self.request, self.parent).values()
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
@cached_property
|
|
446
|
+
def has_row_actions(self) -> bool:
|
|
447
|
+
"""Whether the rows have action buttons (and their endpoint)."""
|
|
448
|
+
return self.inline.has_inline_actions and (self._endpoint("action") is not None)
|
|
449
|
+
|
|
450
|
+
@property
|
|
451
|
+
def action_url(self) -> str | None:
|
|
452
|
+
return (
|
|
453
|
+
self._endpoint("action")
|
|
454
|
+
if self.bulk_actions or self.has_row_actions
|
|
455
|
+
else None
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
@property
|
|
459
|
+
def actions_form_id(self) -> str:
|
|
460
|
+
"""Detached form id for the action select and row checkboxes."""
|
|
461
|
+
return f"{self.prefix}-inline-controls-actions"
|
|
462
|
+
|
|
463
|
+
@property
|
|
464
|
+
def save_label(self) -> str:
|
|
465
|
+
return gettext("Save %(name)s") % {
|
|
466
|
+
"name": self.inline.model._meta.verbose_name_plural
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
@property
|
|
470
|
+
def has_toolbar(self) -> bool:
|
|
471
|
+
return bool(self.filter_form or self.ordering_columns or self.bulk_actions)
|
|
472
|
+
|
|
473
|
+
@property
|
|
474
|
+
def has_footer(self) -> bool:
|
|
475
|
+
return (
|
|
476
|
+
self.paginator is not None
|
|
477
|
+
or self.save_url is not None
|
|
478
|
+
or bool(self.summary_rows)
|
|
479
|
+
or self.has_row_actions
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
@property
|
|
483
|
+
def summary_rows(self) -> list[FooterRow]:
|
|
484
|
+
"""The footer rows shown as a summary in the footer: all of them,
|
|
485
|
+
unless they were rendered in the table's <tfoot>."""
|
|
486
|
+
return [] if self.tfoot_rendered else self.footer_rows
|
|
487
|
+
|
|
488
|
+
def tfoot_rows(
|
|
489
|
+
self, positions: dict[str, int], width: int = 0
|
|
490
|
+
) -> list[TfootRow] | None:
|
|
491
|
+
"""The footer rows laid out in a table ``width`` columns wide whose
|
|
492
|
+
field columns are at ``positions`` (0-based), or ``None`` if a
|
|
493
|
+
value's column isn't among them."""
|
|
494
|
+
return layout_tfoot_rows(self.footer_rows, positions, width)
|
|
495
|
+
|
|
496
|
+
# Footer rows -----------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
@cached_property
|
|
499
|
+
def footer_rows(self) -> list[FooterRow]:
|
|
500
|
+
"""``inline_footer_rows`` computed on the filtered (or shown) rows,
|
|
501
|
+
with every aggregate of every row in a single query."""
|
|
502
|
+
definitions = self.inline.get_inline_footer_rows(self.request, self.parent)
|
|
503
|
+
if not definitions or not hasattr(self, "filtered_queryset"):
|
|
504
|
+
return []
|
|
505
|
+
queryset = (
|
|
506
|
+
self.shown_queryset
|
|
507
|
+
if self.inline.inline_footer_scope == FOOTER_PAGE
|
|
508
|
+
else self.filtered_queryset
|
|
509
|
+
)
|
|
510
|
+
aggregates = {
|
|
511
|
+
f"footer_{row}_{column}": value
|
|
512
|
+
for row, (_, cells) in enumerate(definitions)
|
|
513
|
+
for column, value in cells.items()
|
|
514
|
+
if getattr(value, "contains_aggregate", False)
|
|
515
|
+
}
|
|
516
|
+
results = queryset.order_by().aggregate(**aggregates) if aggregates else {}
|
|
517
|
+
rows = []
|
|
518
|
+
for row, (label, cells) in enumerate(definitions):
|
|
519
|
+
values = []
|
|
520
|
+
for column, value in cells.items():
|
|
521
|
+
key = f"footer_{row}_{column}"
|
|
522
|
+
if key in results:
|
|
523
|
+
value = results[key]
|
|
524
|
+
elif callable(value):
|
|
525
|
+
value = value(queryset)
|
|
526
|
+
values.append(
|
|
527
|
+
FooterCell(
|
|
528
|
+
column=column,
|
|
529
|
+
column_label=self._column_label(column),
|
|
530
|
+
value=str(
|
|
531
|
+
self.inline.format_inline_footer_value(column, value)
|
|
532
|
+
),
|
|
533
|
+
raw=raw_number(value),
|
|
534
|
+
)
|
|
535
|
+
)
|
|
536
|
+
rows.append(FooterRow(label=str(label), cells=values))
|
|
537
|
+
return rows
|
|
538
|
+
|
|
539
|
+
# URLs and config -------------------------------------------------------
|
|
540
|
+
|
|
541
|
+
def _url(self, changes: Mapping[str, str], reset_page: bool = False) -> str:
|
|
542
|
+
params = self.params.copy()
|
|
543
|
+
if reset_page:
|
|
544
|
+
params.pop(self.page_param, None)
|
|
545
|
+
for key, value in changes.items():
|
|
546
|
+
if value:
|
|
547
|
+
params[key] = value
|
|
548
|
+
else:
|
|
549
|
+
params.pop(key, None)
|
|
550
|
+
query = params.urlencode()
|
|
551
|
+
return f"?{query}" if query else "?"
|
|
552
|
+
|
|
553
|
+
@property
|
|
554
|
+
def config_json(self) -> str:
|
|
555
|
+
return json.dumps(
|
|
556
|
+
{
|
|
557
|
+
"prefix": self.prefix,
|
|
558
|
+
"mode": self.mode if self.per_page else None,
|
|
559
|
+
"ajax": self.inline.inline_controls_ajax,
|
|
560
|
+
"pageParam": self.page_param,
|
|
561
|
+
"filterPrefix": self.filter_prefix,
|
|
562
|
+
"filterFormId": self.filter_form_id,
|
|
563
|
+
"filterParams": self.filter_param_names,
|
|
564
|
+
"ordering": [column.as_json() for column in self.ordering_columns],
|
|
565
|
+
"nextUrl": self.next_page_url if self.mode == INFINITE else None,
|
|
566
|
+
"saveUrl": self.save_url,
|
|
567
|
+
"actionUrl": self.action_url,
|
|
568
|
+
"rowActions": self.has_row_actions,
|
|
569
|
+
"actionsFormId": self.actions_form_id,
|
|
570
|
+
"bulkActions": [
|
|
571
|
+
{
|
|
572
|
+
"name": action.name,
|
|
573
|
+
"confirmation": action.confirmation,
|
|
574
|
+
}
|
|
575
|
+
for action in self.bulk_actions
|
|
576
|
+
],
|
|
577
|
+
"pkName": self.inline.model._meta.pk.name,
|
|
578
|
+
"selectors": self.selectors,
|
|
579
|
+
"loadedCount": self.loaded_count,
|
|
580
|
+
"totalCount": self.total_count,
|
|
581
|
+
"messages": {
|
|
582
|
+
"unsaved": gettext(
|
|
583
|
+
"You have unsaved changes in this inline that will be "
|
|
584
|
+
"lost. Continue?"
|
|
585
|
+
),
|
|
586
|
+
"sortRemove": gettext("Remove from sorting"),
|
|
587
|
+
"sortToggle": gettext("Toggle sorting"),
|
|
588
|
+
"saveFailed": gettext(
|
|
589
|
+
"The changes could not be saved. Please try again."
|
|
590
|
+
),
|
|
591
|
+
"actionFailed": gettext(
|
|
592
|
+
"The action could not be run. Please try again."
|
|
593
|
+
),
|
|
594
|
+
"noAction": gettext("No action selected."),
|
|
595
|
+
"noSelection": gettext(
|
|
596
|
+
"Items must be selected in order to perform actions on "
|
|
597
|
+
"them. No items have been changed."
|
|
598
|
+
),
|
|
599
|
+
"selected": gettext("%(sel)s of %(cnt)s selected"),
|
|
600
|
+
"selectAll": gettext("Select all %(total)s"),
|
|
601
|
+
"allSelected": gettext("All %(total)s selected"),
|
|
602
|
+
"selectRow": gettext("Select this row"),
|
|
603
|
+
"selectAllRows": gettext("Select all rows on this page"),
|
|
604
|
+
},
|
|
605
|
+
}
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def bound_primary_keys(formset: Any) -> list[Any]:
|
|
610
|
+
"""Primary keys submitted by a bound model formset, invalid ones dropped."""
|
|
611
|
+
pk_field = formset.model._meta.pk
|
|
612
|
+
pks = []
|
|
613
|
+
for index in range(formset.initial_form_count()):
|
|
614
|
+
raw = formset.data.get(f"{formset.add_prefix(index)}-{pk_field.name}")
|
|
615
|
+
if raw in (None, ""):
|
|
616
|
+
continue
|
|
617
|
+
try:
|
|
618
|
+
pks.append(pk_field.to_python(raw))
|
|
619
|
+
except ValidationError:
|
|
620
|
+
continue
|
|
621
|
+
return pks
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def column_label(inline: Any, name: str) -> str:
|
|
625
|
+
"""The header of the inline's column ``name``."""
|
|
626
|
+
try:
|
|
627
|
+
label = label_for_field(name, inline.model, inline, return_attr=False)
|
|
628
|
+
return str(capfirst(label))
|
|
629
|
+
except AttributeError:
|
|
630
|
+
return name
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def empty_footer_rows(inline: Any, request: HttpRequest | None) -> list[FooterRow]:
|
|
634
|
+
"""``inline_footer_rows`` without values, for the add view: there are no
|
|
635
|
+
saved rows to aggregate yet, but the <tfoot> keeps its structure and
|
|
636
|
+
``data-*`` attributes (``data-value=""``) for the page's JS."""
|
|
637
|
+
definitions = inline.get_inline_footer_rows(request, None)
|
|
638
|
+
return [
|
|
639
|
+
FooterRow(
|
|
640
|
+
label=str(label),
|
|
641
|
+
cells=[
|
|
642
|
+
FooterCell(
|
|
643
|
+
column=column, column_label=column_label(inline, column), value=""
|
|
644
|
+
)
|
|
645
|
+
for column in cells
|
|
646
|
+
],
|
|
647
|
+
)
|
|
648
|
+
for label, cells in definitions or ()
|
|
649
|
+
]
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def layout_tfoot_rows(
|
|
653
|
+
footer_rows: list[FooterRow], positions: dict[str, int], width: int = 0
|
|
654
|
+
) -> list[TfootRow] | None:
|
|
655
|
+
"""``footer_rows`` laid out in a table ``width`` columns wide whose field
|
|
656
|
+
columns are at ``positions`` (0-based), or ``None`` if a value's column
|
|
657
|
+
isn't among them."""
|
|
658
|
+
# Columns before the first field's (Django's narrow "original" one).
|
|
659
|
+
leading = min(positions.values(), default=0)
|
|
660
|
+
rows = []
|
|
661
|
+
for index, row in enumerate(footer_rows):
|
|
662
|
+
if any(cell.column not in positions for cell in row.cells):
|
|
663
|
+
return None
|
|
664
|
+
by_position = {positions[cell.column]: cell for cell in row.cells}
|
|
665
|
+
if not by_position:
|
|
666
|
+
continue
|
|
667
|
+
first, last = min(by_position), max(by_position)
|
|
668
|
+
# The leading columns alone (Django's narrow "original" one) don't
|
|
669
|
+
# fit the label: it spans the empty field columns before the first
|
|
670
|
+
# value too, or goes into that value's cell.
|
|
671
|
+
label_colspan = first if first > leading else 0
|
|
672
|
+
cells = [] if label_colspan else [TfootCell()] * first
|
|
673
|
+
for position in range(first, last + 1):
|
|
674
|
+
cell = by_position.get(position)
|
|
675
|
+
cells.append(
|
|
676
|
+
TfootCell(
|
|
677
|
+
cell=cell,
|
|
678
|
+
key=f"{index}:{cell.column}" if cell else "",
|
|
679
|
+
label_here=not label_colspan and position == first,
|
|
680
|
+
)
|
|
681
|
+
)
|
|
682
|
+
trailing = max(width - last - 1, 0)
|
|
683
|
+
rows.append(TfootRow(index, row.label, label_colspan, cells, trailing))
|
|
684
|
+
return rows
|
|
Binary file
|