diff --git a/docs/configuration/datasets.md b/docs/configuration/datasets.md new file mode 100644 index 00000000..b83e68d7 --- /dev/null +++ b/docs/configuration/datasets.md @@ -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, + ] +``` diff --git a/src/unfold/admin.py b/src/unfold/admin.py index 4e585abb..39d55365 100644 --- a/src/unfold/admin.py +++ b/src/unfold/admin.py @@ -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, ) @@ -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, @@ -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 @@ -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) @@ -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) @@ -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]), @@ -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: @@ -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( @@ -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 diff --git a/src/unfold/datasets.py b/src/unfold/datasets.py new file mode 100644 index 00000000..cec986bf --- /dev/null +++ b/src/unfold/datasets.py @@ -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 diff --git a/src/unfold/forms.py b/src/unfold/forms.py index dacce5d1..948786f9 100644 --- a/src/unfold/forms.py +++ b/src/unfold/forms.py @@ -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 ( @@ -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, ) @@ -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), + } diff --git a/src/unfold/templates/admin/change_form.html b/src/unfold/templates/admin/change_form.html index 6b2cd181..bffa0d06 100644 --- a/src/unfold/templates/admin/change_form.html +++ b/src/unfold/templates/admin/change_form.html @@ -99,6 +99,10 @@ {% endif %} + {% 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 %} diff --git a/src/unfold/templates/admin/search_form.html b/src/unfold/templates/admin/search_form.html index 073f1039..7a3c35e0 100644 --- a/src/unfold/templates/admin/search_form.html +++ b/src/unfold/templates/admin/search_form.html @@ -2,7 +2,7 @@ {% if cl.search_fields %}