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