django-admin-tables 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_tables/__init__.py +22 -0
- django_admin_tables/apps.py +6 -0
- django_admin_tables/base.py +102 -0
- django_admin_tables/columns/__init__.py +5 -0
- django_admin_tables/columns/mixins.py +206 -0
- django_admin_tables/columns/responsive.py +54 -0
- django_admin_tables/fk_links/__init__.py +5 -0
- django_admin_tables/fk_links/mixins.py +134 -0
- django_admin_tables/footer/__init__.py +6 -0
- django_admin_tables/footer/mixins.py +212 -0
- django_admin_tables/footer/rows.py +194 -0
- django_admin_tables/locale/es/LC_MESSAGES/django.mo +0 -0
- django_admin_tables/locale/es/LC_MESSAGES/django.po +64 -0
- django_admin_tables/mixins.py +38 -0
- django_admin_tables/py.typed +0 -0
- django_admin_tables/radio_select/__init__.py +6 -0
- django_admin_tables/radio_select/formsets.py +55 -0
- django_admin_tables/radio_select/mixins.py +126 -0
- django_admin_tables/radio_select/widgets.py +64 -0
- django_admin_tables/row_actions/__init__.py +6 -0
- django_admin_tables/row_actions/actions.py +250 -0
- django_admin_tables/row_actions/mixins.py +427 -0
- django_admin_tables/static/django_admin_tables/columns.css +138 -0
- django_admin_tables/static/django_admin_tables/columns.js +215 -0
- django_admin_tables/static/django_admin_tables/footer-unfold.css +34 -0
- django_admin_tables/static/django_admin_tables/footer.css +53 -0
- django_admin_tables/static/django_admin_tables/radio-select.js +35 -0
- django_admin_tables/static/django_admin_tables/row-actions-unfold.css +43 -0
- django_admin_tables/static/django_admin_tables/row-actions.css +49 -0
- django_admin_tables/static/django_admin_tables/row-actions.js +156 -0
- django_admin_tables/static/django_admin_tables/unsaved-changes.js +46 -0
- django_admin_tables/templates/django_admin_tables/change_list.html +31 -0
- django_admin_tables/templates/django_admin_tables/columns_menu.html +17 -0
- django_admin_tables/templates/django_admin_tables/edit_inline.html +8 -0
- django_admin_tables/templates/django_admin_tables/footer_summary.html +21 -0
- django_admin_tables/templates/django_admin_tables/row_actions.html +21 -0
- django_admin_tables/templates/django_admin_tables/tfoot.html +35 -0
- django_admin_tables/templatetags/__init__.py +0 -0
- django_admin_tables/templatetags/django_admin_tables.py +117 -0
- django_admin_tables/unsaved.py +22 -0
- django_admin_tables-0.1.0.dist-info/METADATA +542 -0
- django_admin_tables-0.1.0.dist-info/RECORD +44 -0
- django_admin_tables-0.1.0.dist-info/WHEEL +4 -0
- django_admin_tables-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Extras for the Django admin's tables: changelists and inlines."""
|
|
2
|
+
|
|
3
|
+
from .columns import HideableColumnsMixin
|
|
4
|
+
from .fk_links import ForeignKeyLinkMixin
|
|
5
|
+
from .footer import FooterRowsMixin
|
|
6
|
+
from .mixins import AdminTablesMixin
|
|
7
|
+
from .radio_select import ExclusiveRadioFieldsMixin
|
|
8
|
+
from .row_actions import RowActionsMixin, row_action
|
|
9
|
+
from .unsaved import UnsavedChangesMixin
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"AdminTablesMixin",
|
|
13
|
+
"ExclusiveRadioFieldsMixin",
|
|
14
|
+
"FooterRowsMixin",
|
|
15
|
+
"ForeignKeyLinkMixin",
|
|
16
|
+
"HideableColumnsMixin",
|
|
17
|
+
"RowActionsMixin",
|
|
18
|
+
"UnsavedChangesMixin",
|
|
19
|
+
"row_action",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
4
|
+
|
|
5
|
+
from django.contrib.admin.options import BaseModelAdmin
|
|
6
|
+
from django.template.loader import select_template
|
|
7
|
+
from django.template.response import TemplateResponse
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
|
|
12
|
+
from django.contrib.admin.views.main import ChangeList
|
|
13
|
+
from django.db.models import Model
|
|
14
|
+
from django.forms.models import BaseInlineFormSet
|
|
15
|
+
from django.http import HttpRequest, HttpResponse
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TableTemplatesMixin(BaseModelAdmin):
|
|
19
|
+
"""The base of the mixins that add to the admin's tables: renders the
|
|
20
|
+
changelist with ``tables_change_list_template``, and a tabular inline
|
|
21
|
+
with ``tables_inline_template``, when one of them has something to add.
|
|
22
|
+
|
|
23
|
+
Each mixin adds its part through ``get_changelist_extras()`` and
|
|
24
|
+
``get_inline_extras()``, calling ``super()``: their keys end up in the
|
|
25
|
+
template's context (``django_admin_tables_<key>``) for the changelist,
|
|
26
|
+
and in the inline's ``django_admin_tables_inline`` for an inline.
|
|
27
|
+
|
|
28
|
+
The changelist template extends the one the admin would use
|
|
29
|
+
(``change_list_template`` included); the inline one includes the
|
|
30
|
+
inline's own ``template``. The ``# type: ignore[misc]`` on ``super()``
|
|
31
|
+
calls is because only the concrete ``ModelAdmin``/``InlineModelAdmin``
|
|
32
|
+
declare them.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
tables_change_list_template = "django_admin_tables/change_list.html"
|
|
36
|
+
tables_inline_template = "django_admin_tables/edit_inline.html"
|
|
37
|
+
|
|
38
|
+
def get_changelist_extras(
|
|
39
|
+
self, request: HttpRequest, cl: ChangeList
|
|
40
|
+
) -> dict[str, Any]:
|
|
41
|
+
return {}
|
|
42
|
+
|
|
43
|
+
def get_inline_extras(
|
|
44
|
+
self, request: HttpRequest, obj: Model | None
|
|
45
|
+
) -> dict[str, Any]:
|
|
46
|
+
return {}
|
|
47
|
+
|
|
48
|
+
def changelist_view(
|
|
49
|
+
self, request: HttpRequest, extra_context: dict[str, Any] | None = None
|
|
50
|
+
) -> HttpResponse:
|
|
51
|
+
response = super().changelist_view(request, extra_context) # type: ignore[misc]
|
|
52
|
+
if not isinstance(response, TemplateResponse) or response.is_rendered:
|
|
53
|
+
return response
|
|
54
|
+
context = response.context_data or {}
|
|
55
|
+
cl = context.get("cl")
|
|
56
|
+
if cl is None:
|
|
57
|
+
return response
|
|
58
|
+
extras = {
|
|
59
|
+
key: value
|
|
60
|
+
for key, value in self.get_changelist_extras(request, cl).items()
|
|
61
|
+
if value
|
|
62
|
+
}
|
|
63
|
+
if not extras:
|
|
64
|
+
return response
|
|
65
|
+
# The admin's changelist_view() renders a name or a list of names.
|
|
66
|
+
template_name = cast("str | Sequence[str]", response.template_name)
|
|
67
|
+
context["django_admin_tables_parent"] = select_template(
|
|
68
|
+
[template_name] if isinstance(template_name, str) else template_name
|
|
69
|
+
)
|
|
70
|
+
for key, value in extras.items():
|
|
71
|
+
context[f"django_admin_tables_{key}"] = value
|
|
72
|
+
response.template_name = self.tables_change_list_template
|
|
73
|
+
return response
|
|
74
|
+
|
|
75
|
+
def get_formset(
|
|
76
|
+
self, request: HttpRequest, obj: Model | None = None, **kwargs: Any
|
|
77
|
+
) -> type[BaseInlineFormSet]:
|
|
78
|
+
formset_class = super().get_formset(request, obj, **kwargs) # type: ignore[misc]
|
|
79
|
+
# django-admin-inline-controls renders the inline's own template
|
|
80
|
+
# inside its wrapper (and alone, when it refreshes the inline): wrap
|
|
81
|
+
# that one, so the extras are in both.
|
|
82
|
+
attribute = (
|
|
83
|
+
"inline_controls_inner_template"
|
|
84
|
+
if hasattr(self, "inline_controls_inner_template")
|
|
85
|
+
else "template"
|
|
86
|
+
)
|
|
87
|
+
template: str = getattr(self, attribute, "")
|
|
88
|
+
# The admin builds its inlines for each request: their template can
|
|
89
|
+
# change with the request. get_formset() may be called more than once.
|
|
90
|
+
if template != self.tables_inline_template:
|
|
91
|
+
extras = {
|
|
92
|
+
key: value
|
|
93
|
+
for key, value in self.get_inline_extras(request, obj).items()
|
|
94
|
+
if value
|
|
95
|
+
}
|
|
96
|
+
if extras:
|
|
97
|
+
self.django_admin_tables_inline = {
|
|
98
|
+
"parent": select_template([template]),
|
|
99
|
+
**extras,
|
|
100
|
+
}
|
|
101
|
+
setattr(self, attribute, self.tables_inline_template)
|
|
102
|
+
return formset_class
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
from django.apps import apps
|
|
6
|
+
from django.contrib.admin.utils import label_for_field
|
|
7
|
+
from django.core import checks
|
|
8
|
+
from django.utils.text import capfirst
|
|
9
|
+
|
|
10
|
+
from ..base import TableTemplatesMixin
|
|
11
|
+
from .responsive import BREAKPOINTS, Breakpoint, max_width, media_rules
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Mapping, Sequence
|
|
15
|
+
|
|
16
|
+
from django.contrib.admin.views.main import ChangeList
|
|
17
|
+
from django.db.models import Model
|
|
18
|
+
from django.http import HttpRequest
|
|
19
|
+
|
|
20
|
+
#: Attribute naming the field behind a generated ``list_display`` callable
|
|
21
|
+
#: (``ForeignKeyLinkMixin``'s links), so it can be listed by that name.
|
|
22
|
+
FIELD_NAME_ATTR = "_django_admin_tables_field"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def column_name(item: Any) -> str:
|
|
26
|
+
"""The name ``item`` (a ``list_display`` entry) is listed by."""
|
|
27
|
+
if isinstance(item, str):
|
|
28
|
+
return item
|
|
29
|
+
return str(getattr(item, FIELD_NAME_ATTR, None) or getattr(item, "__name__", ""))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def column_class(item: Any, index: int) -> str:
|
|
33
|
+
"""The suffix of the ``column-``/``field-`` classes the admin gives
|
|
34
|
+
``item``'s cells (``_coerce_field_name()`` in ``admin_list``)."""
|
|
35
|
+
if isinstance(item, str):
|
|
36
|
+
return item
|
|
37
|
+
if item.__name__ == "<lambda>":
|
|
38
|
+
return f"lambda{index}"
|
|
39
|
+
return item.__name__
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def checkbox_classes() -> str:
|
|
43
|
+
if not apps.is_installed("unfold"):
|
|
44
|
+
return ""
|
|
45
|
+
from unfold.widgets import CHECKBOX_CLASSES
|
|
46
|
+
|
|
47
|
+
return " ".join(CHECKBOX_CLASSES)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class HideableColumnsMixin(TableTemplatesMixin):
|
|
51
|
+
"""Mixin hiding columns, for a ``ModelAdmin`` or a ``TabularInline``:
|
|
52
|
+
|
|
53
|
+
- ``list_display_hideable``: a "Columns" menu on the changelist, to
|
|
54
|
+
hide or show those columns. The choice is per user and per model,
|
|
55
|
+
kept in the browser's ``localStorage``.
|
|
56
|
+
- ``responsive_columns``: columns hidden below a viewport width, on the
|
|
57
|
+
changelist (``list_display`` names) or in a tabular inline (field
|
|
58
|
+
names), with CSS media queries.
|
|
59
|
+
|
|
60
|
+
Both hide on the ``column-<name>`` and ``field-<name>`` classes the
|
|
61
|
+
admin gives each column's cells, so sorting, actions, ``list_editable``
|
|
62
|
+
and the inline's form work as they do without it.
|
|
63
|
+
|
|
64
|
+
They're added by the changelist's and the inline's templates (see
|
|
65
|
+
``TableTemplatesMixin``).
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
#: ``list_display`` entries the user can hide: field names, or the
|
|
69
|
+
#: name of a method or callable.
|
|
70
|
+
list_display_hideable: Sequence[str] = ()
|
|
71
|
+
#: Entries of ``list_display_hideable`` hidden until the user shows them.
|
|
72
|
+
list_display_hidden: Sequence[str] = ()
|
|
73
|
+
#: Columns hidden below a viewport width: ``{"album": "md"}``. Widths
|
|
74
|
+
#: are a name of ``BREAKPOINTS`` (``"sm"``, ``"md"``, ``"lg"``, ``"xl"``,
|
|
75
|
+
#: ``"2xl"``) or pixels.
|
|
76
|
+
responsive_columns: Mapping[str, Breakpoint] = {}
|
|
77
|
+
|
|
78
|
+
def get_list_display_hideable(self, request: HttpRequest) -> Sequence[str]:
|
|
79
|
+
return self.list_display_hideable
|
|
80
|
+
|
|
81
|
+
def get_list_display_hidden(self, request: HttpRequest) -> Sequence[str]:
|
|
82
|
+
return self.list_display_hidden
|
|
83
|
+
|
|
84
|
+
def get_responsive_columns(
|
|
85
|
+
self, request: HttpRequest, obj: Model | None = None
|
|
86
|
+
) -> Mapping[str, Breakpoint]:
|
|
87
|
+
return self.responsive_columns
|
|
88
|
+
|
|
89
|
+
def get_hideable_columns(
|
|
90
|
+
self, request: HttpRequest, cl: ChangeList
|
|
91
|
+
) -> list[dict[str, Any]]:
|
|
92
|
+
"""The hideable columns of ``cl``, in ``list_display`` order."""
|
|
93
|
+
hideable = set(self.get_list_display_hideable(request))
|
|
94
|
+
if not hideable:
|
|
95
|
+
return []
|
|
96
|
+
hidden = set(self.get_list_display_hidden(request))
|
|
97
|
+
responsive = self.get_responsive_columns(request)
|
|
98
|
+
columns = []
|
|
99
|
+
for index, item in enumerate(cl.list_display):
|
|
100
|
+
name = column_name(item)
|
|
101
|
+
if name not in hideable:
|
|
102
|
+
continue
|
|
103
|
+
columns.append(
|
|
104
|
+
{
|
|
105
|
+
"name": name,
|
|
106
|
+
"class": column_class(item, index),
|
|
107
|
+
"label": capfirst(
|
|
108
|
+
str(label_for_field(item, cl.model, model_admin=self))
|
|
109
|
+
),
|
|
110
|
+
"hidden": name in hidden,
|
|
111
|
+
"max_width": (
|
|
112
|
+
max_width(responsive[name]) if name in responsive else None
|
|
113
|
+
),
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
return columns
|
|
117
|
+
|
|
118
|
+
def get_changelist_extras(
|
|
119
|
+
self, request: HttpRequest, cl: ChangeList
|
|
120
|
+
) -> dict[str, Any]:
|
|
121
|
+
extras = super().get_changelist_extras(request, cl)
|
|
122
|
+
columns = self.get_hideable_columns(request, cl)
|
|
123
|
+
if columns:
|
|
124
|
+
opts = cl.model._meta
|
|
125
|
+
extras["columns"] = {
|
|
126
|
+
"columns": columns,
|
|
127
|
+
"unfold": apps.is_installed("unfold"),
|
|
128
|
+
"checkbox_classes": checkbox_classes(),
|
|
129
|
+
"config": {
|
|
130
|
+
"storageKey": (
|
|
131
|
+
f"django-admin-tables:{self.admin_site.name}:"
|
|
132
|
+
f"{opts.app_label}.{opts.model_name}:hidden-columns"
|
|
133
|
+
),
|
|
134
|
+
"hidden": [c["class"] for c in columns if c["hidden"]],
|
|
135
|
+
"columns": [c["class"] for c in columns],
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
extras["responsive"] = media_rules(
|
|
139
|
+
self.get_responsive_columns(request),
|
|
140
|
+
(
|
|
141
|
+
(column_name(item), column_class(item, index))
|
|
142
|
+
for index, item in enumerate(cl.list_display)
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
return extras
|
|
146
|
+
|
|
147
|
+
def get_inline_extras(
|
|
148
|
+
self, request: HttpRequest, obj: Model | None
|
|
149
|
+
) -> dict[str, Any]:
|
|
150
|
+
extras = super().get_inline_extras(request, obj)
|
|
151
|
+
responsive = self.get_responsive_columns(request, obj)
|
|
152
|
+
extras["responsive"] = media_rules(
|
|
153
|
+
responsive, ((name, name) for name in responsive)
|
|
154
|
+
)
|
|
155
|
+
return extras
|
|
156
|
+
|
|
157
|
+
def check(self, **kwargs: Any) -> list[checks.CheckMessage]:
|
|
158
|
+
return [*super().check(**kwargs), *self._check_hideable_columns()]
|
|
159
|
+
|
|
160
|
+
def _check_hideable_columns(self) -> list[checks.CheckMessage]:
|
|
161
|
+
errors: list[checks.CheckMessage] = []
|
|
162
|
+
for name, breakpoint in self.responsive_columns.items():
|
|
163
|
+
try:
|
|
164
|
+
max_width(breakpoint)
|
|
165
|
+
except ValueError:
|
|
166
|
+
errors.append(
|
|
167
|
+
checks.Error(
|
|
168
|
+
f"'{name}' in responsive_columns has an unknown "
|
|
169
|
+
f"breakpoint, {breakpoint!r}: use one of "
|
|
170
|
+
f"{', '.join(BREAKPOINTS)}, or pixels.",
|
|
171
|
+
obj=self.__class__,
|
|
172
|
+
id="django_admin_tables.E003",
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
list_display = getattr(self, "list_display", None)
|
|
176
|
+
if list_display is None: # An inline: there's no changelist.
|
|
177
|
+
return errors
|
|
178
|
+
listed = {column_name(item) for item in list_display}
|
|
179
|
+
errors += [
|
|
180
|
+
checks.Error(
|
|
181
|
+
f"'{name}' in responsive_columns isn't in list_display.",
|
|
182
|
+
obj=self.__class__,
|
|
183
|
+
id="django_admin_tables.E004",
|
|
184
|
+
)
|
|
185
|
+
for name in self.responsive_columns
|
|
186
|
+
if name not in listed
|
|
187
|
+
]
|
|
188
|
+
errors += [
|
|
189
|
+
checks.Error(
|
|
190
|
+
f"'{name}' in list_display_hideable isn't in list_display.",
|
|
191
|
+
obj=self.__class__,
|
|
192
|
+
id="django_admin_tables.E001",
|
|
193
|
+
)
|
|
194
|
+
for name in self.list_display_hideable
|
|
195
|
+
if name not in listed
|
|
196
|
+
]
|
|
197
|
+
errors += [
|
|
198
|
+
checks.Error(
|
|
199
|
+
f"'{name}' in list_display_hidden isn't in list_display_hideable.",
|
|
200
|
+
obj=self.__class__,
|
|
201
|
+
id="django_admin_tables.E002",
|
|
202
|
+
)
|
|
203
|
+
for name in self.list_display_hidden
|
|
204
|
+
if name not in self.list_display_hideable
|
|
205
|
+
]
|
|
206
|
+
return errors
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Columns hidden below a viewport width, with CSS media queries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from collections.abc import Iterable, Mapping
|
|
10
|
+
|
|
11
|
+
#: Named breakpoints, in pixels: Tailwind's, which django-unfold uses.
|
|
12
|
+
BREAKPOINTS = {"sm": 640, "md": 768, "lg": 1024, "xl": 1280, "2xl": 1536}
|
|
13
|
+
|
|
14
|
+
Breakpoint = str | int
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def max_width(breakpoint: Breakpoint) -> float:
|
|
18
|
+
"""The ``max-width`` of the media query hiding a column below
|
|
19
|
+
``breakpoint`` (a name of ``BREAKPOINTS``, or pixels)."""
|
|
20
|
+
if isinstance(breakpoint, str) and breakpoint in BREAKPOINTS:
|
|
21
|
+
pixels = BREAKPOINTS[breakpoint]
|
|
22
|
+
elif isinstance(breakpoint, int) and not isinstance(breakpoint, bool):
|
|
23
|
+
pixels = breakpoint
|
|
24
|
+
else:
|
|
25
|
+
raise ValueError(f"Unknown breakpoint: {breakpoint!r}.")
|
|
26
|
+
if pixels <= 0:
|
|
27
|
+
raise ValueError(f"Unknown breakpoint: {breakpoint!r}.")
|
|
28
|
+
# Just under the breakpoint: at exactly its width, the column shows.
|
|
29
|
+
return pixels - 0.02
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def css_identifier(name: str) -> str:
|
|
33
|
+
"""``name`` escaped for a class selector."""
|
|
34
|
+
return re.sub(r"[^\w-]", lambda match: f"\\{match.group()}", name)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def media_rules(
|
|
38
|
+
breakpoints: Mapping[str, Breakpoint], classes: Iterable[tuple[str, str]]
|
|
39
|
+
) -> list[dict[str, object]]:
|
|
40
|
+
"""The media queries hiding each column below its breakpoint.
|
|
41
|
+
|
|
42
|
+
``classes`` pairs each column's name (a key of ``breakpoints``) with
|
|
43
|
+
the suffix of its ``column-``/``field-`` classes. Returns one rule per
|
|
44
|
+
width, widest first: ``{"max_width": 767.98, "classes": [...]}``.
|
|
45
|
+
"""
|
|
46
|
+
by_width: dict[float, list[str]] = {}
|
|
47
|
+
for name, css_class in classes:
|
|
48
|
+
if name in breakpoints:
|
|
49
|
+
width = max_width(breakpoints[name])
|
|
50
|
+
by_width.setdefault(width, []).append(css_identifier(css_class))
|
|
51
|
+
return [
|
|
52
|
+
{"max_width": width, "classes": by_width[width]}
|
|
53
|
+
for width in sorted(by_width, reverse=True)
|
|
54
|
+
]
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from django.apps import apps
|
|
6
|
+
from django.contrib.admin.options import BaseModelAdmin
|
|
7
|
+
from django.template.loader import render_to_string
|
|
8
|
+
from django.urls import reverse
|
|
9
|
+
from django.utils.html import format_html
|
|
10
|
+
|
|
11
|
+
from ..columns.mixins import FIELD_NAME_ATTR
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Callable, Sequence
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from django.http import HttpRequest
|
|
18
|
+
from django.utils.safestring import SafeString
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
#: django-unfold's link colors: its reset styles links like the text around.
|
|
22
|
+
UNFOLD_LINK_CLASS = "text-primary-600 dark:text-primary-500"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ForeignKeyLinkMixin(BaseModelAdmin):
|
|
26
|
+
"""ModelAdmin mixin rendering chosen FK fields in list_display as links
|
|
27
|
+
to their change view.
|
|
28
|
+
|
|
29
|
+
Meant to sit before ``admin.ModelAdmin`` in a subclass's bases, e.g.
|
|
30
|
+
``class BookAdmin(ForeignKeyLinkMixin, admin.ModelAdmin)``. Inheriting
|
|
31
|
+
from ``BaseModelAdmin`` — the common base of ``ModelAdmin`` and
|
|
32
|
+
``InlineModelAdmin`` — types ``self``/``super()`` while still letting
|
|
33
|
+
``AdminTablesMixin`` combine it with ``ExclusiveRadioFieldsMixin`` on an
|
|
34
|
+
inline too, where there's no ``list_display`` and it does nothing. The
|
|
35
|
+
``# type: ignore[misc]`` on ``super().get_list_display()`` is because
|
|
36
|
+
only the concrete ``ModelAdmin`` declares it.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
list_display_foreign_key_links: Sequence[str] = ()
|
|
40
|
+
#: Template rendering each link, with ``obj``, ``related``, ``url``,
|
|
41
|
+
#: ``field_name`` and ``link_class`` in its context. ``None`` renders a
|
|
42
|
+
#: plain ``<a>`` tag.
|
|
43
|
+
foreign_key_link_template: str | None = None
|
|
44
|
+
#: CSS class of each link. ``None`` picks the admin theme's: django-unfold's
|
|
45
|
+
#: link colors when it's installed, else none.
|
|
46
|
+
foreign_key_link_class: str | None = None
|
|
47
|
+
|
|
48
|
+
def get_list_display_foreign_key_links(self, request: HttpRequest) -> Sequence[str]:
|
|
49
|
+
return self.list_display_foreign_key_links
|
|
50
|
+
|
|
51
|
+
def get_foreign_key_link_class(self) -> str:
|
|
52
|
+
if self.foreign_key_link_class is not None:
|
|
53
|
+
return self.foreign_key_link_class
|
|
54
|
+
return UNFOLD_LINK_CLASS if apps.is_installed("unfold") else ""
|
|
55
|
+
|
|
56
|
+
def get_foreign_key_link(
|
|
57
|
+
self, obj: Any, field_name: str, related: Any, url: str
|
|
58
|
+
) -> str | SafeString:
|
|
59
|
+
"""HTML of the link from ``obj`` to ``related``'s change view.
|
|
60
|
+
|
|
61
|
+
Override to change the markup (a class, a ``target``, an icon, the
|
|
62
|
+
text…), or set ``foreign_key_link_template`` to render it from a
|
|
63
|
+
template instead. Return safe HTML (``format_html()``): the admin
|
|
64
|
+
escapes plain strings.
|
|
65
|
+
"""
|
|
66
|
+
link_class = self.get_foreign_key_link_class()
|
|
67
|
+
if self.foreign_key_link_template:
|
|
68
|
+
return render_to_string(
|
|
69
|
+
self.foreign_key_link_template,
|
|
70
|
+
{
|
|
71
|
+
"obj": obj,
|
|
72
|
+
"related": related,
|
|
73
|
+
"url": url,
|
|
74
|
+
"field_name": field_name,
|
|
75
|
+
"link_class": link_class,
|
|
76
|
+
},
|
|
77
|
+
)
|
|
78
|
+
if link_class:
|
|
79
|
+
return format_html(
|
|
80
|
+
'<a href="{}" class="{}">{}</a>', url, link_class, related
|
|
81
|
+
)
|
|
82
|
+
return format_html('<a href="{}">{}</a>', url, related)
|
|
83
|
+
|
|
84
|
+
def get_list_display(self, request: HttpRequest) -> list[Any]:
|
|
85
|
+
base = list(super().get_list_display(request)) # type: ignore[misc]
|
|
86
|
+
list_editable = getattr(self, "list_editable", ())
|
|
87
|
+
result: list[Any] = []
|
|
88
|
+
|
|
89
|
+
for item in base:
|
|
90
|
+
if (
|
|
91
|
+
isinstance(item, str)
|
|
92
|
+
and item in self.get_list_display_foreign_key_links(request)
|
|
93
|
+
and item not in list_editable
|
|
94
|
+
):
|
|
95
|
+
result.append(self._build_fk_link_callable(item))
|
|
96
|
+
else:
|
|
97
|
+
result.append(item)
|
|
98
|
+
|
|
99
|
+
return result
|
|
100
|
+
|
|
101
|
+
def _build_fk_link_callable(self, field_name: str) -> Callable[[Any], str]:
|
|
102
|
+
"""Build the list_display callable that links to field_name's related object."""
|
|
103
|
+
try:
|
|
104
|
+
field = self.model._meta.get_field(field_name)
|
|
105
|
+
verbose = field.verbose_name
|
|
106
|
+
order_field: str | None = field_name
|
|
107
|
+
except Exception:
|
|
108
|
+
verbose = field_name.replace("_", " ")
|
|
109
|
+
order_field = None
|
|
110
|
+
|
|
111
|
+
def fk_link(obj: Any) -> str | SafeString:
|
|
112
|
+
"""Return an <a> tag to obj's related change view, or '-' if unset."""
|
|
113
|
+
related = getattr(obj, field_name, None)
|
|
114
|
+
if not related:
|
|
115
|
+
return "-"
|
|
116
|
+
|
|
117
|
+
rel_meta = related._meta
|
|
118
|
+
url = reverse(
|
|
119
|
+
f"{self.admin_site.name}:{rel_meta.app_label}_{rel_meta.model_name}_change",
|
|
120
|
+
args=[related.pk],
|
|
121
|
+
)
|
|
122
|
+
return self.get_foreign_key_link(obj, field_name, related, url)
|
|
123
|
+
|
|
124
|
+
fk_link.__name__ = f"{field_name}_link"
|
|
125
|
+
# Listed by its field's name (list_display_hideable, say).
|
|
126
|
+
setattr(fk_link, FIELD_NAME_ATTR, field_name)
|
|
127
|
+
# short_description/admin_order_field are Django's own convention
|
|
128
|
+
# for annotating a list_display callable; there's no stub for
|
|
129
|
+
# attributes bolted onto a plain function like this.
|
|
130
|
+
fk_link.short_description = verbose # type: ignore[attr-defined]
|
|
131
|
+
if order_field:
|
|
132
|
+
fk_link.admin_order_field = order_field # type: ignore[attr-defined]
|
|
133
|
+
|
|
134
|
+
return fk_link
|