django-admin-fieldsets-extra 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.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,7 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class DjangoAdminFieldsetsExtraConfig(AppConfig):
5
+ default_auto_field = "django.db.models.BigAutoField"
6
+ name = "django_admin_fieldsets_extra"
7
+ label = "django_admin_fieldsets_extra"
@@ -0,0 +1,79 @@
1
+ """System checks for ``FieldsetsExtraMixin``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ from django.core import checks
9
+
10
+ from django_admin_fieldsets_extra.mixins import is_inline
11
+
12
+
13
+ def check_fieldsets_with_inlines(model_admin: Any) -> list[checks.CheckMessage]:
14
+ errors: list[checks.CheckMessage] = []
15
+ name = type(model_admin).__qualname__
16
+ layout = model_admin.fieldsets_with_inlines
17
+
18
+ def error(msg: str, id: str) -> None:
19
+ errors.append(checks.Error(msg, obj=type(model_admin), id=id))
20
+
21
+ if not isinstance(layout, list | tuple):
22
+ error(
23
+ f"The value of '{name}.fieldsets_with_inlines' must be a list or tuple.",
24
+ "admin_fieldsets_extra.E001",
25
+ )
26
+ return errors
27
+
28
+ for index, entry in enumerate(layout):
29
+ if is_inline(entry):
30
+ continue
31
+ if not (
32
+ isinstance(entry, list | tuple)
33
+ and len(entry) == 2
34
+ and isinstance(entry[1], Mapping)
35
+ and "fields" in entry[1]
36
+ ):
37
+ error(
38
+ f"The value of '{name}.fieldsets_with_inlines[{index}]' must be a "
39
+ "fieldset — a (name, {'fields': ...}) pair — or an inline class.",
40
+ "admin_fieldsets_extra.E002",
41
+ )
42
+ continue
43
+ save_button = entry[1].get("save_button", False)
44
+ if not isinstance(save_button, bool):
45
+ error(
46
+ f"'save_button' in '{name}.fieldsets_with_inlines[{index}]' must "
47
+ "be True or False.",
48
+ "admin_fieldsets_extra.E004",
49
+ )
50
+
51
+ from django_admin_fieldsets_extra.mixins import FieldsetsExtraMixin
52
+
53
+ hook_overridden = (
54
+ type(model_admin).get_fieldsets_with_inlines
55
+ is not FieldsetsExtraMixin.get_fieldsets_with_inlines
56
+ )
57
+ if hook_overridden:
58
+ derived = model_admin._fieldsets_with_inlines_derived
59
+ for option in ("fieldsets", "inlines"):
60
+ if getattr(model_admin, option) and option not in derived:
61
+ errors.append(
62
+ checks.Warning(
63
+ f"'{name}' overrides 'get_fieldsets_with_inlines()' and "
64
+ f"also sets '{option}', which is ignored whenever that "
65
+ "method returns a layout.",
66
+ hint=f"Remove '{option}', or return it from "
67
+ "get_fieldsets_with_inlines() when there is no layout.",
68
+ obj=type(model_admin),
69
+ id="admin_fieldsets_extra.W001",
70
+ )
71
+ )
72
+
73
+ for option in getattr(model_admin, "_fieldsets_with_inlines_conflicts", ()):
74
+ error(
75
+ f"'{name}' sets both 'fieldsets_with_inlines' and '{option}'; "
76
+ f"'{option}' is derived from 'fieldsets_with_inlines', remove it.",
77
+ "admin_fieldsets_extra.E003",
78
+ )
79
+ return errors
File without changes
@@ -0,0 +1,95 @@
1
+ """django-unfold integration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ try:
9
+ import unfold # noqa: F401
10
+ except ImportError as e:
11
+ raise ImportError(
12
+ "django_admin_fieldsets_extra.contrib.unfold requires django-unfold. "
13
+ "Install it with: pip install django-admin-fieldsets-extra[unfold]"
14
+ ) from e
15
+
16
+ from django import forms
17
+ from django.core import checks
18
+ from django.http import HttpRequest, HttpResponse
19
+
20
+ from django_admin_fieldsets_extra.mixins import FieldsetsExtraMixin, is_inline
21
+
22
+
23
+ def is_tab(options: Mapping[str, Any]) -> bool:
24
+ """Whether a fieldset is one of Unfold's tabs (``"classes": ["tab"]``)."""
25
+ return "tab" in options.get("classes", ())
26
+
27
+
28
+ class UnfoldFieldsetsExtraMixin(FieldsetsExtraMixin):
29
+ """``FieldsetsExtraMixin`` for Unfold's ``ModelAdmin``::
30
+
31
+ from unfold.admin import ModelAdmin
32
+
33
+ @admin.register(Author)
34
+ class AuthorAdmin(UnfoldFieldsetsExtraMixin, ModelAdmin):
35
+ fieldsets_with_inlines = [...]
36
+
37
+ Styles the save buttons with Unfold's colors (light and dark). Unfold's
38
+ tab fieldsets (``"classes": ["tab"]``) are rendered as its tabs, where
39
+ the first of them is in the layout.
40
+ """
41
+
42
+ change_form_template = "admin/fieldsets_extra/unfold/change_form.html"
43
+
44
+ def render_change_form(
45
+ self,
46
+ request: HttpRequest,
47
+ context: dict[str, Any],
48
+ add: bool = False,
49
+ change: bool = False,
50
+ form_url: str = "",
51
+ obj: Any = None,
52
+ ) -> HttpResponse:
53
+ # Position of the first tab fieldset: Unfold renders all its tabs there.
54
+ context["fieldsets_extra_tabs_index"] = next(
55
+ (
56
+ index
57
+ for index, (_, options) in enumerate(self.get_fieldsets(request, obj))
58
+ if is_tab(options)
59
+ ),
60
+ None,
61
+ )
62
+ return super().render_change_form(request, context, add, change, form_url, obj)
63
+
64
+ @property
65
+ def media(self) -> forms.Media:
66
+ return super().media + forms.Media(
67
+ css={"all": ["fieldsets_extra/css/contrib/unfold.css"]},
68
+ )
69
+
70
+ def check(self, **kwargs: Any) -> list[Any]:
71
+ errors = super().check(**kwargs)
72
+ layout = self.fieldsets_with_inlines
73
+ if not isinstance(layout, list | tuple):
74
+ return errors # reported by E001
75
+ for index, entry in enumerate(layout):
76
+ if is_inline(entry) or not (
77
+ isinstance(entry, list | tuple)
78
+ and len(entry) == 2
79
+ and isinstance(entry[1], Mapping)
80
+ ):
81
+ continue
82
+ if entry[1].get("save_button") and is_tab(entry[1]):
83
+ errors.append(
84
+ checks.Error(
85
+ f"'{type(self).__qualname__}.fieldsets_with_inlines"
86
+ f"[{index}]' is one of Unfold's tabs, which can't have "
87
+ "a 'save_button'.",
88
+ obj=type(self),
89
+ id="admin_fieldsets_extra.E101",
90
+ )
91
+ )
92
+ return errors
93
+
94
+
95
+ __all__ = ["UnfoldFieldsetsExtraMixin"]
@@ -0,0 +1,42 @@
1
+ # Spanish translation of django-admin-fieldsets-extra.
2
+ #
3
+ msgid ""
4
+ msgstr ""
5
+ "Project-Id-Version: django-admin-fieldsets-extra 0.1.0\n"
6
+ "Report-Msgid-Bugs-To: \n"
7
+ "POT-Creation-Date: 2026-09-29 11:10-0500\n"
8
+ "PO-Revision-Date: 2026-09-29 18:00+0200\n"
9
+ "Last-Translator: Rodolfo Becerra\n"
10
+ "Language-Team: Spanish\n"
11
+ "Language: es\n"
12
+ "MIME-Version: 1.0\n"
13
+ "Content-Type: text/plain; charset=UTF-8\n"
14
+ "Content-Transfer-Encoding: 8bit\n"
15
+ "Plural-Forms: nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? "
16
+ "1 : 2;\n"
17
+
18
+ #: mixins.py
19
+ #, python-format
20
+ msgid "Save %(name)s"
21
+ msgstr "Guardar %(name)s"
22
+
23
+ #: mixins.py
24
+ msgid "Save"
25
+ msgstr "Guardar"
26
+
27
+ #: mixins.py
28
+ msgid "Saved."
29
+ msgstr "Guardado."
30
+
31
+ #: mixins.py
32
+ msgid "Please correct the errors below."
33
+ msgstr "Por favor, corrija los errores detallados abajo."
34
+
35
+ #: templates/admin/fieldsets_extra/includes/fieldset.html
36
+ msgid "The changes could not be saved. Please try again."
37
+ msgstr "No se pudieron guardar los cambios. Inténtalo de nuevo."
38
+
39
+ #: templates/admin/fieldsets_extra/includes/fieldset.html
40
+ msgid ""
41
+ "Saves only this section. Other changes on the page are kept but not saved."
42
+ msgstr "Guarda solo esta sección. Los demás cambios de la página se mantienen, pero no se guardan."
@@ -0,0 +1,347 @@
1
+ """Extras for Django admin fieldsets: layout with inlines, per-fieldset save."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from django import forms
10
+ from django.contrib.admin.helpers import AdminForm, Fieldset, InlineAdminFormSet
11
+ from django.contrib.admin.options import InlineModelAdmin
12
+ from django.contrib.admin.utils import flatten_fieldsets, quote
13
+ from django.core.exceptions import PermissionDenied
14
+ from django.db import router, transaction
15
+ from django.http import Http404, HttpRequest, HttpResponse
16
+ from django.template.response import TemplateResponse
17
+ from django.urls import NoReverseMatch, URLPattern, path, reverse
18
+ from django.utils.translation import gettext
19
+ from django.views.decorators.http import require_POST
20
+
21
+ from django_admin_fieldsets_extra.types import FieldsetSpec, Layout
22
+
23
+ if TYPE_CHECKING:
24
+ from django.template.response import _TemplateForResponseT
25
+ from typing_extensions import TypeIs
26
+
27
+
28
+ def is_inline(entry: object) -> TypeIs[type[InlineModelAdmin]]:
29
+ return isinstance(entry, type) and issubclass(entry, InlineModelAdmin)
30
+
31
+
32
+ #: Fieldset options of this package; removed before Django sees them (its
33
+ #: ``Fieldset`` rejects unknown options).
34
+ LAYOUT_OPTIONS = frozenset({"save_button"})
35
+
36
+
37
+ def django_fieldset(
38
+ entry: FieldsetSpec,
39
+ ) -> FieldsetSpec:
40
+ """A layout fieldset as Django expects it, without this package's options.
41
+
42
+ Malformed entries are returned as they are, for the checks to report.
43
+ """
44
+ if not (isinstance(entry, list | tuple) and len(entry) == 2):
45
+ return entry
46
+ name, options = entry
47
+ if not isinstance(options, dict) or not LAYOUT_OPTIONS & set(options):
48
+ return entry
49
+ return name, {k: v for k, v in options.items() if k not in LAYOUT_OPTIONS}
50
+
51
+
52
+ def split_layout(
53
+ layout: Layout,
54
+ ) -> tuple[list[FieldsetSpec], list[type[InlineModelAdmin]]]:
55
+ """The fieldsets (Django-ready) and the inline classes of a layout."""
56
+ fieldsets = [django_fieldset(entry) for entry in layout if not is_inline(entry)]
57
+ inlines = [entry for entry in layout if is_inline(entry)]
58
+ return fieldsets, inlines
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class LayoutItem:
63
+ """What the change form renders at one position of the layout."""
64
+
65
+ fieldset: Fieldset | None = None
66
+ #: Position of the fieldset in ``get_fieldsets()`` (its id suffix).
67
+ index: int | None = None
68
+ inline_admin_formset: InlineAdminFormSet | None = None
69
+ #: Endpoint of the fieldset's save button, if it has one.
70
+ save_url: str | None = None
71
+ save_label: str = ""
72
+
73
+ @property
74
+ def is_inline(self) -> bool:
75
+ return self.inline_admin_formset is not None
76
+
77
+
78
+ class FieldsetsExtraMixin:
79
+ """Render inlines between fieldsets, in the order you list them::
80
+
81
+ @admin.register(Author)
82
+ class AuthorAdmin(FieldsetsExtraMixin, admin.ModelAdmin):
83
+ fieldsets_with_inlines = [
84
+ (None, {"fields": ["name"]}),
85
+ BookInline,
86
+ ("Contact", {"fields": ["email", "phone"], "save_button": True}),
87
+ ArticleInline,
88
+ ]
89
+
90
+ ``fieldsets`` and ``inlines`` are derived from it, so the admin's own
91
+ checks and hooks keep working; don't set them as well. A fieldset with
92
+ ``"save_button": True`` gets a button that saves only its fields.
93
+ """
94
+
95
+ fieldsets_with_inlines: Layout = ()
96
+ change_form_template: _TemplateForResponseT | None = (
97
+ "admin/fieldsets_extra/change_form.html"
98
+ )
99
+ fieldset_save_response_template = "admin/fieldsets_extra/fieldset_response.html"
100
+ _fieldsets_with_inlines_conflicts: list[str] = []
101
+ _fieldsets_with_inlines_derived: frozenset[str] = frozenset()
102
+
103
+ # Provided by ModelAdmin.
104
+ fieldsets: Any
105
+ inlines: Any
106
+ model: Any
107
+ opts: Any
108
+ admin_site: Any
109
+
110
+ def __init_subclass__(cls, **kwargs: Any) -> None:
111
+ super().__init_subclass__(**kwargs)
112
+ layout = cls.__dict__.get("fieldsets_with_inlines")
113
+ if not layout or isinstance(layout, str):
114
+ return
115
+ # Checked by check_fieldsets_with_inlines() (E003).
116
+ cls._fieldsets_with_inlines_conflicts = [
117
+ name for name in ("fieldsets", "inlines") if cls.__dict__.get(name)
118
+ ]
119
+ try:
120
+ fieldsets, inlines = split_layout(layout)
121
+ except TypeError: # pragma: no cover - not iterable, reported by E001
122
+ return
123
+ derived = set()
124
+ if not cls.__dict__.get("fieldsets"):
125
+ cls.fieldsets = fieldsets
126
+ derived.add("fieldsets")
127
+ if not cls.__dict__.get("inlines"):
128
+ cls.inlines = inlines
129
+ derived.add("inlines")
130
+ # Checked by check_fieldsets_with_inlines() (W001).
131
+ cls._fieldsets_with_inlines_derived = frozenset(derived)
132
+
133
+ def get_fieldsets_with_inlines(
134
+ self, request: HttpRequest, obj: Any = None
135
+ ) -> Layout:
136
+ """The layout for this request. Override to make it dynamic."""
137
+ return self.fieldsets_with_inlines
138
+
139
+ def get_fieldsets(self, request: HttpRequest, obj: Any = None) -> Any:
140
+ layout = self.get_fieldsets_with_inlines(request, obj)
141
+ if not layout:
142
+ return super().get_fieldsets(request, obj) # type: ignore[misc]
143
+ return split_layout(layout)[0]
144
+
145
+ def get_inlines(self, request: HttpRequest, obj: Any) -> Any:
146
+ layout = self.get_fieldsets_with_inlines(request, obj)
147
+ if not layout:
148
+ return super().get_inlines(request, obj) # type: ignore[misc]
149
+ return split_layout(layout)[1]
150
+
151
+ def get_layout_items(
152
+ self,
153
+ request: HttpRequest,
154
+ obj: Any,
155
+ adminform: Any,
156
+ inline_admin_formsets: Sequence[InlineAdminFormSet],
157
+ ) -> list[LayoutItem]:
158
+ """Pair the layout with what the change view built.
159
+
160
+ Fieldsets are matched by position and inlines by class; inlines the
161
+ user can't see are skipped. Anything the layout doesn't mention
162
+ (e.g. from overridden ``get_fieldsets()`` / ``get_inlines()``) is
163
+ appended at the end, as the admin would render it.
164
+ """
165
+ fieldsets = list(enumerate(adminform))
166
+ formsets = list(inline_admin_formsets)
167
+ can_save = (
168
+ obj is not None
169
+ and obj.pk is not None
170
+ and self.has_change_permission(request, obj) # type: ignore[attr-defined]
171
+ )
172
+ items: list[LayoutItem] = []
173
+ for entry in self.get_fieldsets_with_inlines(request, obj):
174
+ if is_inline(entry):
175
+ match = next((f for f in formsets if type(f.opts) is entry), None)
176
+ if match is not None:
177
+ formsets.remove(match)
178
+ items.append(LayoutItem(inline_admin_formset=match))
179
+ elif fieldsets:
180
+ index, fieldset = fieldsets.pop(0)
181
+ save_url = None
182
+ if can_save and entry[1].get("save_button"):
183
+ save_url = self._fieldset_save_url(obj, index)
184
+ items.append(
185
+ LayoutItem(
186
+ fieldset=fieldset,
187
+ index=index,
188
+ save_url=save_url,
189
+ save_label=self.get_fieldset_save_label(entry[0]),
190
+ )
191
+ )
192
+ items.extend(LayoutItem(fieldset=f, index=i) for i, f in fieldsets)
193
+ items.extend(LayoutItem(inline_admin_formset=f) for f in formsets)
194
+ return items
195
+
196
+ def render_change_form(
197
+ self,
198
+ request: HttpRequest,
199
+ context: dict[str, Any],
200
+ add: bool = False,
201
+ change: bool = False,
202
+ form_url: str = "",
203
+ obj: Any = None,
204
+ ) -> HttpResponse:
205
+ if self.get_fieldsets_with_inlines(request, obj):
206
+ context["fieldsets_with_inlines"] = self.get_layout_items(
207
+ request,
208
+ obj,
209
+ context["adminform"],
210
+ context["inline_admin_formsets"],
211
+ )
212
+ return super().render_change_form( # type: ignore[misc]
213
+ request, context, add, change, form_url, obj
214
+ )
215
+
216
+ # Saving a fieldset ----------------------------------------------------
217
+
218
+ @property
219
+ def media(self) -> forms.Media:
220
+ return super().media + forms.Media( # type: ignore[misc]
221
+ js=["fieldsets_extra/js/fieldsets_extra.js"],
222
+ css={"all": ["fieldsets_extra/css/fieldsets_extra.css"]},
223
+ )
224
+
225
+ def get_urls(self) -> list[URLPattern]:
226
+ opts = self.model._meta
227
+ return [
228
+ path(
229
+ "<path:object_id>/fieldsets/<int:index>/save/",
230
+ self.admin_site.admin_view(self.fieldset_save_view),
231
+ name=f"{opts.app_label}_{opts.model_name}_fieldset_save",
232
+ ),
233
+ *super().get_urls(), # type: ignore[misc]
234
+ ]
235
+
236
+ def _fieldset_save_url(self, obj: Any, index: int) -> str | None:
237
+ opts = self.model._meta
238
+ try:
239
+ return reverse(
240
+ f"{self.admin_site.name}:{opts.app_label}_{opts.model_name}"
241
+ "_fieldset_save",
242
+ args=[quote(obj.pk), index],
243
+ )
244
+ except NoReverseMatch: # pragma: no cover - get_urls() was overridden
245
+ return None
246
+
247
+ def get_fieldset_save_label(self, name: str | None) -> str:
248
+ """Text of a fieldset's save button."""
249
+ if name:
250
+ return gettext("Save %(name)s") % {"name": name}
251
+ return gettext("Save")
252
+
253
+ def fieldset_save_view(
254
+ self, request: HttpRequest, object_id: str, index: int
255
+ ) -> HttpResponse:
256
+ return require_POST(self._fieldset_save)(request, object_id, index)
257
+
258
+ def _fieldset_save(
259
+ self, request: HttpRequest, object_id: str, index: int
260
+ ) -> HttpResponse:
261
+ request.current_app = self.admin_site.name
262
+ obj = self.get_object(request, object_id) # type: ignore[attr-defined]
263
+ if obj is None:
264
+ raise Http404
265
+ if not self.has_change_permission(request, obj): # type: ignore[attr-defined]
266
+ raise PermissionDenied
267
+ layout_fieldsets = [
268
+ entry
269
+ for entry in self.get_fieldsets_with_inlines(request, obj)
270
+ if not is_inline(entry)
271
+ ]
272
+ if index >= len(layout_fieldsets) or not layout_fieldsets[index][1].get(
273
+ "save_button"
274
+ ):
275
+ raise Http404("This fieldset has no save button.")
276
+ name, options = django_fieldset(layout_fieldsets[index])
277
+ readonly = list(self.get_readonly_fields(request, obj)) # type: ignore[attr-defined]
278
+ fields = [
279
+ field
280
+ for field in flatten_fieldsets([(name, options)]) # type: ignore[list-item]
281
+ if field not in readonly
282
+ ]
283
+ if not fields:
284
+ raise Http404("This fieldset has no editable fields.")
285
+
286
+ # Only this fieldset's fields, on the object as stored now: the rest
287
+ # of the change form is neither submitted nor overwritten.
288
+ form_class = self.get_form(request, obj, change=True, fields=fields) # type: ignore[attr-defined]
289
+ form = form_class(request.POST, request.FILES, instance=obj)
290
+ if form.is_valid():
291
+ with transaction.atomic(using=router.db_for_write(self.model)):
292
+ obj = self.save_form(request, form, change=True) # type: ignore[attr-defined]
293
+ self.save_model(request, obj, form, change=True) # type: ignore[attr-defined]
294
+ form.save_m2m()
295
+ message = self.construct_change_message(request, form, []) # type: ignore[attr-defined]
296
+ if message:
297
+ self.log_change(request, obj, message) # type: ignore[attr-defined]
298
+ status, text = "saved", gettext("Saved.")
299
+ non_field_errors = None
300
+ form = form_class(instance=obj)
301
+ else:
302
+ status, text = "invalid", gettext("Please correct the errors below.")
303
+ non_field_errors = form.non_field_errors()
304
+
305
+ adminform = AdminForm(
306
+ form,
307
+ [(name, options)],
308
+ {},
309
+ readonly,
310
+ model_admin=self, # type: ignore[arg-type]
311
+ )
312
+ item = LayoutItem(
313
+ fieldset=next(iter(adminform)),
314
+ index=index,
315
+ save_url=request.path,
316
+ save_label=self.get_fieldset_save_label(name),
317
+ )
318
+ return TemplateResponse(
319
+ request,
320
+ self.fieldset_save_response_template,
321
+ {
322
+ "item": item,
323
+ "status": status,
324
+ "message": text,
325
+ "non_field_errors": non_field_errors,
326
+ "opts": self.opts,
327
+ "original": obj,
328
+ "change": True,
329
+ "is_popup": False,
330
+ },
331
+ )
332
+
333
+ def check(self, **kwargs: Any) -> list[Any]:
334
+ from django_admin_fieldsets_extra.checks import (
335
+ check_fieldsets_with_inlines,
336
+ )
337
+
338
+ return [*super().check(**kwargs), *check_fieldsets_with_inlines(self)] # type: ignore[misc]
339
+
340
+
341
+ __all__ = [
342
+ "LAYOUT_OPTIONS",
343
+ "FieldsetsExtraMixin",
344
+ "LayoutItem",
345
+ "django_fieldset",
346
+ "split_layout",
347
+ ]
File without changes
@@ -0,0 +1,45 @@
1
+ /* django-admin-fieldsets-extra: django-unfold's colors, light and dark. */
2
+ .fieldset-with-save {
3
+ --body-quiet-color: var(--color-font-subtle-light);
4
+ --error-fg: var(--color-red-600);
5
+ --hairline-color: transparent;
6
+ }
7
+
8
+ .dark .fieldset-with-save {
9
+ --body-quiet-color: var(--color-font-subtle-dark);
10
+ --error-fg: var(--color-red-500);
11
+ }
12
+
13
+ .fieldset-with-save > fieldset.module {
14
+ margin-bottom: 0.75rem;
15
+ }
16
+
17
+ .fieldset-save-bar {
18
+ margin-bottom: 1.5rem;
19
+ padding: 0;
20
+ }
21
+
22
+ .fieldset-save-bar .button {
23
+ height: 38px;
24
+ padding: 0 0.75rem;
25
+ border: 1px solid var(--color-base-200);
26
+ border-radius: var(--border-radius, 6px);
27
+ color: var(--color-font-important-light);
28
+ background: #fff;
29
+ font-weight: 500;
30
+ box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
31
+ }
32
+
33
+ .fieldset-save-bar .button:hover {
34
+ background: var(--color-base-50);
35
+ }
36
+
37
+ .dark .fieldset-save-bar .button {
38
+ border-color: var(--color-base-700);
39
+ color: var(--color-font-important-dark);
40
+ background: var(--color-base-900);
41
+ }
42
+
43
+ .dark .fieldset-save-bar .button:hover {
44
+ background: var(--color-base-800);
45
+ }
@@ -0,0 +1,50 @@
1
+ .fieldset-with-save > fieldset.module {
2
+ margin-bottom: 0;
3
+ }
4
+
5
+ .fieldset-save-bar {
6
+ display: flex;
7
+ flex-wrap: wrap;
8
+ align-items: center;
9
+ justify-content: flex-end;
10
+ gap: 8px;
11
+ margin-bottom: 30px;
12
+ padding: 8px 10px;
13
+ border-top: 1px solid var(--hairline-color, #e8e8e8);
14
+ font-size: 0.8125rem;
15
+ }
16
+
17
+ /* Nothing to save while the fieldset is collapsed (Django 5.1+ / 4.2). */
18
+ .fieldset-with-save:has(> fieldset > details:not([open])) > .fieldset-save-bar,
19
+ .fieldset-with-save:has(> fieldset.collapsed) > .fieldset-save-bar {
20
+ display: none;
21
+ }
22
+
23
+ .fieldset-save-bar .button {
24
+ box-sizing: border-box;
25
+ height: 30px;
26
+ margin: 0;
27
+ padding: 0 12px;
28
+ border-radius: 4px;
29
+ font-size: 0.8125rem;
30
+ line-height: 1;
31
+ cursor: pointer;
32
+ }
33
+
34
+ .fieldset-save-status {
35
+ color: var(--body-quiet-color, #666);
36
+ }
37
+
38
+ .fieldset-save-status-invalid,
39
+ .fieldset-save-status-failed {
40
+ color: var(--error-fg, #ba2121);
41
+ }
42
+
43
+ .fieldset-save-errors .errorlist {
44
+ margin: 10px;
45
+ }
46
+
47
+ .fieldset-with-save.fieldset-save-loading {
48
+ opacity: 0.6;
49
+ transition: opacity 0.2s;
50
+ }
@@ -0,0 +1,140 @@
1
+ /*
2
+ * django-admin-fieldsets-extra
3
+ *
4
+ * The "Save <fieldset>" buttons: post only that fieldset's fields and swap
5
+ * in the re-rendered fieldset. Plain script, no dependencies; uses
6
+ * django.jQuery (when present) only to re-run the admin's widget setup.
7
+ */
8
+ (() => {
9
+ const SKIP_TYPES = ["submit", "button", "reset", "image"];
10
+
11
+ /** The admin form's values for the controls inside `container`. */
12
+ function fieldsetFormData(container, form) {
13
+ const data = new FormData();
14
+ const csrf = form.querySelector("[name=csrfmiddlewaretoken]");
15
+ if (csrf) {
16
+ data.append(csrf.name, csrf.value);
17
+ }
18
+ for (const el of container.querySelectorAll(
19
+ "input, select, textarea",
20
+ )) {
21
+ if (
22
+ !el.name ||
23
+ el.disabled ||
24
+ el.form !== form ||
25
+ SKIP_TYPES.includes(el.type)
26
+ ) {
27
+ continue;
28
+ }
29
+ if (
30
+ (el.type === "checkbox" || el.type === "radio") &&
31
+ !el.checked
32
+ ) {
33
+ continue;
34
+ }
35
+ if (el.type === "file") {
36
+ for (const file of el.files) {
37
+ data.append(el.name, file);
38
+ }
39
+ } else if (el.tagName === "SELECT" && el.multiple) {
40
+ for (const option of el.selectedOptions) {
41
+ data.append(el.name, option.value);
42
+ }
43
+ } else {
44
+ data.append(el.name, el.value);
45
+ }
46
+ }
47
+ return data;
48
+ }
49
+
50
+ /** Re-run the admin's widget setup on freshly inserted HTML. */
51
+ function initWidgets(container) {
52
+ const $ = window.django?.jQuery;
53
+ if ($?.fn.djangoAdminSelect2) {
54
+ $(container).find(".admin-autocomplete").djangoAdminSelect2();
55
+ }
56
+ if ($ && typeof window.DateTimeShortcuts !== "undefined") {
57
+ $(".datetimeshortcuts").remove();
58
+ window.DateTimeShortcuts.init();
59
+ }
60
+ if (typeof window.SelectFilter !== "undefined") {
61
+ for (const el of container.querySelectorAll(
62
+ ".selectfilter, .selectfilterstacked",
63
+ )) {
64
+ window.SelectFilter.init(
65
+ el.id,
66
+ el.dataset.fieldName,
67
+ el.classList.contains("selectfilterstacked"),
68
+ );
69
+ }
70
+ }
71
+ if ($) {
72
+ $(container)
73
+ .find(".related-widget-wrapper select")
74
+ .trigger("change");
75
+ }
76
+ container.dispatchEvent(
77
+ new CustomEvent("fieldsets-extra:saved", { bubbles: true }),
78
+ );
79
+ }
80
+
81
+ function showFailure(container) {
82
+ const status = container.querySelector(".fieldset-save-status");
83
+ if (status) {
84
+ status.className =
85
+ "fieldset-save-status fieldset-save-status-failed";
86
+ status.textContent = container.dataset.fieldsetSaveFailed ?? "";
87
+ }
88
+ }
89
+
90
+ async function saveFieldset(container) {
91
+ const form = container.closest("form");
92
+ if (!form || container.classList.contains("fieldset-save-loading")) {
93
+ return;
94
+ }
95
+ container.classList.add("fieldset-save-loading");
96
+ try {
97
+ const response = await fetch(container.dataset.fieldsetSaveUrl, {
98
+ method: "POST",
99
+ body: fieldsetFormData(container, form),
100
+ credentials: "same-origin",
101
+ headers: { "X-Requested-With": "XMLHttpRequest" },
102
+ });
103
+ if (!response.ok) {
104
+ throw new Error(`HTTP ${response.status}`);
105
+ }
106
+ const text = await response.text();
107
+ const doc = new DOMParser().parseFromString(text, "text/html");
108
+ const fresh = doc.getElementById(container.id);
109
+ if (!fresh) {
110
+ throw new Error("Fieldset not found in response");
111
+ }
112
+ const node = document.importNode(fresh, true);
113
+ // Keep a collapsible fieldset open or closed as it was.
114
+ const details = container.querySelector("details");
115
+ const freshDetails = node.querySelector("details");
116
+ if (details && freshDetails) {
117
+ freshDetails.open = details.open;
118
+ }
119
+ container.replaceWith(node);
120
+ initWidgets(node);
121
+ } catch {
122
+ container.classList.remove("fieldset-save-loading");
123
+ showFailure(container);
124
+ }
125
+ }
126
+
127
+ document.addEventListener("click", (event) => {
128
+ const button = event.target.closest("[data-fieldset-save]");
129
+ const container = button?.closest(".fieldset-with-save");
130
+ if (container) {
131
+ event.preventDefault();
132
+ saveFieldset(container);
133
+ }
134
+ });
135
+
136
+ globalThis.DjangoAdminFieldsetsExtra = {
137
+ fieldsetFormData,
138
+ saveFieldset,
139
+ };
140
+ })();
@@ -0,0 +1,34 @@
1
+ {% extends "admin/change_form.html" %}
2
+ {% comment %}
3
+ Renders the fieldsets and the inlines in one loop, in the order of
4
+ `fieldsets_with_inlines`. To customize, extend this template and set it as
5
+ the ModelAdmin's `change_form_template`.
6
+ {% endcomment %}
7
+
8
+ {% block field_sets %}
9
+ {% if fieldsets_with_inlines %}
10
+ {% for item in fieldsets_with_inlines %}
11
+ {% if item.is_inline %}
12
+ {% with inline_admin_formset=item.inline_admin_formset %}
13
+ {% block layout_inline %}
14
+ {% include inline_admin_formset.opts.template %}
15
+ {% endblock layout_inline %}
16
+ {% endwith %}
17
+ {% else %}
18
+ {% with fieldset=item.fieldset %}
19
+ {% block layout_fieldset %}
20
+ {% include "admin/fieldsets_extra/includes/fieldset.html" %}
21
+ {% endblock layout_fieldset %}
22
+ {% endwith %}
23
+ {% endif %}
24
+ {% endfor %}
25
+ {% else %}
26
+ {{ block.super }}
27
+ {% endif %}
28
+ {% endblock field_sets %}
29
+
30
+ {% block inline_field_sets %}
31
+ {% if not fieldsets_with_inlines %}
32
+ {{ block.super }}
33
+ {% endif %}
34
+ {% endblock inline_field_sets %}
@@ -0,0 +1,7 @@
1
+ {% comment %}
2
+ Response of the fieldset save endpoint: the fieldset re-rendered with its
3
+ save bar (status included). The JS swaps in the container.
4
+ {% endcomment %}
5
+ {% block response %}
6
+ {% include "admin/fieldsets_extra/includes/fieldset.html" %}
7
+ {% endblock response %}
@@ -0,0 +1,28 @@
1
+ {% load i18n %}
2
+ {% comment %}
3
+ One layout fieldset (`item`). With a save button, it is wrapped with the
4
+ button in a container the JS replaces after saving; keep its id, the
5
+ data-fieldset-save-url attribute and the data-fieldset-save button.
6
+ {% endcomment %}
7
+ {% with fieldset=item.fieldset %}
8
+ {% if item.save_url %}
9
+ <div class="fieldset-with-save" id="fieldset-{{ item.index }}-container" data-fieldset-save-url="{{ item.save_url }}" data-fieldset-save-failed="{% translate 'The changes could not be saved. Please try again.' %}">
10
+ {% block non_field_errors %}
11
+ {% if non_field_errors %}
12
+ <div class="fieldset-save-errors">{{ non_field_errors }}</div>
13
+ {% endif %}
14
+ {% endblock non_field_errors %}
15
+ {% block fieldset %}
16
+ {% include "admin/includes/fieldset.html" with heading_level=2 prefix="fieldset" id_prefix=0 id_suffix=item.index %}
17
+ {% endblock fieldset %}
18
+ {% block save_bar %}
19
+ <div class="fieldset-save-bar">
20
+ <span class="fieldset-save-status{% if status %} fieldset-save-status-{{ status }}{% endif %}" role="status" aria-live="polite">{{ message }}</span>
21
+ <button type="button" class="button default" data-fieldset-save title="{% translate 'Saves only this section. Other changes on the page are kept but not saved.' %}">{% block save_label %}{{ item.save_label }}{% endblock %}</button>
22
+ </div>
23
+ {% endblock save_bar %}
24
+ </div>
25
+ {% else %}
26
+ {% include "admin/includes/fieldset.html" with heading_level=2 prefix="fieldset" id_prefix=0 id_suffix=item.index %}
27
+ {% endif %}
28
+ {% endwith %}
@@ -0,0 +1,15 @@
1
+ {% extends "admin/fieldsets_extra/change_form.html" %}
2
+ {% comment %}
3
+ The layout with Unfold: its tab fieldsets are rendered as its tabs, all of
4
+ them where the first one is in the layout.
5
+ {% endcomment %}
6
+
7
+ {% block layout_fieldset %}
8
+ {% if "tab" in fieldset.classes %}
9
+ {% if item.index == fieldsets_extra_tabs_index %}
10
+ {% include "unfold/helpers/fieldsets_tabs.html" %}
11
+ {% endif %}
12
+ {% else %}
13
+ {{ block.super }}
14
+ {% endif %}
15
+ {% endblock layout_fieldset %}
@@ -0,0 +1,17 @@
1
+ """Type aliases used across the package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from typing import Any, TypeAlias
7
+
8
+ from django.contrib.admin.options import InlineModelAdmin
9
+
10
+ #: A fieldset, as in ``ModelAdmin.fieldsets``: ``(name, options)``.
11
+ FieldsetSpec: TypeAlias = tuple[str | None, dict[str, Any]]
12
+ #: One entry of ``fieldsets_with_inlines``: a fieldset or an inline class.
13
+ LayoutEntry: TypeAlias = FieldsetSpec | type[InlineModelAdmin]
14
+ #: The whole ``fieldsets_with_inlines``.
15
+ Layout: TypeAlias = Sequence[LayoutEntry]
16
+
17
+ __all__ = ["FieldsetSpec", "Layout", "LayoutEntry"]
@@ -0,0 +1,294 @@
1
+ Metadata-Version: 2.5
2
+ Name: django-admin-fieldsets-extra
3
+ Version: 0.1.0
4
+ Summary: Extras for Django admin fieldsets: interleave them with inlines, save one on its own.
5
+ Project-URL: Homepage, https://github.com/rodolvbg/django-admin-fieldsets-extra
6
+ Project-URL: Issues, https://github.com/rodolvbg/django-admin-fieldsets-extra/issues
7
+ Project-URL: Repository, https://github.com/rodolvbg/django-admin-fieldsets-extra
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,fieldsets,inlines,layout
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: unfold
47
+ Requires-Dist: django-unfold>=0.108; (python_version >= '3.12') and extra == 'unfold'
48
+ Description-Content-Type: text/markdown
49
+
50
+ # django-admin-fieldsets-extra
51
+
52
+ [![Build status](https://github.com/rodolvbg/django-admin-fieldsets-extra/actions/workflows/pytest.yml/badge.svg)](https://github.com/rodolvbg/django-admin-fieldsets-extra/actions/workflows/pytest.yml)
53
+ [![PyPI version](https://img.shields.io/pypi/v/django-admin-fieldsets-extra.svg)](https://pypi.org/project/django-admin-fieldsets-extra/)
54
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-admin-fieldsets-extra)](https://pypi.org/project/django-admin-fieldsets-extra/)
55
+ [![PyPI - Django Version](https://img.shields.io/pypi/djversions/django-admin-fieldsets-extra)](https://pypi.org/project/django-admin-fieldsets-extra/)
56
+ [![Downloads](https://static.pepy.tech/personalized-badge/django-admin-fieldsets-extra?period=month&units=international_system&left_color=black&right_color=blue&left_text=Downloads/month)](https://pepy.tech/project/django-admin-fieldsets-extra)
57
+
58
+ Extras for Django admin fieldsets:
59
+
60
+ - **Interleave fieldsets and inlines** in the change form: put an inline
61
+ right after the fields it belongs with, instead of always at the bottom.
62
+ - **Save a fieldset on its own** with a "Save Contact" button, without
63
+ submitting or reloading the rest of the page.
64
+
65
+ ![Books inline between the name and the contact fieldset, articles before the biography](https://github.com/rodolvbg/django-admin-fieldsets-extra/blob/main/docs/screenshots/hero.png)
66
+
67
+ - One ordered list replaces `fieldsets` + `inlines` (with or without
68
+ inlines in it).
69
+ - No copy of Django's `change_form.html`: it only overrides its
70
+ `field_sets` / `inline_field_sets` blocks, so it follows your Django
71
+ version.
72
+ - Nothing changes in the inlines' DOM: Django's inline JS ("Add another",
73
+ autocomplete, date pickers…) and inline templates work as usual.
74
+ - Works with [django-admin-inline-controls](https://github.com/rodolvbg/django-admin-inline-controls)
75
+ (paginated / filterable inlines, a save button per inline).
76
+
77
+ ## Install
78
+
79
+ ```bash
80
+ pip install django-admin-fieldsets-extra
81
+ ```
82
+
83
+ Add to `INSTALLED_APPS`:
84
+
85
+ ```python
86
+ INSTALLED_APPS = [
87
+ ...
88
+ "django_admin_fieldsets_extra",
89
+ ]
90
+ ```
91
+
92
+ ## Usage
93
+
94
+ ```python
95
+ from django.contrib import admin
96
+
97
+ from django_admin_fieldsets_extra.mixins import FieldsetsExtraMixin
98
+
99
+
100
+ class BookInline(admin.TabularInline):
101
+ model = Book
102
+
103
+
104
+ class ArticleInline(admin.StackedInline):
105
+ model = Article
106
+
107
+
108
+ @admin.register(Author)
109
+ class AuthorAdmin(FieldsetsExtraMixin, admin.ModelAdmin):
110
+ fieldsets_with_inlines = [
111
+ (None, {"fields": ["name"]}),
112
+ BookInline,
113
+ ("Contact", {"fields": ["email", "phone"], "save_button": True}),
114
+ ArticleInline,
115
+ ("Biography", {"fields": ["bio"], "classes": ["collapse"]}),
116
+ ]
117
+ ```
118
+
119
+ Each entry is either a fieldset, exactly as in `ModelAdmin.fieldsets`, or an
120
+ inline class, as in `ModelAdmin.inlines`. They are rendered in that order.
121
+
122
+ - **`fieldsets` and `inlines` are derived from it** (in their relative
123
+ order), so Django's admin checks and every hook that reads them keep
124
+ working. Don't set them as well (`admin_fieldsets_extra.E003`).
125
+ - **Dynamic layouts:** override `get_fieldsets_with_inlines(request,
126
+ obj=None)`; `get_fieldsets()` and `get_inlines()` follow it. Static
127
+ `fieldsets` / `inlines` would be ignored then
128
+ (`admin_fieldsets_extra.W001` warns about it).
129
+ - **Permissions:** an inline the user can't see is simply left out, as
130
+ in the regular change form.
131
+ - **Anything not in the layout** (e.g. extra fieldsets or inlines from your
132
+ own `get_fieldsets()` / `get_inlines()`) is rendered after it.
133
+ - Without `fieldsets_with_inlines`, the mixin does nothing: the regular
134
+ change form.
135
+
136
+ ## Saving a fieldset
137
+
138
+ Add `"save_button": True` to a fieldset of the layout to give it a
139
+ **"Save Contact"** button (named after the fieldset; "Save" if it has no
140
+ name) that saves only its fields:
141
+
142
+ ```python
143
+ (("Contact", {"fields": ["email", "phone"], "save_button": True}),)
144
+ ```
145
+
146
+ ![A fieldset with its own save button, after saving](https://github.com/rodolvbg/django-admin-fieldsets-extra/blob/main/docs/screenshots/save-fieldset.png)
147
+
148
+ `save_button` is an option of this package: it is removed before the
149
+ fieldset reaches Django (whose `Fieldset` rejects unknown options), so
150
+ `AuthorAdmin.fieldsets` and `get_fieldsets()` never contain it.
151
+
152
+ **How it works**
153
+
154
+ - The button is rendered by the server under the fieldset, only on existing
155
+ objects and for users with change permission. It is hidden while the
156
+ fieldset is collapsed.
157
+ - Only the fields inside that fieldset are sent (files included), to
158
+ `<object_id>/fieldsets/<n>/save/`, `n` being its position in
159
+ `get_fieldsets()`.
160
+ - The server builds `get_form(request, obj, fields=<its editable fields>)`
161
+ on the object **as stored in the database**, validates it, calls
162
+ `save_form()`, `save_model()` and `form.save_m2m()` in a transaction, and
163
+ records the change in the history ("Changed Email and Phone."; nothing if
164
+ nothing changed).
165
+ - The fieldset is re-rendered and swapped in place: field errors on their
166
+ fields, form-wide errors (from `clean()`) above the fieldset, "Saved."
167
+ next to the button on success. The rest of the page (other fieldsets,
168
+ inlines, unsaved edits) is left untouched. A
169
+ `fieldsets-extra:saved` event bubbles from the new fieldset.
170
+
171
+ **Caveats**
172
+
173
+ 1. **Validation:** only this fieldset's fields are validated as fields, but
174
+ `Model.clean()` / `ModelForm.clean()` still run, with the other fields'
175
+ *stored* values. A rule that combines fields of two fieldsets sees the
176
+ saved value of the other one, not what is unsaved on screen.
177
+ 2. **`save_model(request, obj, form, change)` gets a form with only these
178
+ fields.** If you override it and read other fields from
179
+ `form.cleaned_data`, it won't work from this button. `save_related()`
180
+ and the inlines' formsets are not called.
181
+ 3. **Concurrency:** the object is re-read from the database right before
182
+ saving, so the page's (possibly stale) values of other fields are never
183
+ written back. As with the regular Save button, two people changing the
184
+ same field at the same time: the last save wins.
185
+ 4. Read-only fields are never saved; a fieldset with only read-only fields
186
+ has no button (the endpoint answers 404).
187
+
188
+ ## Customizing the template
189
+
190
+ The mixin sets `change_form_template` to
191
+ `admin/fieldsets_extra/change_form.html`, which extends
192
+ `admin/change_form.html`. For your own change form template, extend this
193
+ one instead:
194
+
195
+ ```django
196
+ {% extends "admin/fieldsets_extra/change_form.html" %}
197
+
198
+ {% block layout_inline %}
199
+ <div class="my-inline">{{ block.super }}</div>
200
+ {% endblock %}
201
+ ```
202
+
203
+ | Block | Contains |
204
+ |---|---|
205
+ | `field_sets` | The whole layout (falls back to Django's when there is none). |
206
+ | `layout_fieldset` | One fieldset (`fieldset`, and `item.index`, its position in `get_fieldsets()`); includes `admin/fieldsets_extra/includes/fieldset.html`. |
207
+ | `layout_inline` | One inline (`inline_admin_formset`). |
208
+ | `inline_field_sets` | Empty when there is a layout (the inlines are already rendered). |
209
+
210
+ `admin/fieldsets_extra/includes/fieldset.html` renders one fieldset
211
+ and, if it has one, its save button. Its blocks: `non_field_errors`,
212
+ `fieldset`, `save_bar` and `save_label`. It is also the save endpoint's
213
+ response (`fieldset_response.html`, block `response`; set
214
+ `fieldset_save_response_template` on the `ModelAdmin` to use another one).
215
+ The JS replaces the `fieldset-with-save` container by its id and relies on
216
+ the `data-fieldset-save-url` attribute and the `data-fieldset-save` button:
217
+ keep them when overriding blocks.
218
+
219
+ The layout is also in the context as `fieldsets_with_inlines`: a list of
220
+ items with `is_inline`, `fieldset`, `index`, `inline_admin_formset`,
221
+ `save_url` and `save_label`.
222
+ `get_layout_items()` builds it, if you need to change how the layout is
223
+ matched to the form.
224
+
225
+ ## With django-admin-inline-controls
226
+
227
+ Both mixins go on the `ModelAdmin`, `InlineControlsAdminMixin` first:
228
+
229
+ ```python
230
+ from django_admin_inline_controls.mixins import (
231
+ InlineControlsAdminMixin,
232
+ InlineControlsMixin,
233
+ )
234
+
235
+
236
+ class BookInline(InlineControlsMixin, admin.TabularInline):
237
+ model = Book
238
+ inline_per_page = 20
239
+ inline_save_button = True
240
+
241
+
242
+ @admin.register(Author)
243
+ class AuthorAdmin(InlineControlsAdminMixin, FieldsetsExtraMixin, admin.ModelAdmin):
244
+ fieldsets_with_inlines = [
245
+ (None, {"fields": ["name"]}),
246
+ BookInline,
247
+ ("Contact", {"fields": ["email", "phone"], "save_button": True}),
248
+ ]
249
+ ```
250
+
251
+ Neither package knows about the other: the controls are part of each
252
+ inline's own template, and the fieldset save buttons belong to the
253
+ fieldsets, wherever the layout puts them. Fieldsets keep
254
+ their position in `get_fieldsets()`, which is how the save endpoint finds
255
+ them.
256
+
257
+ ## Themes
258
+
259
+ - [django-unfold](https://github.com/rodolvbg/django-admin-fieldsets-extra/blob/main/docs/themes/unfold.md): `UnfoldFieldsetsExtraMixin` (the
260
+ `unfold` extra).
261
+
262
+ ## System checks
263
+
264
+ | ID | Problem |
265
+ |---|---|
266
+ | `admin_fieldsets_extra.E001` | `fieldsets_with_inlines` is not a list or tuple. |
267
+ | `admin_fieldsets_extra.E002` | An entry is neither a `(name, {"fields": ...})` fieldset nor an inline class. |
268
+ | `admin_fieldsets_extra.E003` | `fieldsets` or `inlines` is set as well. |
269
+ | `admin_fieldsets_extra.E004` | A fieldset's `save_button` is not `True` or `False`. |
270
+ | `admin_fieldsets_extra.E101` | An Unfold tab fieldset (`"classes": ["tab"]`) has a `save_button` (`UnfoldFieldsetsExtraMixin`). |
271
+ | `admin_fieldsets_extra.W001` | `get_fieldsets_with_inlines()` is overridden and `fieldsets` or `inlines` is set too: they are ignored whenever it returns a layout (warning). |
272
+
273
+ ## Translations
274
+
275
+ Ships a Spanish (`es`) translation: the save buttons, their tooltips and
276
+ the status messages follow the admin's active language.
277
+
278
+ ## Demo
279
+
280
+ ```bash
281
+ cd example
282
+ python manage.py migrate
283
+ python manage.py seed_demo # admin/admin + an author with books
284
+ python manage.py runserver
285
+
286
+ ```
287
+
288
+ ## Compatibility
289
+
290
+ Django 4.2 – 6.1, Python 3.10+.
291
+
292
+ ## License
293
+
294
+ MIT
@@ -0,0 +1,21 @@
1
+ django_admin_fieldsets_extra/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ django_admin_fieldsets_extra/apps.py,sha256=gBdR50k2iN11kDNLBW_N12CFtviivcGRNUu4yUFQiS0,228
3
+ django_admin_fieldsets_extra/checks.py,sha256=QOUqokdEFF2CSIc1FxNiKEsNq-ZnVuRp5G2QqouyZyI,2965
4
+ django_admin_fieldsets_extra/mixins.py,sha256=DwtQZIXqg2r5MPUee4QI_U-OKq9RnMfiUCx2Z5aq6ZA,13274
5
+ django_admin_fieldsets_extra/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ django_admin_fieldsets_extra/types.py,sha256=p431wYfAzeHUzB2EIbFzQkfofv16B0MWy6mQXTmr99o,614
7
+ django_admin_fieldsets_extra/contrib/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ django_admin_fieldsets_extra/contrib/unfold.py,sha256=Ob825kRI1ctRxcIdu_Fj1raHT96EuhwF-C-fqKkFAf4,3189
9
+ django_admin_fieldsets_extra/locale/es/LC_MESSAGES/django.mo,sha256=_edjI3jbLTN6hM5Nze9EPoqaxS7WxBJLJxg5C2vexEs,964
10
+ django_admin_fieldsets_extra/locale/es/LC_MESSAGES/django.po,sha256=6DXkMM68QLJKugfjiIFTzeKWUVjRsjxCTYyRVUhCUbc,1238
11
+ django_admin_fieldsets_extra/static/fieldsets_extra/css/fieldsets_extra.css,sha256=CKzy-oIRwHQM2UNz5uKGPFwXb_aluwGPlAez4G1gMSM,1103
12
+ django_admin_fieldsets_extra/static/fieldsets_extra/css/contrib/unfold.css,sha256=E_vjl6HCvsCkOM7BCEa7VnXCrluPvJMKaYkxUWm8U4o,1115
13
+ django_admin_fieldsets_extra/static/fieldsets_extra/js/fieldsets_extra.js,sha256=COiDfun-XsDyFt1hxiyEuoIW-7eABgBbk--v67qGXNk,4983
14
+ django_admin_fieldsets_extra/templates/admin/fieldsets_extra/change_form.html,sha256=A2ycbZ8EX5d9nOkCbVnuEO3Kzhq9NJ2TaEaZtmShBGc,1097
15
+ django_admin_fieldsets_extra/templates/admin/fieldsets_extra/fieldset_response.html,sha256=Gt8GBIxtYTmCAI_Qt27eFobn7fHpojon3MeMy9lLe2g,276
16
+ django_admin_fieldsets_extra/templates/admin/fieldsets_extra/includes/fieldset.html,sha256=HYkJW3tvRPL43skleU1P5umSjGdjKJkK43lwFL7v6jA,1609
17
+ django_admin_fieldsets_extra/templates/admin/fieldsets_extra/unfold/change_form.html,sha256=0dHAhlzbFGZ_RMrig_gdBOyIoKU8_4V09qno3k_l_AU,481
18
+ django_admin_fieldsets_extra-0.1.0.dist-info/METADATA,sha256=gZXwy7AHKbGX0KYDYLhIqMH2QPIs_mTrtTpfZqQOqvc,12208
19
+ django_admin_fieldsets_extra-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
20
+ django_admin_fieldsets_extra-0.1.0.dist-info/licenses/LICENSE,sha256=0EJi4J2ISkSQsH8fR7SQvn4J66zEouFMPErgIRKn0cg,1072
21
+ django_admin_fieldsets_extra-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rodolfo Becerra
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.