Skip to content

Commit 0a92919

Browse files
authored
feat: expandable changelist rows / related tables and templates (#1117)
1 parent c5a5632 commit 0a92919

File tree

11 files changed

+315
-47
lines changed

11 files changed

+315
-47
lines changed

docs/sections/index.md

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
---
2+
title: Sections (Expandable rows)
3+
order: 8
4+
description:
5+
---
6+
7+
# Sections - expandable changelist rows
8+
9+
Unfold implements special functionality for handling expandable rows in changelists called sections. Once the `list_sections` attribute is configured, rows in the changelist will display an arrow button at the beginning of the row which can be used to show additional content.
10+
11+
The `list_sections` attribute consists of Python classes inheriting from `TableSection` or `TemplateSection` defined in `unfold.sections`. These classes are responsible for rendering the content in the expandable area.
12+
13+
```python
14+
from unfold.admin import ModelAdmin
15+
from unfold.sections import TableSection, TemplateSection
16+
17+
from .models import SomeModel
18+
19+
# Table for related records
20+
class CustomTableSection(TableSection):
21+
verbose_name = _("Table title") # Displays custom table title
22+
height = 300 # Force the table height. Ideal for large amount of records
23+
related_name = "related_name_set" # Related model field name
24+
fields = ["pk", "title", "custom_field"] # Fields from related model
25+
26+
# Custom field
27+
def custom_field(self, instance):
28+
return instance.pk
29+
30+
# Simple template with custom content
31+
class CardSection(TemplateSection):
32+
template_name = "your_app/some_template.html"
33+
34+
35+
@admin.register(SomeModel)
36+
class SomeAdmin(ModelAdmin):
37+
list_sections = [
38+
CardSection,
39+
CustomTableSection,
40+
]
41+
```
42+
43+
## Query optimisation
44+
45+
When it comes to classes inheriting from `TableSection`, you may find a problem with an extraordinary amount of queries executed on changelist pages. This problem has two parts:
46+
47+
1. `TableSection` works with related fields so another query is required to obtain data from the related table
48+
2. The default page size for changelist is configured to 100 - which is a pretty large number of records per page
49+
50+
The easiest solution for this issue is to configure pagination to a smaller amount of records by setting `list_per_page = 20`. While this solution might work for you, it is not optimal.
51+
52+
The optimal solution is using [`prefetch_related`](https://docs.djangoproject.com/en/5.1/ref/models/querysets/#prefetch-related):
53+
54+
1. Install [django-debug-toolbar](https://github.com/django-commons/django-debug-toolbar) and check all SQL queries that are duplicating for each record in the changelist
55+
2. Override `get_queryset` and use `prefetch_related` on all related rows until you don't have any duplicated SQL queries in django-debug-toolbar output
56+
57+
```python
58+
from unfold.admin import ModelAdmin
59+
60+
from .models import SomeModel
61+
62+
63+
@admin.register(SomeModel)
64+
class SomeAdmin(ModelAdmin):
65+
list_per_page = 20 # Quick solution
66+
list_sections = [CustomTableSection]
67+
68+
# Custom queryset prefetching related records
69+
def get_queryset(self, request):
70+
return (
71+
super()
72+
.get_queryset(request)
73+
.prefetch_related(
74+
"related_field_set",
75+
"related_field__another_related_field",
76+
"related_field__another_related_field__even_more_related_field",
77+
)
78+
)
79+
```
80+
81+
## Multiple related tables
82+
83+
Unfold supports multiple related tables in expandable rows. Specify a section class for each related field and put them into `list_sections`. For each class, you can add a `verbose_name` to display a custom title right above the table to distinguish between different related fields.
84+
85+
```python
86+
from unfold.admin import ModelAdmin
87+
88+
from .models import SomeModel
89+
90+
91+
@admin.register(SomeModel)
92+
class SomeAdmin(ModelAdmin):
93+
list_sections = [
94+
CustomTableSection, OtherCustomTable
95+
]
96+
```

src/unfold/sections.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
from django.contrib.admin.utils import label_for_field, lookup_field
2+
from django.db.models import Model
3+
from django.http import HttpRequest
4+
from django.template.loader import render_to_string
5+
6+
from unfold.utils import display_for_field
7+
8+
9+
class BaseSection:
10+
def __init__(self, request: HttpRequest, instance: Model) -> None:
11+
self.request = request
12+
self.instance = instance
13+
14+
def render(self) -> str:
15+
raise NotImplementedError(
16+
"Section subclasses must implement the render method."
17+
)
18+
19+
20+
class TableSection(BaseSection):
21+
fields = []
22+
related_name = None
23+
verbose_name = None
24+
height = None
25+
26+
def render(self) -> str:
27+
results = getattr(self.instance, self.related_name)
28+
headers = []
29+
rows = []
30+
31+
for field_name in self.fields:
32+
if hasattr(self, field_name):
33+
if hasattr(getattr(self, field_name), "short_description"):
34+
headers.append(getattr(self, field_name).short_description)
35+
else:
36+
headers.append(field_name)
37+
else:
38+
headers.append(label_for_field(field_name, results.model))
39+
40+
for result in results.all():
41+
row = []
42+
43+
for field_name in self.fields:
44+
if hasattr(self, field_name):
45+
row.append(getattr(self, field_name)(result))
46+
else:
47+
field, attr, value = lookup_field(field_name, result)
48+
row.append(display_for_field(value, field, "-"))
49+
50+
rows.append(row)
51+
52+
context = {
53+
"request": self.request,
54+
"table": {
55+
"headers": headers,
56+
"rows": rows,
57+
},
58+
}
59+
60+
if hasattr(self, "verbose_name") and self.verbose_name:
61+
context["title"] = self.verbose_name
62+
63+
if hasattr(self, "height") and self.height:
64+
context["height"] = self.height
65+
66+
return render_to_string(
67+
"unfold/components/table.html",
68+
context=context,
69+
)
70+
71+
72+
class TemplateSection(BaseSection):
73+
template_name = None
74+
75+
def render(self) -> str:
76+
return render_to_string(
77+
self.template_name,
78+
context={
79+
"request": self.request,
80+
"instance": self.instance,
81+
},
82+
)

src/unfold/static/unfold/css/styles.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/unfold/templates/admin/change_list_results.html

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
{% load admin_urls i18n %}
1+
{% load admin_urls i18n unfold %}
22

33
{% if result_hidden_fields %}
44
<div class="hiddenfields">
@@ -11,6 +11,10 @@
1111
<table id="result_list" class="block border-base-200 border-spacing-none border-separate w-full lg:table">
1212
<thead>
1313
<tr>
14+
{% if cl.model_admin.list_sections|length > 0 %}
15+
<th></th>
16+
{% endif %}
17+
1418
{% for header in result_headers %}
1519
<th class="align-middle font-semibold py-2 text-left text-font-important-light dark:text-font-important-dark whitespace-nowrap {{ header.class_attrib }} {% if "action-toggle" in header.text and forloop.counter == 1 %}lg:px-3 lg:w-10{% else %}hidden px-3 lg:table-cell{% endif %}" scope="col">
1620
<div class="flex items-center">
@@ -65,8 +69,8 @@
6569
</tr>
6670
</thead>
6771

68-
<tbody class="block lg:table-row-group">
69-
{% for result in results %}
72+
{% for result in results %}
73+
<tbody class="block lg:table-row-group" x-data="{rowOpen: false}">
7074
{% if result.form and result.form.non_field_errors %}
7175
<tr>
7276
<td class="text-left" colspan="{{ result|length }}">
@@ -76,13 +80,37 @@
7680
{% endif %}
7781

7882
<tr class="{% cycle '' 'bg-base-50 dark:bg-white/[.02]' %} block border mb-3 rounded shadow-sm lg:table-row lg:border-none lg:mb-0 lg:shadow-none dark:border-base-800">
83+
{% if cl.model_admin.list_sections|length > 0 %}
84+
<td class="align-middle border-t border-base-200 cursor-pointer pl-3 relative select-none text-center dark:border-base-800 w-0" x-on:click="rowOpen = !rowOpen">
85+
<div class="absolute bg-primary-600 -bottom-px left-0 top-0 w-0.5 z-10" x-show="rowOpen"></div>
86+
<span class="material-symbols-outlined !block -rotate-90 transition-all" x-bind:class="rowOpen && 'rotate-0'">
87+
expand_more
88+
</span>
89+
</td>
90+
{% endif %}
91+
7992
{% for item in result %}
8093
{{ item }}
8194
{% endfor %}
82-
{% include 'unfold/helpers/actions_row.html' with actions=actions_row instance_pk=result.instance_pk %}
95+
96+
{% include 'unfold/helpers/actions_row.html' with actions=actions_row instance_pk=result.instance.pk %}
8397
</tr>
84-
{% endfor %}
85-
</tbody>
98+
99+
{% if cl.model_admin.list_sections|length > 0 %}
100+
<tr x-show="rowOpen">
101+
<td colspan="{{ result|length|add:1 }}" class="bg-base-200/10 border-t border-base-200 relative shadow-inner p-3 dark:border-base-800">
102+
<div class="absolute bg-primary-600 h-full left-0 top-0 w-0.5"></div>
103+
104+
<div class="grid gap-3 {{ cl.model_admin.list_sections_classes }}">
105+
{% for section in cl.model_admin.list_sections %}
106+
{% render_section cl.model_admin.list_sections|index:forloop.counter0 result.instance %}
107+
{% endfor %}
108+
</div>
109+
</td>
110+
</tr>
111+
{% endif %}
112+
</tbody>
113+
{% endfor %}
86114
</table>
87115
</div>
88116
{% else %}

src/unfold/templates/unfold/components/card.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<div class="border flex flex-col flex-grow overflow-hidden p-6 relative rounded shadow-sm dark:border-base-800 {% if class %} {{ class }}{% endif %}">
1+
<div class="bg-white border flex flex-col flex-grow overflow-hidden p-6 relative rounded shadow-sm dark:bg-base-900 dark:border-base-800 {% if class %} {{ class }}{% endif %}">
22
{% if title %}
33
<h2 class="bg-base-50 border-b font-semibold mb-6 -mt-6 -mx-6 py-4 px-6 text-font-important-light dark:text-font-important-dark dark:border-base-800 dark:bg-white/[.02]">
44
{{ title }}
Lines changed: 42 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,47 @@
1-
{% load unfold %}
1+
{% load i18n unfold %}
22

3-
<div class="{% if card_included == 1 %}-m-6{% else %}lg:border lg:rounded lg:shadow-sm{% endif %} overflow-x-auto lg:dark:border-base-800">
4-
<table class="block border-base-200 border-spacing-none border-separate w-full lg:table">
5-
{% if table.headers %}
6-
<thead class="text-base-900 dark:text-base-100">
7-
<tr class="bg-base-50 dark:bg-white/[.02]">
8-
{% for header in table.headers %}
9-
<th class="align-middle font-semibold py-2 text-left text-sm whitespace-nowrap sortable column-description hidden px-3 lg:table-cell {% if card_included == 1 %}first:pl-6 last:pr-6{% endif %}">
10-
{{ header }}
11-
</th>
12-
{% endfor %}
13-
</tr>
14-
</thead>
15-
{% endif %}
3+
<div class="flex flex-col">
4+
{% if title %}
5+
<h3 class="font-semibold mb-1 text-font-important-light text-sm dark:text-font-important-dark">
6+
{{ title }}
7+
</h3>
8+
{% endif %}
9+
10+
<div class="{% if card_included == 1 %}-m-6{% else %} bg-white flex flex-col grow lg:border lg:border-base-200 lg:overflow-hidden lg:rounded lg:shadow-sm{% endif %} lg:dark:border-base-800 dark:bg-base-900">
11+
<div {% if height %} class="min-h-[37px]" style="max-height: {{ height }}px;" data-simplebar{% endif %}>
12+
<table class="block border-spacing-none border-separate w-full lg:table">
13+
{% if table.headers %}
14+
<thead class="text-base-900 dark:text-base-100 {% if height %}sticky top-0{% endif %}">
15+
<tr class="bg-base-50 dark:bg-white/[.02]">
16+
{% for header in table.headers %}
17+
<th class="align-middle border-b border-base-200 font-semibold py-2 text-left text-sm whitespace-nowrap sortable column-description hidden px-3 lg:table-cell dark:border-base-800 {% if card_included == 1 %}first:pl-6 last:pr-6{% endif %}">
18+
{{ header|capfirst }}
19+
</th>
20+
{% endfor %}
21+
</tr>
22+
</thead>
23+
{% endif %}
1624

17-
{% if table.rows %}
18-
<tbody class="block lg:table-row-group">
19-
{% for row in table.rows %}
20-
<tr class="{% if striped == 1 %}{% cycle '' 'bg-base-50 dark:bg-white/[.02]' %}{% endif %} block {% if not card_included == 1 %}border mb-3 rounded shadow-sm{% else %}border-t{% endif %} lg:table-row lg:border-none lg:mb-0 lg:shadow-none dark:border-base-800">
21-
{% for cell in row %}
22-
<td class="px-3 py-2 align-middle flex border-t border-base-200 font-normal gap-4 min-w-0 overflow-hidden text-left before:flex before:capitalize before:content-[attr(data-label)] before:items-center before:mr-auto first:border-t-0 lg:before:hidden lg:first:border-t lg:py-3 lg:table-cell dark:border-base-800 {% if card_included == 1 %}lg:first:pl-6 lg:last:pr-6{% endif %}" {% if table.headers %}data-label="{{ table.headers|index:forloop.counter0 }}"{% endif %}>
23-
{{ cell }}
24-
</td>
25+
{% if table.rows %}
26+
<tbody class="block lg:table-row-group">
27+
{% for row in table.rows %}
28+
<tr class="{% if striped == 1 %}{% cycle '' 'bg-base-50 dark:bg-white/[.02]' %}{% endif %} block group {% if forloop.first %}first-row{% endif %} {% if not card_included == 1 %}border mb-3 rounded shadow-sm{% else %}border-b{% endif %} lg:table-row lg:border-none lg:mb-0 lg:shadow-none dark:border-base-800">
29+
{% for cell in row %}
30+
<td class="px-3 py-2 align-middle flex border-t group-[.first-row]:border-t-0 border-base-200 font-normal gap-4 min-w-0 overflow-hidden text-left before:flex before:capitalize before:content-[attr(data-label)] before:items-center before:mr-auto first:border-t-0 lg:before:hidden lg:first:border-t lg:py-3 lg:table-cell dark:border-base-800 {% if card_included == 1 %}lg:first:pl-6 lg:last:pr-6{% endif %}" {% if table.headers %}data-label="{{ table.headers|index:forloop.counter0 }}"{% endif %}>
31+
{{ cell }}
32+
</td>
33+
{% endfor %}
34+
</tr>
2535
{% endfor %}
26-
</tr>
27-
{% endfor %}
28-
</tbody>
36+
</tbody>
37+
{% endif %}
38+
</table>
39+
</div>
40+
41+
{% if not table.rows %}
42+
<p class="bg-white flex grow items-center justify-center py-2 dark:bg-gray-900">
43+
{% trans "No data" %}
44+
</p>
2945
{% endif %}
30-
</table>
46+
</div>
3147
</div>

src/unfold/templatetags/unfold.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from django import template
55
from django.contrib.admin.helpers import AdminForm, Fieldset
66
from django.contrib.admin.views.main import ChangeList
7+
from django.db.models import Model
78
from django.db.models.options import Options
89
from django.forms import Field
910
from django.http import HttpRequest
@@ -85,6 +86,11 @@ def tab_list(context: RequestContext, page: str, opts: Optional[Options] = None)
8586
)
8687

8788

89+
@register.simple_tag(name="render_section", takes_context=True)
90+
def render_section(context: Context, section_class, instance: Model) -> str:
91+
return section_class(context.request, instance).render()
92+
93+
8894
@register.simple_tag(name="has_nav_item_active")
8995
def has_nav_item_active(items: list) -> bool:
9096
for item in items:
@@ -214,14 +220,17 @@ def render(self, context: RequestContext) -> str:
214220

215221
if "component_class" in values:
216222
values = ComponentRegistry.create_instance(
217-
values["component_class"], request=context.request
223+
values["component_class"],
224+
request=context.request if hasattr(context, "request") else None,
218225
).get_context_data(**values)
219226

220227
if self.include_context:
221228
values.update(context.flatten())
222229

223230
return render_to_string(
224-
self.template_name, request=context.request, context=values
231+
self.template_name,
232+
request=context.request if hasattr(context, "request") else None,
233+
context=values,
225234
)
226235

227236

0 commit comments

Comments
 (0)