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.
- django_admin_fieldsets_extra/__init__.py +1 -0
- django_admin_fieldsets_extra/apps.py +7 -0
- django_admin_fieldsets_extra/checks.py +79 -0
- django_admin_fieldsets_extra/contrib/__init__.py +0 -0
- django_admin_fieldsets_extra/contrib/unfold.py +95 -0
- django_admin_fieldsets_extra/locale/es/LC_MESSAGES/django.mo +0 -0
- django_admin_fieldsets_extra/locale/es/LC_MESSAGES/django.po +42 -0
- django_admin_fieldsets_extra/mixins.py +347 -0
- django_admin_fieldsets_extra/py.typed +0 -0
- django_admin_fieldsets_extra/static/fieldsets_extra/css/contrib/unfold.css +45 -0
- django_admin_fieldsets_extra/static/fieldsets_extra/css/fieldsets_extra.css +50 -0
- django_admin_fieldsets_extra/static/fieldsets_extra/js/fieldsets_extra.js +140 -0
- django_admin_fieldsets_extra/templates/admin/fieldsets_extra/change_form.html +34 -0
- django_admin_fieldsets_extra/templates/admin/fieldsets_extra/fieldset_response.html +7 -0
- django_admin_fieldsets_extra/templates/admin/fieldsets_extra/includes/fieldset.html +28 -0
- django_admin_fieldsets_extra/templates/admin/fieldsets_extra/unfold/change_form.html +15 -0
- django_admin_fieldsets_extra/types.py +17 -0
- django_admin_fieldsets_extra-0.1.0.dist-info/METADATA +294 -0
- django_admin_fieldsets_extra-0.1.0.dist-info/RECORD +21 -0
- django_admin_fieldsets_extra-0.1.0.dist-info/WHEEL +4 -0
- django_admin_fieldsets_extra-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -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"]
|
|
Binary file
|
|
@@ -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
|
+
[](https://github.com/rodolvbg/django-admin-fieldsets-extra/actions/workflows/pytest.yml)
|
|
53
|
+
[](https://pypi.org/project/django-admin-fieldsets-extra/)
|
|
54
|
+
[](https://pypi.org/project/django-admin-fieldsets-extra/)
|
|
55
|
+
[](https://pypi.org/project/django-admin-fieldsets-extra/)
|
|
56
|
+
[](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
|
+

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

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