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,823 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: django-admin-inline-controls
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pagination, filtering and ordering for Django admin inlines.
|
|
5
|
+
Project-URL: Homepage, https://github.com/rodolvbg/django-admin-inline-controls
|
|
6
|
+
Project-URL: Issues, https://github.com/rodolvbg/django-admin-inline-controls/issues
|
|
7
|
+
Project-URL: Repository, https://github.com/rodolvbg/django-admin-inline-controls
|
|
8
|
+
Author-email: Rodolfo Valentín Becerra García <rodolvbg@gmail.com>
|
|
9
|
+
Maintainer-email: Rodolfo Valentín Becerra García <rodolvbg@gmail.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: admin,django,filter,inline,ordering,pagination
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Environment :: Web Environment
|
|
15
|
+
Classifier: Framework :: Django
|
|
16
|
+
Classifier: Framework :: Django :: 4.2
|
|
17
|
+
Classifier: Framework :: Django :: 5.0
|
|
18
|
+
Classifier: Framework :: Django :: 5.1
|
|
19
|
+
Classifier: Framework :: Django :: 5.2
|
|
20
|
+
Classifier: Framework :: Django :: 6.0
|
|
21
|
+
Classifier: Framework :: Django :: 6.1
|
|
22
|
+
Classifier: Intended Audience :: Developers
|
|
23
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
24
|
+
Classifier: Operating System :: OS Independent
|
|
25
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
26
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
27
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
28
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
29
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
30
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
31
|
+
Classifier: Typing :: Typed
|
|
32
|
+
Requires-Python: >=3.10
|
|
33
|
+
Requires-Dist: django>=4.2
|
|
34
|
+
Provides-Extra: dev
|
|
35
|
+
Requires-Dist: coverage[toml]>=7.4; extra == 'dev'
|
|
36
|
+
Requires-Dist: django-stubs[compatible-mypy]>=5; extra == 'dev'
|
|
37
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
38
|
+
Requires-Dist: pre-commit>=4; extra == 'dev'
|
|
39
|
+
Requires-Dist: pytest-cov>=5; extra == 'dev'
|
|
40
|
+
Requires-Dist: pytest-django>=4.8; extra == 'dev'
|
|
41
|
+
Requires-Dist: pytest-playwright>=0.5; extra == 'dev'
|
|
42
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
43
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
44
|
+
Requires-Dist: tox-uv>=1; extra == 'dev'
|
|
45
|
+
Requires-Dist: tox>=4; extra == 'dev'
|
|
46
|
+
Provides-Extra: filters
|
|
47
|
+
Requires-Dist: django-filter>=23.2; extra == 'filters'
|
|
48
|
+
Provides-Extra: nested
|
|
49
|
+
Requires-Dist: django-nested-admin>=4.1; extra == 'nested'
|
|
50
|
+
Provides-Extra: unfold
|
|
51
|
+
Requires-Dist: django-unfold>=0.108; (python_version >= '3.12') and extra == 'unfold'
|
|
52
|
+
Description-Content-Type: text/markdown
|
|
53
|
+
|
|
54
|
+
# django-admin-inline-controls
|
|
55
|
+
|
|
56
|
+
[](https://github.com/rodolvbg/django-admin-inline-controls/actions/workflows/pytest.yml)
|
|
57
|
+
[](https://pypi.org/project/django-admin-inline-controls/)
|
|
58
|
+
[](https://pypi.org/project/django-admin-inline-controls/)
|
|
59
|
+
[](https://pypi.org/project/django-admin-inline-controls/)
|
|
60
|
+
[](https://pepy.tech/project/django-admin-inline-controls)
|
|
61
|
+
|
|
62
|
+
Pagination, filtering and sortable columns for Django admin inlines —
|
|
63
|
+
without leaving the change form.
|
|
64
|
+
|
|
65
|
+

|
|
66
|
+
|
|
67
|
+
- **Pagination**: page links, or infinite scroll that appends rows as you
|
|
68
|
+
reach the end of the inline.
|
|
69
|
+
- **Filters**: generated from lookups (`"status"`, `"title__icontains"`,
|
|
70
|
+
`"published__gte"`), your own `forms.Form`, or a django-filter `FilterSet`.
|
|
71
|
+
- **Sortable columns**: click a tabular header (or the toolbar links on
|
|
72
|
+
stacked inlines); multi-column, like the changelist.
|
|
73
|
+
- Filters, sorting and page links refresh **only that inline** in place.
|
|
74
|
+
Several controlled inlines on the same page keep independent state.
|
|
75
|
+
- Rows loaded from several pages are saved together, and unsaved edits are
|
|
76
|
+
never silently thrown away.
|
|
77
|
+
- Optional **"Save books" button** that saves only that inline, without
|
|
78
|
+
submitting (or reloading) the rest of the page.
|
|
79
|
+
- **Actions** on the selected rows, like the changelist's: checkboxes,
|
|
80
|
+
"select all", an action menu and a built-in `delete_selected`.
|
|
81
|
+
- **Footer rows** for totals, averages…: `Sum("pages")` below its column.
|
|
82
|
+
- Only depends on Django. No jQuery plugins, no htmx; works with
|
|
83
|
+
`TabularInline` and `StackedInline`.
|
|
84
|
+
|
|
85
|
+
## Install
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
pip install django-admin-inline-controls
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Add to `INSTALLED_APPS`:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
INSTALLED_APPS = [
|
|
95
|
+
...
|
|
96
|
+
"django_admin_inline_controls",
|
|
97
|
+
]
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Usage
|
|
101
|
+
|
|
102
|
+
Mix `InlineControlsMixin` in before `TabularInline` or `StackedInline`:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from django.contrib import admin
|
|
106
|
+
from django.db.models.functions import Lower
|
|
107
|
+
|
|
108
|
+
from django_admin_inline_controls.mixins import InlineControlsMixin
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class BookInline(InlineControlsMixin, admin.TabularInline):
|
|
112
|
+
model = Book
|
|
113
|
+
extra = 0
|
|
114
|
+
inline_per_page = 20
|
|
115
|
+
inline_ordering_fields = {
|
|
116
|
+
"title": Lower("title"), # column name -> ordering expression
|
|
117
|
+
"published": "published",
|
|
118
|
+
"pages": "pages",
|
|
119
|
+
}
|
|
120
|
+
inline_filter_fields = ["title__icontains", "status", "featured", "published__gte"]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@admin.register(Author)
|
|
124
|
+
class AuthorAdmin(admin.ModelAdmin):
|
|
125
|
+
inlines = [BookInline]
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
State lives in the URL, namespaced by the formset prefix
|
|
129
|
+
(`?books-page=2&books-o=-pages&books-f-status=published`), so links are
|
|
130
|
+
shareable and several inlines never clash.
|
|
131
|
+
|
|
132
|
+
### Options
|
|
133
|
+
|
|
134
|
+
| Attribute | Default | |
|
|
135
|
+
|---|---|---|
|
|
136
|
+
| `inline_per_page` | `None` | Rows per page. `None` disables pagination. |
|
|
137
|
+
| `inline_pagination` | `"pages"` | `"pages"` or `"infinite"` (load more on scroll). |
|
|
138
|
+
| `inline_ordering_fields` | `()` | Sortable columns: a list of field names, or `{column: expression}`. The expression can be a field path, an ORM expression (`Lower("title")`, `F("date").asc(nulls_last=True)`) or an explicit `(ascending, descending)` pair. |
|
|
139
|
+
| `inline_default_ordering` | `()` | Applied after the user's ordering. Defaults to the queryset's / model's ordering; `pk` is always appended so pages are stable. |
|
|
140
|
+
| `inline_filter_fields` | `()` | Filter lookups. A form is generated from them. |
|
|
141
|
+
| `inline_filter_form` | `None` | Your own filter `forms.Form`. |
|
|
142
|
+
| `inline_controls_ajax` | `True` | Refresh the inline in place instead of reloading the page. |
|
|
143
|
+
| `inline_save_button` | `False` | Show a button that saves only this inline. See [Saving only the inline](#saving-only-the-inline). |
|
|
144
|
+
| `inline_bulk_actions` | `()` | Actions for the selected rows. See [Bulk actions](#bulk-actions). |
|
|
145
|
+
| `inline_actions` | `()` | Buttons on each saved row, as in django-inline-actions. See [Row actions](#row-actions). |
|
|
146
|
+
| `inline_footer_rows` | `()` | Rows of totals, averages… below the table. See [Footer rows](#footer-rows). |
|
|
147
|
+
| `inline_footer_scope` | `"filtered"` | What the footer rows add up: `"filtered"` or `"page"`. |
|
|
148
|
+
| `inline_footer_tfoot` | `True` | Render the footer rows in the table's `<tfoot>` (tabular inlines); `False` shows them as a summary line. |
|
|
149
|
+
| `inline_footer_tfoot_template` | `django_admin_inline_controls/tfoot.html` | Template of that `<tfoot>`. |
|
|
150
|
+
|
|
151
|
+
Only columns declared in `inline_ordering_fields` can be sorted: ordering
|
|
152
|
+
by an arbitrary field from the URL would let anyone infer the values of
|
|
153
|
+
fields the inline never shows.
|
|
154
|
+
|
|
155
|
+
### Custom filters
|
|
156
|
+
|
|
157
|
+
Generated fields depend on the model field and lookup (choices → select,
|
|
158
|
+
`BooleanField` → Yes/No/any, dates → date input, relations →
|
|
159
|
+
`ModelChoiceField`, `__in` → multiple select, `__isnull` → Yes/No/any…).
|
|
160
|
+
Override `get_inline_filter_formfield(lookup)` to change one, or bring
|
|
161
|
+
your own form and handle the fields that aren't plain lookups:
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
class BookFilterForm(forms.Form):
|
|
165
|
+
q = forms.CharField(required=False, label="Search")
|
|
166
|
+
status = forms.ChoiceField(
|
|
167
|
+
required=False, choices=[("", "---"), *Book.Status.choices]
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class BookInline(InlineControlsMixin, admin.TabularInline):
|
|
172
|
+
model = Book
|
|
173
|
+
inline_per_page = 20
|
|
174
|
+
inline_filter_form = BookFilterForm
|
|
175
|
+
inline_filter_fields = ["status"] # applied as lookups; "q" is handled below
|
|
176
|
+
|
|
177
|
+
def filter_inline_queryset(self, request, queryset, filters):
|
|
178
|
+
if search := filters.get("q"):
|
|
179
|
+
queryset = queryset.filter(Q(title__icontains=search) | Q(isbn=search))
|
|
180
|
+
return super().filter_inline_queryset(request, queryset, filters)
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Every option also has a `get_*` hook taking `(request, obj)` —
|
|
184
|
+
`get_inline_per_page`, `get_inline_ordering_fields`,
|
|
185
|
+
`get_inline_filter_fields`, `get_inline_filter_form_class`,
|
|
186
|
+
`get_inline_filter_form_kwargs` (e.g. to pass the parent object to your
|
|
187
|
+
form) — and `get_inline_filtered_queryset()` is the single seam to plug in
|
|
188
|
+
any other filtering backend.
|
|
189
|
+
|
|
190
|
+
### Infinite scroll
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
class ArticleInline(InlineControlsMixin, admin.TabularInline):
|
|
194
|
+
model = Article
|
|
195
|
+
inline_per_page = 30
|
|
196
|
+
inline_pagination = "infinite"
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The next page is appended when the "Load more" link scrolls into view (or
|
|
200
|
+
is clicked). Loaded rows join the formset, so everything visible is saved
|
|
201
|
+
with the object. Keep in mind Django's formset limits: by default a
|
|
202
|
+
formset accepts at most 1000 forms per submission (`max_num`).
|
|
203
|
+
|
|
204
|
+
### Saving only the inline
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
from django_admin_inline_controls.mixins import (
|
|
208
|
+
InlineControlsAdminMixin,
|
|
209
|
+
InlineControlsMixin,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class BookInline(InlineControlsMixin, admin.TabularInline):
|
|
214
|
+
model = Book
|
|
215
|
+
inline_per_page = 20
|
|
216
|
+
inline_save_button = True
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@admin.register(Author)
|
|
220
|
+
class AuthorAdmin(InlineControlsAdminMixin, admin.ModelAdmin):
|
|
221
|
+
inlines = [BookInline]
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
A **"Save books"** button (named after the inline's `verbose_name_plural`)
|
|
225
|
+
appears in the inline's footer, next to the pagination.
|
|
226
|
+
|
|
227
|
+

|
|
228
|
+
|
|
229
|
+
**How it works**
|
|
230
|
+
|
|
231
|
+
- Only that inline is sent: the JS posts its fields (files included), its
|
|
232
|
+
management form and the CSRF token with `fetch`. Nothing from the parent
|
|
233
|
+
form or from the other inlines.
|
|
234
|
+
- The server builds just that formset against the parent object **as
|
|
235
|
+
stored in the database** (not the unsaved parent form), checks the
|
|
236
|
+
permissions, validates it and calls your `save_formset()` inside a
|
|
237
|
+
transaction. The change is recorded in the parent's history ("Changed
|
|
238
|
+
Title for book X"), like a regular admin save; a save with no changes
|
|
239
|
+
records nothing.
|
|
240
|
+
- The response is the re-rendered inline, swapped in place:
|
|
241
|
+
- on errors, they are shown on their rows, as in a normal save, and the
|
|
242
|
+
submitted values are kept so they can be fixed and saved again;
|
|
243
|
+
- on success, a "Saved." status is shown and the inline no longer counts
|
|
244
|
+
as having unsaved changes.
|
|
245
|
+
- Unsaved changes elsewhere on the page (the parent form, other inlines)
|
|
246
|
+
are left untouched: still on screen, still unsaved.
|
|
247
|
+
- The current filters, ordering and page are kept. In infinite mode, as
|
|
248
|
+
many pages as were loaded are shown again after saving.
|
|
249
|
+
|
|
250
|
+
**Requirements and caveats**
|
|
251
|
+
|
|
252
|
+
1. **The parent `ModelAdmin` needs `InlineControlsAdminMixin`.** An inline
|
|
253
|
+
cannot register URLs of its own, so the endpoint lives on the parent
|
|
254
|
+
admin (`<object_id>/inline-controls/<prefix>/save/`). A system check
|
|
255
|
+
(`admin_inline_controls.E008`) reports a missing mixin; without it no
|
|
256
|
+
button is shown.
|
|
257
|
+
2. **Permissions:** the user needs change permission on the parent object
|
|
258
|
+
(as for any save from the change form) and add, change or delete
|
|
259
|
+
permission on the inline's model. Within the formset, the inline's own
|
|
260
|
+
permissions apply exactly as in the change form (view-only rows are
|
|
261
|
+
not validated, rows can only be deleted with delete permission, …).
|
|
262
|
+
3. **`save_formset(request, form, formset, change)` gets an unchanged
|
|
263
|
+
parent form**, built from the saved instance (`changed_data` is empty),
|
|
264
|
+
because the real one was not submitted. If you override `save_formset()`
|
|
265
|
+
and read `form.cleaned_data`, that won't work from this button.
|
|
266
|
+
`save_model()` and `save_related()` are not called, so the parent's own
|
|
267
|
+
fields (e.g. an `auto_now` "modified" date) are not updated.
|
|
268
|
+
4. **Changes to the parent form are not saved** by this button. It says so
|
|
269
|
+
in its tooltip; use the admin's regular "Save" buttons to save
|
|
270
|
+
everything.
|
|
271
|
+
5. Not available on nested_admin inlines (`admin_inline_controls.E102`).
|
|
272
|
+
|
|
273
|
+
### Bulk actions
|
|
274
|
+
|
|
275
|
+
Like `ModelAdmin.actions`, for the rows of one inline:
|
|
276
|
+
|
|
277
|
+
```python
|
|
278
|
+
from django.contrib import admin, messages
|
|
279
|
+
from django_admin_inline_controls.actions import inline_action
|
|
280
|
+
from django_admin_inline_controls.mixins import (
|
|
281
|
+
InlineControlsAdminMixin,
|
|
282
|
+
InlineControlsMixin,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class BookInline(InlineControlsMixin, admin.TabularInline):
|
|
287
|
+
model = Book
|
|
288
|
+
inline_per_page = 20
|
|
289
|
+
inline_bulk_actions = ["mark_published", "export_csv", "delete_selected"]
|
|
290
|
+
|
|
291
|
+
@inline_action(
|
|
292
|
+
permissions=["change"],
|
|
293
|
+
description="Mark selected %(verbose_name_plural)s as published",
|
|
294
|
+
)
|
|
295
|
+
def mark_published(self, request, queryset):
|
|
296
|
+
count = queryset.update(status=Book.Status.PUBLISHED)
|
|
297
|
+
self.message_user(request, f"{count} books published.", messages.SUCCESS)
|
|
298
|
+
|
|
299
|
+
@inline_action(description="Export selected %(verbose_name_plural)s to CSV")
|
|
300
|
+
def export_csv(self, request, queryset):
|
|
301
|
+
response = HttpResponse(content_type="text/csv")
|
|
302
|
+
response["Content-Disposition"] = 'attachment; filename="books.csv"'
|
|
303
|
+
...
|
|
304
|
+
return response
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@admin.register(Author)
|
|
308
|
+
class AuthorAdmin(InlineControlsAdminMixin, admin.ModelAdmin):
|
|
309
|
+
inlines = [BookInline]
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+

|
|
313
|
+
|
|
314
|
+
**How it works**
|
|
315
|
+
|
|
316
|
+
- Every saved row gets a checkbox next to its name; the action bar at the
|
|
317
|
+
top of the inline has a "select all rows on this page" checkbox, the
|
|
318
|
+
action menu, **Go** and the "2 of 25 selected" count. When the whole
|
|
319
|
+
page is selected and there are more rows, **"Select all 25"** extends the
|
|
320
|
+
selection to every row matching the current filters, on every page.
|
|
321
|
+
- The checkboxes and the menu are not part of the change form: they are
|
|
322
|
+
never submitted when the object is saved, and ticking them doesn't count
|
|
323
|
+
as an unsaved change.
|
|
324
|
+
- **Go** posts only the action name and the selected primary keys. The
|
|
325
|
+
action runs on a queryset that is always restricted to children of the
|
|
326
|
+
object being edited (and to the current filters), so a tampered primary
|
|
327
|
+
key can't reach other rows.
|
|
328
|
+
- If the inline has unsaved edits, you are asked first: the inline is
|
|
329
|
+
re-rendered after the action, so they would be lost.
|
|
330
|
+
|
|
331
|
+
**Writing actions**
|
|
332
|
+
|
|
333
|
+
- Same signature as changelist actions: `action(inline, request,
|
|
334
|
+
queryset)`. Entries of `inline_bulk_actions` can be method names, callables or
|
|
335
|
+
`"delete_selected"`. The parent object is `request.inline_controls_parent`.
|
|
336
|
+
- `@inline_action` is `@admin.action` (`permissions`, `description`) plus
|
|
337
|
+
`confirmation`: a prompt shown before running. `description` and
|
|
338
|
+
`confirmation` may use `%(verbose_name)s` and `%(verbose_name_plural)s`;
|
|
339
|
+
`confirmation` also `%(count)s`, the number of rows it will run on.
|
|
340
|
+
Plain `@admin.action` functions work too.
|
|
341
|
+
- `permissions=["change"]` checks the inline's `has_change_permission()`
|
|
342
|
+
(with the parent object); actions the user may not run are not offered,
|
|
343
|
+
and are refused if posted anyway.
|
|
344
|
+
- Use `self.message_user()` as in a `ModelAdmin`: the messages are shown
|
|
345
|
+
next to the action menu.
|
|
346
|
+
- Return `None` to re-render the inline (keeping the page, filters and
|
|
347
|
+
ordering; in infinite mode, the loaded rows), a file response (it is
|
|
348
|
+
downloaded and the page stays as is), or a redirect (followed). Any other
|
|
349
|
+
response replaces the page.
|
|
350
|
+
- Actions don't record anything in the history by themselves, as in the
|
|
351
|
+
changelist. The built-in **`delete_selected`** deletes the rows one by
|
|
352
|
+
one (so `delete()` overrides and signals run), asks "Delete 3 selected
|
|
353
|
+
books? This cannot be undone.", records the deletions in the parent's
|
|
354
|
+
history and reports rows protected by `on_delete=PROTECT` instead of
|
|
355
|
+
failing.
|
|
356
|
+
|
|
357
|
+
**Requirements:** like the save button, `InlineControlsAdminMixin` on the
|
|
358
|
+
parent `ModelAdmin` (`admin_inline_controls.E010`), and the user needs view
|
|
359
|
+
or change permission on the parent object. Not available on nested_admin
|
|
360
|
+
inlines (`admin_inline_controls.E103`).
|
|
361
|
+
|
|
362
|
+
### Row actions
|
|
363
|
+
|
|
364
|
+
Buttons on each saved row, in an **Actions** column: no selection, no menu,
|
|
365
|
+
no "Go". The API is [django-inline-actions](https://github.com/escaped/django-inline-actions)'s
|
|
366
|
+
— `inline_actions`, `get_inline_actions(request, obj)`, `ViewAction`,
|
|
367
|
+
`DeleteAction`, `(request, obj, parent_obj=None)` — so its inlines move
|
|
368
|
+
here by changing the imports.
|
|
369
|
+
|
|
370
|
+

|
|
371
|
+
|
|
372
|
+
```python
|
|
373
|
+
from django_admin_inline_controls.actions import DefaultActionsMixin
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
class BookInline(DefaultActionsMixin, InlineControlsMixin, admin.TabularInline):
|
|
377
|
+
model = Book
|
|
378
|
+
inline_actions = ["toggle_featured"] # plus View and Delete
|
|
379
|
+
|
|
380
|
+
def toggle_featured(self, request, obj, parent_obj=None):
|
|
381
|
+
obj.featured = not obj.featured
|
|
382
|
+
obj.save(update_fields=["featured"])
|
|
383
|
+
self.message_user(request, f"“{obj}” updated.", messages.SUCCESS)
|
|
384
|
+
|
|
385
|
+
def get_toggle_featured_label(self, obj):
|
|
386
|
+
return "Unfeature" if obj.featured else "Feature"
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
- **`inline_actions`** lists method names or callables, and is gathered
|
|
390
|
+
from every class of the inline (bases first): mixins add theirs.
|
|
391
|
+
`inline_actions = None` removes the column.
|
|
392
|
+
- **Built-in:** the mixins `ViewAction` (`view_action`: a link to the row's
|
|
393
|
+
change view, when its model has one on the site and the user may view
|
|
394
|
+
it), `DeleteAction` (`delete_action`: asks "Delete “Book 01”? This cannot
|
|
395
|
+
be undone.", deletes it like `delete_selected` and records it in the
|
|
396
|
+
parent's history; needs the delete permission) and `DefaultActionsMixin`
|
|
397
|
+
(both). Without a mixin, list `"view"` / `"delete"`.
|
|
398
|
+
- **Your own**, as methods or callables, in either form:
|
|
399
|
+
- `action(self, request, obj, parent_obj=None)` for one row;
|
|
400
|
+
- `action(self, request, queryset)`, like a [bulk action](#bulk-actions)
|
|
401
|
+
(`@inline_action`, `permissions`, `confirmation`…), run on a queryset
|
|
402
|
+
with only that row: the same function can be in both lists.
|
|
403
|
+
- **Per row:** the label, CSS classes and HTML attributes come from the
|
|
404
|
+
function's `short_description`, `css_classes` and `attribute_properties`
|
|
405
|
+
(a dict, escaped, or a string), or from `get_<action>_label(obj)`,
|
|
406
|
+
`get_<action>_css(obj)` and `get_<action>_attr(obj)` on the inline.
|
|
407
|
+
Override `get_inline_actions(request, obj)` to offer some actions on
|
|
408
|
+
some rows only (`obj` is the row; the parent is
|
|
409
|
+
`request.inline_controls_parent`), as `DeleteAction` does.
|
|
410
|
+
- They return what bulk actions do: `None` re-renders the inline with the
|
|
411
|
+
messages from `self.message_user()` (shown in its footer), a file is
|
|
412
|
+
downloaded, a redirect is followed.
|
|
413
|
+
- The buttons are rendered by the server, in a read-only column added to
|
|
414
|
+
the inline's fields (`render_inline_actions`, as in django-inline-actions;
|
|
415
|
+
put it in your `fields` or `fieldsets` to place it yourself), and shown
|
|
416
|
+
once the JS is ready. Its template is `inline_actions_template`
|
|
417
|
+
(`django_admin_inline_controls/row_actions.html`).
|
|
418
|
+
|
|
419
|
+
**Coming from django-inline-actions:** change
|
|
420
|
+
`inline_actions.admin.InlineActionsMixin` for `InlineControlsMixin`,
|
|
421
|
+
`inline_actions.actions` for `django_admin_inline_controls.actions`, and put
|
|
422
|
+
`InlineControlsAdminMixin` on the parent `ModelAdmin` instead of
|
|
423
|
+
`InlineActionsModelAdminMixin`. The actions run without submitting the
|
|
424
|
+
whole change form (unsaved changes elsewhere on the page are kept), only
|
|
425
|
+
the inline is refreshed, and each action's `permissions` are checked by the
|
|
426
|
+
server before it runs. Buttons on the changelist's rows aren't covered:
|
|
427
|
+
keep `InlineActionsModelAdminMixin` for those, next to
|
|
428
|
+
`InlineControlsAdminMixin` (they don't clash on a `ModelAdmin`).
|
|
429
|
+
|
|
430
|
+
**Requirements:** as for bulk actions, `InlineControlsAdminMixin` on the
|
|
431
|
+
parent `ModelAdmin` (`admin_inline_controls.E019`); not available on
|
|
432
|
+
nested_admin inlines (`admin_inline_controls.E105`). They only appear in
|
|
433
|
+
the change view, once the parent exists.
|
|
434
|
+
|
|
435
|
+
### Footer rows
|
|
436
|
+
|
|
437
|
+
Totals, averages or any other summary below the inline's columns:
|
|
438
|
+
|
|
439
|
+
```python
|
|
440
|
+
from django.db.models import Avg, Count, Sum
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
class BookInline(InlineControlsMixin, admin.TabularInline):
|
|
444
|
+
model = Book
|
|
445
|
+
inline_per_page = 20
|
|
446
|
+
inline_footer_rows = [
|
|
447
|
+
("Total", {"title": Count("pk"), "pages": Sum("pages")}),
|
|
448
|
+
("Average", {"pages": Avg("pages")}),
|
|
449
|
+
]
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+

|
|
453
|
+
|
|
454
|
+
- Each row is a label and `{column: value}`. A value is an **aggregate**
|
|
455
|
+
(`Sum`, `Avg`, `Count`, `Max`…, `filter=` included), a
|
|
456
|
+
**`callable(queryset)`** for anything else, or a constant. All the
|
|
457
|
+
aggregates of all the rows run in **one query**.
|
|
458
|
+
- **What is added up** — `inline_footer_scope`:
|
|
459
|
+
- `"filtered"` (default): every row matching the current filters, on
|
|
460
|
+
every page. With infinite scroll it doesn't depend on what is loaded.
|
|
461
|
+
- `"page"`: the rows shown (not available with infinite scroll,
|
|
462
|
+
`admin_inline_controls.E016`).
|
|
463
|
+
- Values come from the **database**. The rows follow filtering, sorting,
|
|
464
|
+
paging, saving the inline and actions, since they arrive with the
|
|
465
|
+
refreshed inline — with `inline_save_button`, saving the inline updates
|
|
466
|
+
them without reloading the page.
|
|
467
|
+
- **Where they go — rendered by the server, no JS:** on tabular inlines,
|
|
468
|
+
the rows are the table's own `<tfoot>`, each value under its column; the
|
|
469
|
+
label spans the columns before the first value. The inline's template is
|
|
470
|
+
rendered as usual (Django's, a theme's or your own) and the `<tfoot>` is
|
|
471
|
+
inserted before its `</table>`, so there is no copy of Django's template
|
|
472
|
+
to keep in sync. On stacked inlines, with `inline_footer_tfoot = False`,
|
|
473
|
+
or when a value's column isn't a column of the table, they are a summary
|
|
474
|
+
line in the footer ("Total: Pages 3250").
|
|
475
|
+
- **In the add view** there are no saved rows to aggregate yet: tabular
|
|
476
|
+
inlines get the same `<tfoot>`, labels and `data-*` attributes with empty
|
|
477
|
+
values (`data-value=""`), for your JS to fill in from the new rows if you
|
|
478
|
+
want; `get_inline_footer_rows(request, obj)` gets `obj=None`. Stacked
|
|
479
|
+
inlines show nothing until the object is saved.
|
|
480
|
+
|
|
481
|
+
#### Changing how they look
|
|
482
|
+
|
|
483
|
+
- **The values' format**, in Python: by default the plain value, floats
|
|
484
|
+
and decimals rounded to 2 decimals at most (`1234.5`). Override
|
|
485
|
+
`format_inline_footer_value(column, value)` for currencies, units,
|
|
486
|
+
localized numbers… (return safe HTML for markup):
|
|
487
|
+
|
|
488
|
+
```python
|
|
489
|
+
from django.utils.formats import number_format
|
|
490
|
+
|
|
491
|
+
def format_inline_footer_value(self, column, value):
|
|
492
|
+
if column == "amount":
|
|
493
|
+
return format_html("{} <small>USD</small>", number_format(value, 2))
|
|
494
|
+
return super().format_inline_footer_value(column, value)
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
- **Which rows**, in Python: `get_inline_footer_rows(request, obj)` — e.g.
|
|
498
|
+
one row per VAT rate from your own model method.
|
|
499
|
+
- **The markup**, in templates: the `<tfoot>` is
|
|
500
|
+
`django_admin_inline_controls/tfoot.html` (blocks `tfoot`, `tfoot_row`,
|
|
501
|
+
`tfoot_label`, `tfoot_cell`, `tfoot_value`), chosen per inline with
|
|
502
|
+
`inline_footer_tfoot_template`; the summary line is in `footer.html`
|
|
503
|
+
(blocks `footer_rows`, `footer_row`, `footer_cell`). Extend them and
|
|
504
|
+
override only the block you need:
|
|
505
|
+
|
|
506
|
+
```django
|
|
507
|
+
{% extends "django_admin_inline_controls/tfoot.html" %}
|
|
508
|
+
{% block tfoot_value %}<strong>{{ slot.cell.value }}</strong>{% endblock %}
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
#### Using the values from your own JS
|
|
512
|
+
|
|
513
|
+
Every value is in the HTML the server renders, with its raw number, for
|
|
514
|
+
your scripts to read — nothing to call:
|
|
515
|
+
|
|
516
|
+
| Attribute / selector | What it is |
|
|
517
|
+
|---|---|
|
|
518
|
+
| `[data-footer-key="<row index>:<column>"]` | A value's element (in the `<tfoot>` or the summary). |
|
|
519
|
+
| `data-footer-row`, `data-column` | Its row label and column. |
|
|
520
|
+
| `data-value` | Its number as plain digits (`"1234.5"`), `""` when it isn't a number; the element's text is the formatted value. |
|
|
521
|
+
|
|
522
|
+
```js
|
|
523
|
+
const total = document.querySelector(
|
|
524
|
+
'#books-inline-controls [data-footer-row="Total"][data-column="pages"]',
|
|
525
|
+
);
|
|
526
|
+
Number(total.dataset.value); // 3250
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
When the inline is refreshed in place (filtering, paging, saving it,
|
|
530
|
+
actions) the footer comes back re-rendered, and `inline-controls:updated`
|
|
531
|
+
bubbles from the new content: read the values again there.
|
|
532
|
+
|
|
533
|
+
## Optional extras
|
|
534
|
+
|
|
535
|
+
The core only depends on Django. Integrations with third-party packages are
|
|
536
|
+
opt-in.
|
|
537
|
+
|
|
538
|
+
### django-filter
|
|
539
|
+
|
|
540
|
+
```bash
|
|
541
|
+
pip install django-admin-inline-controls[filters]
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
```python
|
|
545
|
+
from django_admin_inline_controls.contrib.filters import FilterSetInlineControlsMixin
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
class BookInline(FilterSetInlineControlsMixin, admin.TabularInline):
|
|
549
|
+
model = Book
|
|
550
|
+
inline_per_page = 20
|
|
551
|
+
inline_filterset_class = BookFilterSet
|
|
552
|
+
|
|
553
|
+
def get_inline_filterset_kwargs(self, request, obj):
|
|
554
|
+
return {"parent": obj} # extra kwargs for your FilterSet's __init__
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
The filterset gets the request too (`self.request`).
|
|
558
|
+
|
|
559
|
+
### django-nested-admin
|
|
560
|
+
|
|
561
|
+
```bash
|
|
562
|
+
pip install django-admin-inline-controls[nested]
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
```python
|
|
566
|
+
import nested_admin
|
|
567
|
+
from django_admin_inline_controls.contrib.nested import NestedInlineControlsMixin
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
class BookInline(NestedInlineControlsMixin, nested_admin.NestedTabularInline):
|
|
571
|
+
model = Book
|
|
572
|
+
inline_per_page = 20
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
nested_admin keeps its own client-side formset state, so with it filters,
|
|
576
|
+
sorting and page links reload the page instead of swapping the inline, and
|
|
577
|
+
infinite scroll, the save-inline button and bulk and row actions are not
|
|
578
|
+
available.
|
|
579
|
+
|
|
580
|
+
### Themes
|
|
581
|
+
|
|
582
|
+
- [django-unfold](https://github.com/rodolvbg/django-admin-inline-controls/blob/main/docs/themes/unfold.md): `UnfoldInlineControlsMixin` (the
|
|
583
|
+
`unfold` extra).
|
|
584
|
+
|
|
585
|
+
## Customizing templates
|
|
586
|
+
|
|
587
|
+
Three templates render the controls around the inline's own `template`
|
|
588
|
+
(which is left untouched: `admin/edit_inline/tabular.html`, a custom one,
|
|
589
|
+
…). Each is chosen per inline, so a template of yours can extend the
|
|
590
|
+
library's and override only the blocks it needs:
|
|
591
|
+
|
|
592
|
+
| Option | Default | Renders |
|
|
593
|
+
|---|---|---|
|
|
594
|
+
| `inline_controls_template` | `django_admin_inline_controls/inline.html` | The wrapper: toolbar, the inline itself, footer. |
|
|
595
|
+
| `inline_controls_toolbar_template` | `django_admin_inline_controls/toolbar.html` | Actions, filters, sort links. |
|
|
596
|
+
| `inline_controls_footer_template` | `django_admin_inline_controls/footer.html` | Pagination / infinite scroll, save button. |
|
|
597
|
+
|
|
598
|
+
```python
|
|
599
|
+
class BookInline(InlineControlsMixin, admin.TabularInline):
|
|
600
|
+
model = Book
|
|
601
|
+
inline_controls_toolbar_template = "admin/demo/book_inline_toolbar.html"
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
```django
|
|
605
|
+
{# templates/admin/demo/book_inline_toolbar.html #}
|
|
606
|
+
{% extends "django_admin_inline_controls/toolbar.html" %}
|
|
607
|
+
|
|
608
|
+
{% block filter_apply_label %}Search{% endblock %}
|
|
609
|
+
|
|
610
|
+
{% block filter_field %}
|
|
611
|
+
<div class="my-filter">{{ block.super }}</div>
|
|
612
|
+
{% endblock %}
|
|
613
|
+
|
|
614
|
+
{% block toolbar_end %}
|
|
615
|
+
<a href="{% url 'book-help' %}">Help</a>
|
|
616
|
+
{% endblock %}
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
To change them for every inline, put templates with the same paths in your
|
|
620
|
+
project's `templates/` directory (before the app templates), or set the
|
|
621
|
+
options on a base inline class of your own.
|
|
622
|
+
|
|
623
|
+
In all three, `controls` is the inline's state (filter form, ordering
|
|
624
|
+
columns, page links, actions, URLs…) and `inline_admin_formset` is
|
|
625
|
+
Django's. The JS finds its elements by the `inline-controls-*` classes, the
|
|
626
|
+
`data-inline-controls-*` attributes and the container's id: keep them when
|
|
627
|
+
you replace a block's markup (`{{ block.super }}` keeps the original).
|
|
628
|
+
|
|
629
|
+
**`inline.html`**
|
|
630
|
+
|
|
631
|
+
| Block | Contains |
|
|
632
|
+
|---|---|
|
|
633
|
+
| `container` | The whole wrapper `<div>`. |
|
|
634
|
+
| `container_classes`, `container_attrs` | Extra classes / attributes for the wrapper (empty). |
|
|
635
|
+
| `before_toolbar`, `before_inline`, `after_inline`, `after_footer` | Empty slots between the parts. |
|
|
636
|
+
| `toolbar` | Includes `inline_controls_toolbar_template`. |
|
|
637
|
+
| `inline` | Includes the inline's own template. |
|
|
638
|
+
| `footer` | Includes `inline_controls_footer_template`. |
|
|
639
|
+
| `inline_without_controls` | What is rendered when the controls are off (the add view): the inline and, if it has footer rows, their empty `<tfoot>`. |
|
|
640
|
+
|
|
641
|
+
**`toolbar.html`**
|
|
642
|
+
|
|
643
|
+
| Block | Contains |
|
|
644
|
+
|---|---|
|
|
645
|
+
| `toolbar` | The whole toolbar. |
|
|
646
|
+
| `toolbar_classes` | Extra classes (empty). |
|
|
647
|
+
| `toolbar_start`, `toolbar_end` | Empty slots at both ends. |
|
|
648
|
+
| `actions` | The action bar. |
|
|
649
|
+
| `action_menu`, `action_label`, `action_empty_option`, `action_option` | The "Action:" select and its options (`action_option` is rendered once per action, with `action`). |
|
|
650
|
+
| `action_button`, `action_button_label` | The **Go** button. |
|
|
651
|
+
| `action_selection` | The "2 of 25 selected" count and the "Select all" link. |
|
|
652
|
+
| `action_status` | Where action messages appear. |
|
|
653
|
+
| `filters` | The filter form. |
|
|
654
|
+
| `filter_fields`, `filter_field` | All fields / one field (once per field, with `field`). |
|
|
655
|
+
| `filter_buttons`, `filter_apply_button`, `filter_apply_label`, `filter_clear_button`, `filter_clear_label` | The **Filter** and **Clear** buttons. |
|
|
656
|
+
| `ordering`, `ordering_label`, `ordering_column` | The "Sort by:" links (once per column, with `column`). |
|
|
657
|
+
|
|
658
|
+
**`footer.html`**
|
|
659
|
+
|
|
660
|
+
| Block | Contains |
|
|
661
|
+
|---|---|
|
|
662
|
+
| `footer` | The whole footer. |
|
|
663
|
+
| `footer_classes` | Extra classes (empty). |
|
|
664
|
+
| `footer_start`, `footer_end` | Empty slots at both ends. |
|
|
665
|
+
| `footer_rows`, `footer_row`, `footer_cell` | The footer rows' summary line (`footer_row` once per row, with `row`; `footer_cell` once per value, with `cell`), when they aren't in the table's `<tfoot>`. |
|
|
666
|
+
| `pagination` | Page links or the infinite-scroll status. |
|
|
667
|
+
| `page_links`, `page_link` | The page links (`page_link` once per link, with `link`). |
|
|
668
|
+
| `result_count` | "25 results". |
|
|
669
|
+
| `infinite`, `infinite_count`, `load_more`, `load_more_label` | Infinite mode: "Showing 15 of 70" and "Load more". |
|
|
670
|
+
| `save`, `save_status`, `save_button`, `save_label` | The save-inline button and its status. |
|
|
671
|
+
| `row_actions_status` | Where the row actions' messages appear. |
|
|
672
|
+
|
|
673
|
+
**`row_actions.html`** (one row's buttons): `row_actions`, `row_action`
|
|
674
|
+
(once per action, with `action`: `name`, `label`, `css_classes`, `attrs`,
|
|
675
|
+
`confirmation` and, for `view`, `url`).
|
|
676
|
+
|
|
677
|
+
**`inline_response.html`** (the save/action endpoints' response):
|
|
678
|
+
`response`, `inlines`. Set `inline_controls_response_template` on the
|
|
679
|
+
`ModelAdmin` to use another one.
|
|
680
|
+
|
|
681
|
+
## Adapting to another admin markup
|
|
682
|
+
|
|
683
|
+
The templates decide the HTML of the controls, but the JS also has to find
|
|
684
|
+
its way in the **inline's** markup — Django's `tabular.html` /
|
|
685
|
+
`stacked.html`, or whatever your theme (Unfold, Jazzmin, Grappelli…) or
|
|
686
|
+
your own inline template renders. Where to look is configurable per inline
|
|
687
|
+
with `inline_controls_selectors`, merged over these defaults
|
|
688
|
+
(`DEFAULT_SELECTORS` in `django_admin_inline_controls.controls`):
|
|
689
|
+
|
|
690
|
+
| Key | Default | Used for |
|
|
691
|
+
|---|---|---|
|
|
692
|
+
| `container` | `.inline-group fieldset` | Where the toolbar and footer are moved to. |
|
|
693
|
+
| `heading` | `h2` | Inside `container`: the toolbar goes right after it (or its `<summary>`). |
|
|
694
|
+
| `footer_parent` | `:scope > details` | Inside `container`: the footer is appended here, else to `container`. |
|
|
695
|
+
| `table_head` | `.inline-group table thead` | The header row with the sortable columns. |
|
|
696
|
+
| `column_header` | `th.column-{name}` | A sortable column's header (`{name}`: the column). |
|
|
697
|
+
| `form_rows` | `[id]` | Inside the inline group: each form's container (`{prefix}`: the formset's). Its index comes from its id (`<prefix>-<n>`) or, without one, from its fields' names. |
|
|
698
|
+
| `saved_row` | `.has_original` | A form container of a saved object (not a new one). |
|
|
699
|
+
| `row_label` | `:scope > td.original > p`, `:scope > h3`, `:scope > td.original` | Inside a saved row: where its action checkbox goes. |
|
|
700
|
+
| `tabular_rows` | `{group} .tabular.inline-related tbody:first > tr.form-row` | jQuery selector of the rows Django's `inlines.js` manages, re-initialized after a refresh (`{group}`: `#<prefix>-group`). |
|
|
701
|
+
| `stacked_rows` | `{group} .inline-related` | The same for stacked inlines. |
|
|
702
|
+
|
|
703
|
+
Each value is one selector or a list tried in order. Only set the keys that
|
|
704
|
+
differ:
|
|
705
|
+
|
|
706
|
+
```python
|
|
707
|
+
class BookInline(InlineControlsMixin, admin.TabularInline):
|
|
708
|
+
model = Book
|
|
709
|
+
template = "admin/my_theme/tabular.html"
|
|
710
|
+
inline_ordering_fields = ["title", "pages"]
|
|
711
|
+
inline_controls_selectors = {
|
|
712
|
+
"container": ".card",
|
|
713
|
+
"heading": ".card-title",
|
|
714
|
+
"table_head": "table.grid thead",
|
|
715
|
+
"column_header": 'th[data-col="{name}"]',
|
|
716
|
+
"row_label": ":scope > td.row-name > .label",
|
|
717
|
+
"tabular_rows": "{group} table.grid tbody > tr.form-row",
|
|
718
|
+
}
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
For a whole theme, set it on a base inline class of your own. When
|
|
722
|
+
something isn't found, the controls degrade instead of breaking: sort links
|
|
723
|
+
stay in the toolbar if a column header is missing, checkboxes go into the
|
|
724
|
+
row itself, and the toolbar and footer stay around the inline. The
|
|
725
|
+
management form and the fields' names come from Django's formset, so they
|
|
726
|
+
are never configured. On tabular inlines, the footer rows are laid out
|
|
727
|
+
under the header cells with the `column-<field>` class, whatever columns
|
|
728
|
+
come before or after them.
|
|
729
|
+
|
|
730
|
+
To place the toolbar and footer yourself, listen for
|
|
731
|
+
`inline-controls:place` (it bubbles from the controls' wrapper,
|
|
732
|
+
`.inline-controls`, before they are moved) and cancel it:
|
|
733
|
+
|
|
734
|
+
```js
|
|
735
|
+
document.addEventListener("inline-controls:place", (event) => {
|
|
736
|
+
const { toolbar, footer } = event.detail;
|
|
737
|
+
event.target.querySelector(".my-panel-header").append(toolbar);
|
|
738
|
+
event.target.querySelector(".my-panel-footer").append(footer);
|
|
739
|
+
event.preventDefault();
|
|
740
|
+
});
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
## System checks
|
|
744
|
+
|
|
745
|
+
Misconfigurations are reported by `manage.py check` (and at startup):
|
|
746
|
+
|
|
747
|
+
| ID | Problem |
|
|
748
|
+
|---|---|
|
|
749
|
+
| `admin_inline_controls.E001` | `inline_pagination` is not `"pages"` or `"infinite"`. |
|
|
750
|
+
| `admin_inline_controls.E002` | `inline_per_page` is not a positive integer or `None`. |
|
|
751
|
+
| `admin_inline_controls.E003` | `inline_pagination = "infinite"` without `inline_per_page`. |
|
|
752
|
+
| `admin_inline_controls.E004` | `inline_filter_fields` is a string instead of a list or tuple. |
|
|
753
|
+
| `admin_inline_controls.E005` | `inline_filter_fields` refers to a field the model doesn't have. |
|
|
754
|
+
| `admin_inline_controls.E006` | `inline_ordering_fields` is not a list, tuple or dict. |
|
|
755
|
+
| `admin_inline_controls.E007` | An `inline_ordering_fields` column starts with `-` or contains a comma. |
|
|
756
|
+
| `admin_inline_controls.E008` | `inline_save_button = True` but the parent `ModelAdmin` lacks `InlineControlsAdminMixin`. |
|
|
757
|
+
| `admin_inline_controls.E009` | An `inline_bulk_actions` entry is not a method of the inline, a callable or a built-in action. |
|
|
758
|
+
| `admin_inline_controls.E010` | `inline_bulk_actions` is set but the parent `ModelAdmin` lacks `InlineControlsAdminMixin`. |
|
|
759
|
+
| `admin_inline_controls.E018` | An `inline_actions` entry is not a method of the inline, a callable or a built-in row action. |
|
|
760
|
+
| `admin_inline_controls.E019` | `inline_actions` is set but the parent `ModelAdmin` lacks `InlineControlsAdminMixin`. |
|
|
761
|
+
| `admin_inline_controls.E011` | `inline_controls_selectors` is not a dict. |
|
|
762
|
+
| `admin_inline_controls.E012` | An `inline_controls_selectors` key is unknown, or its value is not a selector or a non-empty list of selectors. |
|
|
763
|
+
| `admin_inline_controls.E013` | `inline_footer_rows` is not a list of `(label, {column: value})` pairs. |
|
|
764
|
+
| `admin_inline_controls.E014` | An `inline_footer_rows` column is not a field of the model or of the inline. |
|
|
765
|
+
| `admin_inline_controls.E015` | `inline_footer_scope` is not `"filtered"` or `"page"`. |
|
|
766
|
+
| `admin_inline_controls.E016` | `inline_footer_scope = "page"` with infinite scroll. |
|
|
767
|
+
| `admin_inline_controls.E017` | `inline_footer_tfoot` is not `True` or `False`. |
|
|
768
|
+
| `admin_inline_controls.E101` | `inline_pagination = "infinite"` on a nested_admin inline. |
|
|
769
|
+
| `admin_inline_controls.E102` | `inline_save_button = True` on a nested_admin inline. |
|
|
770
|
+
| `admin_inline_controls.E103` | `inline_bulk_actions` on a nested_admin inline. |
|
|
771
|
+
| `admin_inline_controls.E104` | Unfold's `per_page` on an inline with `UnfoldInlineControlsMixin`. |
|
|
772
|
+
| `admin_inline_controls.E105` | `inline_actions` on a nested_admin inline. |
|
|
773
|
+
|
|
774
|
+
## JavaScript files
|
|
775
|
+
|
|
776
|
+
Each inline loads only the scripts it uses, through its `media`: `core.js`
|
|
777
|
+
(pagination, filters, sorting, placing the controls) always, `save.js`
|
|
778
|
+
with `inline_save_button`, `actions.js` with `inline_bulk_actions` or
|
|
779
|
+
`inline_actions`, and
|
|
780
|
+
`contrib/unfold.js` with `UnfoldInlineControlsMixin`. The admin merges the
|
|
781
|
+
media of every inline on the page, so each file loads at most once.
|
|
782
|
+
|
|
783
|
+
## JavaScript events
|
|
784
|
+
|
|
785
|
+
After an inline is refreshed in place, or rows are appended, an
|
|
786
|
+
`inline-controls:updated` event bubbles from the new content: hook your own
|
|
787
|
+
widget initialization there (or read the footer values again, see
|
|
788
|
+
[Using the values from your own JS](#using-the-values-from-your-own-js)).
|
|
789
|
+
Before the toolbar and footer are placed, a
|
|
790
|
+
cancelable `inline-controls:place` event bubbles from the controls'
|
|
791
|
+
wrapper (see [Adapting to another admin markup](#adapting-to-another-admin-markup)). The admin's own inline machinery, autocomplete,
|
|
792
|
+
date/time shortcuts and `filter_horizontal` widgets are re-initialized
|
|
793
|
+
automatically.
|
|
794
|
+
|
|
795
|
+
## Demo
|
|
796
|
+
|
|
797
|
+
```bash
|
|
798
|
+
cd example
|
|
799
|
+
python manage.py migrate
|
|
800
|
+
python manage.py seed_demo # admin/admin + an author with many books
|
|
801
|
+
python manage.py runserver
|
|
802
|
+
|
|
803
|
+
```
|
|
804
|
+
|
|
805
|
+
## Translations
|
|
806
|
+
|
|
807
|
+
Ships a Spanish (`es`) translation; every text of the controls — the
|
|
808
|
+
toolbar, the footer, the generated filter labels ("Title (contains)"), the
|
|
809
|
+
action messages and the JS prompts, which the server sends already
|
|
810
|
+
translated — follows the admin's active language.
|
|
811
|
+
|
|
812
|
+
Messages identical to Django admin's own ("Filter", "Delete selected
|
|
813
|
+
%(verbose_name_plural)s", …) use the admin's translation, since
|
|
814
|
+
`django.contrib.admin` comes first in `INSTALLED_APPS`: the inline then
|
|
815
|
+
reads exactly like the changelist.
|
|
816
|
+
|
|
817
|
+
## Compatibility
|
|
818
|
+
|
|
819
|
+
Django 4.2 – 6.1, Python 3.10+.
|
|
820
|
+
|
|
821
|
+
## License
|
|
822
|
+
|
|
823
|
+
MIT
|