Metadata-Version: 2.5
Name: django-admin-inline-controls
Version: 0.1.2
Summary: Pagination, filtering and ordering for Django admin inlines.
Project-URL: Homepage, https://github.com/rodolvbg/django-admin-inline-controls
Project-URL: Issues, https://github.com/rodolvbg/django-admin-inline-controls/issues
Project-URL: Repository, https://github.com/rodolvbg/django-admin-inline-controls
Author-email: Rodolfo Valentín Becerra García <rodolvbg@gmail.com>
Maintainer-email: Rodolfo Valentín Becerra García <rodolvbg@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: admin,django,filter,inline,ordering,pagination
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Web Environment
Classifier: Framework :: Django
Classifier: Framework :: Django :: 4.2
Classifier: Framework :: Django :: 5.0
Classifier: Framework :: Django :: 5.1
Classifier: Framework :: Django :: 5.2
Classifier: Framework :: Django :: 6.0
Classifier: Framework :: Django :: 6.1
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: django>=4.2
Provides-Extra: dev
Requires-Dist: coverage[toml]>=7.4; extra == 'dev'
Requires-Dist: django-stubs[compatible-mypy]>=5; extra == 'dev'
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pre-commit>=4; extra == 'dev'
Requires-Dist: pytest-cov>=5; extra == 'dev'
Requires-Dist: pytest-django>=4.8; extra == 'dev'
Requires-Dist: pytest-playwright>=0.5; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Requires-Dist: tox-uv>=1; extra == 'dev'
Requires-Dist: tox>=4; extra == 'dev'
Requires-Dist: unittest-parametrize>=1.9; extra == 'dev'
Provides-Extra: filters
Requires-Dist: django-filter>=23.2; extra == 'filters'
Provides-Extra: nested
Requires-Dist: django-nested-admin>=4.1; extra == 'nested'
Provides-Extra: unfold
Requires-Dist: django-unfold>=0.108; (python_version >= '3.12') and extra == 'unfold'
Description-Content-Type: text/markdown

# django-admin-inline-controls

[![Build status](https://github.com/rodolvbg/django-admin-inline-controls/actions/workflows/pytest.yml/badge.svg)](https://github.com/rodolvbg/django-admin-inline-controls/actions/workflows/pytest.yml)
[![PyPI version](https://img.shields.io/pypi/v/django-admin-inline-controls.svg)](https://pypi.org/project/django-admin-inline-controls/)
[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-admin-inline-controls)](https://pypi.org/project/django-admin-inline-controls/)
[![PyPI - Django Version](https://img.shields.io/pypi/djversions/django-admin-inline-controls)](https://pypi.org/project/django-admin-inline-controls/)
[![Downloads](https://static.pepy.tech/personalized-badge/django-admin-inline-controls?period=month&units=international_system&left_color=black&right_color=blue&left_text=Downloads/month)](https://pepy.tech/project/django-admin-inline-controls)

Pagination, filtering and sortable columns for Django admin inlines —
without leaving the change form.

![A tabular inline with an action menu and two selected rows, filters, sorting by pages, page links and the Save books button](https://raw.githubusercontent.com/rodolvbg/django-admin-inline-controls/HEAD/docs/screenshots/hero.png)

- **Pagination**: page links, or infinite scroll that appends rows as you
  reach the end of the inline.
- **Filters**: generated from lookups (`"status"`, `"title__icontains"`,
  `"published__gte"`), your own `forms.Form`, or a django-filter `FilterSet`.
- **Sortable columns**: click a tabular header (or the toolbar links on
  stacked inlines); multi-column, like the changelist.
- Filters, sorting and page links refresh **only that inline** in place.
  Several controlled inlines on the same page keep independent state.
- Rows loaded from several pages are saved together, and unsaved edits are
  never silently thrown away.
- Optional **"Save books" button** that saves only that inline, without
  submitting (or reloading) the rest of the page.
- **Actions** on the selected rows, like the changelist's: checkboxes,
  "select all", an action menu and a built-in `delete_selected`.
- Totals below the columns, or buttons on each row? Add
  [django-admin-tables](https://github.com/rodolvbg/django-admin-tables)'
  footer rows and row actions: they follow the filters and the paging of
  these controls.
- Only depends on Django. No jQuery plugins, no htmx; works with
  `TabularInline` and `StackedInline`.

## Install

```bash
pip install django-admin-inline-controls
```

Add to `INSTALLED_APPS`:

```python
INSTALLED_APPS = [
    ...
    "django_admin_inline_controls",
]
```

## Usage

Mix `InlineControlsMixin` in before `TabularInline` or `StackedInline`:

```python
from django.contrib import admin
from django.db.models.functions import Lower

from django_admin_inline_controls.mixins import InlineControlsMixin


class BookInline(InlineControlsMixin, admin.TabularInline):
    model = Book
    extra = 0
    inline_per_page = 20
    inline_ordering_fields = {
        "title": Lower("title"),  # column name -> ordering expression
        "published": "published",
        "pages": "pages",
    }
    inline_filter_fields = ["title__icontains", "status", "featured", "published__gte"]


@admin.register(Author)
class AuthorAdmin(admin.ModelAdmin):
    inlines = [BookInline]
```

State lives in the URL, namespaced by the formset prefix
(`?books-page=2&books-o=-pages&books-f-status=published`), so links are
shareable and several inlines never clash.

### Options

| Attribute | Default | |
|---|---|---|
| `inline_per_page` | `None` | Rows per page. `None` disables pagination. |
| `inline_pagination` | `"pages"` | `"pages"` or `"infinite"` (load more on scroll). |
| `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. |
| `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. |
| `inline_filter_fields` | `()` | Filter lookups. A form is generated from them. |
| `inline_filter_form` | `None` | Your own filter `forms.Form`. |
| `inline_filter_autocomplete` | `False` | Filters by a relation search as you type. `True` or a list of lookups. See [Searchable filters](#searchable-filters-and-only-the-values-in-use). |
| `inline_filter_only_used_values` | `False` | Filters offer only the values the parent's rows have. `True` or a list of lookups. |
| `inline_filter_sheet` | `False` | The filters behind a **Filters** button (with how many are on), like Unfold's `list_filter_sheet`: `True` always, `"narrow"` only when the inline is narrow (a phone, a side column). See [Filters behind a button](#filters-behind-a-button). |
| `inline_controls_ajax` | `True` | Refresh the inline in place instead of reloading the page. |
| `inline_save_button` | `False` | Show a button that saves only this inline. See [Saving only the inline](#saving-only-the-inline). |
| `inline_bulk_actions` | `()` | Actions for the selected rows. See [Bulk actions](#bulk-actions). |

Only columns declared in `inline_ordering_fields` can be sorted: ordering
by an arbitrary field from the URL would let anyone infer the values of
fields the inline never shows.

Internally, each feature lives in a mixin of its own, in
`django_admin_inline_controls.features`, with its options, its methods and
the script it adds only when it's on:

| Mixin | Options |
|---|---|
| `InlinePaginationMixin` | `inline_per_page`, `inline_pagination` |
| `InlineOrderingMixin` | `inline_ordering_fields`, `inline_default_ordering` |
| `InlineFiltersMixin` | `inline_filter_*` |
| `InlineSaveMixin` (and `InlineSaveAdminMixin` on the parent) | `inline_save_button` |
| `InlineBulkActionsMixin` (and `InlineBulkActionsAdminMixin` on the parent) | `inline_bulk_actions` |
| `InlineControlsBaseMixin` | `inline_controls_ajax`, `inline_controls_selectors`, the templates |

`InlineControlsMixin` and `InlineControlsAdminMixin` are their sum: use
them, and look in (or override the methods of) each feature's mixin. The
split is only how the code is organized: the features share one toolbar and
footer, and the system checks read every option, so a feature's mixin does
not work on its own (an inline with just `InlinePaginationMixin` fails
`manage.py check`). Don't mix them in one by one.

### Custom filters

Generated fields depend on the model field and lookup (choices → select,
`BooleanField` → Yes/No/any, dates → date input, relations →
`ModelChoiceField`, `__in` → multiple select, `__isnull` → Yes/No/any…).
Override `get_inline_filter_formfield(lookup)` to change one, or bring
your own form and handle the fields that aren't plain lookups:

```python
class BookFilterForm(forms.Form):
    q = forms.CharField(required=False, label="Search")
    status = forms.ChoiceField(
        required=False, choices=[("", "---"), *Book.Status.choices]
    )


class BookInline(InlineControlsMixin, admin.TabularInline):
    model = Book
    inline_per_page = 20
    inline_filter_form = BookFilterForm
    inline_filter_fields = ["status"]  # applied as lookups; "q" is handled below

    def filter_inline_queryset(self, request, queryset, filters):
        if search := filters.get("q"):
            queryset = queryset.filter(Q(title__icontains=search) | Q(isbn=search))
        return super().filter_inline_queryset(request, queryset, filters)
```

#### Filters behind a button

On a phone, a row of filters takes the screen before any row of the
inline. `inline_filter_sheet` puts them behind a **Filters** button, which
says how many are on:

```python
class BookInline(InlineControlsMixin, admin.TabularInline):
    inline_filter_fields = ["status", "published__gte"]
    inline_filter_sheet = "narrow"  # or True: behind the button on every screen
```

With `"narrow"`, the button only appears when the inline is narrower than
640px (its own width, not the screen's: an inline in a side column counts
as narrow); otherwise the filters are as without it. Open or closed, it
stays so while filtering or paging. Browsers too old to show a closed
`<details>`' content (before 2025) keep the button on wide inlines too.

![Filters behind a button on a phone, open, with one filter on](https://raw.githubusercontent.com/rodolvbg/django-admin-inline-controls/HEAD/docs/screenshots/filter-sheet.png)

#### Searchable filters, and only the values in use

```python
class BookInline(InlineControlsMixin, admin.TabularInline):
    model = Book
    inline_filter_fields = ["status", "publisher", "tags__in"]
    inline_filter_autocomplete = ["publisher", "tags__in"]  # or True
    inline_filter_only_used_values = True  # or a list of lookups
```

- **`inline_filter_autocomplete`**: the filters by a relation
  (`"publisher"`, `"tags__in"`) search as you type, with the admin's own
  autocomplete — like `autocomplete_fields`, so the related model needs a
  `ModelAdmin` with `search_fields` on the site
  (`admin_inline_controls.E021`). It keeps working after the inline is
  refreshed, and with django-unfold.
- **`inline_filter_only_used_values`**: the filters by choices or by a
  relation offer only the values the parent's rows have — the statuses or
  publishers of this author's books, not every one — like
  django-admin-select-filter's `filter_only_used_values`. An autocomplete
  filter still searches every object. Not applied to django-filter
  `FilterSet`s, which have their own querysets.

Every option also has a `get_*` hook taking `(request, obj)` —
`get_inline_per_page`, `get_inline_ordering_fields`,
`get_inline_filter_fields`, `get_inline_filter_form_class`,
`get_inline_filter_form_kwargs` (e.g. to pass the parent object to your
form) — and `get_inline_filtered_queryset()` is the single seam to plug in
any other filtering backend.

### Infinite scroll

```python
class ArticleInline(InlineControlsMixin, admin.TabularInline):
    model = Article
    inline_per_page = 30
    inline_pagination = "infinite"
```

The next page is appended when the "Load more" link scrolls into view (or
is clicked). Loaded rows join the formset, so everything visible is saved
with the object. Keep in mind Django's formset limits: by default a
formset accepts at most 1000 forms per submission (`max_num`).

### Saving only the inline

```python
from django_admin_inline_controls.mixins import (
    InlineControlsAdminMixin,
    InlineControlsMixin,
)


class BookInline(InlineControlsMixin, admin.TabularInline):
    model = Book
    inline_per_page = 20
    inline_save_button = True


@admin.register(Author)
class AuthorAdmin(InlineControlsAdminMixin, admin.ModelAdmin):
    inlines = [BookInline]
```

A **"Save books"** button (named after the inline's `verbose_name_plural`)
appears in the inline's footer, next to the pagination.

![The "Save books" button after saving an edited row](https://raw.githubusercontent.com/rodolvbg/django-admin-inline-controls/HEAD/docs/screenshots/save-inline.png)

**How it works**

- Only that inline is sent: the JS posts its fields (files included), its
  management form and the CSRF token with `fetch`. Nothing from the parent
  form or from the other inlines.
- The server builds just that formset against the parent object **as
  stored in the database** (not the unsaved parent form), checks the
  permissions, validates it and calls your `save_formset()` inside a
  transaction. The change is recorded in the parent's history ("Changed
  Title for book X"), like a regular admin save; a save with no changes
  records nothing.
- The response is the re-rendered inline, swapped in place:
  - on errors, they are shown on their rows, as in a normal save, and the
    submitted values are kept so they can be fixed and saved again;
  - on success, a "Saved." status is shown and the inline no longer counts
    as having unsaved changes.
- Unsaved changes elsewhere on the page (the parent form, other inlines)
  are left untouched: still on screen, still unsaved.
- The current filters, ordering and page are kept. In infinite mode, as
  many pages as were loaded are shown again after saving.

**Requirements and caveats**

1. **The parent `ModelAdmin` needs `InlineControlsAdminMixin`.** An inline
   cannot register URLs of its own, so the endpoint lives on the parent
   admin (`<object_id>/inline-controls/<prefix>/save/`). A system check
   (`admin_inline_controls.E008`) reports a missing mixin; without it no
   button is shown.
2. **Permissions:** the user needs change permission on the parent object
   (as for any save from the change form) and add, change or delete
   permission on the inline's model. Within the formset, the inline's own
   permissions apply exactly as in the change form (view-only rows are
   not validated, rows can only be deleted with delete permission, …).
3. **`save_formset(request, form, formset, change)` gets an unchanged
   parent form**, built from the saved instance (`changed_data` is empty),
   because the real one was not submitted. If you override `save_formset()`
   and read `form.cleaned_data`, that won't work from this button.
   `save_model()` and `save_related()` are not called, so the parent's own
   fields (e.g. an `auto_now` "modified" date) are not updated.
4. **Changes to the parent form are not saved** by this button. It says so
   in its tooltip; use the admin's regular "Save" buttons to save
   everything.
5. Not available on nested_admin inlines (`admin_inline_controls.E102`).

### Bulk actions

Like `ModelAdmin.actions`, for the rows of one inline:

```python
from django.contrib import admin, messages
from django_admin_inline_controls.actions import inline_action
from django_admin_inline_controls.mixins import (
    InlineControlsAdminMixin,
    InlineControlsMixin,
)


class BookInline(InlineControlsMixin, admin.TabularInline):
    model = Book
    inline_per_page = 20
    inline_bulk_actions = ["mark_published", "export_csv", "delete_selected"]

    @inline_action(
        permissions=["change"],
        description="Mark selected %(verbose_name_plural)s as published",
    )
    def mark_published(self, request, queryset):
        count = queryset.update(status=Book.Status.PUBLISHED)
        self.message_user(request, f"{count} books published.", messages.SUCCESS)

    @inline_action(description="Export selected %(verbose_name_plural)s to CSV")
    def export_csv(self, request, queryset):
        response = HttpResponse(content_type="text/csv")
        response["Content-Disposition"] = 'attachment; filename="books.csv"'
        ...
        return response


@admin.register(Author)
class AuthorAdmin(InlineControlsAdminMixin, admin.ModelAdmin):
    inlines = [BookInline]
```

![Inline actions: row checkboxes, the action menu and the selection count](https://raw.githubusercontent.com/rodolvbg/django-admin-inline-controls/HEAD/docs/screenshots/actions.png)

**How it works**

- Every saved row gets a checkbox next to its name; the action bar at the
  top of the inline has a "select all rows on this page" checkbox, the
  action menu, **Go** and the "2 of 25 selected" count. When the whole
  page is selected and there are more rows, **"Select all 25"** extends the
  selection to every row matching the current filters, on every page.
- The checkboxes and the menu are not part of the change form: they are
  never submitted when the object is saved, and ticking them doesn't count
  as an unsaved change.
- **Go** posts only the action name and the selected primary keys. The
  action runs on a queryset that is always restricted to children of the
  object being edited (and to the current filters), so a tampered primary
  key can't reach other rows.
- If the inline has unsaved edits, you are asked first: the inline is
  re-rendered after the action, so they would be lost.

**Writing actions**

- Same signature as changelist actions: `action(inline, request,
  queryset)`. Entries of `inline_bulk_actions` can be method names, callables or
  `"delete_selected"`. The parent object is `request.inline_controls_parent`.
- `@inline_action` is `@admin.action` (`permissions`, `description`) plus
  `confirmation`: a prompt shown before running. `description` and
  `confirmation` may use `%(verbose_name)s` and `%(verbose_name_plural)s`;
  `confirmation` also `%(count)s`, the number of rows it will run on.
  Plain `@admin.action` functions work too.
- `permissions=["change"]` checks the inline's `has_change_permission()`
  (with the parent object); actions the user may not run are not offered,
  and are refused if posted anyway.
- Use `self.message_user()` as in a `ModelAdmin`: the messages are shown
  next to the action menu.
- Return `None` to re-render the inline (keeping the page, filters and
  ordering; in infinite mode, the loaded rows), a file response (it is
  downloaded and the page stays as is), or a redirect (followed). Any other
  response replaces the page.
- Actions don't record anything in the history by themselves, as in the
  changelist. The built-in **`delete_selected`** deletes the rows one by
  one (so `delete()` overrides and signals run), asks "Delete 3 selected
  books? This cannot be undone.", records the deletions in the parent's
  history and reports rows protected by `on_delete=PROTECT` instead of
  failing.

**Requirements:** like the save button, `InlineControlsAdminMixin` on the
parent `ModelAdmin` (`admin_inline_controls.E010`), and the user needs view
or change permission on the parent object. Not available on nested_admin
inlines (`admin_inline_controls.E103`).

### Row actions

Buttons on each row ("Feature", "Delete"…) are in
[django-admin-tables](https://github.com/rodolvbg/django-admin-tables#row-actions),
which recognizes these controls: with its `RowActionsMixin` on the inline
(and on the parent `ModelAdmin`), an action refreshes only the inline, in
place, keeping its filters and page.

```python
from django_admin_tables import RowActionsMixin, row_action


class BookInline(InlineControlsMixin, RowActionsMixin, admin.TabularInline):
    model = Book
    row_actions = ["view", "feature", "delete"]

    @row_action(permissions=["change"], description="Feature")
    def feature(self, request, obj):
        ...


@admin.register(Author)
class AuthorAdmin(InlineControlsAdminMixin, RowActionsMixin, admin.ModelAdmin):
    inlines = [BookInline]
```

(Until 0.1.1 this package had its own `inline_actions`.)

### Footer rows

Totals, averages or any other summary below the columns are in
[django-admin-tables](https://github.com/rodolvbg/django-admin-tables#footer-rows),
which recognizes these controls: with its `FooterRowsMixin` on the same
inline, its `footer_scope = "filtered"` adds up every row matching the
filters, on every page, `"page"` the rows shown, and the totals come back
with the refreshed inline (filtering, paging, saving it, actions).

```python
from django.db.models import Count, Sum
from django_admin_tables import FooterRowsMixin


class BookInline(InlineControlsMixin, FooterRowsMixin, admin.TabularInline):
    model = Book
    inline_per_page = 20
    footer_rows = [("Total", {"title": Count("pk"), "pages": Sum("pages")})]
```

(Until 0.1.1 this package had its own `inline_footer_rows`.)

## Optional extras

The core only depends on Django. Integrations with third-party packages are
opt-in.

### django-filter

```bash
pip install django-admin-inline-controls[filters]
```

```python
from django_admin_inline_controls.contrib.filters import FilterSetInlineControlsMixin


class BookInline(FilterSetInlineControlsMixin, admin.TabularInline):
    model = Book
    inline_per_page = 20
    inline_filterset_class = BookFilterSet

    def get_inline_filterset_kwargs(self, request, obj):
        return {"parent": obj}  # extra kwargs for your FilterSet's __init__
```

The filterset gets the request too (`self.request`).

### django-nested-admin

```bash
pip install django-admin-inline-controls[nested]
```

```python
import nested_admin
from django_admin_inline_controls.contrib.nested import NestedInlineControlsMixin


class BookInline(NestedInlineControlsMixin, nested_admin.NestedTabularInline):
    model = Book
    inline_per_page = 20
```

nested_admin keeps its own client-side formset state, so with it filters,
sorting and page links reload the page instead of swapping the inline, and
infinite scroll, the save-inline button and bulk actions are not
available.

### Themes

- [django-unfold](https://github.com/rodolvbg/django-admin-inline-controls/blob/HEAD/docs/themes/unfold.md): `UnfoldInlineControlsMixin` (the
  `unfold` extra).

## Customizing templates

Three templates render the controls around the inline's own `template`
(which is left untouched: `admin/edit_inline/tabular.html`, a custom one,
…). Each is chosen per inline, so a template of yours can extend the
library's and override only the blocks it needs:

| Option | Default | Renders |
|---|---|---|
| `inline_controls_template` | `django_admin_inline_controls/inline.html` | The wrapper: toolbar, the inline itself, footer. |
| `inline_controls_toolbar_template` | `django_admin_inline_controls/toolbar.html` | Actions, filters, sort links. |
| `inline_controls_footer_template` | `django_admin_inline_controls/footer.html` | Pagination / infinite scroll, save button. |

```python
class BookInline(InlineControlsMixin, admin.TabularInline):
    model = Book
    inline_controls_toolbar_template = "admin/demo/book_inline_toolbar.html"
```

```django
{# templates/admin/demo/book_inline_toolbar.html #}
{% extends "django_admin_inline_controls/toolbar.html" %}

{% block filter_apply_label %}Search{% endblock %}

{% block filter_field %}
  <div class="my-filter">{{ block.super }}</div>
{% endblock %}

{% block toolbar_end %}
  <a href="{% url 'book-help' %}">Help</a>
{% endblock %}
```

To change them for every inline, put templates with the same paths in your
project's `templates/` directory (before the app templates), or set the
options on a base inline class of your own.

In all three, `controls` is the inline's state (filter form, ordering
columns, page links, actions, URLs…) and `inline_admin_formset` is
Django's. The JS finds its elements by the `inline-controls-*` classes, the
`data-inline-controls-*` attributes and the container's id: keep them when
you replace a block's markup (`{{ block.super }}` keeps the original).

**`inline.html`**

| Block | Contains |
|---|---|
| `container` | The whole wrapper `<div>`. |
| `container_classes`, `container_attrs` | Extra classes / attributes for the wrapper (empty). |
| `before_toolbar`, `before_inline`, `after_inline`, `after_footer` | Empty slots between the parts. |
| `toolbar` | Includes `inline_controls_toolbar_template`. |
| `inline` | Includes the inline's own template. |
| `footer` | Includes `inline_controls_footer_template`. |
| `inline_without_controls` | What is rendered when the controls are off (the add view): the inline. |

**`toolbar.html`**

| Block | Contains |
|---|---|
| `toolbar` | The whole toolbar. |
| `toolbar_classes` | Extra classes (empty). |
| `toolbar_start`, `toolbar_end` | Empty slots at both ends. |
| `actions` | The action bar. |
| `action_menu`, `action_label`, `action_empty_option`, `action_option` | The "Action:" select and its options (`action_option` is rendered once per action, with `action`). |
| `action_button`, `action_button_label` | The **Go** button. |
| `action_selection` | The "2 of 25 selected" count and the "Select all" link. |
| `action_status` | Where action messages appear. |
| `filters` | The filter form. |
| `filter_toggle` | The **Filters** button, with `inline_filter_sheet`. |
| `filter_fields`, `filter_field` | All fields / one field (once per field, with `field`). |
| `filter_buttons`, `filter_apply_button`, `filter_apply_label`, `filter_clear_button`, `filter_clear_label` | The **Filter** and **Clear** buttons. |
| `ordering`, `ordering_label`, `ordering_column` | The "Sort by:" links (once per column, with `column`). |

**`footer.html`**

| Block | Contains |
|---|---|
| `footer` | The whole footer. |
| `footer_classes` | Extra classes (empty). |
| `footer_start`, `footer_end` | Empty slots at both ends. |
| `pagination` | Page links or the infinite-scroll status. |
| `page_links`, `page_link` | The page links (`page_link` once per link, with `link`). |
| `result_count` | "25 results". |
| `infinite`, `infinite_count`, `load_more`, `load_more_label` | Infinite mode: "Showing 15 of 70" and "Load more". |
| `save`, `save_status`, `save_button`, `save_label` | The save-inline button and its status. |

**`inline_response.html`** (the save/action endpoints' response):
`response`, `inlines`. Set `inline_controls_response_template` on the
`ModelAdmin` to use another one.

## Adapting to another admin markup

The templates decide the HTML of the controls, but the JS also has to find
its way in the **inline's** markup — Django's `tabular.html` /
`stacked.html`, or whatever your theme (Unfold, Jazzmin, Grappelli…) or
your own inline template renders. Where to look is configurable per inline
with `inline_controls_selectors`, merged over these defaults
(`DEFAULT_SELECTORS` in `django_admin_inline_controls.controls`):

| Key | Default | Used for |
|---|---|---|
| `container` | `.inline-group fieldset` | Where the toolbar and footer are moved to. |
| `heading` | `h2` | Inside `container`: the toolbar goes right after it (or its `<summary>`). |
| `footer_parent` | `:scope > details` | Inside `container`: the footer is appended here, else to `container`. |
| `table_head` | `.inline-group table thead` | The header row with the sortable columns. |
| `column_header` | `th.column-{name}` | A sortable column's header (`{name}`: the column). |
| `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. |
| `saved_row` | `.has_original` | A form container of a saved object (not a new one). |
| `row_label` | `:scope > td.original > p`, `:scope > h3`, `:scope > td.original` | Inside a saved row: where its action checkbox goes. |
| `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`). |
| `stacked_rows` | `{group} .inline-related` | The same for stacked inlines. |

Each value is one selector or a list tried in order. Only set the keys that
differ:

```python
class BookInline(InlineControlsMixin, admin.TabularInline):
    model = Book
    template = "admin/my_theme/tabular.html"
    inline_ordering_fields = ["title", "pages"]
    inline_controls_selectors = {
        "container": ".card",
        "heading": ".card-title",
        "table_head": "table.grid thead",
        "column_header": 'th[data-col="{name}"]',
        "row_label": ":scope > td.row-name > .label",
        "tabular_rows": "{group} table.grid tbody > tr.form-row",
    }
```

For a whole theme, set it on a base inline class of your own. When
something isn't found, the controls degrade instead of breaking: sort links
stay in the toolbar if a column header is missing, checkboxes go into the
row itself, and the toolbar and footer stay around the inline. The
management form and the fields' names come from Django's formset, so they
are never configured.

To place the toolbar and footer yourself, listen for
`inline-controls:place` (it bubbles from the controls' wrapper,
`.inline-controls`, before they are moved) and cancel it:

```js
document.addEventListener("inline-controls:place", (event) => {
    const { toolbar, footer } = event.detail;
    event.target.querySelector(".my-panel-header").append(toolbar);
    event.target.querySelector(".my-panel-footer").append(footer);
    event.preventDefault();
});
```

## System checks

Misconfigurations are reported by `manage.py check` (and at startup). The
full list of IDs is in [docs/system-checks.md](https://github.com/rodolvbg/django-admin-inline-controls/blob/HEAD/docs/system-checks.md).

## JavaScript files

Each inline loads only the scripts it uses, through its `media`: `core.js`
(pagination, filters, sorting, placing the controls) always, `save.js`
with `inline_save_button`, `actions.js` with `inline_bulk_actions`, and
`contrib/unfold.js` with `UnfoldInlineControlsMixin`. The admin merges the
media of every inline on the page, so each file loads at most once.

## JavaScript events

After an inline is refreshed in place, or rows are appended, an
`inline-controls:updated` event bubbles from the new content: hook your own
widget initialization there (or read the footer values again, see
[Using the values from your own JS](#using-the-values-from-your-own-js)).
Before the toolbar and footer are placed, a
cancelable `inline-controls:place` event bubbles from the controls'
wrapper (see [Adapting to another admin markup](#adapting-to-another-admin-markup)). The admin's own inline machinery, autocomplete,
date/time shortcuts and `filter_horizontal` widgets are re-initialized
automatically.

## Demo

```bash
cd example
python manage.py migrate
python manage.py seed_demo      # admin/admin + an author with many books
python manage.py runserver

```

## Translations

Ships a Spanish (`es`) translation; every text of the controls — the
toolbar, the footer, the generated filter labels ("Title (contains)"), the
action messages and the JS prompts, which the server sends already
translated — follows the admin's active language.

Messages identical to Django admin's own ("Filter", "Delete selected
%(verbose_name_plural)s", …) use the admin's translation, since
`django.contrib.admin` comes first in `INSTALLED_APPS`: the inline then
reads exactly like the changelist.

## Compatibility

Django 4.2 – 6.1, Python 3.10+.

## License

MIT
