Skip to content

Commit 9c71a26

Browse files
authored
feat: datasets (#1607)
1 parent 03f6531 commit 9c71a26

File tree

12 files changed

+289
-19
lines changed

12 files changed

+289
-19
lines changed

docs/configuration/datasets.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
title: Datasets
3+
order: 20
4+
description: Discover how to display Django admin changelists within changeform pages using Datasets. Understand key features like list display, search, sorting and pagination to show related data alongside model forms.
5+
---
6+
7+
Datasets allow you to display Django admin changelists within changeform pages. This is useful when you want to show related data alongside a model's edit form. A Dataset is essentially a specialized ModelAdmin that is not registered with the standard `@admin.register` decorator and displays as a changelist table within another model's changeform page. It can optionally be shown in a tab interface.
8+
9+
Datasets support core changelist functionality including list display fields and links, search, sorting, and pagination. You can also customize the queryset to filter the displayed objects. However, some changelist features are not available in Datasets - list filters, admin actions, and bulk operations are not supported.
10+
11+
When implementing a Dataset, you need to handle permissions explicitly in your queryset. Use the `get_queryset()` method to filter objects based on the current user's permissions, restrict data based on the parent object being edited, and handle the case when creating a new object (no parent exists yet).
12+
13+
```python
14+
# admin.py
15+
16+
from unfold.admin import ModelAdmin
17+
from unfold.datasets import BaseDataset
18+
19+
20+
class SomeDatasetAdmin(ModelAdmin):
21+
search_fields = ["name"]
22+
list_display = ["name", "city", "country", "custom_field"]
23+
list_display_links = ["name", "city"]
24+
list_per_page = 20 # Default: 10
25+
tab = True # Displays as tab. Default: False
26+
# list_filter = [] # Warning: this is not supported
27+
# actions = [] # Warning: this is not supported
28+
29+
def get_queryset(self, request):
30+
# `extra_context` contains current changeform object
31+
obj = self.extra_context.get("object")
32+
33+
# If we are on create object page display no results
34+
if not obj:
35+
return super().get_queryset(request).none()
36+
37+
# If there is a permission requirement, make sure that
38+
# everything is properly handled here
39+
return super().get_queryset(request).filter(
40+
related_field__pk=obj.pk
41+
)
42+
43+
44+
class SomeDataset(BaseDataset):
45+
model = SomeModel
46+
model_admin = SomeDatasetAdmin
47+
48+
49+
class UserAdmin(ModelAdmin):
50+
change_form_datasets = [
51+
SomeDataset,
52+
]
53+
```

src/unfold/admin.py

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from django.contrib.admin import TabularInline as BaseTabularInline
88
from django.contrib.admin import display, helpers
99
from django.contrib.admin.options import InlineModelAdmin
10+
from django.contrib.admin.views import main
11+
from django.contrib.admin.views.main import IGNORED_PARAMS
1012
from django.contrib.contenttypes.admin import (
1113
GenericStackedInline as BaseGenericStackedInline,
1214
)
@@ -23,6 +25,7 @@
2325
from django.views import View
2426

2527
from unfold.checks import UnfoldModelAdminChecks
28+
from unfold.datasets import BaseDataset
2629
from unfold.forms import (
2730
ActionForm,
2831
PaginationGenericInlineFormSet,
@@ -60,6 +63,7 @@ class ModelAdmin(BaseModelAdminMixin, ActionModelAdminMixin, BaseModelAdmin):
6063
change_form_after_template = None
6164
change_form_outer_before_template = None
6265
change_form_outer_after_template = None
66+
change_form_datasets = ()
6367
compressed_fields = False
6468
readonly_preprocess_fields = {}
6569
warn_unsaved_form = False
@@ -96,6 +100,39 @@ def changelist_view(
96100

97101
return super().changelist_view(request, extra_context)
98102

103+
def changeform_view(
104+
self,
105+
request: HttpRequest,
106+
object_id: Optional[str] = None,
107+
form_url: str = "",
108+
extra_context: Optional[dict[str, Any]] = None,
109+
) -> TemplateResponse:
110+
self.request = request
111+
extra_context = extra_context or {}
112+
datasets = self.get_changeform_datasets(request)
113+
114+
# Monkeypatch IGNORED_PARAMS to add dataset page and search arguments into ignored params
115+
ignored_params = []
116+
for dataset in datasets:
117+
ignored_params.append(f"{dataset.model._meta.model_name}-q")
118+
ignored_params.append(f"{dataset.model._meta.model_name}-p")
119+
120+
main.IGNORED_PARAMS = (*IGNORED_PARAMS, *ignored_params)
121+
122+
rendered_datasets = []
123+
for dataset in datasets:
124+
rendered_datasets.append(
125+
dataset(
126+
request=request,
127+
extra_context={
128+
"object": object_id,
129+
},
130+
)
131+
)
132+
133+
extra_context["datasets"] = rendered_datasets
134+
return super().changeform_view(request, object_id, form_url, extra_context)
135+
99136
def get_list_display(self, request: HttpRequest) -> list[str]:
100137
list_display = super().get_list_display(request)
101138

@@ -104,7 +141,9 @@ def get_list_display(self, request: HttpRequest) -> list[str]:
104141

105142
return list_display
106143

107-
def get_fieldsets(self, request: HttpRequest, obj=None) -> FieldsetsType:
144+
def get_fieldsets(
145+
self, request: HttpRequest, obj: Optional[Model] = None
146+
) -> FieldsetsType:
108147
if not obj and self.add_fieldsets:
109148
return self.add_fieldsets
110149
return super().get_fieldsets(request, obj)
@@ -168,7 +207,7 @@ def wrapper(*args, **kwargs):
168207
+ urls
169208
)
170209

171-
def _path_from_custom_url(self, custom_url) -> URLPattern:
210+
def _path_from_custom_url(self, custom_url: tuple[str, str, View]) -> URLPattern:
172211
return path(
173212
custom_url[0],
174213
self.admin_site.admin_view(custom_url[2]),
@@ -177,13 +216,15 @@ def _path_from_custom_url(self, custom_url) -> URLPattern:
177216
)
178217

179218
def get_action_choices(
180-
self, request: HttpRequest, default_choices=BLANK_CHOICE_DASH
181-
):
219+
self,
220+
request: HttpRequest,
221+
default_choices: list[tuple[str, str]] = BLANK_CHOICE_DASH,
222+
) -> list[tuple[str, str]]:
182223
default_choices = [("", _("Select action"))]
183224
return super().get_action_choices(request, default_choices)
184225

185226
@display(description=mark_safe(checkbox.render("action_toggle_all", 1)))
186-
def action_checkbox(self, obj: Model):
227+
def action_checkbox(self, obj: Model) -> str:
187228
return checkbox.render(helpers.ACTION_CHECKBOX_NAME, str(obj.pk))
188229

189230
def response_change(self, request: HttpRequest, obj: Model) -> HttpResponse:
@@ -200,7 +241,7 @@ def response_add(
200241
return redirect(request.GET["next"])
201242
return res
202243

203-
def get_changelist(self, request, **kwargs):
244+
def get_changelist(self, request: HttpRequest, **kwargs: Any) -> ChangeList:
204245
return ChangeList
205246

206247
def get_formset_kwargs(
@@ -214,6 +255,9 @@ def get_formset_kwargs(
214255

215256
return formset_kwargs
216257

258+
def get_changeform_datasets(self, request: HttpRequest) -> list[type[BaseDataset]]:
259+
return self.change_form_datasets
260+
217261

218262
class BaseInlineMixin:
219263
formfield_overrides = FORMFIELD_OVERRIDES_INLINE

src/unfold/datasets.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
from typing import Any, Optional
2+
3+
from django.contrib import admin
4+
from django.http import HttpRequest
5+
from django.template.loader import render_to_string
6+
7+
from unfold.views import DatasetChangeList
8+
9+
10+
class BaseDataset:
11+
tab = False
12+
13+
def __init__(
14+
self, request: HttpRequest, extra_context: Optional[dict[str, Any]]
15+
) -> None:
16+
self.request = request
17+
self.extra_context = extra_context
18+
19+
self.model_admin_instance = self.model_admin(
20+
model=self.model, admin_site=admin.site
21+
)
22+
self.model_admin_instance.extra_context = self.extra_context
23+
24+
@property
25+
def contents(self) -> str:
26+
return render_to_string(
27+
"unfold/helpers/dataset.html",
28+
request=self.request,
29+
context={
30+
"dataset": self,
31+
"cl": self.cl(),
32+
"opts": self.model._meta,
33+
},
34+
)
35+
36+
def cl(self) -> DatasetChangeList:
37+
list_display = self.model_admin_instance.get_list_display(self.request)
38+
list_display_links = self.model_admin_instance.get_list_display_links(
39+
self.request, list_display
40+
)
41+
sortable_by = self.model_admin_instance.get_sortable_by(self.request)
42+
search_fields = self.model_admin_instance.get_search_fields(self.request)
43+
cl = DatasetChangeList(
44+
request=self.request,
45+
model=self.model,
46+
model_admin=self.model_admin_instance,
47+
list_display=list_display,
48+
list_display_links=list_display_links,
49+
list_filter=[],
50+
date_hierarchy=[],
51+
search_fields=search_fields,
52+
list_select_related=[],
53+
list_per_page=10,
54+
list_max_show_all=False,
55+
list_editable=[],
56+
sortable_by=sortable_by,
57+
search_help_text=[],
58+
)
59+
cl.formset = None
60+
61+
return cl
62+
63+
@property
64+
def model_name(self) -> str:
65+
return self.model._meta.model_name
66+
67+
@property
68+
def model_verbose_name(self) -> str:
69+
return self.model._meta.verbose_name_plural

src/unfold/forms.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from collections.abc import Generator
2-
from typing import Optional, Union
2+
from typing import Any, Optional, Union
33

44
from django import forms
55
from django.contrib.admin.forms import (
@@ -8,6 +8,7 @@
88
from django.contrib.admin.forms import (
99
AdminPasswordChangeForm as BaseAdminOwnPasswordChangeForm,
1010
)
11+
from django.contrib.admin.views.main import ChangeListSearchForm
1112
from django.contrib.auth.forms import (
1213
AdminPasswordChangeForm as BaseAdminPasswordChangeForm,
1314
)
@@ -240,3 +241,14 @@ class PaginationInlineFormSet(PaginationFormSetMixin, BaseInlineFormSet):
240241

241242
class PaginationGenericInlineFormSet(PaginationFormSetMixin, BaseGenericInlineFormSet):
242243
pass
244+
245+
246+
class DatasetChangeListSearchForm(ChangeListSearchForm):
247+
def __init__(self, *args: Any, **kwargs: Any) -> None:
248+
super().__init__(*args, **kwargs)
249+
250+
from django.contrib.admin.views.main import SEARCH_VAR
251+
252+
self.fields = {
253+
SEARCH_VAR: forms.CharField(required=False, strip=False),
254+
}

src/unfold/templates/admin/change_form.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,10 @@
9999
{% endif %}
100100
</form>
101101

102+
{% for dataset in datasets %}
103+
{{ dataset.contents }}
104+
{% endfor %}
105+
102106
{% if adminform.model_admin.change_form_outer_after_template %}
103107
{% include adminform.model_admin.change_form_outer_after_template %}
104108
{% endif %}

src/unfold/templates/admin/search_form.html

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
{% if cl.search_fields %}
44
<div id="toolbar">
5-
<form id="changelist-search" method="get" role="search" x-data="searchForm()">
5+
<form {% if not cl.is_dataset %}id="changelist-search" x-data="searchForm()"{% endif %} method="get" role="search">
66
<div class="bg-white border border-base-200 flex flex-row items-center px-3 rounded-default relative shadow-xs w-full focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-primary-600 lg:w-96 dark:bg-base-900 dark:border-base-700">
77
<button type="submit" class="flex items-center focus:outline-hidden" id="searchbar-submit">
88
<span class="material-symbols-outlined md-18 text-base-400 dark:text-base-500">search</span>
@@ -14,10 +14,12 @@
1414
class="grow font-medium min-w-0 overflow-hidden p-2 placeholder-font-subtle-light truncate focus:outline-hidden dark:bg-base-900 dark:placeholder-font-subtle-dark dark:text-font-default-dark"
1515
name="{{ search_var }}"
1616
value="{{ cl.query }}"
17-
id="searchbar"
17+
{% if not cl.is_dataset %}id="searchbar"{% endif %}
1818
placeholder="{% if cl.search_help_text %}{{ cl.search_help_text }}{% else %}{% trans "Type to search" %}{% endif %}" />
1919

20-
{% include "unfold/helpers/shortcut.html" with shortcut="/" %}
20+
{% if not cl.is_dataset %}
21+
{% include "unfold/helpers/shortcut.html" with shortcut="/" %}
22+
{% endif %}
2123
</div>
2224

2325
{% for pair in cl.filter_params.items %}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{% load admin_list unfold_list %}
2+
3+
<div {% if cl.model_admin.tab %}x-show="activeTab == 'dataset-{{ dataset.model_name }}'"{% endif %}>
4+
{% if not cl.model_admin.tab %}
5+
<h2 class="inline-heading bg-base-100 font-semibold mb-6 px-4 py-3 rounded-default text-font-important-light @min-[1570px]:-mx-4 dark:bg-white/[.02] dark:text-font-important-dark">
6+
{{ dataset.model_verbose_name|capfirst }}
7+
</h2>
8+
{% endif %}
9+
10+
{% if cl.search_fields %}
11+
<div class="flex flex-col gap-4 mb-4 sm:flex-row empty:hidden lg:border lg:border-base-200 lg:dark:border-base-800 lg:-mb-8 lg:p-3 lg:pb-11 lg:rounded-t-default">
12+
{% unfold_search_form cl %}
13+
</div>
14+
{% endif %}
15+
16+
{% unfold_result_list cl %}
17+
18+
{% pagination cl %}
19+
</div>

src/unfold/templates/unfold/helpers/empty_results.html

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
{% url cl.opts|admin_urlname:"add" as add_url %}
44
{% blocktranslate with name=cl.opts.verbose_name asvar title %}Add {{ name }}{% endblocktranslate %}
55

6-
<div class="bg-white border border-base-200 flex flex-col items-center px-8 py-24 rounded-default shadow-xs dark:bg-base-900 dark:border-base-800">
7-
<div class="border border-base-300 border-dashed flex h-24 items-center justify-center mb-8 rounded-full w-24 dark:border-base-700">
8-
<span class="material-symbols-outlined text-base-500 text-5xl! dark:text-base-400">inbox</span>
9-
</div>
6+
<div class="bg-white border border-base-200 flex flex-col items-center px-8 shadow-xs dark:bg-base-900 dark:border-base-800 {% if cl.search_fields %}rounded-b-default{% else %}rounded-default{% endif %} {% if cl.is_dataset %}py-16{% else %}py-24{% endif %}">
7+
{% if not cl.is_dataset %}
8+
<div class="border border-base-300 border-dashed flex h-24 items-center justify-center mb-8 rounded-full w-24 dark:border-base-700">
9+
<span class="material-symbols-outlined text-base-500 text-5xl! dark:text-base-400">inbox</span>
10+
</div>
11+
{% endif %}
1012

1113
<h2 class="font-semibold mb-1 text-xl text-font-important-light tracking-tight dark:text-font-important-dark">
1214
{% trans "No results found" %}

src/unfold/templates/unfold/helpers/tab_items.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@
2424
{% endif %}
2525
</a>
2626
{% endfor %}
27+
28+
{% for dataset in datasets_list %}
29+
<a href="#dataset-{{ dataset.model_name }}" x-on:click="activeTab = 'dataset-{{ dataset.model_name }}'" x-bind:class="{'active': activeTab == 'dataset-{{ dataset.model_name }}'}">
30+
{{ dataset.model_verbose_name|capfirst }}
31+
</a>
32+
{% endfor %}
2733
{% endif %}
2834
</nav>
2935
{% endif %}

src/unfold/templatetags/unfold.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ def _get_tabs_list(
6565
@register.simple_tag(name="tab_list", takes_context=True)
6666
def tab_list(context: RequestContext, page: str, opts: Optional[Options] = None) -> str:
6767
inlines_list = []
68-
68+
datasets_list = []
6969
data = {
7070
"nav_global": context.get("nav_global"),
7171
"actions_detail": context.get("actions_detail"),
@@ -85,6 +85,13 @@ def tab_list(context: RequestContext, page: str, opts: Optional[Options] = None)
8585
if len(inlines_list) > 0:
8686
data["inlines_list"] = inlines_list
8787

88+
for dataset in context.get("datasets", []):
89+
if dataset and hasattr(dataset, "tab"):
90+
datasets_list.append(dataset)
91+
92+
if len(datasets_list) > 0:
93+
data["datasets_list"] = datasets_list
94+
8895
return render_to_string(
8996
"unfold/helpers/tab_list.html",
9097
request=context["request"],

0 commit comments

Comments
 (0)