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.
Files changed (44) hide show
  1. django_admin_tables/__init__.py +22 -0
  2. django_admin_tables/apps.py +6 -0
  3. django_admin_tables/base.py +102 -0
  4. django_admin_tables/columns/__init__.py +5 -0
  5. django_admin_tables/columns/mixins.py +206 -0
  6. django_admin_tables/columns/responsive.py +54 -0
  7. django_admin_tables/fk_links/__init__.py +5 -0
  8. django_admin_tables/fk_links/mixins.py +134 -0
  9. django_admin_tables/footer/__init__.py +6 -0
  10. django_admin_tables/footer/mixins.py +212 -0
  11. django_admin_tables/footer/rows.py +194 -0
  12. django_admin_tables/locale/es/LC_MESSAGES/django.mo +0 -0
  13. django_admin_tables/locale/es/LC_MESSAGES/django.po +64 -0
  14. django_admin_tables/mixins.py +38 -0
  15. django_admin_tables/py.typed +0 -0
  16. django_admin_tables/radio_select/__init__.py +6 -0
  17. django_admin_tables/radio_select/formsets.py +55 -0
  18. django_admin_tables/radio_select/mixins.py +126 -0
  19. django_admin_tables/radio_select/widgets.py +64 -0
  20. django_admin_tables/row_actions/__init__.py +6 -0
  21. django_admin_tables/row_actions/actions.py +250 -0
  22. django_admin_tables/row_actions/mixins.py +427 -0
  23. django_admin_tables/static/django_admin_tables/columns.css +138 -0
  24. django_admin_tables/static/django_admin_tables/columns.js +215 -0
  25. django_admin_tables/static/django_admin_tables/footer-unfold.css +34 -0
  26. django_admin_tables/static/django_admin_tables/footer.css +53 -0
  27. django_admin_tables/static/django_admin_tables/radio-select.js +35 -0
  28. django_admin_tables/static/django_admin_tables/row-actions-unfold.css +43 -0
  29. django_admin_tables/static/django_admin_tables/row-actions.css +49 -0
  30. django_admin_tables/static/django_admin_tables/row-actions.js +156 -0
  31. django_admin_tables/static/django_admin_tables/unsaved-changes.js +46 -0
  32. django_admin_tables/templates/django_admin_tables/change_list.html +31 -0
  33. django_admin_tables/templates/django_admin_tables/columns_menu.html +17 -0
  34. django_admin_tables/templates/django_admin_tables/edit_inline.html +8 -0
  35. django_admin_tables/templates/django_admin_tables/footer_summary.html +21 -0
  36. django_admin_tables/templates/django_admin_tables/row_actions.html +21 -0
  37. django_admin_tables/templates/django_admin_tables/tfoot.html +35 -0
  38. django_admin_tables/templatetags/__init__.py +0 -0
  39. django_admin_tables/templatetags/django_admin_tables.py +117 -0
  40. django_admin_tables/unsaved.py +22 -0
  41. django_admin_tables-0.1.0.dist-info/METADATA +542 -0
  42. django_admin_tables-0.1.0.dist-info/RECORD +44 -0
  43. django_admin_tables-0.1.0.dist-info/WHEEL +4 -0
  44. 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,6 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class DjangoAdminTablesConfig(AppConfig):
5
+ name = "django_admin_tables"
6
+ verbose_name = "Django Admin Tables"
@@ -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,5 @@
1
+ """Changelist columns the user can hide, remembered in their browser."""
2
+
3
+ from .mixins import HideableColumnsMixin
4
+
5
+ __all__ = ["HideableColumnsMixin"]
@@ -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,5 @@
1
+ """Foreign keys in ``list_display`` shown as links to their change view."""
2
+
3
+ from .mixins import UNFOLD_LINK_CLASS, ForeignKeyLinkMixin
4
+
5
+ __all__ = ["UNFOLD_LINK_CLASS", "ForeignKeyLinkMixin"]
@@ -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
@@ -0,0 +1,6 @@
1
+ """Totals, averages… below a table's columns: the changelist's, an inline's."""
2
+
3
+ from .mixins import FooterRowsMixin
4
+ from .rows import FOOTER_FILTERED, FOOTER_PAGE
5
+
6
+ __all__ = ["FOOTER_FILTERED", "FOOTER_PAGE", "FooterRowsMixin"]