Skip to content
Open
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
35 changes: 35 additions & 0 deletions docs/inlines/nested.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
title: Nested inlines
order: 5
description:
---

# Nested inlines

Nested inlines provide a way to display hierarchical relationships between related objects within the inlines sections of the changeform page. This feature allows you to visualize and manage parent-child relationships directly in the Django admin interface.

To implement nested inlines, simply use the `inlines` property which takes a list of inline classes as its value. You have the flexibility to use either the standard `TabularInline` or `StackedInline` classes without any additional configuration requirements.

Within the nested hierarchy, you can freely combine both `StackedInline` and `TabularInline` components to create a mixed layout structure.

```python
from unfold.admin import ModelAdmin, TabularInline, StackedInline


class ProjectAdmin(ModelAdmin):
inlines = [TaskInline]


class TaskInline(TabularInline):
inlines = [SubTaskInline]


class SubTaskInline(TabularInline):
inlines = [AnotherInline] # This is not going to work
```


## Limitations

- Nested inlines support only one level of nesting. You cannot create more than two levels of nested inlines.
- Nesting through many-to-many (M2M) relationships is not supported.
22 changes: 17 additions & 5 deletions src/unfold/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@
PaginationGenericInlineFormSet,
PaginationInlineFormSet,
)
from unfold.mixins import ActionModelAdminMixin, BaseModelAdminMixin
from unfold.mixins import (
ActionModelAdminMixin,
BaseModelAdminMixin,
NestedInlinesModelAdminMixin,
)
from unfold.overrides import FORMFIELD_OVERRIDES_INLINE
from unfold.typing import FieldsetsType
from unfold.views import ChangeList
Expand All @@ -43,7 +47,12 @@
)


class ModelAdmin(BaseModelAdminMixin, ActionModelAdminMixin, BaseModelAdmin):
class ModelAdmin(
BaseModelAdminMixin,
ActionModelAdminMixin,
NestedInlinesModelAdminMixin,
BaseModelAdmin,
):
action_form = ActionForm
custom_urls = ()
add_fieldsets = ()
Expand All @@ -65,11 +74,14 @@ class ModelAdmin(BaseModelAdminMixin, ActionModelAdminMixin, BaseModelAdmin):

@property
def media(self):
if not hasattr(self, "request"):
return super().media

media = super().media

if hasattr(self, "nested_formset_media"):
media += self.nested_formset_media

if not hasattr(self, "request"):
return media

for filter in self.get_list_filter(self.request):
if (
isinstance(filter, (tuple, list))
Expand Down
7 changes: 6 additions & 1 deletion src/unfold/mixins/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
from unfold.mixins.action_model_admin import ActionModelAdminMixin
from unfold.mixins.base_model_admin import BaseModelAdminMixin
from unfold.mixins.nested_inlines_model_admin import NestedInlinesModelAdminMixin

__all__ = ["BaseModelAdminMixin", "ActionModelAdminMixin"]
__all__ = [
"BaseModelAdminMixin",
"ActionModelAdminMixin",
"NestedInlinesModelAdminMixin",
]
248 changes: 248 additions & 0 deletions src/unfold/mixins/nested_inlines_model_admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
from typing import Any, Optional

from django.contrib.admin import options
from django.contrib.admin.helpers import InlineAdminFormSet
from django.contrib.admin.options import InlineModelAdmin
from django.db.models import Model
from django.forms import BaseInlineFormSet, Media, ModelForm
from django.http import HttpRequest, HttpResponse
from django.utils.translation import gettext_lazy as _


def nested_all_valid(formsets: list[BaseInlineFormSet]) -> bool:
validation_result = all(formset.is_valid() for formset in formsets)

for formset in formsets:
for form in formset:
if not hasattr(form, "nested_formsets"):
continue

for nested_formset in form.nested_formsets:
if not nested_formset.formset.is_valid():
return False

if (
nested_formset.formset.has_changed()
and hasattr(form, "cleaned_data")
and len(form.cleaned_data) == 0
and form.instance.pk is None
):
form.add_error(
None, _("You can not create nested object without parent")
)
return False

return validation_result


class NestedInlinesModelAdminMixin:
# Build custom media for all nested formsets and process it
# later in media property in ModelAdmin
nested_formset_media = Media()

def _create_formsets(
self, request: HttpRequest, obj: Optional[Model] = None, change: bool = False
) -> tuple[list[BaseInlineFormSet], list[InlineModelAdmin]]:
formsets, inline_instances = super()._create_formsets(request, obj, change)

self._build_nested_formsets(request, obj, formsets, inline_instances, change)

return formsets, inline_instances

def changeform_view(
self,
request: HttpRequest,
object_id: Optional[str] = None,
form_url: str = "",
extra_context: Optional[dict[str, Any]] = None,
) -> HttpResponse:
# Monkey patch all_valid to do nested formsets validation. Applied because
# we don't want to completely override `BaseModelAdmin._changeform_view()`
options.all_valid = nested_all_valid
return super().changeform_view(request, object_id, form_url, extra_context)

def save_formset(
self,
request: HttpRequest,
form: ModelForm,
formset: BaseInlineFormSet,
change: bool,
) -> None:
super().save_formset(request, form, formset, change)

for form in formset.forms:
if not hasattr(form, "nested_formsets"):
continue

if form in formset.deleted_forms:
continue

for nested_formset in form.nested_formsets:
self.save_formset(request, form, nested_formset.formset, change)

def _build_nested_formsets(
self,
request: HttpRequest,
obj: Model,
formsets: list[BaseInlineFormSet],
inline_instances: list[InlineModelAdmin],
change: bool,
) -> None:
from unfold.admin import StackedInline, TabularInline

for formset, inline in zip(formsets, inline_instances):
# Existing forms in formset
for form in formset.forms:
nested_formsets = []

if not hasattr(inline, "inlines"):
continue

for inline_class in inline.inlines:
inline_formset = self._get_nested_formset(
request, obj, form, inline, inline_class, change
)

if not inline_formset:
continue

if issubclass(inline_class, StackedInline):
inline_formset.inline_type = "stacked"
elif issubclass(inline_class, TabularInline):
inline_formset.inline_type = "tabular"

nested_formsets.append(inline_formset)
self.nested_formset_media += inline_formset.media

form.nested_formsets = nested_formsets

# Add nested forms to template form in formsets
if (
hasattr(formset, "empty_form")
and hasattr(inline, "inlines")
and inline.has_add_permission(request, obj)
):
formset.form.nested_formsets = []

for inline_class in inline.inlines:
inline_formset = self._get_nested_formset(
request, obj, formset.empty_form, inline, inline_class, change
)

if not inline_formset:
continue

if issubclass(inline_class, StackedInline):
inline_formset.inline_type = "stacked"
elif issubclass(inline_class, TabularInline):
inline_formset.inline_type = "tabular"

formset.form.nested_formsets.append(inline_formset)
self.nested_formset_media += inline_formset.media

def _get_nested_formset(
self,
request: HttpRequest,
obj: Model,
form: ModelForm,
parent_inline: InlineModelAdmin,
inline_class: type[InlineModelAdmin],
change: bool,
) -> Optional[InlineAdminFormSet]:
inline = inline_class(parent_inline.model, self.admin_site)

if not self._check_nested_inline_permissions(request, inline, obj):
return None

if not inline.has_add_permission(request, obj):
inline.max_num = 0

InlineFormSet = inline.get_formset(request, form.instance)

prefix = f"{form.prefix}-{InlineFormSet.get_default_prefix()}"
formset_params = self.get_formset_kwargs(request, obj, inline, prefix)

formset_params.update(
{
"instance": form.instance,
"prefix": prefix,
}
)
inline_formset = InlineFormSet(**formset_params)

# Bypass validation of each view-only inline form (since the form's
# data won't be in request.POST), unless the form was deleted.
if not inline.has_change_permission(request, obj if change else None):
for index, form in enumerate(inline_formset.initial_forms):
if self._user_deleted_form(prefix, request, inline, obj, index):
continue
form._errors = {}
form.cleaned_data = form.initial

return InlineAdminFormSet(
inline=inline,
formset=inline_formset,
model_admin=self.opts,
fieldsets=list(inline.get_fieldsets(request, obj)),
prepopulated_fields=dict(inline.get_prepopulated_fields(request, obj)),
readonly_fields=list(inline.get_readonly_fields(request, obj)),
**self._nested_inline_permissions(request, inline, inline_formset, obj),
)

def _check_nested_inline_permissions(
self,
request: HttpRequest,
inline: InlineModelAdmin,
obj: Optional[Model] = None,
) -> bool:
if not (
inline.has_view_or_change_permission(request, obj)
or inline.has_add_permission(request, obj)
or inline.has_delete_permission(request, obj)
):
return False

return True

def _user_deleted_form(
self,
prefix: str,
request: HttpRequest,
inline: InlineModelAdmin,
obj: Model,
index: int,
) -> bool:
return (
inline.has_delete_permission(request, obj)
and f"{prefix}-{index}-DELETE" in request.POST
)

def _nested_inline_permissions(
self,
request: HttpRequest,
inline: InlineModelAdmin,
inline_formset: BaseInlineFormSet,
obj: Model,
) -> dict[str, bool]:
can_edit_parent = (
self.has_change_permission(request, obj)
if obj
else self.has_add_permission(request)
)

if can_edit_parent:
has_add_permission = inline.has_add_permission(request, obj)
has_change_permission = inline.has_change_permission(request, obj)
has_delete_permission = inline.has_delete_permission(request, obj)
else:
has_add_permission = has_change_permission = has_delete_permission = False
inline_formset.extra = inline_formset.max_num = 0

has_view_permission = inline.has_view_permission(request, obj)

return {
"has_add_permission": has_add_permission,
"has_change_permission": has_change_permission,
"has_delete_permission": has_delete_permission,
"has_view_permission": has_view_permission,
}
Loading