Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions docs/configuration/datasets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
title: Datasets
order: 20
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.
---

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.

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.

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

```python
# admin.py

from unfold.admin import ModelAdmin
from unfold.datasets import BaseDataset


class SomeDatasetAdmin(ModelAdmin):
search_fields = ["name"]
list_display = ["name", "city", "country", "custom_field"]
list_display_links = ["name", "city"]
list_per_page = 20 # Default: 10
tab = True # Displays as tab. Default: False
# list_filter = [] # Warning: this is not supported
# actions = [] # Warning: this is not supported

def get_queryset(self, request):
# `extra_context` contains current changeform object
obj = self.extra_context.get("object")

# If we are on create object page display no results
if not obj:
return super().get_queryset(request).none()

# If there is a permission requirement, make sure that
# everything is properly handled here
return super().get_queryset(request).filter(
related_field__pk=obj.pk
)


class SomeDataset(BaseDataset):
model = SomeModel
model_admin = SomeDatasetAdmin


class UserAdmin(ModelAdmin):
change_form_datasets = [
SomeDataset,
]
```
56 changes: 50 additions & 6 deletions src/unfold/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from django.contrib.admin import TabularInline as BaseTabularInline
from django.contrib.admin import display, helpers
from django.contrib.admin.options import InlineModelAdmin
from django.contrib.admin.views import main
from django.contrib.admin.views.main import IGNORED_PARAMS
from django.contrib.contenttypes.admin import (
GenericStackedInline as BaseGenericStackedInline,
)
Expand All @@ -23,6 +25,7 @@
from django.views import View

from unfold.checks import UnfoldModelAdminChecks
from unfold.datasets import BaseDataset
from unfold.forms import (
ActionForm,
PaginationGenericInlineFormSet,
Expand Down Expand Up @@ -60,6 +63,7 @@ class ModelAdmin(BaseModelAdminMixin, ActionModelAdminMixin, BaseModelAdmin):
change_form_after_template = None
change_form_outer_before_template = None
change_form_outer_after_template = None
change_form_datasets = ()
compressed_fields = False
readonly_preprocess_fields = {}
warn_unsaved_form = False
Expand Down Expand Up @@ -96,6 +100,39 @@ def changelist_view(

return super().changelist_view(request, extra_context)

def changeform_view(
self,
request: HttpRequest,
object_id: Optional[str] = None,
form_url: str = "",
extra_context: Optional[dict[str, Any]] = None,
) -> TemplateResponse:
self.request = request
extra_context = extra_context or {}
datasets = self.get_changeform_datasets(request)

# Monkeypatch IGNORED_PARAMS to add dataset page and search arguments into ignored params
ignored_params = []
for dataset in datasets:
ignored_params.append(f"{dataset.model._meta.model_name}-q")
ignored_params.append(f"{dataset.model._meta.model_name}-p")

main.IGNORED_PARAMS = (*IGNORED_PARAMS, *ignored_params)

rendered_datasets = []
for dataset in datasets:
rendered_datasets.append(
dataset(
request=request,
extra_context={
"object": object_id,
},
)
)

extra_context["datasets"] = rendered_datasets
return super().changeform_view(request, object_id, form_url, extra_context)

def get_list_display(self, request: HttpRequest) -> list[str]:
list_display = super().get_list_display(request)

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

return list_display

def get_fieldsets(self, request: HttpRequest, obj=None) -> FieldsetsType:
def get_fieldsets(
self, request: HttpRequest, obj: Optional[Model] = None
) -> FieldsetsType:
if not obj and self.add_fieldsets:
return self.add_fieldsets
return super().get_fieldsets(request, obj)
Expand Down Expand Up @@ -168,7 +207,7 @@ def wrapper(*args, **kwargs):
+ urls
)

def _path_from_custom_url(self, custom_url) -> URLPattern:
def _path_from_custom_url(self, custom_url: tuple[str, str, View]) -> URLPattern:
return path(
custom_url[0],
self.admin_site.admin_view(custom_url[2]),
Expand All @@ -177,13 +216,15 @@ def _path_from_custom_url(self, custom_url) -> URLPattern:
)

def get_action_choices(
self, request: HttpRequest, default_choices=BLANK_CHOICE_DASH
):
self,
request: HttpRequest,
default_choices: list[tuple[str, str]] = BLANK_CHOICE_DASH,
) -> list[tuple[str, str]]:
default_choices = [("", _("Select action"))]
return super().get_action_choices(request, default_choices)

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

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

def get_changelist(self, request, **kwargs):
def get_changelist(self, request: HttpRequest, **kwargs: Any) -> ChangeList:
return ChangeList

def get_formset_kwargs(
Expand All @@ -214,6 +255,9 @@ def get_formset_kwargs(

return formset_kwargs

def get_changeform_datasets(self, request: HttpRequest) -> list[type[BaseDataset]]:
return self.change_form_datasets


class BaseInlineMixin:
formfield_overrides = FORMFIELD_OVERRIDES_INLINE
Expand Down
69 changes: 69 additions & 0 deletions src/unfold/datasets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
from typing import Any, Optional

from django.contrib import admin
from django.http import HttpRequest
from django.template.loader import render_to_string

from unfold.views import DatasetChangeList


class BaseDataset:
tab = False

def __init__(
self, request: HttpRequest, extra_context: Optional[dict[str, Any]]
) -> None:
self.request = request
self.extra_context = extra_context

self.model_admin_instance = self.model_admin(
model=self.model, admin_site=admin.site
)
self.model_admin_instance.extra_context = self.extra_context

@property
def contents(self) -> str:
return render_to_string(
"unfold/helpers/dataset.html",
request=self.request,
context={
"dataset": self,
"cl": self.cl(),
"opts": self.model._meta,
},
)

def cl(self) -> DatasetChangeList:
list_display = self.model_admin_instance.get_list_display(self.request)
list_display_links = self.model_admin_instance.get_list_display_links(
self.request, list_display
)
sortable_by = self.model_admin_instance.get_sortable_by(self.request)
search_fields = self.model_admin_instance.get_search_fields(self.request)
cl = DatasetChangeList(
request=self.request,
model=self.model,
model_admin=self.model_admin_instance,
list_display=list_display,
list_display_links=list_display_links,
list_filter=[],
date_hierarchy=[],
search_fields=search_fields,
list_select_related=[],
list_per_page=10,
list_max_show_all=False,
list_editable=[],
sortable_by=sortable_by,
search_help_text=[],
)
cl.formset = None

return cl

@property
def model_name(self) -> str:
return self.model._meta.model_name

@property
def model_verbose_name(self) -> str:
return self.model._meta.verbose_name_plural
14 changes: 13 additions & 1 deletion src/unfold/forms.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from collections.abc import Generator
from typing import Optional, Union
from typing import Any, Optional, Union

from django import forms
from django.contrib.admin.forms import (
Expand All @@ -8,6 +8,7 @@
from django.contrib.admin.forms import (
AdminPasswordChangeForm as BaseAdminOwnPasswordChangeForm,
)
from django.contrib.admin.views.main import ChangeListSearchForm
from django.contrib.auth.forms import (
AdminPasswordChangeForm as BaseAdminPasswordChangeForm,
)
Expand Down Expand Up @@ -240,3 +241,14 @@ class PaginationInlineFormSet(PaginationFormSetMixin, BaseInlineFormSet):

class PaginationGenericInlineFormSet(PaginationFormSetMixin, BaseGenericInlineFormSet):
pass


class DatasetChangeListSearchForm(ChangeListSearchForm):
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)

from django.contrib.admin.views.main import SEARCH_VAR

self.fields = {
SEARCH_VAR: forms.CharField(required=False, strip=False),
}
4 changes: 4 additions & 0 deletions src/unfold/templates/admin/change_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@
{% endif %}
</form>

{% for dataset in datasets %}
{{ dataset.contents }}
{% endfor %}

{% if adminform.model_admin.change_form_outer_after_template %}
{% include adminform.model_admin.change_form_outer_after_template %}
{% endif %}
Expand Down
8 changes: 5 additions & 3 deletions src/unfold/templates/admin/search_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

{% if cl.search_fields %}
<div id="toolbar">
<form id="changelist-search" method="get" role="search" x-data="searchForm()">
<form {% if not cl.is_dataset %}id="changelist-search" x-data="searchForm()"{% endif %} method="get" role="search">
<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">
<button type="submit" class="flex items-center focus:outline-hidden" id="searchbar-submit">
<span class="material-symbols-outlined md-18 text-base-400 dark:text-base-500">search</span>
Expand All @@ -14,10 +14,12 @@
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"
name="{{ search_var }}"
value="{{ cl.query }}"
id="searchbar"
{% if not cl.is_dataset %}id="searchbar"{% endif %}
placeholder="{% if cl.search_help_text %}{{ cl.search_help_text }}{% else %}{% trans "Type to search" %}{% endif %}" />

{% include "unfold/helpers/shortcut.html" with shortcut="/" %}
{% if not cl.is_dataset %}
{% include "unfold/helpers/shortcut.html" with shortcut="/" %}
{% endif %}
</div>

{% for pair in cl.filter_params.items %}
Expand Down
19 changes: 19 additions & 0 deletions src/unfold/templates/unfold/helpers/dataset.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{% load admin_list unfold_list %}

<div {% if cl.model_admin.tab %}x-show="activeTab == 'dataset-{{ dataset.model_name }}'"{% endif %}>
{% if not cl.model_admin.tab %}
<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">
{{ dataset.model_verbose_name|capfirst }}
</h2>
{% endif %}

{% if cl.search_fields %}
<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">
{% unfold_search_form cl %}
</div>
{% endif %}

{% unfold_result_list cl %}

{% pagination cl %}
</div>
10 changes: 6 additions & 4 deletions src/unfold/templates/unfold/helpers/empty_results.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
{% url cl.opts|admin_urlname:"add" as add_url %}
{% blocktranslate with name=cl.opts.verbose_name asvar title %}Add {{ name }}{% endblocktranslate %}

<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">
<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">
<span class="material-symbols-outlined text-base-500 text-5xl! dark:text-base-400">inbox</span>
</div>
<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 %}">
{% if not cl.is_dataset %}
<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">
<span class="material-symbols-outlined text-base-500 text-5xl! dark:text-base-400">inbox</span>
</div>
{% endif %}

<h2 class="font-semibold mb-1 text-xl text-font-important-light tracking-tight dark:text-font-important-dark">
{% trans "No results found" %}
Expand Down
6 changes: 6 additions & 0 deletions src/unfold/templates/unfold/helpers/tab_items.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@
{% endif %}
</a>
{% endfor %}

{% for dataset in datasets_list %}
<a href="#dataset-{{ dataset.model_name }}" x-on:click="activeTab = 'dataset-{{ dataset.model_name }}'" x-bind:class="{'active': activeTab == 'dataset-{{ dataset.model_name }}'}">
{{ dataset.model_verbose_name|capfirst }}
</a>
{% endfor %}
{% endif %}
</nav>
{% endif %}
9 changes: 8 additions & 1 deletion src/unfold/templatetags/unfold.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ def _get_tabs_list(
@register.simple_tag(name="tab_list", takes_context=True)
def tab_list(context: RequestContext, page: str, opts: Optional[Options] = None) -> str:
inlines_list = []

datasets_list = []
data = {
"nav_global": context.get("nav_global"),
"actions_detail": context.get("actions_detail"),
Expand All @@ -85,6 +85,13 @@ def tab_list(context: RequestContext, page: str, opts: Optional[Options] = None)
if len(inlines_list) > 0:
data["inlines_list"] = inlines_list

for dataset in context.get("datasets", []):
if dataset and hasattr(dataset, "tab"):
datasets_list.append(dataset)

if len(datasets_list) > 0:
data["datasets_list"] = datasets_list

return render_to_string(
"unfold/helpers/tab_list.html",
request=context["request"],
Expand Down
Loading