Developer & AI Agent Reference
July 16, 2026 · View on GitHub
This document provides key patterns and gotchas for developers and AI assistants working with django-smartbase-admin.
Table of Contents
| Section | What it covers |
|---|---|
| Demo Schema Reference | Models used in all examples (Article, Category, Tag, Author, Comment) |
| SBAdminField | Defining list columns, annotations, supporting_annotates, admin methods, ordering with computed fields, sbadmin_list_display_data, inline cell editing (tabulator_editor / per_cell_editable_field) |
| Configuration | INSTALLED_APPS, role config, menu items, queryset restrictions, custom permissions |
| Filter Widgets | Built-in widgets, custom filters, filter_query_lambda for M2M filtering |
| Form Widgets | input prefix/suffix text and icon buttons, SBAdminTextTagsWidget, Meta.widgets initialization, required select placeholders, SBAdminJsonEditorWidget for schema-driven JSON, SBAdminPermissionWidget for collapsible permission tree |
| Standalone Form Views | Full SBAdmin pages backed by a regular Django form instead of a model change view |
| Dynamic Regions | HTMX-refreshed form regions, trigger fields, active fields, inactive field policies, modal usage, custom templates |
| Admin Registration | @admin.register with sb_admin_site, sbadmin_list_filter vs list_filter |
Full-text search (search_fields) | How search_fields maps SBAdmin names to ORM lookups and how to avoid duplicate rows |
| Selection Actions | Modal forms for bulk operations, ListActionModalView, confirmation modals, SBAdminCustomAction params, per-action permissions, success/error handling |
| Row Actions | Per-row icon buttons with SBAdminRowAction, RowActionModalView, and row-aware enablement |
| Field Formatters | Badge formatters, array_badge_formatter, BadgeType options, automatic choice formatting |
| XLSX Export Field Formatting | Per-column Excel cell formats via XLSXFieldOptions.cell_format (named, dict, or SBAdminXLSXFormat) |
| View on Site link in list | List column with "View on site" icon via admin method, redirect view, view_on_site_link_formatter |
| Performance Optimization | Subquery patterns, ArrayAgg, avoiding N+1 queries |
| Common Errors | Frequent errors and solutions |
| Inlines | SBAdminTableInline, SBAdminStackedInline for related models |
| Validated Singleton Inline Creation on Add | Why default-only singleton inlines can be skipped and how SBAdmin creates them during add |
| Fake Inlines | Show related models without a real Django FK to the parent — cross-database, unmanaged, or indirect relationships |
| Global Autocomplete Widget Customization | label_lambda, search_query_lambda, dependent dropdowns, subclassing for computed values |
| Pre-filtered List Views | Tab-based filtered views with sbadmin_list_view_config, default tab from menu, programmatic URL building |
| Nested List View | One-level self-referential tree rendering via Tabulator dataTree with TabulatorNestedPlugin |
| Tree Widget & Tree List View | Multi-level tree rendering for django-treebeard MP_Node models — form widget, filter widget, actions/tree_list.html |
| List View Plugins | Protocol for reshaping the list pipeline globally — queryset hooks, per-request state, Tabulator definition |
| List-View AJAX Notifications | Surface Django messages from list-action requests via the standard notification slot; fail-soft pattern for the list query |
| Fieldset Options | Supported keys for fieldsets / sbadmin_fieldsets, including descriptions, classes, dynamic regions, and collapse |
| Detail View Layout (Sidebar) | Placing fieldsets in the right sidebar using DETAIL_STRUCTURE_RIGHT_CLASS |
| Detail View Tabs | Organizing fieldsets and inlines into tabs with sbadmin_tabs |
| Dashboard Widgets | Standalone dashboards: simple widgets, lightweight subwidgets, real subwidgets with separate or grouped AJAX |
| Detail View Widgets | Embedding dashboard-style list/chart widgets inside detail fieldsets |
| Logo Customization | Override logo via static files |
URL-Callable Action Methods (@sbadmin_action) | @sbadmin_action decorator for URL-callable view methods |
| SBAdmin Attribute Reference | Quick reference for all sbadmin_ prefixed attributes |
| Audit Logging | Built-in audit trail — installation, configuration, skip models/fields, history button, programmatic entries, programmatic URLs |
| Messaging | Built-in in-app messaging — install, attachment upload path/storage settings, messaging_config (types/audiences/poller), inbox vs management views, menu wiring, custom recipient audiences, contributing |
| Internationalization | Locale workflow, makemessages.py, compilemessages.py, JS translation strings |
| MCP (AI agents) | Optional MCP server: install, host wiring, Cursor config |
| Testing | How to install test dependencies, run tests, and add new tests |
| SBAdminWizardView | Multi-step wizard with SBAdminWizardStep — attributes, lifecycle, formsets, navigation, template |
Return Navigation (back_url) | Back/Save returns to the originating page via SBAdminViewService, back_url query param + hidden field |
| Contributing to This Document | Guidelines for adding new sections and examples |
Quick lookup:
- Adding a column? → SBAdminField
- When should I set
filter_field? → When to setfilter_field - Extra data for formatters? → sbadmin_list_display_data
- XLSX custom formatting per column? → XLSX Export Field Formatting
- Filtering by related model? → Filter Widgets (filter_query_lambda)
- Comma-separated tags input? → Form Widgets
- Copy-to-clipboard text input? → Input prefix and suffix
- Schema-driven JSON editor (array/object editing)? →
SBAdminJsonEditorWidget - Collapsible permission tree for Groups? →
SBAdminPermissionWidget - Full admin page with a non-model form? → Standalone Form Views
- Show/hide fields based on another form field? → Dynamic Regions
- Refresh choices or layout without reloading the whole form? → Dynamic Regions
- Dynamic form sections in stacked inlines? → Dynamic regions in stacked inlines
- Dynamic form sections in action/row modals? → Dynamic regions in action modals
- Dynamic regions in custom form views? → Dynamic regions in custom views
- Bulk action with modal? → Selection Actions
- Full-text search config? → Full-text search (
search_fields) - Per-row icon action? → Row Actions
- Confirmation dialog (no form)? → Confirmation-Only Modals
- Per-action permissions? → Per-Action Permissions
- Manual audit log entries? → Programmatic Audit Entries
- N+1 query issues? → Performance Optimization
- Autocomplete customization? → Global Autocomplete Widget Customization
- Ordering by computed field? → Ordering with Computed SBAdminField
- Menu opens filtered tab? → Default Tab and Menu Link to Filtered View
- Building pre-filtered URLs? → Building Pre-filtered URLs Programmatically
- Tree / parent-child rows in list view (one level, self-FK)? → Nested List View
- Multi-level tree / django-treebeard
MP_Node? → Tree Widget & Tree List View - Pick parent from a tree in a form? →
SBAdminTreeWidgetas a form widget - Filter a list by tree node? →
SBAdminTreeFilterWidget - Reshape list queryset / Tabulator options globally? → List View Plugins
- Show a Django message from a list-view request? → List-View AJAX Notifications
- Fail-soft empty response from a list view? → Failing Soft on Errors
- Collapse a form fieldset? → Fieldset Options
- Fields in sidebar? → Detail View Layout (Sidebar)
- Fieldsets/inlines in tabs? → Detail View Tabs
- Building a dashboard page? → Dashboard Widgets
- One AJAX call for several dashboard widgets? → Real Subwidgets With One AJAX
- List or chart inside detail page? → Detail View Widgets
- Custom permission system (non-Django)? → Custom Permission System
- Audit trail / change history? → Audit Logging
- Send in-app messages / notifications to users? → Messaging
- Custom message recipient group? → Recipient audiences
- Render a single value as a badge? → Single-Value Badge
- Regenerate translations? → Internationalization
- Wire MCP / Cursor? → MCP (AI agents)
- “View on site” icon next to a list column? → View on Site link in list
- Required singleton inline not created on add? → Validated Singleton Inline Creation on Add
- Making a method URL-callable? → URL-Callable Action Methods (
@sbadmin_action) - What permission does my custom action require? → Permission defaults
- Inline for model without FK to parent? → Fake Inlines
- Cross-database or unmanaged inline? → Fake Inlines
- Multiple inlines for the same model? → Fake Inlines
Demo Schema Reference
Examples throughout this document use a consistent CMS-style schema:
# blog/models.py
class Author(models.Model):
name = models.CharField(max_length=100)
email = models.EmailField()
is_active = models.BooleanField(default=True)
class Category(models.Model):
name = models.CharField(max_length=100)
parent = models.ForeignKey("self", null=True, blank=True, on_delete=models.CASCADE)
class Tag(models.Model):
name = models.CharField(max_length=50)
class Article(models.Model):
title = models.CharField(max_length=200)
status = models.CharField(max_length=20) # draft, published, archived
category = models.ForeignKey(Category, on_delete=models.CASCADE)
author = models.ForeignKey(Author, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
class ArticleTag(models.Model):
"""M2M junction table for Article <-> Tag."""
article = models.ForeignKey(Article, on_delete=models.CASCADE, related_name="article_tags")
tag = models.ForeignKey(Tag, on_delete=models.CASCADE)
class Comment(models.Model):
article = models.ForeignKey(Article, on_delete=models.CASCADE, related_name="comments")
text = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
class ArticleMeta(models.Model):
"""Singleton metadata row for each Article."""
article = models.OneToOneField(Article, on_delete=models.CASCADE, related_name="meta")
heading = models.CharField(max_length=200, default="Metadata")
description = models.TextField(blank=True, default="")
SBAdminField - List Display Columns
Basic Usage
from django_smartbase_admin.engine.field import SBAdminField
class ArticleAdmin(SBAdmin):
sbadmin_list_display = (
"title", # Simple field reference
SBAdminField(name="status_display", ...), # Custom field with options
)
Key Parameters
| Parameter | Type | Description |
|---|---|---|
name | str | Required. Field identifier - can reference a model field OR an admin method |
title | str | Column header label |
annotate | Expression | Django ORM expression (F, Concat, Case, etc.) |
supporting_annotates | dict | Additional annotations passed to admin method |
filter_field | str | ORM lookup the filter widget should target. Defaults to name — only set it to point the filter at a different column than the one displayed. See When to set filter_field |
filter_widget | FilterWidget | Custom filter widget |
filter_disabled | bool | Disable filtering for this field |
python_formatter | callable | Format value: (obj_id, value) -> formatted_value |
list_visible | bool | Show/hide column in list |
tabulator_options | TabulatorFieldOptions | Per-column Tabulator settings (width, grow, max, custom SBAdmin options) |
tabulator_editor | str | Tabulator editor type ("number", "input", …). Makes the column editable inline. See Inline cell editing |
per_cell_editable_field | str | Name of a per-row boolean in the row data that gates editability per cell (used only together with tabulator_editor). See Inline cell editing |
Tabulator Options (table)
| Option | Type | Description |
|---|---|---|
sbadminKeepDataWidth | bool | Keep column natural width (prevent stretch) when using fitDataFillAvailableSpace. Best for icon/utility columns. |
from django_smartbase_admin.engine.field import SBAdminField, TabulatorFieldOptions
sbadmin_list_display = (
SBAdminField(
name="id",
tabulator_options=TabulatorFieldOptions(sbadminKeepDataWidth=True),
),
)
Inline cell editing
A list column can be edited in place (Tabulator cell editor) instead of only through a change form. The pieces:
-
tabulator_editoron theSBAdminField— the Tabulator editor type ("number","input","select", …). This is what makes the column editable; it is emitted as the column'seditor. With it set and nothing else, every cell in the column is editable. -
Enable the
dataEditModuleon the view's tabulator definition. It is not in the default module set, so add it:def get_tabulator_definition(self, request): definition = super().get_tabulator_definition(request) definition["modules"].append("dataEditModule") return definitionThe module does two things: installs the per-cell
editablegate (see below) and wirescellEdited → POSTto theaction_table_data_editaction. -
Implement
table_data_edit_form_validon the view. The inheritedaction_table_data_editvalidates the request withTableDataEditFormand calls this hook with the bound form. Itscleaned_datacontainscurrentRowId(already decoded from JSON),columnFieldName, andcellValue. Persist the change and return anHttpResponse, commonly withrender_notifications(request)(optionally with anHX-Trigger: SBAdminReloadTableDataheader viatrigger_client_eventto refresh the table after the edit). Overridetable_data_edit_form_invalid(request, form, object_id)to customize the default HTTP 400 response.
from django.http import HttpResponse
from django_smartbase_admin.utils import render_notifications
class MyAdmin(SBAdmin):
sbadmin_list_display = (SBAdminField(name="qty", tabulator_editor="number"),)
def get_tabulator_definition(self, request):
definition = super().get_tabulator_definition(request)
definition["modules"].append("dataEditModule")
return definition
def table_data_edit_form_valid(self, request, form, object_id):
row_id = form.cleaned_data["currentRowId"]
column = form.cleaned_data["columnFieldName"]
value = form.cleaned_data["cellValue"]
... # authorize + persist
return HttpResponse(status=200, content=render_notifications(request))
Per-cell editability — per_cell_editable_field
tabulator_editor is all-or-nothing per column. To make editability vary per row, set
per_cell_editable_field to the name of a per-row boolean present in the row data. The
dataEditModule turns it into a Tabulator editable callback
((cell) => Boolean(row.getData()[field])): the cell opens its editor only when that
boolean is truthy.
from django.db.models import BooleanField, Case, Q, Value, When
SBAdminField(
name="qty",
tabulator_editor="number",
per_cell_editable_field="qty_editable",
# the boolean must be selectable from the row data — provide it as a supporting annotate
supporting_annotates={
"qty_editable": Case(
When(~Q(status="locked"), then=Value(True)),
default=Value(False),
output_field=BooleanField(),
)
},
)
Rules and gotchas:
per_cell_editable_fieldneedstabulator_editor. With no editor there is nothing to open, so the gate does nothing on its own.- The named field must exist in the row data. A missing field reads as
undefined→Boolean(undefined)→false→ the cell is read-only. Provide it viasupporting_annotates(orsbadmin_list_display_data). Such non-column keys are preserved through_strip_to_visible_keysspecifically because a column references them viaper_cell_editable_field. - Needs the
dataEditModule. Both theeditablegate (viamodifyTabulatorOptions) and the save pipeline live in that module. - Override
table_data_edit_form_valid, not the action. This keeps the inherited form validation and MCP component metadata. The form restrictscolumnFieldNameto fields that declaretabulator_editor. - Customize form responses through the dedicated hooks. Override
table_data_edit_form_invalidwhen the default HTTP 400 response is not suitable. - Editing an ungated cell still POSTs to
action_table_data_edit; enforce real authorization/validation there too — the client gate is UX only.
tabulator_editor | per_cell_editable_field | Result |
|---|---|---|
| set | unset | Every cell in the column is editable. |
| set | set | Editable only where the row boolean is truthy. |
| unset | set | Nothing editable (the gate has no editor to act on). |
| unset | unset | Plain read-only column. |
When to set filter_field
filter_field defaults to name and that default is almost always what you want. Set it only when the column displays one value but the filter should target a different ORM path:
# ✅ Annotated column, filter via the underlying FK.
SBAdminField(
name="author_display",
annotate=F("author__name"),
filter_field="author",
filter_widget=AutocompleteFilterWidget(model=Author),
)
If an SBAdminField points at an admin method that uses @admin.display(ordering=...)
and the field is filterable, always set filter_field explicitly. The
ordering value is for sorting, not a reliable substitute for filter wiring.
Without an explicit filter_field, pre-filtered list-view config such as the
"All" tab can be built with the method name instead of the ORM lookup.
# ❌ BAD - filter key may become "author_display" before field initialization.
SBAdminField(
name="author_display",
annotate=F("author__name"),
filter_widget=AutocompleteFilterWidget(model=Author),
)
@admin.display(description=_("Author"), ordering="author")
def author_display(self, obj_id, value, **kwargs):
return value
# ✅ GOOD - filter key is always the intended ORM lookup.
SBAdminField(
name="author_display",
annotate=F("author__name"),
filter_field="author",
filter_widget=AutocompleteFilterWidget(model=Author),
)
@admin.display(description=_("Author"), ordering="author")
def author_display(self, obj_id, value, **kwargs):
return value
If an SBAdminField points at an admin method that uses @admin.display(ordering=...)
and the field is filterable, always set filter_field explicitly. The
ordering value is for sorting, not a reliable substitute for filter wiring.
Without an explicit filter_field, pre-filtered list-view config such as the
"All" tab can be built with the method name instead of the ORM lookup.
# ❌ BAD - filter key may become "shipper_image" before field initialization.
SBAdminField(
name="shipper_image",
annotate=F("shipper__shortcut"),
filter_widget=AutocompleteFilterWidget(model=Shipper),
)
@admin.display(description=_("Dopravca"), ordering="shipper")
def shipper_image(self, obj_id, value, **kwargs):
return ShipperService.format_image(value)
# ✅ GOOD - filter key is always the intended ORM lookup.
SBAdminField(
name="shipper_image",
annotate=F("shipper__shortcut"),
filter_field="shipper",
filter_widget=AutocompleteFilterWidget(model=Shipper),
)
@admin.display(description=_("Dopravca"), ordering="shipper")
def shipper_image(self, obj_id, value, **kwargs):
return ShipperService.format_image(value)
Gotchas:
- Two
SBAdminFields must not resolve to the samefilter_field— they'd render form inputs with the samename/idand JS only reaches the first. Caught statically assbadmin.W001. - Custom widgets that hardcode their
Q(...)(i.e. ignoreself.field.filter_field) shouldn't setfilter_fieldat all — it would only create a collision risk without affecting the actual filter. filter_fieldis the key insbadmin_list_view_config["url_params"]["filterData"], notSBAdminField.name. A mismatched key silently disables the filter on load and produces a spurious*on the tab. Caught statically assbadmin.W002.
Admin Methods (like Django admin)
Define a method on your admin class with the same name as the SBAdminField.name:
class ArticleAdmin(SBAdmin):
def status_display(self, obj_id, value, **additional_data):
"""
Auto-discovered method (name matches SBAdminField.name).
Receives: self, obj_id, annotated value, supporting_annotates as kwargs.
"""
category_name = additional_data.get("category_name_val")
return f"{value} - {category_name}"
sbadmin_list_display = (
SBAdminField(
name="status_display", # Same as method name - auto-discovered
annotate=F("status"),
supporting_annotates={"category_name_val": F("category__name")},
),
)
Why supporting_annotates?
The supporting_annotates approach provides two key performance benefits:
-
Lazy loading: When a column is hidden/deselected by the user, all its related annotations (including
supporting_annotates) are automatically excluded from the query. This prevents unnecessary database computation. -
Shared queryset: Filters operate on the same queryset as the list display. Annotations defined in
supporting_annotatescan be used for filtering while keeping the filtering and display logic cohesive.
Annotation Name Conflicts
CRITICAL: Annotation keys in supporting_annotates must NOT match model field names!
# ❌ BAD - 'created_at' conflicts with model field
supporting_annotates={"created_at": F("created_at")}
# ✅ GOOD - Use a different key name
supporting_annotates={"created_at_val": F("created_at")}
supporting_annotates Requires Expressions
CRITICAL: Values in supporting_annotates must be Django ORM expressions (like F()), NOT plain strings!
# ❌ BAD - Causes "QuerySet.annotate() received non-expression(s)" error
supporting_annotates={
"author_name": "author__name",
"category_name": "category__name",
}
# ✅ GOOD - Use F() expressions
from django.db.models import F
supporting_annotates={
"author_name_val": F("author__name"),
"category_name_val": F("category__name"),
}
This applies even when referencing simple model fields - always wrap field names in F().
Mixed Types in Expressions
When using Concat, Coalesce, or Case, always specify output_field:
# ❌ BAD - FieldError: Expression contains mixed types
Concat(F("author__name"), Value(" <"), F("author__email"), Value(">"))
# ✅ GOOD - Explicit output_field on all parts
from django.db.models import TextField
Concat(
Coalesce(F("author__name"), Value(""), output_field=TextField()),
Value(" <", output_field=TextField()),
Coalesce(F("author__email"), Value(""), output_field=TextField()),
Value(">", output_field=TextField()),
output_field=TextField(),
)
Ordering with Computed SBAdminField
Problem: You have a computed field in sbadmin_list_display (with annotate=) and want to use it in the ordering attribute:
from django.db.models import Case, TextField, Value, When
from blog.models import Article
# ❌ BAD - causes FieldError on detail/change page
@admin.register(Article, site=sb_admin_site)
class ArticleAdmin(SBAdmin):
sbadmin_list_display = (
SBAdminField(
name="status_display",
title="Status",
annotate=Case(
When(status="published", then=Value("Published")),
When(status="draft", then=Value("Draft")),
default=Value("Other"),
output_field=TextField(),
),
),
)
ordering = ("status_display", "-created_at") # Fails on detail page!
Why it fails: Django's ordering attribute is applied via get_queryset() which is called for BOTH list and detail views. The computed annotation only exists in the list view context (via sbadmin_list_display). When loading the detail/change page, Django tries to resolve status_display as a database field but it doesn't exist.
Solution: Override get_list_ordering() which is only used for the list view:
from django.db.models import Case, TextField, Value, When
from blog.models import Article
# ✅ GOOD - get_list_ordering is only called for list view
@admin.register(Article, site=sb_admin_site)
class ArticleAdmin(SBAdmin):
sbadmin_list_display = (
SBAdminField(
name="status_display",
title="Status",
annotate=Case(
When(status="published", then=Value("Published")),
When(status="draft", then=Value("Draft")),
default=Value("Other"),
output_field=TextField(),
),
),
)
# No class-level `ordering` attribute!
def get_list_ordering(self, request):
"""Define ordering for list view - can use computed fields."""
return ("status_display", "-created_at")
Key points:
- SBAdmin uses
get_list_ordering()for list view ordering (not Django'sget_ordering()) get_list_ordering()is only called for the list view, so computed fields fromsbadmin_list_displayare available- No need to check for detail/change pages - this method is list-view specific
- For simple ordering without computed fields, you can still use the
orderingclass attribute
sbadmin_list_display_data - Extra Data Fields
Use sbadmin_list_display_data to ensure data is always fetched, even when the user hides a column that provides it.
Problem: Column A provides data via supporting_annotates. Column B needs that data. User hides Column A → data is no longer fetched → Column B breaks.
Solution: List the data in sbadmin_list_display_data to ensure it's always available.
class ArticleAdmin(SBAdmin):
sbadmin_list_display = (
SBAdminField(
name="author_display", # User CAN hide this column
title="Author",
annotate=F("author__name"),
supporting_annotates={
"author_id_val": F("author_id"),
},
),
SBAdminField(
name="author_link", # This column needs author_id_val
title="Profile",
annotate=Value("", output_field=TextField()),
),
)
# Ensure author_id_val is ALWAYS fetched, even if "Author" column is hidden
sbadmin_list_display_data = ("author_id_val",)
def author_link(self, obj_id, value, **additional_data):
author_id = additional_data.get("author_id_val")
return format_html('<a href="/authors/{}">View</a>', author_id)
Key points:
- Use when another column depends on data from a column that can be hidden
- Use for model fields needed in formatters (e.g.,
author_idfor links) - Use for hidden data-only fields defined with
list_visible=False supporting_annotatesare only fetched when their parent column is visible - use this to force fetch
For data-only fields (hidden columns with annotations), define the field and reference it:
class ArticleAdmin(SBAdmin):
sbadmin_list_display = (
SBAdminField(
name="computed_score",
annotate=F("views") + F("comments_count"),
list_visible=False, # Hidden column
),
# ... other visible columns
)
# Reference the hidden field to include it in the queryset
sbadmin_list_display_data = ("computed_score",)
Configuration
INSTALLED_APPS Setup
Add django_smartbase_admin and its dependencies to your INSTALLED_APPS. Important: django_smartbase_admin must be listed AFTER any apps that register model admins, to ensure those admins are registered before the configuration is initialized.
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"easy_thumbnails",
"widget_tweaks",
"ckeditor",
"ckeditor_uploader",
"blog", # Your app with model admins - BEFORE django_smartbase_admin
"django_smartbase_admin", # MUST be last (or after apps with model admins)
]
Required Setup
- Settings:
SB_ADMIN_CONFIGURATION = "blog.sbadmin_config.SBAdminConfiguration" - URLs: Include
sb_admin_site.urls - Config Class: Implement
get_configuration_for_roles
from django_smartbase_admin.engine.configuration import SBAdminConfigurationBase, SBAdminRoleConfiguration
from django_smartbase_admin.engine.menu_item import SBAdminMenuItem
from django_smartbase_admin.views.dashboard_view import SBAdminDashboardView
_role_config = SBAdminRoleConfiguration(
default_view=SBAdminMenuItem(view_id="dashboard"),
menu_items=[
SBAdminMenuItem(label="Dashboard", icon="All-application", view_id="dashboard"),
SBAdminMenuItem(label="Articles", icon="Box", view_id="blog_article"),
],
registered_views=[
SBAdminDashboardView(widgets=[], title="Dashboard"),
],
)
class SBAdminConfiguration(SBAdminConfigurationBase):
def get_configuration_for_roles(self, user_roles):
return _role_config
Note: Use registered_views with SBAdminDashboardView to register custom views. Model admin views (like blog_article) are automatically discovered from the admin site registry when django_smartbase_admin loads (which is why the INSTALLED_APPS ordering matters).
Supported Versions
Package metadata currently targets:
- Django
>= 4.2, < 7.0 - Python
3.10through3.14
Menu Item View IDs
Format: {app_label}_{model_name} (lowercase)
Example: blog.models.Article → view_id="blog_article"
Menu Item Icons
Icons are SVG sprites from the static/sb_admin/sprites/sb_admin/ directory. Use the filename without extension:
Available Icons:
Accept-email, Ad-product, Add-one, Add-picture, Add-three, Aiming, All-application,
Alphabetical-sorting, Alphabetical-sorting-two, Application, Application-menu,
Application-two, Arrow-circle-down, Arrow-circle-left, Arrow-circle-right,
Arrow-circle-up, At-sign, Attention, Back-one, Bank-card, Bank-card-one, Bolt-one,
Bookmark, Box, Calendar, Camera, Caution, Check, Check-correct, Check-one,
Check-one-filled, Check-small, Close, Close-one, Close-small, Column,
Corner-up-left, Corner-up-right, Cut, Cylinder, Delete, Delete-two, Double-down,
Double-left, Double-right, Double-up, Down, Down-c, Down-small, Download,
Download-one, Drag, Edit, Electric-drill, Excel-one, Export, Figma-component,
Filter, Find, Fire-extinguisher, Gas, Go-ahead, Go-on, Hamburger-button,
Headset-one, Help, Home, Id-card-h, Info, Left, Left-c, Left-small,
Left-small-down, Left-small-up, Lightning, Lightning-fill, Like, Link-two,
List-checkbox, Lock, Login, Logout, Magic, Magic-wand, Mail, Mail-download,
Mail-open, Message-emoji, Message-one, Minus, Minus-the-top, Moon, More,
More-one, More-three, More-two, Paperclip, Parallel-gateway, People-top-card,
Percentage, Phone-telephone, Picture-one, Pin, Pin-filled, Plus, Preview-close,
Preview-close-one, Preview-open, Printer, Pull, Pushpin, Reduce-one, Refresh-one,
Return, Star-2, Star-2-filled, Right, Right-c, Right-small, Right-small-down,
Right-small-up, Save, Search, Send-email, Setting-config, Setting-two, Shop,
Shopping, Shopping-bag, Shopping-cart-one, Sort, Sort-amount-down, Sort-amount-up,
Sort-one, Sort-three, Sort-alt, Star, Success, Sun-one, Switch, Table-report,
Tag, Tag-one, Thumbs-down, Thumbs-down-filled, Thumbs-up, Thumbs-up-filled,
Time, Tips-one, To-top, Transfer-data, Translate, Translation,
Triangle-round-rectangle, Truck, Undo, Unlock, Up, Up-c, Up-small, Upload,
Upload-one, User-business, View-grid-list, Write, Writing-fluently-filled,
Zoom-in, Zoom-out
Example:
SBAdminMenuItem(label="Dashboard", icon="All-application", view_id="dashboard"),
SBAdminMenuItem(label="Authors", icon="User-business", view_id="blog_author"),
SBAdminMenuItem(label="Articles", icon="Box", view_id="blog_article"),
SBAdminMenuItem(label="Settings", icon="Setting-config", view_id="blog_settings"),
Nested Menu Items (sub_items)
Use sub_items to create nested/dropdown menu sections:
_role_config = SBAdminRoleConfiguration(
default_view=SBAdminMenuItem(view_id="dashboard"),
menu_items=[
SBAdminMenuItem(label="Dashboard", icon="All-application", view_id="dashboard"),
SBAdminMenuItem(
label="Content",
icon="Box",
sub_items=[
SBAdminMenuItem(label="Articles", view_id="blog_article"),
SBAdminMenuItem(label="Categories", view_id="blog_category"),
SBAdminMenuItem(label="Tags", view_id="blog_tag"),
],
),
SBAdminMenuItem(
label="People",
icon="User-business",
sub_items=[
SBAdminMenuItem(label="Authors", view_id="blog_author"),
SBAdminMenuItem(label="Comments", view_id="blog_comment"),
],
),
],
registered_views=[
SBAdminDashboardView(widgets=[], title="Dashboard"),
],
)
Key points:
- Parent menu items with
sub_itemsdon't need aview_id(they act as dropdown containers) - Child items in
sub_itemstypically don't need icons (parent icon is shown) - Nested items automatically highlight when their view is active
Global Queryset Filtering
Override restrict_queryset on SBAdminRoleConfiguration to apply global filters for specific models across all views. For reusable filtering logic, extract it to a separate module:
# blog/queryset_restrictions.py
from blog.models import Article, Author
def apply_model_restrictions(qs):
"""Apply global queryset restrictions based on queryset's model."""
if qs.model == Article:
qs = qs.filter(status__in=["published", "draft"])
elif qs.model == Author:
qs = qs.filter(is_active=True)
return qs
# blog/sbadmin_config.py
from django_smartbase_admin.engine.configuration import SBAdminConfigurationBase, SBAdminRoleConfiguration
from django_smartbase_admin.engine.menu_item import SBAdminMenuItem
from django_smartbase_admin.views.dashboard_view import SBAdminDashboardView
from blog.queryset_restrictions import apply_model_restrictions
class BlogRoleConfiguration(SBAdminRoleConfiguration):
"""Role configuration with queryset restrictions."""
def restrict_queryset(self, qs, model, request, request_data, global_filter=True, global_filter_data_map=None):
"""Apply global queryset restrictions."""
return apply_model_restrictions(qs)
_role_config = BlogRoleConfiguration(
default_view=SBAdminMenuItem(view_id="dashboard"),
menu_items=[
SBAdminMenuItem(label="Dashboard", icon="All-application", view_id="dashboard"),
SBAdminMenuItem(label="Articles", icon="Box", view_id="blog_article"),
],
registered_views=[
SBAdminDashboardView(widgets=[], title="Dashboard"),
],
)
class SBAdminConfiguration(SBAdminConfigurationBase):
def get_configuration_for_roles(self, user_roles):
return _role_config
Key points:
- Extract restriction logic to a separate module (e.g.,
queryset_restrictions.py) to avoid circular imports - Use
qs.modelto check the model type - simpler than passing model as a separate parameter - Override
restrict_querysetonSBAdminRoleConfigurationsubclass to call your restriction function - Import
apply_model_restrictionsdirectly in filter widgets and subqueries:apply_model_restrictions(Article.objects.all())
User-Based Queryset Filtering (Thread-Local Request)
For user-based filtering (e.g., restricting data by user permissions or tenant), use SBAdminThreadLocalService to access the current request when it's not passed directly. This is useful when apply_model_restrictions is called from filter widgets or subqueries where request isn't available as a parameter.
# blog/queryset_restrictions.py
from django_smartbase_admin.services.thread_local import SBAdminThreadLocalService
from blog.models import Article, Author
def _get_current_request(request=None):
"""Get request from parameter or thread-local storage."""
if request is not None:
return request
try:
return SBAdminThreadLocalService.get_request()
except LookupError:
return None
def apply_model_restrictions(qs, request=None):
"""Apply global queryset restrictions based on queryset's model.
For models requiring user-based filtering:
- admin users: see all records
- restricted users: see only records matching their allowed scope
- no request available: return empty queryset (fail-safe)
"""
if qs.model == Article:
current_request = _get_current_request(request)
if current_request is None:
return qs.none() # Fail-safe: no access without request
user = getattr(current_request, "user", None)
if user is None:
return qs.none() # Fail-safe: no access without user
qs = qs.filter(status__in=["published", "draft"])
# Example: filter by user's allowed categories
allowed_categories = getattr(user, "allowed_categories", None)
if allowed_categories is not None:
qs = qs.filter(category__in=allowed_categories)
elif qs.model == Author:
qs = qs.filter(is_active=True).order_by("name")
return qs
# blog/sbadmin_config.py
class BlogRoleConfiguration(SBAdminRoleConfiguration):
def restrict_queryset(self, qs, model, request, request_data, global_filter=True, global_filter_data_map=None):
"""Apply global queryset restrictions, passing request for user-based filtering."""
return apply_model_restrictions(qs, request=request)
Key points:
SBAdminThreadLocalService.get_request()returns the current request stored in thread-local/context-var storage- Raises
LookupErrorif no request is set (e.g., outside of a request context) - Fail-safe pattern: Return
qs.none()when request or user is unavailable to prevent data leakage - Pass
requestexplicitly fromrestrict_querysetwhen available; the function falls back to thread-local when called from filter widgets or subqueries - User object should have properties like
allowed_categoriesthat return filtering criteria (orNonefor full access)
Custom Permission System (has_permission)
By default, SBAdminRoleConfiguration.has_permission() uses Django's built-in model permissions (user.has_perm("app.view_model"), etc.). If your project uses a different permission system (e.g., external IAM, JWT claims, session-based permissions), you can override has_permission() on your role configuration subclass to replace this behavior globally.
How the permission chain works:
Every permission check in SBAdmin flows through the role configuration:
SBAdminBaseView.has_permission()
→ SBAdminViewService.has_permission()
→ SBAdminRoleConfiguration.has_permission() ← override this
This covers all SBAdmin-routed views: admin list/detail views, inlines, autocomplete widgets, and custom actions. By overriding has_permission() on the configuration, you get a single entry point for your custom permission logic. Since SBAdmin uses a fully custom menu (via SBAdminMenuItem), Django's admin index page is not used, so has_module_permission() does not need to be overridden.
Example — session-based permissions:
# myapp/sbadmin_config.py
from django_smartbase_admin.engine.configuration import SBAdminConfigurationBase, SBAdminRoleConfiguration
from django_smartbase_admin.engine.menu_item import SBAdminMenuItem
from django_smartbase_admin.views.dashboard_view import SBAdminDashboardView
PERM_ADMIN = "admin"
PERM_ACCESS = "access"
# Map model names to required permissions (beyond PERM_ACCESS)
MODEL_PERMISSIONS = {
"article": [], # Only PERM_ACCESS needed
"comment": ["moderator"], # Needs PERM_ACCESS + moderator
}
DEFAULT_PERMISSIONS = ["editor"] # Fallback for unlisted models
def _get_session_permissions(request) -> set[str]:
"""Get permissions from session (populated at login by your auth backend)."""
return set(request.session.get("permissions", []))
def has_model_permission(request, model_name: str) -> bool:
"""Check if user has permission to access a specific model."""
permissions = _get_session_permissions(request)
if PERM_ADMIN in permissions:
return True # Admin bypasses all checks
if PERM_ACCESS not in permissions:
return False # No base access
additional = MODEL_PERMISSIONS.get(model_name.lower(), DEFAULT_PERMISSIONS)
if not additional:
return True # Only PERM_ACCESS required
return any(perm in permissions for perm in additional)
class AppRoleConfiguration(SBAdminRoleConfiguration):
"""Role configuration with custom permission system."""
def has_permission(
self, request, request_data, view, model=None, obj=None, permission=None
):
"""Replace Django model permissions with custom session-based permissions.
This is called for all permission checks routed through SBAdmin:
admin views, inlines, autocomplete widgets, and custom actions.
"""
if not request.user.is_authenticated:
return False
if model:
return has_model_permission(request, model._meta.model_name)
return True
_role_config = AppRoleConfiguration(
default_view=SBAdminMenuItem(view_id="dashboard"),
menu_items=[
SBAdminMenuItem(label="Dashboard", icon="All-application", view_id="dashboard"),
SBAdminMenuItem(label="Articles", icon="Box", view_id="blog_article"),
],
registered_views=[
SBAdminDashboardView(widgets=[], title="Dashboard"),
],
)
class SBAdminConfiguration(SBAdminConfigurationBase):
def get_configuration_for_roles(self, user_roles):
return _role_config
Key points:
- Override
has_permission()on yourSBAdminRoleConfigurationsubclass — this is the single central hook for all permission checks (views, inlines, autocomplete widgets, actions) - The method signature is:
has_permission(self, request, request_data, view, model=None, obj=None, permission=None) - SBAdmin uses a custom menu (
SBAdminMenuItem), so Django'shas_module_permission()is irrelevant — you do not need to override it - Inlines (
SBAdminTableInline,SBAdminStackedInline) don't need permission overrides — their checks go through the configuration'shas_permission() - The default implementation checks
request.user.has_perm()— your override completely replaces this, so Django model permissions are not consulted at all
SBAdminRoleConfiguration — Overridable Methods Summary
SBAdminRoleConfiguration is the central place to customize SBAdmin behavior. All overrides go on a single subclass:
| Method | Purpose | Documented in |
|---|---|---|
has_permission() | Replace Django model permissions with custom system | Custom Permission System |
has_action_permission() | Decide which Django permission a URL-callable action needs (default: "change", unless @sbadmin_action(permission=...) is set) | Permission defaults |
restrict_queryset() | Apply global queryset filters (e.g., hide soft-deleted records) | Global Queryset Filtering |
get_autocomplete_widget() | Customize autocomplete labels, search, and dependent dropdowns | Global Autocomplete Widget Customization |
enable_url_compression | Toggle compression for ?params= and _changelist_filters payloads. Default True; set False to emit plain JSON in URLs. Decoding accepts both formats. | URL Params Compression Toggle |
mcp_readonly | Make MCP requests read-only for non-superusers. Default False; set True to block MCP add/change/delete and mutating actions while leaving browser admin writes unchanged. | MCP Read-Only Toggle |
mcp_whoami_sbadmin | Tell MCP which change view is the current user's own profile. | Current User / Whoami |
URL Params Compression Toggle
SBAdminRoleConfiguration.enable_url_compression controls whether SBAdmin compresses URL params payloads with LZ-String.
_role_config = SBAdminRoleConfiguration(
default_view=SBAdminMenuItem(view_id="dashboard"),
menu_items=[...],
registered_views=[...],
enable_url_compression=False, # default is True
)
Key points:
True(default): shorter URL payloads using LZ-String.False: plain JSON payloads in URLs (useful for debugging or compatibility checks).- Decoding remains backward compatible in both modes (plain JSON and compressed payloads are both accepted).
MCP Read-Only Toggle
SBAdminRoleConfiguration.mcp_readonly controls whether MCP requests can mutate data. It is off by default. When enabled, requests marked with request.is_mcp deny add, change, and delete model permissions plus any custom action whose resolved permission is not "view". Superusers bypass the read-only gate.
_role_config = SBAdminRoleConfiguration(
default_view=SBAdminMenuItem(view_id="dashboard"),
menu_items=[...],
registered_views=[...],
mcp_readonly=True, # default is False
)
class AppRoleConfiguration(SBAdminRoleConfiguration):
mcp_readonly = True
Key points:
- Browser/admin UI requests are unchanged; only MCP requests (
request.is_mcp) are gated. - Read-only custom actions must declare
permission="view"on@sbadmin_action(...)orSBAdminCustomAction(...). - Mutating custom actions default to
"change"if no permission is declared. - The gate runs in
SBAdminViewService.has_permission()before project-specifichas_permission()overrides, so a custom permission system cannot accidentally bypass it.
Typical subclass combining all three:
# myapp/sbadmin_config.py
from django_smartbase_admin.admin.widgets import SBAdminAutocompleteWidget
from django_smartbase_admin.engine.configuration import SBAdminConfigurationBase, SBAdminRoleConfiguration
from django_smartbase_admin.engine.menu_item import SBAdminMenuItem
from django_smartbase_admin.views.dashboard_view import SBAdminDashboardView
from myapp.models import Author
from myapp.permissions import has_model_permission
from myapp.queryset_restrictions import apply_model_restrictions, author_label, author_search
class AppRoleConfiguration(SBAdminRoleConfiguration):
"""Role configuration with custom permissions, queryset restrictions, and autocomplete widgets."""
def has_permission(self, request, request_data, view, model=None, obj=None, permission=None):
if not request.user.is_authenticated:
return False
if model:
return has_model_permission(request, model._meta.model_name)
return True
def restrict_queryset(self, qs, model, request, request_data, global_filter=True, global_filter_data_map=None):
return apply_model_restrictions(qs, request=request)
def get_autocomplete_widget(self, view, request, form_field, db_field, model, multiselect=False):
if model == Author:
return SBAdminAutocompleteWidget(
form_field,
model=model,
multiselect=multiselect,
label_lambda=author_label,
search_query_lambda=author_search,
)
return super().get_autocomplete_widget(view, request, form_field, db_field, model, multiselect)
_role_config = AppRoleConfiguration(
default_view=SBAdminMenuItem(view_id="dashboard"),
menu_items=[
SBAdminMenuItem(label="Dashboard", icon="All-application", view_id="dashboard"),
SBAdminMenuItem(label="Articles", icon="Box", view_id="blog_article"),
],
registered_views=[
SBAdminDashboardView(widgets=[], title="Dashboard"),
],
)
class SBAdminConfiguration(SBAdminConfigurationBase):
def get_configuration_for_roles(self, user_roles):
return _role_config
View on Site link in list
You can show a “View on site” icon next to a list column value (e.g. article title) that opens the object’s frontend URL in a new tab. The link goes through a redirect view: no per-row database or URL lookup is done when rendering the list; the redirect runs only when the user clicks the icon.
Enabling the link
Use an admin method (a column whose name matches a method on the admin). The method calls view_on_site_link_formatter and passes sbadmin_view_id and sbadmin_view_on_site; the formatter cannot be used as python_formatter because the list action does not inject these kwargs into additional_data.
from django.contrib import admin
from django.db.models import F
from django_smartbase_admin.admin.admin_base import SBAdmin
from django_smartbase_admin.admin.site import sb_admin_site
from django_smartbase_admin.engine.field import SBAdminField
from django_smartbase_admin.engine.field_formatter import view_on_site_link_formatter
from blog.models import Article
@admin.register(Article, site=sb_admin_site)
class ArticleAdmin(SBAdmin):
def get_title_with_view_on_site(self, object_id, value, **kwargs):
"""Column: title + View on site icon. Pass view id and flag for the formatter."""
return view_on_site_link_formatter(
object_id,
value,
sbadmin_view_id=self.get_id(),
sbadmin_view_on_site=True,
)
sbadmin_list_display = (
SBAdminField(
name="get_title_with_view_on_site",
title="Title",
annotate=F("title"),
),
# ... other columns
)
Redirect view and URL
The framework provides ViewOnSiteRedirectView:
- Path:
view-on-site/<str:view>/<int:object_id>/ - URL name:
sb_admin:view_on_site_redirect
The view resolves the admin from the view id, loads the object, calls get_view_on_site_url(obj), and redirects. If the URL is None, it returns 404.
Formatter kwargs
view_on_site_link_formatter(object_id, value, **kwargs) expects:
sbadmin_view_id: the list view id (e.g.blog_article), used to build the redirect URL.sbadmin_view_on_site: optional, defaultTrue; if falsy, the formatter returns only the value (no icon/link).
Because these are not passed to python_formatter’s additional_data, you must use an admin method that calls the formatter with these kwargs explicitly.
CSS classes
The formatter outputs:
- Wrapper:
view-on-site-cell— flex container for the value + link. - Link:
view-on-site-link— the “View on site” anchor (icon, tooltip, opens in new tab).
Styles live in static/sb_admin/src/css/_tabulator.css: the icon is hidden on small screens and fades in on row hover (or when the link has focus).
Summary
| Piece | Location / name |
|---|---|
| Redirect view | ViewOnSiteRedirectView in views/view_on_site_redirect_view.py |
| URL name | sb_admin:view_on_site_redirect (kwargs: view, object_id) |
| Formatter | view_on_site_link_formatter in engine/field_formatter.py |
| URL for redirect | get_view_on_site_url(self, obj) — already on every admin. |
| CSS | .view-on-site-cell, .view-on-site-link in _tabulator.css |
Key points:
- The list only renders a link to the redirect URL; the redirect runs once on click and then calls
get_view_on_site_url(obj). - Use an admin method (not
python_formatter) so you can passsbadmin_view_idandsbadmin_view_on_siteto the formatter.
Filter Widgets
Built-in Widgets
| Widget | Use Case |
|---|---|
StringFilterWidget | Text fields |
BooleanFilterWidget | Boolean fields |
DateFilterWidget | Date/DateTime fields |
AutocompleteFilterWidget | ForeignKey/M2M fields |
FromValuesAutocompleteWidget | Filter from distinct values |
ChoiceFilterWidget | Static choices (single selection) |
MultipleChoiceFilterWidget | Static choices (multiple selection) |
Recommendation: Prefer
MultipleChoiceFilterWidgetoverChoiceFilterWidgetfor choice-based filters. It provides a better UX and gives users more flexibility to select multiple values at once.
Grouped Choices
Both ChoiceFilterWidget and MultipleChoiceFilterWidget accept Django-style grouped choices in addition to the flat form. Grouped input renders a header (<optgroup> in select templates, a styled header <li> in the checkbox-dropdown templates); flat input renders identically to before.
# Flat (unchanged)
MultipleChoiceFilterWidget(choices=[
("draft", "Draft"),
("published", "Published"),
])
# Grouped — shipper is the group header
MultipleChoiceFilterWidget(choices=[
("GLS", [("1", "Insurance"), ("2", "Signature required")]),
("SPS", [("5", "Special handling"), ("6", "Overweight")]),
])
Detection follows the same rule Django's ChoiceWidget.optgroups uses: the top-level structure is grouped if the second element of the first item is a list/tuple. Mixing flat and grouped choices in the same call is not supported.
Custom Filter Widget Example
from django_smartbase_admin.engine.filter_widgets import FromValuesAutocompleteWidget
class NonEmptyValuesFilter(FromValuesAutocompleteWidget):
"""Excludes empty/null values from filter choices."""
def get_queryset(self, request=None):
qs = super().get_queryset(request)
return qs.exclude(**{f"{self.field.name}__exact": ""}).exclude(**{f"{self.field.name}__isnull": True})
Complex Filter with filter_query_lambda
For filtering via related models (e.g., filtering articles by selecting tags), use AutocompleteFilterWidget with filter_query_lambda:
from django.db.models import Q
from django_smartbase_admin.engine.filter_widgets import AutocompleteFilterWidget
from blog.models import Tag, ArticleTag
class TagFilterWidget(AutocompleteFilterWidget):
"""Filter articles by selecting tags."""
def __init__(self):
super().__init__(
model=Tag,
multiselect=True,
value_field="id",
label_lambda=lambda request, item: item.name,
filter_query_lambda=self._filter_by_tags,
)
def _filter_by_tags(self, request, selected_ids):
if not selected_ids:
return Q()
# Query the junction table to find article IDs
article_ids = ArticleTag.objects.filter(
tag_id__in=selected_ids
).values_list("article_id", flat=True)
return Q(id__in=article_ids)
def get_queryset(self, request=None):
return Tag.objects.all().order_by("name")
Use in SBAdminField:
SBAdminField(
name="tags_display",
title="Tags",
annotate=Value("", output_field=TextField()),
filter_field="article_tags__tag",
filter_widget=TagFilterWidget(),
),
Filter Widget Behavior Parameters
Control filter dropdown behavior with close_dropdown_on_change and allow_clear:
| Parameter | Type | Default | Description |
|---|---|---|---|
close_dropdown_on_change | bool | False | If True, the filter dropdown closes automatically after the filter value changes. Useful for single-step filters like boolean or simple text inputs. Set to False for widgets where users typically make multiple changes before closing (e.g., multiselect autocomplete). |
allow_clear | bool | True | If True, shows a "Clear" button in the filter dropdown to reset the filter value. Set to False to hide the clear button. |
Examples:
from django_smartbase_admin.engine.filter_widgets import AutocompleteFilterWidget
# Single-step filter - closes dropdown after selection
class StatusFilterWidget(AutocompleteFilterWidget):
def __init__(self):
super().__init__(
model=Article,
multiselect=False,
close_dropdown_on_change=True, # Close after selecting status
allow_clear=True,
)
# Multi-step filter - keep dropdown open for multiple selections
class TagFilterWidget(AutocompleteFilterWidget):
def __init__(self):
super().__init__(
model=Tag,
multiselect=True,
close_dropdown_on_change=False, # Keep open for multiple tag selections
allow_clear=True,
)
# Filter without clear button
class RequiredCategoryFilterWidget(AutocompleteFilterWidget):
def __init__(self):
super().__init__(
model=Category,
multiselect=False,
close_dropdown_on_change=True,
allow_clear=False, # Hide clear button - category is required
)
Key points:
- Built-in widgets like
StringFilterWidgetandBooleanFilterWidgetdefault toclose_dropdown_on_change=Truefor better UX AutocompleteFilterWidgetdefaults toclose_dropdown_on_change=Falseto allow multiple selections- The clear button is automatically hidden for required form fields (controlled by
form_field.required)
Filter-Only Fields (No Column)
To add a filter that doesn't appear as a visible column, use list_visible=False:
sbadmin_list_display = (
# Visible column with filter
SBAdminField(
name="category_display",
title="Category",
filter_field="category",
filter_widget=CategoryFilterWidget(),
),
# Filter only - no column shown
SBAdminField(
name="author_filter",
title="Author",
annotate=Value("", output_field=TextField()),
filter_field="author",
filter_widget=AuthorFilterWidget(),
list_visible=False, # Hidden from columns, visible in filter panel
),
)
list_filter = (
"category", # Shows in filter panel
"author", # Shows in filter panel (even though column is hidden)
)
This is useful when you want to filter by related data that doesn't need its own column display.
Form Widgets
Use these when you need SBAdmin-styled form controls outside list filters.
SBAdminTextTagsWidget - delimiter-separated text tags
Use SBAdminTextTagsWidget for a single text field that stores multiple values separated by a delimiter (default: comma). This is useful when you want tag-like UX without a related model or M2M table.
from django import forms
from django_smartbase_admin.admin.admin_base import SBAdminBaseFormInit
from django_smartbase_admin.admin.widgets import SBAdminTextTagsWidget
class ArticleTagNamesForm(SBAdminBaseFormInit, forms.Form):
tag_names = forms.CharField(
label="Tag names",
required=False,
help_text="Comma-separated values",
widget=SBAdminTextTagsWidget(
delimiter=",",
attrs={"placeholder": "news, featured, internal"},
),
)
Key points:
- Stored value remains a plain string in the underlying input; the widget only upgrades the UX.
delimitercontrols how pasted/typed values are split.- Duplicate values are prevented client-side.
- Works with dynamically-added rows in SBAdmin formsets and wizard formsets.
Input prefix and suffix (text and number widgets)
Pass optional prefix and/or suffix strings to SBAdminTextInputWidget or
SBAdminNumberWidget for passive text affixes (e.g. currency, units, URL stem).
Use prefix_icon / suffix_icon with prefix_button_attrs /
suffix_button_attrs when the affix should be a clickable icon button. Omit all
affix options for a normal input.
from django import forms
from django_smartbase_admin.admin.admin_base import SBAdminBaseForm
from django_smartbase_admin.admin.widgets import (
SBAdminNumberWidget,
SBAdminTextInputWidget,
)
from blog.models import Article
class ArticleForm(SBAdminBaseForm):
class Meta:
model = Article
fields = ("slug", "price", "discount")
widgets = {
"slug": SBAdminTextInputWidget(prefix="https://blog.example.com/"),
"price": SBAdminNumberWidget(suffix="€"),
"discount": SBAdminNumberWidget(prefix="-", suffix="%"),
"public_url": SBAdminTextInputWidget(
attrs={"readonly": "readonly"},
suffix_icon="Minus-the-top",
suffix_button_attrs={
"title": "Copy URL",
"aria-label": "Copy URL",
"data-sbadmin-copy-button": True,
"data-sbadmin-copy-label": "Copy URL",
"data-sbadmin-copied-label": "Copied",
},
),
}
Key points:
prefix/suffixare passive text affixes;prefix_icon/suffix_iconrender clickable icon buttons.- Each side accepts only one affix shape: use either
prefixorprefix_icon, and eithersuffixorsuffix_icon. prefix_button_attrsandsuffix_button_attrsare normal HTML attributes. Always includetitleandaria-labelfor icon-only buttons.- Copy-to-clipboard behavior is globally delegated from
main.jsviadata-sbadmin-copy-button. Copy buttons can omitdata-sbadmin-copy-target;main.jsfalls back to the nearest input inside the affix wrapper. - Read-only inputs add
input-affix--readonlyto the wrapper so affix addons match the read-only input styling. - Other widgets can support the same pattern via
SBAdminInputAffixMixin(mix in and forwardprefix/suffixplus the icon-button args in__init__). SBAdminCopyableTextInputWidgetremains available as a compatibility wrapper, but new code should prefer the generic icon-button affix API.
Meta.widgets are initialized automatically in SBAdminBaseForm
When a form inherits from SBAdminBaseForm, widgets defined in Meta.widgets are initialized even if the field is not re-declared on the form class.
from django_smartbase_admin.admin.admin_base import SBAdminBaseForm
from django_smartbase_admin.admin.widgets import SBAdminRadioDropdownWidget
from blog.models import Article
class ArticleForm(SBAdminBaseForm):
class Meta:
model = Article
fields = ("title", "status")
widgets = {
"status": SBAdminRadioDropdownWidget(
choices=Article._meta.get_field("status").choices
),
}
Why this matters: You no longer need to re-declare status = forms.ChoiceField(...) just to get SBAdmin widget initialization. Meta.widgets is enough.
Required selects and empty placeholder option
SBAdminSelectWidget now disables the empty option by default for required fields. This prevents users from going back to an invalid blank value after choosing a real one.
from django_smartbase_admin.admin.admin_base import SBAdminBaseForm
from django_smartbase_admin.admin.widgets import SBAdminSelectWidget
from blog.models import Article
class ArticleForm(SBAdminBaseForm):
class Meta:
model = Article
fields = ("title", "status")
widgets = {
"status": SBAdminSelectWidget(
disable_empty_option=False, # keep placeholder selectable
),
}
Key points:
- Default behavior is safer for required fields: blank placeholder is shown but disabled.
- Set
disable_empty_option=Falseif you explicitly want the empty option to remain selectable. - The setting only affects empty values (
""/None) on required fields.
SBAdminJsonEditorField / SBAdminJsonEditorWidget — schema-driven JSON editor
SBAdmin-themed wrapper around @json-editor/json-editor (loaded from CDN). Pair it with SBAdminJsonEditorField to edit an array/object value via a JSON Schema and validate it server-side against the same schema (uses jsonschema).
from django_smartbase_admin.admin.widgets import SBAdminJsonEditorField
tags_config = SBAdminJsonEditorField(
required=False,
schema={
"type": "array",
"items": {
"type": "object",
"required": ["name"], # red asterisk on the label, enforced server-side
"headerTemplate": "{{i1}}. {{ self.name }}",
"properties": {
"name": {"type": "string", "title": "Name"},
"active": {"type": "boolean", "format": "checkbox", "title": "Active"},
},
},
},
add_to_top=True, # optional: prepend new rows (root array only)
)
Key points:
required: [...]in the schema renders a red*next to the label and is enforced server-side. The schema-validation logic lives on the widget (SBAdminJsonEditorWidget.run_schema_validation);SBAdminJsonEditorField.validate()simply delegates to it. Use the field for the standard wiring, or callwidget.run_schema_validation(value)from your own field/validator if you want to keep using a plainforms.JSONField.- Reorder (move-up/move-down) is enabled by default; pass
editor_options={"disable_array_reorder": True}to disable. - Client-side errors are inline only — submitting still goes through. Server-side validation is what actually rejects bad submissions, so always wire it via the field (or
run_schema_validation). add_to_top=Truereorders the root array only; nested arrays still append.- Multiple editors on the same page get unique input
nameprefixes automatically (form_name_rootis set per widget).
SBAdminPermissionWidget — collapsible, searchable permission tree
Replace Django's default multi-select widget for auth.Permission with a
collapsible tree. Two modes:
Default mode — groups all permissions by app_label / model. Every
Django permission is shown. Each model row shows the four standard
permissions (view / add / change / delete) as compact checkboxes, plus any
custom codenames as individual rows. A section-level "select all" switch
toggles all permissions in that section, and a client-side search input filters
permissions with text highlighting.
Groups mode — pass groups (a list of :class:PermissionGroup) to
define business-level permission options. Each visible option can map to one
or more strict app_label.model:codename permission refs from the widget
queryset. Explicit groups render first; any queryset permissions not
referenced by those groups render afterward using the default app/model
grouping.
Unbound forms, including add pages, select no permissions by default. This is
intentional fail-closed behavior for permission editors. Pass
preselect_all=True only when new objects should explicitly start with every
permission in the widget queryset selected.
Default mode rendering:
┌─ Authentication and Authorization ────────────────────────┐
│ Search permissions… [🔍] │
├─ ▼ Auth ──────────────────────────────────────────────────┤
│ ┌─ Permission ──────────────────────────── [5/5] ──────┐ │
│ │ ☐ Can view permission ☐ Can add permission │ │
│ │ ☐ Can change permission ☐ Can delete permission │ │
│ │ ☐ Custom action │ │
│ └────────────────────────────────────────────────────────┘ │
│ ┌─ Group ──────────────────────────────── [0/4] ───────┐ │
│ │ ☐ Can view group ☐ Can add group │ │
│ │ ☐ Can change group ☐ Can delete group │ │
│ └────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Usage in a Group admin (default mode):
from django.contrib import admin
from django.contrib.auth.models import Group
from django_smartbase_admin.admin.admin_base import SBAdmin, SBAdminBaseForm
from django_smartbase_admin.admin.site import sb_admin_site
from django_smartbase_admin.admin.widgets import SBAdminPermissionWidget
class GroupForm(SBAdminBaseForm):
class Meta:
model = Group
fields = "__all__"
widgets = {
"permissions": SBAdminPermissionWidget(),
}
@admin.register(Group, site=sb_admin_site)
class GroupAdmin(SBAdmin):
form = GroupForm
Groups mode — PermissionGroup and PermissionOption
Omit groups to automatically render all permissions from the form field
queryset grouped by app and model. Pass groups= when you want to define
business permission sections first while still showing every other queryset
permission below them.
PermissionGroup fields:
| Field | Type | Default | Description |
|---|---|---|---|
label | str | required | Display name for the collapsible section. |
options | list[PermissionOption] | [] | Visible permission rows in the section. |
help_text | str | "" | Help text displayed below the section header. |
PermissionOption fields:
| Field | Type | Default | Description |
|---|---|---|---|
label | str | required | Display name for the visible checkbox. |
codenames | list[str] | required | Strict permission refs in app_label.model:codename format. |
help_text | str | "" | Help text displayed below the option label. |
SBAdminPermissionWidget also accepts queryset= and
preselect_all=False. Use queryset= when assigning the widget
dynamically after the form field has already been constructed, so the widget
does not have to rely on Django's field/widget binding. Use
preselect_all=True only for an intentional opt-in "all selected" initial
state on unbound forms.
Examples:
from django.contrib.auth.models import Group, Permission, User
from django_smartbase_admin.admin.widgets import (
PermissionGroup,
PermissionOption,
SBAdminPermissionWidget,
)
# One visible option can submit several real Django permissions.
SBAdminPermissionWidget(
groups=[
PermissionGroup(
label=_("Packages"),
help_text=_("Controls package workflows."),
options=[
PermissionOption(
label=_("Create package"),
help_text=_("Includes required lookup permissions."),
codenames=[
"packages.package:add_package",
"accounts.userpickupaddress:view_userpickupaddress",
"carriers.shipper:view_shipper",
],
),
],
),
],
)
# Multiple business sections.
SBAdminPermissionWidget(
groups=[
PermissionGroup(
label=_("Articles"),
options=[
PermissionOption(
label=_("Edit articles"),
codenames=["blog.article:change_article"],
),
],
),
PermissionGroup(
label=_("User administration"),
options=[
PermissionOption(
label=_("Invite users"),
codenames=[
"auth.user:add_user",
"auth.user:view_user",
],
),
],
),
],
)
# Dynamic form setup — pass the same queryset to the field and widget.
permissions = self._account_user_permissions()
self.fields["permissions"].queryset = permissions
self.fields["permissions"].widget = SBAdminPermissionWidget(
queryset=permissions,
groups=[
PermissionGroup(
label=AccountGroupName.USER.label,
options=[
PermissionOption(
label=_("Create package"),
codenames=[
"packages.package:add_package",
"accounts.userpickupaddress:view_userpickupaddress",
"carriers.shipper:view_shipper",
],
),
],
),
],
)
Key points:
- Selected values are stored as a JSON array of permission PKs in a hidden
<input>, matching the pattern used bySBAdminMultipleChoiceWidget. SBAdminPermissionWidget.value_from_datadict()parses that JSON back to a Python list when the form is submitted.- The form field queryset decides which permissions may be referenced. In groups mode, every
PermissionOption.codenamesref must exist in that queryset. - Permissions referenced by
PermissionOptionare marked as seen and are not duplicated in the automatic leftover groups. - Group options are all-or-nothing: a
PermissionOptioncheckbox renders checked only when all of its backing permission IDs are already selected. Partial legacy selections are not shown in the automatic fallback section because those permissions are still marked as seen, and the client rewrites the hidden input from visible checked boxes. When adding codenames to an existing option or changing option membership, ship a data migration that updates existing groups/users to the intended complete permission set, or intentionally clears the old partial assignment. - When the widget is assigned after the field has been constructed, pass
queryset=explicitly. - Permission refs must use
app_label.model:codename. Bare codenames are not supported. - If a permission ref is malformed or missing from the widget queryset, rendering raises
ImproperlyConfigured. - Standard permissions (view / add / change / delete) are detected by the
view_<model>/add_<model>/change_<model>/delete_<model>codename prefix. Any other codename is treated as a custom permission and rendered as an individual row. - The search input filters permissions client-side with real-time highlighting.
- Default-mode app sections are collapsible and render permissions grouped by model.
- In groups mode, each
PermissionGroupbecomes its own collapsible section, eachPermissionOptionrenders as one checkbox that may submit multiple permission IDs, and all unseen permissions are appended in automatic app/model sections.
Standalone Form Views (SBAdminStandaloneFormView)
Use SBAdminStandaloneFormView for a full SBAdmin page backed by a regular
Django form instead of a model add/change view. Typical uses include calculators,
import screens, report builders, and other workflows that should live in the
SBAdmin navigation but do not edit one model instance.
from django import forms
from django_smartbase_admin.engine.actions import sbadmin_action
from django_smartbase_admin.engine.admin_view import SBAdminView
from django_smartbase_admin.engine.modal_view import SBAdminStandaloneFormView
class ArticleReportForm(forms.Form):
status = forms.ChoiceField(
label="Status",
choices=(("draft", "Draft"), ("published", "Published")),
)
class ArticleReportFormView(SBAdminStandaloneFormView):
template_name = "blog/article_report.html"
form_class = ArticleReportForm
def get_context_data(self, **kwargs):
context = self.view.get_global_context(self.request)
context.update(super().get_context_data(**kwargs))
context["page_title"] = self.view.title
return context
def form_valid(self, form):
rows = build_article_report(status=form.cleaned_data["status"])
return self.render_to_response(self.get_context_data(form=form, rows=rows))
class ArticleReportView(SBAdminView):
label = "Article report"
title = "Article report"
view_id = "article_report"
menu_action = "report"
@sbadmin_action(permission="view")
def report(self, request, modifier, object_id=None):
return ArticleReportFormView.as_view(view=self)(request)
Register the SBAdminView in the role configuration and point a menu item at
its view_id:
from django_smartbase_admin.engine.configuration import SBAdminRoleConfiguration
from django_smartbase_admin.engine.menu_item import SBAdminMenuItem
_role_config = SBAdminRoleConfiguration(
default_view=SBAdminMenuItem(view_id="article_report"),
menu_items=[
SBAdminMenuItem(label="Article report", icon="Table-report", view_id="article_report"),
],
registered_views=[
ArticleReportView(),
],
)
Template example:
{% extends "sb_admin/sb_admin_base.html" %}
{% block content %}
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn btn-primary">Run report</button>
</form>
{% if rows %}
{# Render report output here. #}
{% endif %}
{% endblock %}
Key points:
- Pass
view=selfthroughArticleReportFormView.as_view(view=self)so the form view can use SBAdmin context, permissions, dynamic forms, and fieldsets. - Merge
self.view.get_global_context(self.request)intoget_context_data()so the template gets the navigation shell, notifications, view id, and SBAdmin request metadata. - Use
get_form_kwargs()for request-aware forms or runtime configuration. - If the form uses
SBDynamicRegion,SBAdminStandaloneFormViewalready handles dynamic-region POST fragments; do not hand-roll region response code. - Keep permission decisions on the owning
SBAdminView/ role configuration.
Dynamic Regions (SBDynamicRegion)
Use dynamic regions when one form control should refresh a section of fields without reloading the whole admin page or modal. Typical cases:
- An
Article.statusselection changes which publishing fields are visible. - An
Article.categoryselection changes choices for relatedTagorAuthorfields. - A normal
SBAdminadd/change form needs a dependent section. - A stacked inline row needs fields that depend on another field in the same row.
- A row action modal needs a different set of inputs based on the selected
Article. - A custom
Categorywidget should refresh only the dependent part of the form.
Dynamic regions are server-rendered. SBAdmin adds HTMX attributes to the trigger field, requests a region fragment, and swaps only the target wrapper. The refreshed form is initialized from the current request data, so callbacks should read current values from bound fields (form["field"].value()), not from cleaned_data.
Imports
from django_smartbase_admin.engine.dynamic_forms import (
SBDynamicRegion,
SBInactiveFieldPolicy,
)
Use forms based on SBAdminBaseFormInit / SBAdminBaseForm; these call prepare_dynamic_regions(request) during initialization.
Where to use dynamic regions
Use the same SBDynamicRegion marker in these places:
| Use case | Where to define the region | Endpoint behavior |
|---|---|---|
| Normal model admin add/change form | SBAdmin.sbadmin_fieldsets, or the form's Meta.sbadmin_fieldsets | SBAdmin uses the admin sbadmin_dynamic_region/<object id> or sbadmin_dynamic_region/add action. |
| Stacked inline row | SBAdminStackedInline.sbadmin_fieldsets, or the inline form's Meta.sbadmin_fieldsets | SBAdmin injects the inline form prefix into hx-vals, so only the changed inline row is refreshed. |
| Action modal | The modal form_class.Meta.sbadmin_fieldsets | ActionModalView, ListActionModalView, and RowActionModalView use the current modal URL. |
| Custom view + standalone form | The form's Meta.sbadmin_fieldsets, with sbadmin_dynamic_region_source=SBDynamicRegionSource.FORM passed when constructing the form | Your view handles sbadmin_dynamic_region=<name> POSTs and renders the dynamic-region fragment. |
Basic tutorial: switch fields by article status
Put SBDynamicRegion directly inside Meta.sbadmin_fieldsets["fields"] at the exact render position. The region owns every field it may render through fields=...; get_active_fields returns the subset/layout that should be rendered for the current state.
from django.contrib import admin
from django import forms
from django_smartbase_admin.admin.admin_base import SBAdmin, SBAdminBaseForm
from django_smartbase_admin.admin.site import sb_admin_site
from django_smartbase_admin.engine.dynamic_forms import (
SBDynamicRegion,
SBInactiveFieldPolicy,
)
from blog.models import Article
class ArticleForm(SBAdminBaseForm):
status = forms.ChoiceField(
choices=(
("draft", "Draft"),
("published", "Published"),
("archived", "Archived"),
),
initial="draft",
)
class Meta:
model = Article
fields = ("title", "status", "category", "author")
sbadmin_fieldsets = (
(None, {"fields": ("title", "status")}),
(
"Publishing",
{
"fields": (
SBDynamicRegion(
name="publishing_fields",
trigger_fields=("status",),
fields=("category", "author"),
get_active_fields=lambda form, request, region: (
(("category", "author"),)
if form["status"].value() == "published"
else ("category",)
),
inactive_field_policy=SBInactiveFieldPolicy.PRESERVE,
),
),
},
),
)
@admin.register(Article, site=sb_admin_site)
class ArticleAdmin(SBAdmin):
form = ArticleForm
What this does:
statusgets HTMX trigger attributes automatically.- The initial render shows only
categoryfor draft articles. - Switching
statustopublishedrefreshes thepublishing_fieldswrapper and renderscategory+authoron one row becauseget_active_fieldsreturns a grouped tuple. - Hidden fields stay in
form.fields, but inactive fields are handled according toinactive_field_policy.
You can also define the same region directly on the admin class if the fields are all normal model fields:
@admin.register(Article, site=sb_admin_site)
class ArticleAdmin(SBAdmin):
sbadmin_fieldsets = (
(None, {"fields": ("title", "status")}),
(
"Publishing",
{
"fields": (
SBDynamicRegion(
name="publishing_fields",
trigger_fields=("status",),
fields=("category", "author"),
get_active_fields=lambda form, request, region: (
(("category", "author"),)
if form["status"].value() == "published"
else ("category",)
),
),
),
},
),
)
Dynamic regions in stacked inlines
Define inline regions directly on the SBAdminStackedInline when the trigger and controlled fields belong to the inline model. SBAdmin wraps inline formsets with dynamic-region support and includes the current inline form prefix in the refresh request.
from django.contrib import admin
from django_smartbase_admin.admin.admin_base import SBAdmin, SBAdminStackedInline
from django_smartbase_admin.admin.site import sb_admin_site
from django_smartbase_admin.engine.dynamic_forms import SBDynamicRegion
from blog.models import Article, ArticleMeta
class ArticleMetaInline(SBAdminStackedInline):
model = ArticleMeta
extra = 0
max_num = 1
sbadmin_fieldsets = (
(
"Metadata",
{
"fields": (
"heading",
SBDynamicRegion(
name="metadata_description",
trigger_fields=("heading",),
fields=("description",),
is_visible=lambda form, request, region: bool(
form["heading"].value()
),
),
),
},
),
)
@admin.register(Article, site=sb_admin_site)
class ArticleAdmin(SBAdmin):
inlines = (ArticleMetaInline,)
If the inline needs extra non-model trigger fields, define a custom inline form based on SBAdminBaseForm and put the region in that form's Meta.sbadmin_fieldsets; then assign it with form = ArticleMetaInlineForm on the inline.
SBDynamicRegion options
| Option | Type | Description |
|---|---|---|
name | str | Required unique region name. Used in sbadmin_dynamic_region, wrapper IDs, and client events. |
trigger_fields | iterable of field names | Fields that trigger a refresh. SBAdmin binds HTMX attrs to these widgets. |
fields | iterable of field names | All fields controlled by this region. Only these fields can become active. |
is_visible | callable | Optional (form, request, region) -> bool. If false, the whole region renders empty. |
get_active_fields | callable | Optional (form, request, region) -> iterable. Return active field names or grouped iterables. Defaults to all fields. |
inactive_field_policy | SBInactiveFieldPolicy | PRESERVE by default. Controls cleaning/submission behavior for inactive fields. |
template | str | Optional custom template instead of the default inline fieldset renderer. |
defer_trigger | str | None | Optional HTMX trigger for self-loading regions, e.g. "load". Initial render emits trigger attrs and skips default inline fields until the fragment response. |
Unknown field names in get_active_fields are ignored. A grouped tuple/list such as ("category", "author") renders those fields together, same as grouped fields in normal Django admin fieldsets.
Inactive field policies
Inactive fields are fields listed in region.fields but not returned by get_active_fields.
Use SBInactiveFieldPolicy.PRESERVE when hidden values should remain untouched. SBAdmin temporarily skips validation/cleaning for inactive fields, so required hidden fields do not block submission and do not appear in cleaned_data.
SBDynamicRegion(
name="publishing_fields",
trigger_fields=("status",),
fields=("category", "author"),
get_active_fields=lambda form, request, region: (
("category", "author")
if form["status"].value() == "published"
else ("category",)
),
inactive_field_policy=SBInactiveFieldPolicy.PRESERVE,
)
Use SBInactiveFieldPolicy.CLEAR when hidden values must be erased. SBAdmin marks the inactive field non-required and removes bound data using the widget's normal data names, including multi-widget suffixes such as _0 / _1.
SBDynamicRegion(
name="review_fields",
trigger_fields=("status",),
fields=("category", "author"),
get_active_fields=lambda form, request, region: (
("category", "author")
if form["status"].value() == "published"
else ("category",)
),
inactive_field_policy=SBInactiveFieldPolicy.CLEAR,
)
Region callbacks
Callbacks receive (form, request, region). Prefer current form values:
def article_review_fields(form, request, region):
if form["status"].value() == "published":
return ("category", "author")
return ("category",)
Do not rely on cleaned_data in these callbacks. Dynamic-region refreshes are POST requests that build an unbound form with initial values extracted from the current request data through each widget's value_from_datadict().
HTMX trigger wiring
Each trigger_fields widget receives HTMX attributes automatically:
hx-post: current modal/form path for standalone forms, or the adminsbadmin_dynamic_regionaction URL for admin forms.hx-trigger:"change"by default.hx-target: the dynamic region wrapper ID.hx-include:closest form, so current unsaved form values are sent.hx-indicator: the region loader ID.hx-swap:none; dynamic-region fragments use out-of-band wrapper replacement.hx-sync:closest form:replacefor field-triggered regions andthis:replacefor deferred self-loading regions.hx-vals: includes{"sbadmin_dynamic_region": "<region name>"}.
To use a custom trigger event, set dynamic_region_trigger_event on the widget:
from django import forms
class CategoryAutocompleteWidget(forms.TextInput):
sb_admin_widget = True
dynamic_region_trigger_event = "SBAutocompleteChange"
If several regions share the same trigger field, the admin dynamic-region action returns all related regions for that trigger so cross-fieldset dependent regions stay in sync.
Deferred self-loading regions
Use defer_trigger="load" when a region should request its content after the page is visible, for example a slow read-only detail widget.
SBDynamicRegion(
name="tracking_preview",
fields=("tracking_preview",),
defer_trigger="load",
template="blog/includes/tracking_preview_region.html",
)
On the initial render, dynamic_region.is_deferred_initial is true and dynamic_region.trigger_attrs contains the wrapper's HTMX attrs. The default renderer skips inline fields while deferred; a custom template may render a skeleton or placeholder. On the fragment render, dynamic_region.is_fragment is true and inline fields should be rendered normally.
Dynamic regions in action modals
The common non-change-form use case is an action modal. Define the dynamic regions on the modal form; ActionModalView, ListActionModalView, and RowActionModalView wrap the form as a standalone dynamic-region form and use the current modal URL as the refresh endpoint.
from django import forms
from django.utils.translation import gettext_lazy as _
from django_smartbase_admin.admin.admin_base import SBAdminBaseFormInit
from django_smartbase_admin.engine.dynamic_forms import SBDynamicRegion
from django_smartbase_admin.engine.modal_view import ListActionModalView
from blog.models import Category
class AssignCategoryForm(SBAdminBaseFormInit, forms.Form):
status = forms.ChoiceField(
choices=(("draft", "Draft"), ("published", "Published"))
)
category = forms.ModelChoiceField(
queryset=Category.objects.all(),
required=False,
)
class Meta:
sbadmin_fieldsets = (
(
None,
{
"fields": (
"status",
SBDynamicRegion(
name="category_region",
trigger_fields=("status",),
fields=("category",),
is_visible=lambda form, request, region: (
form["status"].value() == "published"
),
),
),
},
),
)
class AssignCategoryView(ListActionModalView):
form_class = AssignCategoryForm
modal_title = _("Assign Category")
def process_form_valid_list_selection_queryset(self, request, form, selection_queryset):
category = form.cleaned_data.get("category")
if category is not None:
selection_queryset.update(category=category)
Row action modals can initialize dynamic regions from the row object through the form instance. Use get_object() / form instance in the usual modal pattern, then let callbacks read current bound values.
Dynamic regions in custom views
For a standalone form view outside an SBAdmin modal, pass request, sbadmin_dynamic_region_source=SBDynamicRegionSource.FORM, and usually sbadmin_dynamic_region_endpoint=request.path when constructing the form. Add a small POST branch that renders sb_admin/includes/dynamic_region.html when sbadmin_dynamic_region=<name> is present.
from django import forms
from django.http import HttpResponse
from django.template.loader import render_to_string
from django.views.generic.edit import FormView
from django_smartbase_admin.admin.admin_base import SBAdminBaseFormInit
from django_smartbase_admin.engine.dynamic_forms import (
SBADMIN_DYNAMIC_REGION_PARAM,
SBDynamicRegion,
SBDynamicRegionSource,
dynamic_region_initial_from_data,
)
class ImportProductsForm(SBAdminBaseFormInit, forms.Form):
source = forms.ChoiceField(
choices=(("file", "File"), ("url", "URL")),
initial="file",
)
upload = forms.FileField(required=False)
remote_url = forms.URLField(required=False)
class Meta:
sbadmin_fieldsets = (
(
None,
{
"fields": (
"source",
SBDynamicRegion(
name="source_details",
trigger_fields=("source",),
fields=("upload", "remote_url"),
get_active_fields=lambda form, request, region: (
("remote_url",)
if form["source"].value() == "url"
else ("upload",)
),
),
),
},
),
)
class ImportProductsView(FormView):
template_name = "blog/import_products.html"
form_class = ImportProductsForm
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs.update(
{
"request": self.request,
"sbadmin_dynamic_region_source": SBDynamicRegionSource.FORM,
"sbadmin_dynamic_region_endpoint": self.request.path,
}
)
return kwargs
def post(self, request, *args, **kwargs):
region_name = request.POST.get(SBADMIN_DYNAMIC_REGION_PARAM)
if region_name:
return self.render_dynamic_region(request, region_name)
return super().post(request, *args, **kwargs)
def render_dynamic_region(self, request, region_name):
form_class = self.get_form_class()
form = form_class(
request=request,
sbadmin_dynamic_region_source=SBDynamicRegionSource.FORM,
sbadmin_dynamic_region_endpoint=request.path,
initial=dynamic_region_initial_from_data(form_class, request.POST),
)
region = form.get_dynamic_region(region_name, request)
if region is None:
return HttpResponse("", status=404)
html = render_to_string(
"sb_admin/includes/dynamic_region.html",
{
"dynamic_region": form.get_dynamic_region_context(
region, request, is_fragment=True
),
},
request=request,
)
return HttpResponse(html)
Render the form with SBAdmin fieldsets so the dynamic region marker is converted into its wrapper:
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{% for fieldset in form.fieldsets %}
{% include "sb_admin/includes/fieldset.html" %}
{% endfor %}
</form>
Custom region templates
Pass template="path/to/template.html" to take over rendering for the region. The template receives:
dynamic_regionformregionregion_stateactive_field_names
SBDynamicRegion(
name="article_publish_fields",
trigger_fields=("status",),
fields=("category", "author"),
template="blog/includes/article_publish_fields.html",
)
The default template renders region_state.active_fields with sb_admin/includes/inline_fieldset.html. Fragment responses render the wrapper with hx-swap-oob="outerHTML" and omit the loader; the full page render wraps the region with a loader.
Custom templates should use dynamic_region.is_fragment to detect fragment renders and dynamic_region.is_deferred_initial to detect initial deferred placeholders. Do not use a separate sbadmin_dynamic_region_fragment template variable.
{% if dynamic_region.is_deferred_initial %}
{% include "blog/includes/tracking_preview_skeleton.html" %}
{% else %}
{% include "sb_admin/includes/inline_fieldset_fields.html" with fieldset=dynamic_fieldset %}
{% endif %}
Placement rules and gotchas
- Put
SBDynamicRegiondirectly in a fieldset'sfieldstuple. Regions nested inside grouped fields like("status", SBDynamicRegion(...))are ignored. - Keep
region.nameunique within a form. - Every field that may render must be declared in
fields=...;get_active_fieldscannot activate fields outside that list. - Use
Meta.sbadmin_fieldsetswhen defining regions.Meta.fieldsetsis supported for normal standalone fieldsets, butsbadmin_fieldsetsis the explicit SBAdmin layout hook. - For model admin change/add forms, the region endpoint is
sbadmin_dynamic_region/<object id>orsbadmin_dynamic_region/add. sbadmin_standalone_dynamic_regionsis no longer supported. UseSBDynamicRegionSource.FORMwhen the form should read its ownMeta.sbadmin_fieldsetsinstead of the parent admin view's fieldsets.- For standalone/modal forms, the endpoint is the current request path.
Admin Registration
from django.contrib import admin
from django_smartbase_admin.admin.site import sb_admin_site
from django_smartbase_admin.admin.admin_base import SBAdmin
from blog.models import Article
@admin.register(Article, site=sb_admin_site)
class ArticleAdmin(SBAdmin):
model = Article
sbadmin_list_display = (...)
sbadmin_list_filter = (...) # Preferred for SBAdminField names
search_fields = (...)
Full-text search (search_fields)
When defining search_fields on SBAdmin, prefer SBAdminField.name values over raw ORM relation paths. SBAdmin maps those names through each field's filter_field and then builds the ORM lookups.
# ❌ BAD - raw relation paths can spawn duplicate rows in full-text search
class ArticleAdmin(SBAdmin):
sbadmin_list_display = (
SBAdminField(name="author_display", annotate=F("author__name"), filter_field="author"),
SBAdminField(name="category_display", annotate=F("category__name"), filter_field="category"),
)
search_fields = ("author__name", "category__name")
# ✅ GOOD - use SBAdmin field names so SBAdmin maps through filter_field
class ArticleAdmin(SBAdmin):
sbadmin_list_display = (
SBAdminField(name="author_display", annotate=F("author__name"), filter_field="author"),
SBAdminField(name="category_display", annotate=F("category__name"), filter_field="category"),
)
search_fields = ("author_display", "category_display")
Key points:
search_fieldsaccepts prefixes (^,=,@) the same way as Django admin; SBAdmin keeps the prefix and maps only the field name part.- If a value in
search_fieldsmatchesSBAdminField.name, SBAdmin rewrites it to that field'sfilter_fieldbefore building the ORM lookup. - If you pass raw ORM relation paths directly (for example
author__name), full-text search can duplicate rows on relation-heavy queries. - Full-text search now logs a warning when configured lookups can spawn duplicates; it does not auto-apply
.distinct().
Default Visible Filters: sbadmin_list_filter vs list_filter
Use sbadmin_list_filter to specify which filters are visible by default. This attribute accepts SBAdminField name values directly, making it the preferred choice when using computed/annotated fields.
When to use each:
| Attribute | Use When | Accepts |
|---|---|---|
sbadmin_list_filter | You have SBAdminField with annotate= or custom filter_widget | SBAdminField name values |
list_filter | All filters are simple model fields | Model field names or filter_field values |
# ❌ BAD - Django's list_filter fails validation on annotated fields
sbadmin_list_display = (
SBAdminField(
name="status_display", # Computed annotation, NOT a model field
annotate=Case(...),
filter_widget=MultipleChoiceFilterWidget(...),
),
SBAdminField(
name="tags_display", # Computed annotation
annotate=get_tag_names_subquery(),
filter_widget=AutocompleteFilterWidget(...),
),
)
list_filter = (
"status_display", # ❌ Fails: admin.E116 - not a model field
"tags_display", # ❌ Fails: admin.E116 - not a model field
)
# ✅ GOOD - sbadmin_list_filter accepts SBAdminField names
sbadmin_list_display = (
SBAdminField(
name="status_display",
annotate=Case(...),
filter_widget=MultipleChoiceFilterWidget(...),
),
SBAdminField(
name="tags_display",
annotate=get_tag_names_subquery(),
filter_widget=AutocompleteFilterWidget(...),
),
)
sbadmin_list_filter = (
"status_display", # ✅ Works: matches SBAdminField name
"tags_display", # ✅ Works: matches SBAdminField name
)
For simple model fields, both work:
sbadmin_list_display = (
"title",
SBAdminField(name="status", filter_field="status"),
SBAdminField(name="author_name", filter_field="author__email"),
SBAdminField(name="comment_count", filter_disabled=True),
)
# Either works for model fields:
sbadmin_list_filter = ("title", "status", "author_name")
# OR
list_filter = ("title", "status", "author__email")
---
## Selection Actions (Bulk Actions)
Add custom actions that operate on selected rows in the list view.
```python
from django import forms
from django.contrib import admin, messages
from django.utils.translation import gettext_lazy as _
from django_smartbase_admin.admin.admin_base import SBAdmin, SBAdminBaseFormInit
from django_smartbase_admin.admin.site import sb_admin_site
from django_smartbase_admin.admin.widgets import SBAdminAutocompleteWidget
from django_smartbase_admin.engine.actions import SBAdminFormViewAction
from django_smartbase_admin.engine.modal_view import ListActionModalView
from blog.models import Article, Category
Modal with Form
For actions that need user input, use SBAdminFormViewAction with ListActionModalView:
class AssignCategoryForm(SBAdminBaseFormInit, forms.Form):
category = forms.ModelChoiceField(
label=_("Category"),
queryset=Category.objects.all(),
widget=SBAdminAutocompleteWidget(
model=Category,
multiselect=False,
label_lambda=lambda request, item: item.name,
),
)
class AssignCategoryView(ListActionModalView):
form_class = AssignCategoryForm
modal_title = _("Assign Category")
def process_form_valid_list_selection_queryset(self, request, form, selection_queryset):
category = form.cleaned_data["category"]
selection_queryset.update(category=category)
@admin.register(Article, site=sb_admin_site)
class ArticleAdmin(SBAdmin):
def get_sbadmin_list_selection_actions(self, request):
return [
SBAdminFormViewAction(
target_view=AssignCategoryView,
title=_("Assign Category"),
view=self,
action_id="assign_category",
open_in_modal=True,
),
]
Confirmation-Only Modals (No Form Fields)
Use an empty form for confirm/cancel dialogs. The modal renders the title with Close/Continue buttons.
class ConfirmForm(SBAdminBaseFormInit, forms.Form):
pass
class ArchiveArticlesView(ListActionModalView):
form_class = ConfirmForm
modal_title = _("Archive Selected Articles")
def process_form_valid_list_selection_queryset(self, request, form, selection_queryset):
count = selection_queryset.update(status="archived")
messages.success(request, _("%d article(s) archived.") % count)
@admin.register(Article, site=sb_admin_site)
class ArticleAdmin(SBAdmin):
def get_sbadmin_list_selection_actions(self, request):
return [
SBAdminFormViewAction(
target_view=ArchiveArticlesView,
title=_("Archive Selected"),
view=self,
action_id="archive_articles",
open_in_modal=True,
css_class="btn-destructive",
),
]
requires_confirmation on Action Modals
Use requires_confirmation = True when an action should show a final confirmation step before running process_form_valid(...). The first valid submit renders confirmation_template_name; the action is processed only after the confirmation submit posts _confirmed=1.
For actions without visible form fields, the confirmation step is rendered immediately on GET. For actions with visible fields, the modal shows the form first, validates the submitted values, then renders the confirmation step. Override get_confirmation_data(...) to build the preview data; return None to skip confirmation for that request, or raise SBAdminActionError to show a non-field error.
from django_smartbase_admin.engine.exceptions import SBAdminActionError
class ConfirmArchiveForm(SBAdminBaseFormInit, forms.Form):
pass
class ArchiveArticlesView(ListActionModalView):
form_class = ConfirmArchiveForm
modal_title = _("Archive Selected Articles")
requires_confirmation = True
confirmation_message = _("Archive {count} selected article(s)?")
def get_confirmation_data(self, request, form):
selection_queryset = self.get_selection_queryset(request, form)
count = selection_queryset.count()
if not count:
raise SBAdminActionError(_("Select at least one article."))
return {"count": count}
def process_form_valid_list_selection_queryset(self, request, form, selection_queryset):
count = selection_queryset.update(status="archived")
messages.success(request, _("%d article(s) archived.") % count)
Key points:
confirmation_messageis formatted withconfirmation_message.format(**data), wheredatais returned byget_confirmation_data(...).- The confirmation template replays the form's hidden fields and selected rows, so
process_form_valid(...)receives the same valid form on the second submit. - Use
requires_confirmationfor action flows backed byActionModalView,ListActionModalView, orRowActionModalView; directSBAdminCustomActionmethods are not modal form flows and should implement their own confirmation UI if needed.
SBAdminCustomAction Parameters
SBAdminFormViewAction extends SBAdminCustomAction. Available parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
title | str | Required | Button label |
target_view | class | Required (FormViewAction only) | The ListActionModalView subclass |
view | SBAdmin | Required | The admin class instance (self) |
action_id | str | None | Unique identifier for this action |
open_in_modal | bool | False | Open the action in a modal dialog |
css_class | str | None | CSS class for the button (e.g., "btn-destructive" for red) |
icon | str | None | Icon name (from sprite set) |
group | str | None | Group actions under a dropdown menu |
sub_actions | list | None | Nested actions under this action |
no_params | bool | False | If True, don't pass current list params to the action URL |
open_in_new_tab | bool | False | Open action URL in a new browser tab |
url | str | None | Direct URL (for non-form actions) |
template | str | None | Custom template for the action button |
Per-Action Permissions (has_permission_for_action)
Override has_permission_for_action on your admin class to control which actions are visible per request. This is useful when an action exists for the view, but only some users or request states should see it.
PUBLISH_ACTION_ID = "publish_articles"
class ConfirmForm(SBAdminBaseFormInit, forms.Form):
pass
class PublishArticlesView(ListActionModalView):
form_class = ConfirmForm
modal_title = _("Publish Selected Articles")
def process_form_valid_list_selection_queryset(self, request, form, selection_queryset):
selection_queryset.update(status="published")
class ArchiveArticlesView(ListActionModalView):
form_class = ConfirmForm
modal_title = _("Archive Selected Articles")
def process_form_valid_list_selection_queryset(self, request, form, selection_queryset):
selection_queryset.update(status="archived")
@admin.register(Article, site=sb_admin_site)
class ArticleAdmin(SBAdmin):
def get_sbadmin_list_selection_actions(self, request):
return [
SBAdminFormViewAction(
target_view=PublishArticlesView,
title=_("Publish Selected"),
view=self,
action_id=PUBLISH_ACTION_ID,
open_in_modal=True,
),
SBAdminFormViewAction(
target_view=ArchiveArticlesView,
title=_("Archive Selected"),
view=self,
action_id="archive_articles",
open_in_modal=True,
css_class="btn-destructive",
),
]
def has_permission_for_action(self, request, action):
if getattr(action, "action_id", None) == PUBLISH_ACTION_ID:
permissions = set(request.session.get("permissions", []))
if "editor" not in permissions and "admin" not in permissions:
return False
return super().has_permission_for_action(request, action)
Key points:
- Use
has_permission_for_actionwhen an action should be hidden for some users or request states - The default
has_permission_for_actiondelegates toSBAdminRoleConfiguration.has_permission() SBAdminFormViewActionmodal views are automatically URL-callable — no extra decoration needed- When using
SBAdminCustomActionwithaction_idpointing to a method, that method must be decorated with@sbadmin_action - Modal views can usually do their work in
process_form_valid_list_selection_queryset()and letActionModalViewbuild the success response with notifications, modal close, and table reload events
Modal Error Handling
from django_smartbase_admin.engine.modal_view import SBAdminActionError
class AssignCategoryView(ListActionModalView):
def process_form_valid_list_selection_queryset(self, request, form, selection_queryset):
if not selection_queryset.exists():
raise SBAdminActionError(_("No articles selected."))
selection_queryset.update(category=form.cleaned_data["category"])
ActionModalView.post() catches SBAdminActionError, adds the message as a non-field form error, and re-renders the modal. The modal template automatically displays form.errors and form.non_field_errors in a styled alert box.
Row Actions (Per-Row List Buttons)
Use SBAdminRowAction for small icon buttons rendered inside each list row. Row actions are declared once, processed through get_sbadmin_row_actions_processed(), then materialized per row into _row_actions during the list data pipeline.
Interaction Modes and Dropdowns
Pass either sub_actions for a per-row dropdown, or exactly one of target_view, action_id, or url for a direct row button:
from django import forms
from django.contrib import admin, messages
from django.utils.translation import gettext_lazy as _
from django_smartbase_admin.admin.admin_base import SBAdmin, SBAdminBaseFormInit
from django_smartbase_admin.admin.site import sb_admin_site
from django_smartbase_admin.engine.actions import SBAdminRowAction, sbadmin_action
from django_smartbase_admin.engine.const import MODIFIER_OBJECT_ID
from django_smartbase_admin.engine.modal_view import RowActionModalView, SBAdminActionError
from blog.models import Article
class PublishArticleForm(SBAdminBaseFormInit, forms.Form):
pass
class PublishArticleView(RowActionModalView):
form_class = PublishArticleForm
modal_title = _("Publish Article")
def process_form_valid_object(self, request, form, obj):
if obj is None:
raise SBAdminActionError(_("Article was not found."))
obj.status = "published"
obj.save(update_fields=["status"])
messages.success(request, _("Article published."))
@admin.register(Article, site=sb_admin_site)
class ArticleAdmin(SBAdmin):
model = Article
def get_sbadmin_row_actions(self, request):
return [
# Mode 1: open a per-row modal. The row object is loaded by pk.
SBAdminRowAction(
target_view=PublishArticleView,
title=_("Publish"),
icon="Check-correct",
view=self,
enabled_field="status",
enabled_value="draft",
),
# Mode 2: call an @sbadmin_action method without a modal.
SBAdminRowAction(
action_id="action_archive_article",
title=lambda row: _("Archive %(title)s") % {"title": row.get("title", "")},
icon="Delete",
view=self,
css_class=lambda row: "btn btn-small btn-only-icon btn-destructive",
enabled_if=lambda row: row.get("status") != "archived",
),
# Mode 3: plain link. MODIFIER_OBJECT_ID is replaced with the row pk.
SBAdminRowAction(
url=f"https://example.com/articles/{MODIFIER_OBJECT_ID}/",
title=_("View externally"),
icon="Preview-open",
open_in_new_tab=True,
),
# Dropdown parent: children are materialized with the same row pk.
SBAdminRowAction(
title=_("More actions"),
icon="More",
sub_actions=[
SBAdminRowAction(
action_id="action_archive_article",
title=_("Archive"),
icon="Delete",
view=self,
),
SBAdminRowAction(
url=f"https://example.com/articles/{MODIFIER_OBJECT_ID}/audit/",
title=_("Audit trail"),
icon="History",
open_in_new_tab=True,
),
],
),
]
@sbadmin_action
def action_archive_article(self, request, modifier, object_id):
Article.objects.filter(pk=object_id).update(status="archived")
messages.success(request, _("Article archived."))
return self.build_action_response(request)
Restricted Querysets in Row Modals
RowActionModalView loads the row object through the owning admin's get_queryset(request) by default. This is important: the object lookup keeps normal admin queryset restrictions, role filters, tenant filters, and other restrict_queryset rules in force.
Do not override get_object_queryset() just to return Model.objects.all() or a raw manager queryset. That bypasses the admin's restricted queryset and can expose or mutate objects the user should not reach.
Only override get_object_queryset() when the row action intentionally resolves a different model. In that case, apply the same restriction path explicitly:
class PublishArticleView(RowActionModalView):
form_class = PublishArticleForm
modal_title = _("Publish Article")
# No get_object_queryset override needed.
# Default: self.view.get_queryset(request)
# ❌ BAD - bypasses admin restrictions
class PublishArticleView(RowActionModalView):
def get_object_queryset(self, request):
return Article.objects.all()
# ✅ GOOD - preserve the admin's restricted queryset
class PublishArticleView(RowActionModalView):
def get_object_queryset(self, request):
return self.view.get_queryset(request)
Row-Aware Attributes
title, icon, and css_class can be static values or callables receiving the row dict. Use class attributes and override the getter methods on a subclass when the logic is reused:
class DraftOnlyRowAction(SBAdminRowAction):
title = _("Publish")
icon = "Check-correct"
target_view = PublishArticleView
enabled_field = "status"
enabled_value = "draft"
def get_css_class(self, row):
return "btn-icon btn-primary" if row.get("is_featured") else "btn-icon"
Instantiate reusable subclasses from get_sbadmin_row_actions() when they need view=self, for example DraftOnlyRowAction(view=self).
Per-row enablement supports two patterns:
| Option | Use When |
|---|---|
enabled_if | You need custom logic. It receives the row dict and takes precedence over enabled_field. |
enabled_field + enabled_value | You need a simple equality check like row["status"] == "draft". |
Processing Contract
Framework consumers call processed getters, not raw getters:
| Surface | Raw declaration getter | Processed consumer getter |
|---|---|---|
| List buttons | get_sbadmin_list_actions() | get_sbadmin_list_actions_processed() |
| Bulk selection buttons | get_sbadmin_list_selection_actions() | get_sbadmin_list_selection_actions_processed() |
| Row buttons | get_sbadmin_row_actions() | get_sbadmin_row_actions_processed() |
| Detail buttons | get_sbadmin_detail_actions() | get_sbadmin_detail_actions_processed() |
| Inline row buttons | get_sbadmin_inline_list_actions() | get_sbadmin_inline_list_actions_processed() |
Key points:
- Use
get_sbadmin_row_actions()when the action needsview=self. URL-only actions can also be declared in thesbadmin_row_actionsclass attribute. MODIFIER_OBJECT_IDis the literal token"__object_id__"; use it only in direct custom URLs. Row method/modal actions receive the row pk through theobject_idargument.RowActionModalViewresolves objects withself.view.get_queryset(request)by default. Do not override this with a raw model manager unless you also preserve queryset restrictions.- Row actions are injected after list plugins reshape final data.
TabulatorNestedPlugininjects actions into hydrated child rows too. - Methods referenced by
action_idmust be decorated with@sbadmin_action; non-modal methods can returnself.build_action_response(request)to render notifications and trigger table reloads.
Field Formatters
Built-in formatters for common display patterns in django_smartbase_admin.engine.field_formatter:
from django_smartbase_admin.engine.field_formatter import (
array_badge_formatter, # Display list as horizontal badges
newline_separated_array_badge_formatter, # Display list as vertical badges (one per line)
boolean_formatter, # Yes/No badges
build_choice_formatter_for_field, # Choice value → label (auto-assigned on choice fields)
datetime_formatter, # Localized datetime
datetime_formatter_with_format, # Custom date/time format
link_formatter, # Clickable URL
rich_text_formatter, # HTML content with max-width
format_array, # Custom badge formatting with BadgeType
format_badge, # Single value → one badge (BadgeType or colour token)
badge_formatter, # python_formatter factory wrapping format_badge
)
SBAdminField.init_field_static() also auto-assigns python_formatter for date, boolean, and choice (flatchoices) model fields. Choice fields use build_choice_formatter_for_field. Set python_formatter explicitly or use an admin method to override.
Array Badge Formatters
Display lists as styled badge pills (great for tags, categories):
from django_smartbase_admin.engine.field_formatter import (
array_badge_formatter, # Horizontal: [Tag1] [Tag2] [Tag3]
newline_separated_array_badge_formatter, # Vertical: [Tag1]
# [Tag2]
# [Tag3]
)
class ArticleAdmin(SBAdmin):
def tags_display(self, obj_id, value, **additional_data):
tag_names = additional_data.get("tag_names_arr") or []
return newline_separated_array_badge_formatter(obj_id, tag_names)
sbadmin_list_display = (
SBAdminField(
name="tags_display",
title="Tags",
annotate=Value("", output_field=TextField()),
supporting_annotates={"tag_names_arr": get_tag_names_subquery()},
filter_disabled=True,
),
)
Badge Types
Use format_array directly for custom badge colors:
from django_smartbase_admin.engine.field_formatter import format_array, BadgeType
# BadgeType options: NOTICE (default), SUCCESS/POSITIVE (green), WARNING (yellow),
# ERROR (red), NEUTRAL (gray), PRIMARY (brand color)
format_array(["Published", "Featured"], badge_type=BadgeType.SUCCESS)
Single-Value Badge (format_badge / badge_formatter)
For a single value (not a list), format_badge(value, badge_type=BadgeType.NOTICE, simple=True) renders one badge. badge_type accepts a BadgeType or a raw colour token (e.g. "warning"); an empty/None value renders nothing; simple=False keeps the leading dot indicator.
from django_smartbase_admin.engine.field_formatter import (
format_badge, badge_formatter, BadgeType,
)
class ArticleAdmin(SBAdmin):
# Inline use in an admin method:
def status_display(self, obj_id, value, **additional_data):
color = BadgeType.SUCCESS if value == "published" else BadgeType.NEUTRAL
return format_badge(value, color)
sbadmin_list_display = (
SBAdminField(name="status_display", title="Status", annotate=F("status"), filter_disabled=True),
# Or `badge_formatter(...)` straight as a column's python_formatter:
SBAdminField(
name="category_badge",
title="Category",
annotate=F("category__name"),
python_formatter=badge_formatter(BadgeType.PRIMARY),
filter_disabled=True,
),
)
Key points:
- Prefer
format_badgeover hand-written<span class="badge …">markup so colour/markup stays consistent (it's whatboolean_formatterand the messaging badges use). badge_formatter(badge_type, simple=True)returns a(object_id, value)callable — drop it straight intopython_formatter.
XLSX Export Field Formatting
Use XLSXFieldOptions.cell_format to apply an Excel format to one exported column.
Supported cell_format values:
str— key fromSBAdminXLSXOptions.cell_formatsdict— raw xlsxwriter format propsSBAdminXLSXFormat— class-based format definition
from django_smartbase_admin.engine.field import SBAdminField, XLSXFieldOptions
from django_smartbase_admin.services.xlsx_export import SBAdminXLSXFormat
class ArticleAdmin(SBAdmin):
sbadmin_list_display = (
SBAdminField(
name="price",
title="Price",
xlsx_options=XLSXFieldOptions(
cell_format=SBAdminXLSXFormat(
num_format='#,##0.00 "€"',
align="right",
)
),
),
)
Named lookup via cell_formats:
class ArticleAdmin(SBAdmin):
def get_sbadmin_xlsx_options(self, request):
options = super().get_sbadmin_xlsx_options(request)
options.cell_formats = {
"money": {"num_format": '#,##0.00 "€"', "align": "right"},
}
return options
sbadmin_list_display = (
SBAdminField(
name="price",
xlsx_options=XLSXFieldOptions(cell_format="money"),
),
)
Performance Optimization
Preventing N+1 Queries with Subqueries
Use Subquery and ArrayAgg in supporting_annotates to fetch related data in a single query instead of per-row queries:
from django.contrib.postgres.aggregates import ArrayAgg
from django.db.models import DateTimeField, OuterRef, Subquery, TextField, Value
from django.db.models.functions import Coalesce
from blog.models import ArticleTag, Comment
# Subquery for scalar value (e.g., get latest comment time)
def get_latest_comment_subquery() -> Subquery:
return Subquery(
Comment.objects.filter(article_id=OuterRef("pk"))
.order_by("-created_at")
.values("created_at")[:1],
output_field=DateTimeField(),
)
# Subquery for array aggregation (e.g., get list of tag names)
def get_tag_names_subquery() -> Subquery:
return Subquery(
ArticleTag.objects.filter(article_id=OuterRef("pk"))
.values("article_id")
.annotate(names=ArrayAgg("tag__name", distinct=True))
.values("names")[:1]
)
class ArticleAdmin(SBAdmin):
def latest_comment_display(self, obj_id, value, **additional_data):
comment_time = additional_data.get("latest_comment_val")
return comment_time.strftime("%Y-%m-%d") if comment_time else "-"
def tags_display(self, obj_id, value, **additional_data):
tag_names = additional_data.get("tag_names_arr") or []
if not tag_names:
return ""
badges = "".join(f'<span class="badge">{name}</span>' for name in tag_names if name)
return mark_safe(badges)
sbadmin_list_display = (
SBAdminField(
name="latest_comment_display",
title="Last Comment",
annotate=Value("", output_field=TextField()),
supporting_annotates={
"latest_comment_val": get_latest_comment_subquery(),
},
filter_disabled=True,
),
SBAdminField(
name="tags_display",
title="Tags",
annotate=Value("", output_field=TextField()),
supporting_annotates={
"tag_names_arr": get_tag_names_subquery(),
},
filter_disabled=True,
),
)
Key patterns:
- Use
OuterRef("pk")to reference the parent model's primary key - Add
[:1]to limit subquery to single result for scalar values - Use
ArrayAggfromdjango.contrib.postgres.aggregatesfor PostgreSQL array aggregation - Always specify
output_fieldon Subquery for type hints
Pagination
Control initial rows displayed with list_per_page:
class ArticleAdmin(SBAdmin):
list_per_page = 20 # Show 20 rows initially
Common Errors
| Error | Cause | Solution |
|---|---|---|
| "Menu item is missing view X" | view_id doesn't match registered admin | Use format {app_label}_{model_name} |
| "The annotation 'X' conflicts with a field" | supporting_annotates key matches model field | Rename annotation key |
| "Expression contains mixed types" | Missing output_field in expression | Add output_field=TextField() to all parts |
| "relation 'django_smartbase_admin_X' does not exist" | Missing migrations | Run python manage.py migrate |
| "Cannot resolve keyword 'X' into field" on detail page | Using computed SBAdminField name in ordering | Override get_list_ordering() - see Ordering with Computed SBAdminField |
| "admin.E116: The value of 'list_filter[N]' refers to 'X', which does not refer to a Field" | Using list_filter with SBAdminField names for annotated fields | Use sbadmin_list_filter instead - see Default Visible Filters |
Spurious * on a sbadmin_list_view_config tab on first load (no user edits) | filterData key doesn't resolve to a real form input, two filters collide, or an ordering field is missing from sbadmin_list_display | Run python manage.py check; look for sbadmin.W001 / W002 / W003 and follow their hints. If the checks are clean, the remaining cause is value byte-for-byte round-trip (typically MultipleChoiceFilterWidget declared as a plain string instead of [{"value": …, "label": …}, …]). |
Inlines
Use SBAdminTableInline instead of Django's admin.TabularInline for inlines in SBAdmin. This provides automatic autocomplete widgets for FK fields.
from django_smartbase_admin.admin.admin_base import SBAdminTableInline
from blog.models import ArticleTag
class ArticleTagInline(SBAdminTableInline):
model = ArticleTag
extra = 0
verbose_name = _("Tag")
verbose_name_plural = _("Tags")
@admin.register(Article, site=sb_admin_site)
class ArticleAdmin(SBAdmin):
model = Article
inlines = [ArticleTagInline]
When an admin overrides get_inlines() to show different inlines per request/object,
keep every possible real inline class in the static inlines attribute as a
superset. SBAdmin registers inline views and their autocomplete action URLs from
that static list during configuration initialization; get_inlines() should only
decide which already-registered inline classes render for the current object.
Available inline classes:
SBAdminTableInline- Tabular layout (likeTabularInline)SBAdminStackedInline- Stacked layout (likeStackedInline)SBAdminGenericTableInline- For GenericForeignKey relationsSBAdminGenericStackedInline- Stacked GenericForeignKey
Validated Singleton Inline Creation on Add
Django treats extra inline forms with default-only values as unchanged. For required singleton inlines, this may result in skipped creation unless the form is considered changed during validation.
Behavior in SBAdmin
For parent add flow, SBAdmin supports validated singleton creation with:
min_num = 1max_num = 1validate_min = Truevalidate_max = True
When these conditions are met, SBAdmin marks the first extra inline form as changed during formset full_clean(). This lets normal validation/save flow create the related object (including default-only values).
❌ BAD - Singleton inline can still be skipped
from django.contrib import admin
from django_smartbase_admin.admin.admin_base import SBAdmin, SBAdminStackedInline
from django_smartbase_admin.admin.site import sb_admin_site
from blog.models import Article, ArticleMeta
class ArticleMetaInline(SBAdminStackedInline):
model = ArticleMeta
min_num = 1
max_num = 1
# Missing validate_min / validate_max
can_delete = False
extra = 1
@admin.register(Article, site=sb_admin_site)
class ArticleAdmin(SBAdmin):
inlines = [ArticleMetaInline]
✅ GOOD - Validated singleton is created on add
from django.contrib import admin
from django_smartbase_admin.admin.admin_base import SBAdmin, SBAdminStackedInline
from django_smartbase_admin.admin.site import sb_admin_site
from blog.models import Article, ArticleMeta
class ArticleMetaInline(SBAdminStackedInline):
model = ArticleMeta
min_num = 1
max_num = 1
validate_min = True
validate_max = True
can_delete = False
extra = 1
@admin.register(Article, site=sb_admin_site)
class ArticleAdmin(SBAdmin):
inlines = [ArticleMetaInline]
Key points:
- Scope is intentionally add-only to keep update flow conservative.
- Validation still runs normally; this does not bypass field/model validation.
- If required inline fields have no defaults and remain empty, validation can still fail (expected).
Fake Inlines (SBAdminFakeInlineMixin)
Django's built-in inlines require a real ForeignKey from the inline model to the parent model on the same database. Fake inlines remove that constraint. Use them when:
- The inline model lives in a different database (cross-database relationship)
- The models are unmanaged (
Meta.managed = False) and Django can't introspect the FK - The relationship is indirect (e.g., through a junction table or computed path)
- You need a read-only inline showing related data without a direct Django FK
- You need multiple inlines for the same model on one parent admin (e.g., active vs archived comments) — Django forbids duplicate model inlines, but each fake inline creates a unique proxy model
How It Works
SBAdminFakeInlineMixin creates a dynamic proxy model from the inline model at startup. This proxy tricks Django's formset machinery into accepting the inline without a real FK. A monkeypatch on _get_foreign_key catches the ValueError that Django raises for the missing FK and substitutes a synthetic one.
At query time, the mixin:
- Annotates the queryset with a fake FK field (
inline_fake_relationship) usingget_fake_inline_identifier_annotate() - Intercepts
.filter()calls from the formset and delegates tofilter_fake_inline_identifier_by_parent_instance()to apply the actual parent-child filtering
Usage
Mix SBAdminFakeInlineMixin with SBAdminTableInline (or SBAdminStackedInline). Register the inline on the parent admin via sbadmin_fake_inlines (not inlines).
from django.contrib import admin
from django.db.models import F
from django_smartbase_admin.admin.admin_base import SBAdmin, SBAdminTableInline
from django_smartbase_admin.admin.site import sb_admin_site
from django_smartbase_admin.engine.fake_inline import SBAdminFakeInlineMixin
from blog.models import Article, Comment
class CommentFakeInline(SBAdminFakeInlineMixin, SBAdminTableInline):
model = Comment
fields = ("text", "created_at")
readonly_fields = ("text", "created_at")
verbose_name = "Comment"
verbose_name_plural = "Comments"
extra = 0
can_delete = False
max_num = 0
path_to_parent_instance_id = "article_id"
def get_fake_inline_identifier_annotate(self):
return F("article_id")
def has_add_permission(self, request, obj=None):
return False
@admin.register(Article, site=sb_admin_site)
class ArticleAdmin(SBAdmin):
sbadmin_fake_inlines = [CommentFakeInline]
sbadmin_fieldsets = [
(None, {"fields": ["title", "status", "category"]}),
]
Key Parameters and Methods
| Parameter / Method | Type | Description |
|---|---|---|
model | Model class | Required. The inline model (can be unmanaged, cross-database) |
path_to_parent_instance_id | str | Required. Lookup path from inline model to parent's PK (e.g., "article_id", "article__author_id") |
get_fake_inline_identifier_annotate() | method → Expression | Required override. Returns an F() expression that annotates each inline row with the parent's identifier. Must correspond to path_to_parent_instance_id |
filter_fake_inline_identifier_by_parent_instance() | method | Filters inline queryset by parent instance. Default implementation uses path_to_parent_instance_id — override for custom logic |
save_new_fake_inline_instance() | method | Handles saving new inline instances. Override for writable fake inlines |
get_queryset() | method | Override to add extra filtering (e.g., .filter(active=True)) or ordering. Must call super() first |
Writable Pattern
For editable fake inlines, set path_to_parent_instance_id and allow mutations. The mixin's save_new_fake_inline_instance() sets the FK value on new instances before saving:
class CommentFakeInline(SBAdminFakeInlineMixin, SBAdminTableInline):
model = Comment
fields = ("text",)
extra = 1
can_delete = True
path_to_parent_instance_id = "article_id"
def get_fake_inline_identifier_annotate(self):
return F("article_id")
Custom Queryset Filtering
Override get_queryset to add extra conditions. Always call super() first to get the properly configured fake queryset:
class ActiveCommentFakeInline(SBAdminFakeInlineMixin, SBAdminTableInline):
model = Comment
path_to_parent_instance_id = "article_id"
# ...
def get_fake_inline_identifier_annotate(self):
return F("article_id")
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.filter(is_approved=True).order_by("-created_at")
Registration
CRITICAL: Fake inlines go in sbadmin_fake_inlines, NOT inlines:
# ❌ BAD - Regular inlines list; Django will fail looking for a real FK
class ArticleAdmin(SBAdmin):
inlines = [CommentFakeInline]
# ✅ GOOD - Fake inlines list; SBAdminFakeInlineMixin handles the fake FK
class ArticleAdmin(SBAdmin):
sbadmin_fake_inlines = [CommentFakeInline]
Using with Tabs
Fake inlines work with sbadmin_tabs just like regular inlines — reference by class:
class ArticleAdmin(SBAdmin):
sbadmin_fake_inlines = [CommentFakeInline]
sbadmin_tabs = {
"Content": [None],
"Comments": [CommentFakeInline],
}
Key points:
SBAdminFakeInlineMixinmust be the first parent class (beforeSBAdminTableInline/SBAdminStackedInline) for correct MROpath_to_parent_instance_idis a Django ORM lookup path (e.g.,"article_id","article__author_id")get_fake_inline_identifier_annotate()must return anF()expression that maps each inline row to the parent PK it belongs to- Permission checks flow through
SBAdminRoleConfiguration.has_permission()using the inline'soriginal_model - The dynamic proxy model is created once at startup and cached in the Django app registry
- Works across databases — the inline queryset uses the database router for the inline's model, not the parent's
Source: django_smartbase_admin/engine/fake_inline.py — SBAdminFakeInlineMixin, FakeQueryset, SBAdminFakeInlineFormset; django_smartbase_admin/monkeypatch/fake_inline_monkeypatch.py
Global Autocomplete Widget Customization
Override get_autocomplete_widget on your SBAdminRoleConfiguration subclass to customize autocomplete widgets globally. This applies to all auto-generated admin form fields including inlines.
Parameters
| Parameter | Type | Description |
|---|---|---|
model | Model class | Required. The Django model to query |
multiselect | bool | Allow multiple selections (default: True) |
label_lambda | callable | (request, item) -> str - Format how items appear in dropdown |
value_lambda | callable | (request, item) -> any - Extract value from item (default: primary key) |
value_field | str | Field name to use as value (alternative to value_lambda) |
search_query_lambda | callable | (request, qs, model, search_term, lang_code) -> QuerySet - Define which fields to search. Default searches all CharField/TextField |
filter_search_lambda | callable | (request, search_term, forward_data) -> Q - Pre-filter queryset before search. Use for dependent dropdowns (with forward) or general filtering (e.g., limit to items with related data) |
filter_query_lambda | callable | (request, selected_values) -> Q - How selected values filter the main list (for filter widgets only) |
forward | list[str] | Field names to forward to filter_search_lambda for dependent dropdowns |
allow_add | bool | Allow creating new items inline (default: False, not supported with multiselect) |
create_value_field | str | When allow_add=True, create a new model instance using the typed value as this field (e.g. "name"). Required to enable creation. |
forward_to_create | list[str] | When creating a new value, also copy selected forward fields into the new object (useful for dependent dropdowns / setting FK like parent). |
hide_clear_button | bool | Hide the clear/reset button (default: False) |
Lambda Signatures
# label_lambda - Format display label
def author_label(request, item) -> str:
return f"{item.name} <{item.email}>"
# value_lambda - Extract value (default uses primary key)
def author_value(request, item) -> any:
return item.pk
# search_query_lambda - Define searchable fields
def author_search(request, qs, model, search_term, language_code) -> QuerySet:
if not search_term:
return qs
return qs.filter(Q(name__icontains=search_term) | Q(email__icontains=search_term))
# filter_search_lambda - Pre-filter queryset before search
# Use case 1: Dependent dropdowns (filter based on another field's value)
def subcategory_filter(request, search_term, forward_data) -> Q:
parent_id = forward_data.get("parent_category")
if parent_id:
return Q(parent_id=parent_id)
return Q()
# Use case 2: General filtering (limit to items with related data)
def content_type_filter(request, search_term, forward_data) -> Q:
# Only show content types that have audit logs
ct_ids = AuditLog.objects.values_list("content_type", flat=True).distinct()
return Q(pk__in=ct_ids)
# filter_query_lambda - How selection filters main list (filter widgets only)
def author_filter_query(request, selected_ids) -> Q:
return Q(author_id__in=selected_ids)
Example
Override get_autocomplete_widget on your SBAdminRoleConfiguration subclass:
from django.db.models import Q
from django_smartbase_admin.admin.widgets import SBAdminAutocompleteWidget
from django_smartbase_admin.engine.configuration import SBAdminRoleConfiguration
from blog.models import Author
def author_label(request, item):
return f"{item.name} <{item.email}>"
def author_search(request, qs, model, search_term, language_code):
if not search_term:
return qs
return qs.filter(Q(name__icontains=search_term) | Q(email__icontains=search_term))
class BlogRoleConfiguration(SBAdminRoleConfiguration):
def get_autocomplete_widget(self, view, request, form_field, db_field, model, multiselect=False):
if model == Author:
return SBAdminAutocompleteWidget(
form_field,
model=model,
multiselect=multiselect,
label_lambda=author_label,
search_query_lambda=author_search,
)
return super().get_autocomplete_widget(view, request, form_field, db_field, model, multiselect)
Example: Dependent Dropdown with Forward
# In a form, make "subcategory" dropdown depend on selected "category"
subcategory = forms.ModelChoiceField(
queryset=Category.objects.all(),
widget=SBAdminAutocompleteWidget(
model=Category,
multiselect=False,
forward=["category"], # Forward category field value
filter_search_lambda=lambda req, term, fwd: Q(parent_id=fwd.get("category")) if fwd.get("category") else Q(),
),
)
Forward with radio and checkbox widgets
forward=[...] also works when the forwarded source field renders as a radio group or checkbox group (not just plain inputs/selects). SBAdmin reads checked input values and forwards them to filter_search_lambda.
from django import forms
from django.db.models import Q
from django_smartbase_admin.admin.admin_base import SBAdminBaseFormInit
from django_smartbase_admin.admin.widgets import (
SBAdminAutocompleteWidget,
SBAdminRadioDropdownWidget,
)
from blog.models import Article, Author
class AuthorByStatusForm(SBAdminBaseFormInit, forms.Form):
status = forms.ChoiceField(
choices=Article._meta.get_field("status").choices,
widget=SBAdminRadioDropdownWidget(
choices=Article._meta.get_field("status").choices
),
)
author = forms.ModelChoiceField(
queryset=Author.objects.all(),
widget=SBAdminAutocompleteWidget(
model=Author,
multiselect=False,
forward=["status"],
filter_search_lambda=lambda req, term, fwd: (
Q(article__status=fwd.get("status")) if fwd.get("status") else Q()
),
),
)
Key points:
- Radio widgets forward a single value.
- Checkbox groups forward a list of checked values.
- This works for nested form prefixes too, so the same pattern is safe inside inline/formset rows.
Example: Create on the fly with create_value_field + forward_to_create
Use this when you want “type to search” and the ability to create a missing value from the input. Creation is enabled only when:
allow_add=Truecreate_value_fieldis set- widget is not multiselect (creation is currently not supported for multiselect)
from django import forms
from django.db.models import Q
from django_smartbase_admin.admin.admin_base import SBAdminBaseFormInit
from django_smartbase_admin.admin.widgets import SBAdminAutocompleteWidget
from blog.models import Category
class CreateCategoryInlineForm(SBAdminBaseFormInit, forms.Form):
# NOTE: used by SBAdminAutocompleteWidget to detect FK fields and store them as `<field>_id` on create
model = Category
parent = forms.ModelChoiceField(
label="Parent category",
required=False,
queryset=Category.objects.all(),
widget=SBAdminAutocompleteWidget(
model=Category,
multiselect=False,
label_lambda=lambda request, item: item.name,
),
)
category = forms.ModelChoiceField(
label="Category",
queryset=Category.objects.all(),
widget=SBAdminAutocompleteWidget(
model=Category,
multiselect=False,
allow_add=True,
create_value_field="name", # new Category(name="<typed>")
forward=["parent"], # makes `parent` available in `forward_data`
forward_to_create=["parent"], # new Category(parent_id=<selected parent>)
label_lambda=lambda request, item: item.name,
filter_search_lambda=lambda req, term, fwd: Q(parent_id=fwd.get("parent")) if fwd.get("parent") else Q(),
),
)
Key points (creation):
forward_to_createpulls values from the widget’sforwarddata, so the field must also be present inforward=[...].- For FK fields, SBAdmin will try to store forwarded values under
<field>_idduring creation (soModel.objects.create(**data)accepts raw PKs). This requires the form to expose the model viaform.model(seemodel = Categoryabove).
Key points:
search_query_lambdashould search the same fields shown inlabel_lambdafor intuitive UXfilter_search_lambdaruns BEFORE search - use for dependent dropdowns OR general pre-filtering (e.g., limit to items with related data)search_query_lambdadefines WHICH fields to search- Global config does NOT apply to manually-created widgets - pass lambdas directly
Subclassing for Computed Label Values
When label_lambda needs a computed value (like a count from related tables), subclass SBAdminAutocompleteWidget and override get_queryset to add the annotation:
from django.db.models import Count, OuterRef, Subquery
from django.db.models.functions import Coalesce
from django_smartbase_admin.admin.widgets import SBAdminAutocompleteWidget
from blog.models import Category, Article
class CategoryAutocompleteWidget(SBAdminAutocompleteWidget):
"""Autocomplete widget with article count annotation."""
def get_queryset(self, request=None):
qs = super().get_queryset(request)
article_count_subquery = Subquery(
Article.objects.filter(category=OuterRef("pk"))
.values("category")
.annotate(count=Count("id"))
.values("count")
)
return qs.annotate(
article_count=Coalesce(article_count_subquery, 0)
)
# Usage in form
class ArticleForm(SBAdminBaseFormInit, forms.Form):
category = forms.ModelChoiceField(
queryset=Category.objects.all(),
widget=CategoryAutocompleteWidget(
model=Category,
multiselect=False,
label_lambda=lambda request, item: f"{item.name} ({item.article_count} articles)",
),
)
Why subclass instead of annotating the form field queryset?
- The autocomplete widget fetches its own data via
get_queryset()- it does NOT use the form field's queryset - The form field queryset is only used for validation and
cleaned_data - Annotations in the widget's
get_querysetare available tolabel_lambda
Why use Subquery instead of Count?
- Multiple
Count()aggregates on related tables create Cartesian products - Example:
Count("articles") + Count("children")with 2 articles and 2 children returns 8 instead of 4 - Separate
Subqueryannotations avoid this by calculating each count independently
Autocomplete for Text Field Values (Not ForeignKey)
When you need an autocomplete that stores a text value (not a ForeignKey), use SBAdminAutocompleteWidget with label_lambda and value_lambda to return text instead of the model's pk.
Simple Inline Approach (No Distinct)
When duplicates in the dropdown are acceptable (or the source field is already unique), use lambdas directly:
from django import forms
from django.contrib import admin
from django.db.models import Q
from django_smartbase_admin.admin.admin_base import SBAdmin, SBAdminBaseForm
from django_smartbase_admin.admin.site import sb_admin_site
from django_smartbase_admin.admin.widgets import SBAdminAutocompleteWidget
from blog.models import Article, Author
class ArticleForm(SBAdminBaseForm):
"""Form with author name autocomplete storing text value."""
author_name = forms.CharField(
label="Author Name",
required=False,
widget=SBAdminAutocompleteWidget(
model=Author,
multiselect=False,
label_lambda=lambda req, item: item.name,
value_lambda=lambda req, item: item.name,
filter_search_lambda=lambda req, search, fwd: Q(is_active=True),
),
)
class Meta:
model = Article
fields = "__all__"
@admin.register(Article, site=sb_admin_site)
class ArticleAdmin(SBAdmin):
model = Article
form = ArticleForm
Subclass Approach (With Distinct or Custom Lookup)
Use this when there's no direct model relationship - you're storing a text value (not a FK) but want autocomplete suggestions from another model's field. The subclass tells the widget how to look up and display values from a field that isn't the primary key:
from django import forms
from django.contrib import admin
from django_smartbase_admin.admin.admin_base import SBAdmin, SBAdminBaseForm
from django_smartbase_admin.admin.site import sb_admin_site
from django_smartbase_admin.admin.widgets import SBAdminAutocompleteWidget
from blog.models import Article, Author
class AuthorEmailAutocompleteWidget(SBAdminAutocompleteWidget):
"""Autocomplete widget for distinct author emails."""
def get_queryset(self, request=None):
return (
Author.objects.exclude(email__exact="")
.exclude(email__isnull=True)
.order_by("email")
.distinct("email")
)
def get_value_field(self):
return "email" # Field to look up existing values (not pk)
def get_label(self, request, item):
return item.email
def get_value(self, request, item):
return item.email
class ArticleForm(SBAdminBaseForm):
"""Form with author email autocomplete from distinct Author emails."""
author_email = forms.CharField(
label="Author Email",
required=False,
widget=AuthorEmailAutocompleteWidget(model=Author, multiselect=False),
)
class Meta:
model = Article
fields = "__all__"
@admin.register(Article, site=sb_admin_site)
class ArticleAdmin(SBAdmin):
model = Article
form = ArticleForm
When to use each approach:
| Approach | Use When |
|---|---|
| Inline lambdas | Source field values are unique, duplicates are acceptable, editing existing values not needed |
| Subclass | No FK relationship, need .distinct(), or need to look up existing values when editing (requires get_value_field()) |
Key points:
- Form field is
CharField(notModelChoiceField) since we're storing a text value - Form must inherit from
SBAdminBaseFormfor proper widget initialization label_lambdaandvalue_lambdareturn text field value instead of model's pk- Critical: Override
get_value_field()to return the text field name - this is used to look up existing values when editing (default is"id"which fails for text values) - Use
multiselect=Falsefor single selection filter_search_lambdareturns aQobject - for.distinct()or complex queryset changes, subclass the widget and overrideget_queryset()
Pre-filtered List Views (sbadmin_list_view_config)
Use sbadmin_list_view_config to define pre-filtered view tabs that appear at the top of the list view.
Usage
class ArticleAdmin(SBAdmin):
sbadmin_list_view_config = [
{
"name": "Published",
"url_params": {"filterData": {"status": "published"}},
},
{
"name": "Drafts",
"url_params": {"filterData": {"status": "draft"}},
},
]
list_filter = ("status",)
Structure
| Key | Type | Description |
|---|---|---|
name | str | Required. Tab label shown in the UI |
url_params | dict | Required. Contains filterData with filter key-value pairs |
Filter Data for Named Tabs
The keys in filterData must match each field's filter_field (which defaults to name). For SBAdminField(name="id_list", filter_field="id") the key is "id", not "id_list". A mismatched key silently disables the filter at load time and produces a spurious * on the tab — sbadmin.W002 catches this on manage.py check. See When to set filter_field.
Value format in filterData depends on the filter widget type used by the field:
| Filter Widget | Value Format in filterData | Example |
|---|---|---|
ChoiceFilterWidget | String | "published" |
MultipleChoiceFilterWidget | Array of objects with value and label | [{"value": "published", "label": "Published"}] |
Example: Pre-filtered tabs with MultipleChoiceFilterWidget:
class ArticleAdmin(SBAdmin):
sbadmin_list_display = (
SBAdminField(
name="status",
filter_widget=MultipleChoiceFilterWidget(choices=[
("published", "Published"),
("draft", "Draft"),
("archived", "Archived"),
]),
),
)
sbadmin_list_view_config = [
{
"name": "Published", # Must be array even for single value
"url_params": {"filterData": {"status": [{"value": "published", "label": "Published"}]}},
},
{
"name": "Active", # Multiple values in array
"url_params": {"filterData": {"status": [
{"value": "published", "label": "Published"},
{"value": "draft", "label": "Draft"},
]}},
},
]
list_filter = ("status",)
Note:
MultipleChoiceFilterWidgetvalues must always be an array (even for single selections) because the JavaScript uses.map()to parse the values.
Displaying All Filters in Named Tabs
When switching to a named tab, only filters specified in filterData are shown in the filter bar above the table. To display all filter widgets (even those without values), include them with empty string (""):
class ArticleAdmin(SBAdmin):
sbadmin_list_display = (
SBAdminField(name="status", filter_widget=MultipleChoiceFilterWidget(choices=[...])),
SBAdminField(name="category", filter_widget=FromValuesAutocompleteWidget()),
SBAdminField(name="author", filter_widget=AutocompleteFilterWidget(model=Author)),
)
sbadmin_list_view_config = [
{
"name": "Published",
"url_params": {"filterData": {
"status": [{"value": "published", "label": "Published"}],
"category": "", # Display filter widget (empty)
"author": "", # Display filter widget (empty)
}},
},
]
list_filter = ("status", "category", "author")
Note: The automatic "All" tab already displays all
list_filterfields. For custom tabs, you must explicitly include each filter you want visible in the filter bar.
An "All" tab is automatically added as the first tab.
Source: django_smartbase_admin/engine/admin_base_view.py - SBAdminBaseListView.sbadmin_list_view_config
Default Tab and Menu Link to Filtered View
By default, clicking a menu item opens the "All" tab. To make a custom tab the default (e.g., show "Published" articles when clicking the menu item):
- Override
get_base_configto reorder tabs so your custom tab is first - Override
get_menu_view_urlto construct a URL with filter parameters
from django.contrib import admin
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django_smartbase_admin.admin.admin_base import SBAdmin
from django_smartbase_admin.admin.site import sb_admin_site
from django_smartbase_admin.engine.field import SBAdminField
from django_smartbase_admin.engine.filter_widgets import MultipleChoiceFilterWidget
from django_smartbase_admin.services.views import SBAdminViewService
from blog.models import Article
@admin.register(Article, site=sb_admin_site)
class ArticleAdmin(SBAdmin):
model = Article
sbadmin_list_display = (
SBAdminField(
name="status",
filter_widget=MultipleChoiceFilterWidget(choices=[
("published", "Published"),
("draft", "Draft"),
("archived", "Archived"),
]),
),
"title",
"category",
)
sbadmin_list_filter = ("status",)
sbadmin_list_view_config = [
{
"name": "Published",
"url_params": {
"filterData": {
"status": [{"value": "published", "label": "Published"}],
}
},
},
]
def get_base_config(self, request):
"""Reorder tabs: move custom tabs before 'All'.
Default order from super(): [All, Published, ...]
After rotation: [Published, ..., All]
"""
configs = super().get_base_config(request)
return configs[1:] + configs[:1]
def get_menu_view_url(self, request) -> str:
"""Build menu URL that opens the first custom tab (Published) directly."""
base = reverse(f"sb_admin:{self.get_id()}_changelist")
filter_data = self.sbadmin_list_view_config[0]["url_params"]["filterData"]
params = SBAdminViewService.build_list_params_url(self.get_id(), filter_data)
return f"{base}?{params}"
How it works:
get_base_configreturns the list of tab configs. The default order is[All, ...custom tabs...]. Rotating withconfigs[1:] + configs[:1]puts custom tabs first and "All" last.get_menu_view_urlis called by the menu rendering to get the URL for this model's menu item. By default it returns the changelist URL (which opens "All"). Overriding it to include filter params makes the menu link open the filtered view directly.- The tab order and menu URL are independent — override both for a consistent experience.
Key points:
get_base_configrotation (configs[1:] + configs[:1]) works for any number of custom tabs — the first custom tab becomes the defaultself.get_id()returns the view ID in{app_label}_{model_name}formatSBAdminViewService.build_list_params_urlencodes filter data into URL query parameters- Without
get_menu_view_urloverride, clicking the menu item would show the "All" tab even ifget_base_configreorders tabs (the menu URL is constructed separately)
Building Pre-filtered URLs Programmatically
Use SBAdminViewService.build_list_params_url() to generate URLs with pre-applied filters from Python code (e.g., for redirects or links between admin pages).
from django.urls import reverse
from django_smartbase_admin.services.views import SBAdminViewService
# View ID follows pattern: {app_label}_{model_name}
VIEW_ID = "blog_article"
# Filter data format depends on the filter widget type
filter_data = {
# AutocompleteFilterWidget: array of {value, label}
"category": [
{"value": 5, "label": "Technology"},
],
# MultipleChoiceFilterWidget: array of {value, label}
"status": [
{"value": "published", "label": "Published"},
{"value": "draft", "label": "Draft"},
],
}
# Build the URL
base_url = reverse("sb_admin:blog_article_changelist")
params_str = SBAdminViewService.build_list_params_url(VIEW_ID, filter_data)
full_url = f"{base_url}?{params_str}"
Filter Data Format by Widget Type:
| Widget | Format | Example |
|---|---|---|
AutocompleteFilterWidget | [{"value": ..., "label": ...}] | [{"value": 5, "label": "Tech"}] |
MultipleChoiceFilterWidget | [{"value": ..., "label": ...}] | [{"value": "draft", "label": "Draft"}] |
ChoiceFilterWidget | String | "published" |
BooleanFilterWidget | Boolean | True or False |
StringFilterWidget | String | "search term" |
DateFilterWidget | String (ISO format) | "2024-01-15" |
Example: Link from Comment to filtered Article list
# blog/admin.py
from django.shortcuts import redirect
from django.urls import reverse
from django_smartbase_admin.admin.admin_base import SBAdmin
from django_smartbase_admin.services.views import SBAdminViewService
from blog.models import Article, Comment
@admin.register(Comment, site=sb_admin_site)
class CommentAdmin(SBAdmin):
def response_change(self, request, obj):
"""After saving a comment, redirect to article list filtered by author."""
if "_view_author_articles" in request.POST:
author = obj.article.author
# Build filter with {value, label} format for AutocompleteFilterWidget
filter_data = {
"author": [
{"value": author.pk, "label": author.name},
],
}
view_id = "blog_article"
base_url = reverse("sb_admin:blog_article_changelist")
params_str = SBAdminViewService.build_list_params_url(view_id, filter_data)
return redirect(f"{base_url}?{params_str}")
return super().response_change(request, obj)
Key points:
- View ID format:
{app_label}_{model_name}(lowercase, underscore-separated) AutocompleteFilterWidgetandMultipleChoiceFilterWidgetrequire array format withvalueandlabelkeys- The
labelis displayed in the filter UI;valueis used for the actual query - Use
reverse("sb_admin:{app_label}_{model_name}_changelist")to get the base URL
Source: django_smartbase_admin/services/views.py - SBAdminViewService.build_list_params_url
Nested List View (sbadmin_nested)
Render a self-referential model as a one-level tree in the list view using Tabulator's dataTree. Pagination is by parent groups (one page = N roots + their children), filters apply across the whole tree, and restrict_queryset is re-enforced on the parent side of the FK.
The Category model in the demo schema has a self-referential parent FK — the examples below use it as the concrete example.
Minimal Setup
Two things are needed: register TabulatorNestedPlugin globally on your role configuration, then opt-in per-admin via sbadmin_nested.
# blog/sbadmin_config.py
from django_smartbase_admin.engine.configuration import SBAdminConfigurationBase, SBAdminRoleConfiguration
from django_smartbase_admin.plugins.nested import TabulatorNestedPlugin
_role_config = SBAdminRoleConfiguration(
# ... menu_items, registered_views, etc. ...
plugins=[TabulatorNestedPlugin],
)
class SBAdminConfiguration(SBAdminConfigurationBase):
def get_configuration_for_roles(self, user_roles):
return _role_config
# blog/admin.py
from django.contrib import admin
from django_smartbase_admin.admin.admin_base import SBAdmin
from django_smartbase_admin.admin.site import sb_admin_site
from blog.models import Category
@admin.register(Category, site=sb_admin_site)
class CategoryAdmin(SBAdmin):
model = Category
sbadmin_list_display = ("name", "parent")
sbadmin_nested = {"parent_field": "parent"}
Result: root categories render as top-level rows with an expand chevron; one level of children appears under each root. Grandchildren are not rendered.
Configuration Keys
| Key | Type | Default | Description |
|---|---|---|---|
parent_field | str | required | Name of the self-referential FK on the model (e.g. "parent"). Must be a ForeignKey("self", null=True) — validated at request time. |
element_column | str | first visible column | Column that gets the tree expand/collapse chevron (dataTreeElementColumn). Accepts either a column name from sbadmin_list_display or a raw Tabulator field id. |
start_expanded | bool | False | Render all parent rows expanded on first load. |
only_show_filtered_children | bool | True | If True, children only appear when they match the current filter; parents always hydrate even if they didn't match. If False, all direct children of each visible parent are included. |
parent_field_guarantees_root | bool | False | Set to True only when the FK is guaranteed to point directly at a root row (no grandchildren / deeper nesting). Skips the root-guard subquery for faster parent grouping. |
Example with all keys:
@admin.register(Category, site=sb_admin_site)
class CategoryAdmin(SBAdmin):
model = Category
sbadmin_list_display = ("name", "parent")
sbadmin_nested = {
"parent_field": "parent",
"element_column": "name", # chevron on the name column
"start_expanded": True,
"only_show_filtered_children": False,
"parent_field_guarantees_root": False,
}
Dynamic Config: get_sbadmin_nested()
Like most sbadmin_* attributes, sbadmin_nested has a request-aware companion. Useful for toggling the tree per user role or per query parameter:
@admin.register(Category, site=sb_admin_site)
class CategoryAdmin(SBAdmin):
model = Category
def get_sbadmin_nested(self, request):
# Flat list for search; tree otherwise.
if request.GET.get("q"):
return None
return {"parent_field": "parent"}
Return None to disable the tree for that request — the admin renders a normal flat list.
Key points:
TabulatorNestedPluginmust be added toSBAdminRoleConfiguration.pluginsand the admin must declaresbadmin_nested— the plugin self-guards and is a no-op for admins without it.parent_fieldmust be a self-referentialForeignKey; proxy models pointing at the concrete model also validate.- Only one level of nesting is supported. Grandchildren are dropped; flatten deeper hierarchies before displaying.
- Pagination counts parent groups;
list_per_pagecontrols roots-per-page, not rows-per-page. - Filters apply to the whole tree by default (
only_show_filtered_children=True). Parents still hydrate even when only a child matched. restrict_querysetis re-enforced on the parent side of the FK automatically — a child whose parent was filtered out won't leak as a phantom root.
Source: django_smartbase_admin/plugins/nested.py — TabulatorNestedPlugin, resolve_nested
Tree Widget & Tree List View (treebeard MP_Node)
For multi-level hierarchical data backed by django-treebeard materialized-path nodes (MP_Node), SBAdmin ships three complementary pieces built on top of Fancytree:
| Piece | Purpose |
|---|---|
SBAdminTreeWidget | Form widget to pick a node (e.g. a parent) from a searchable collapsible tree |
SBAdminTreeFilterWidget | Filter widget to restrict a list view by subtree |
sb_admin/actions/tree_list.html | Changelist template that replaces the flat Tabulator table with a Fancytree tree — supports drag reorder |
When to use this vs
sbadmin_nested? —sbadmin_nestedis for one-level trees over an ordinary self-referentialForeignKey. UseMP_Node+ the tree widget/list for arbitrary-depth hierarchies (taxonomies, menu trees, folder structures), especially when you need drag-and-drop reorder across levels.
Demo Model
The demo schema's Category model (self-FK) covers the one-level sbadmin_nested case. For the multi-level tree examples below, extend the schema with a separate treebeard-backed Topic taxonomy (e.g. Technology > Programming > Python):
# blog/models.py
from treebeard.mp_tree import MP_Node
from django.db import models
class Topic(MP_Node):
name = models.CharField(max_length=100)
slug = models.SlugField(max_length=120, unique=True)
node_order_by = ["name"]
def __str__(self):
return self.name
MP_Node provides the path, depth, and numchild fields plus steplen / _inc_path / _get_basepath helpers that the widget relies on.
Subclassing SBAdminTreeWidgetMixin
Both the form widget and the filter widget derive from SBAdminTreeWidgetMixin. You subclass it once to teach SBAdmin how to render rows for your model, then reuse the subclass anywhere:
# blog/tree_widgets.py
from django_smartbase_admin.admin.widgets import SBAdminTreeWidget
from django_smartbase_admin.engine.filter_widgets import SBAdminTreeFilterWidget
from blog.models import Topic
class TopicTreeMixin:
"""Shared config for any Topic tree widget (form, filter, list)."""
model = Topic
order_by = ("path",)
path_field = "path"
@classmethod
def get_tree_title(cls, request, item):
"""Fancytree node label (HTML allowed)."""
return item["name"]
@classmethod
def get_label(cls, request, item):
"""Label shown in the bound form field's pill after selection."""
return item.name
class TopicTreeWidget(TopicTreeMixin, SBAdminTreeWidget):
"""Form widget: pick a Topic (e.g. a parent) from a tree."""
class TopicTreeFilterWidget(TopicTreeMixin, SBAdminTreeFilterWidget):
"""Filter widget: restrict a list view to a Topic subtree."""
Required overrides on the mixin:
| Classmethod / attribute | Purpose |
|---|---|
model | The treebeard model class. |
order_by | Ordering used when walking the queryset — typically ("path",). |
get_tree_title(cls, request, item) | Text shown inside each tree row. item is a dict of the values listed by get_tree_base_values() (at minimum id and path). |
get_label(cls, request, item) | Label shown in the selected-value pill of a form widget. Only required when using the widget as a form field. |
Optional extensions:
| Extension point | When to override |
|---|---|
get_tree_base_values() | Fetch additional columns from the DB (e.g. ["id", "path", "url"]) so get_tree_title / get_additional_data can use them. |
get_additional_data(cls, request, item, tree_process_global_data) | Inject per-row data into the Fancytree node (rendered in additional_columns). |
tree_process_global_data(cls, request, queryset, **kwargs) | Precompute shared state (e.g. permission maps) once per request and feed it to get_additional_data. |
SBAdminTreeWidget — form widget for picking a node
Use on a ModelChoiceField to let users pick a parent (or any node) from the tree. The widget supports a parent-only mode that disables selecting the current object or its descendants — essential for "change parent" forms to avoid cycles.
# blog/forms.py
from django import forms
from django_smartbase_admin.admin.admin_base import SBAdminBaseForm
from django_smartbase_admin.engine.filter_widgets import SBAdminTreeWidgetMixin
from blog.models import Topic
from blog.tree_widgets import TopicTreeWidget
class TopicForm(SBAdminBaseForm):
parent = forms.ModelChoiceField(
label="Parent topic",
required=False,
queryset=Topic.objects.all(),
widget=TopicTreeWidget(
model=Topic,
relationship_pick_mode=SBAdminTreeWidgetMixin.RELATIONSHIP_PICK_MODE_PARENT,
# Optional: render extra columns in the tree popup
additional_columns=[
{"field": "slug", "title": "Slug"},
],
),
)
class Meta:
model = Topic
fields = ["name", "slug", "parent"]
Key parameters:
| Parameter | Default | Description |
|---|---|---|
relationship_pick_mode | None | Set to RELATIONSHIP_PICK_MODE_PARENT when the field is a parent reference; the widget disables selecting the edited object itself and its descendants and raises ValidationError if the user tampers with the value client-side. |
inline | False | Render the tree inline (always visible) instead of in a popup. Switches the template to sb_admin/widgets/tree_select_inline.html. |
additional_columns | None | List of {"field": ..., "title": ...} dicts shown as extra columns next to the node title. |
tree_strings | Sensible defaults | Override UI strings (loading, loadError, moreData, noData). |
SBAdminTreeFilterWidget — filter widget
Swap in as the filter_widget on an SBAdminField to get a tree-based filter UI — users expand/collapse subtrees and tick nodes to restrict the list.
# blog/admin.py
from django.contrib import admin
from django_smartbase_admin.admin.admin_base import SBAdmin
from django_smartbase_admin.admin.site import sb_admin_site
from django_smartbase_admin.engine.field import SBAdminField
from blog.models import Topic
from blog.tree_widgets import TopicTreeFilterWidget
@admin.register(Topic, site=sb_admin_site)
class TopicAdmin(SBAdmin):
sbadmin_list_display = (
"name",
SBAdminField(
name="topic_subtree",
title="Subtree",
filter_field="path",
filter_widget=TopicTreeFilterWidget(model=Topic, multiselect=True),
list_visible=False,
),
)
sbadmin_list_filter = ("topic_subtree",)
The filter's selected values are materialized-path prefixes; combined with a filter_field resolving to the model's path, treebeard's path semantics naturally restrict to the selected subtrees.
Tree List View (sb_admin/actions/tree_list.html)
For the full tree changelist (no flat Tabulator rows — the whole list renders as a Fancytree with drag reorder), override change_list_template on your admin. The tree pulls its data from a URL you expose via @sbadmin_action using get_tree_data:
# blog/admin.py
from django.contrib import admin
from django.http import JsonResponse
from django_smartbase_admin.admin.admin_base import SBAdmin
from django_smartbase_admin.admin.site import sb_admin_site
from django_smartbase_admin.engine.actions import sbadmin_action
from blog.models import Topic
from blog.tree_widgets import TopicTreeMixin
@admin.register(Topic, site=sb_admin_site)
class TopicAdmin(TopicTreeMixin, SBAdmin):
change_list_template = "sb_admin/actions/tree_list.html"
# Keep the default reorder template — reorder mode uses the flat list.
# reorder_list_template = "sb_admin/actions/list.html" # already default
sbadmin_list_display = ("name", "slug")
@sbadmin_action
def action_tree_json(self, request, modifier, object_id):
"""Emit Fancytree node JSON for the whole tree."""
qs = self.get_queryset(request)
data = self.get_tree_data(request, qs, values=["name", "slug"])
return JsonResponse(data, safe=False)
def get_context_data(self, request):
context = super().get_context_data(request)
context.update(
{
"list_title": "Topic tree",
"tree_json_url": self.get_action_url("action_tree_json"),
"additional_columns": [
{"field": "slug", "title": "Slug"},
],
}
)
return context
def get_tabulator_definition(self, request):
definition = super().get_tabulator_definition(request)
# Enable drag reorder on the Fancytree list — POST target for reordered paths.
definition["treeReorderUrl"] = self.get_action_url("action_tree_reorder")
return definition
The tree_list.html template expects these context values (set them in get_context_data as shown above):
| Context key | Description |
|---|---|
list_title | Label of the main tree column (usually the model's verbose name). |
tree_json_url | URL that returns the Fancytree JSON — the admin's @sbadmin_action endpoint. |
additional_columns | Extra columns ([{"field": ..., "title": ...}, ...]) shown alongside the tree. |
tabulator_definition.treeReorderUrl | (optional) URL that accepts the reorder POST when users drag nodes. Omit to render a read-only tree. |
Saving reorder with process_treebeard_tree
When users drag nodes in the tree list view, the JS posts the full new tree shape [{"key": "<path>", "children": [...]}, ...] to your treeReorderUrl. Use SBAdminTreeWidgetMixin.process_treebeard_tree to translate that payload into the minimal set of path / depth / numchild updates and persist them in a single bulk_update:
import json
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from django_smartbase_admin.engine.actions import sbadmin_action
from blog.models import Topic
class TopicAdmin(TopicTreeMixin, SBAdmin):
# ... fields above ...
@sbadmin_action
@require_POST
def action_tree_reorder(self, request, modifier, object_id):
tree_widget_data = json.loads(request.body)
objs_by_path = {obj.path: obj for obj in Topic.objects.all()}
to_update = self.process_treebeard_tree(tree_widget_data, objs_by_path)
Topic.objects.bulk_update(to_update, ["path", "depth", "numchild"])
return JsonResponse({"status": "ok"})
process_treebeard_tree walks the submitted tree in order, recomputes each node's path / depth / numchild, and returns only the objects whose values actually changed — bulk updating that reduced set is safe and cheap even for large trees.
Key points:
- Requires django-treebeard — the model must inherit from
MP_Node(materialized path).AL_Node/NS_Nodeare not supported; the depth math hard-codes the materialized-path encoding. SBAdminTreeWidgetMixinis the single subclassing point — always share it between the form widget, filter widget, and list view to keep row labels consistent.- Set
relationship_pick_mode=RELATIONSHIP_PICK_MODE_PARENTon every parent-selection form widget — without it the user can pick the edited node or its children and create a cycle. - The tree list view is wired by hand — nothing auto-registers
action_tree_json/action_tree_reorderURLs or the context values; the template is a scaffold, not a turnkey changelist. Decorate URL-callable methods with@sbadmin_action. - Keep
reorder_list_templateat its default (sb_admin/actions/list.html) — reorder mode uses the flat list even when the normal list is a tree. Users enter reorder mode via the standard list reorder action. - Works across databases — tree traversal is purely in-Python over the flat queryset; no DB-specific features required (unlike
TabulatorNestedPluginwhich needs Postgres).
Source: django_smartbase_admin/engine/filter_widgets.py — SBAdminTreeWidgetMixin, SBAdminTreeFilterWidget, process_treebeard_tree; django_smartbase_admin/admin/widgets.py — SBAdminTreeWidget; django_smartbase_admin/templates/sb_admin/actions/tree_list.html; django_smartbase_admin/templates/sb_admin/widgets/tree_base.html.
List View Plugins (SBAdminPlugin)
SBAdminPlugin is the protocol the list-view pipeline exposes for cross-admin reshaping — Tabulator definition, queryset shaping, and post-formatting. TabulatorNestedPlugin is the built-in example; write your own when you need the same reshape across every list view (e.g. tenant scoping, soft-delete dimming, custom tree variants).
Registration
Plugins live on SBAdminRoleConfiguration.plugins. The list is iterated for every list view in the role:
# blog/sbadmin_config.py
from django_smartbase_admin.engine.configuration import SBAdminRoleConfiguration
from django_smartbase_admin.plugins.nested import TabulatorNestedPlugin
from blog.plugins import SoftDeleteDimmingPlugin
_role_config = SBAdminRoleConfiguration(
plugins=[TabulatorNestedPlugin, SoftDeleteDimmingPlugin],
# ...
)
Plugins are stateless classmethod-only classes — within a single request they share state across hooks through get_request_data_plugin_store. They must self-guard: inspect view.sbadmin_nested / admin config, and return their input unchanged when they don't apply.
Hook Pipeline
Hooks are called in this order by SBAdminListAction:
| # | Hook | Purpose |
|---|---|---|
| 1 | modify_tabulator_definition(view, request, definition) | Inject Tabulator options (dataTree, custom modules, etc.) into the JSON sent to the client. |
| 2 | modify_count_queryset(action, request, qs) | Reshape the qs .count() runs on. Used by the nested plugin to group by parent id so pagination counts parent groups. |
| 3 | modify_base_queryset(action, request, qs, values) | Store-only. Observe the unfiltered .values()-applied qs. Reshaping here leaks into the visible page — return qs unchanged. |
| 4 | modify_data_queryset(action, request, qs, page_num, page_size) | Reshape the unsliced, filtered, ordered data qs. Caller slices [from:to] on the return value. |
| 5 | modify_final_data(action, request, data) | Reshape already-formatted row dicts (e.g. assemble _children from group metadata). Runs after column formatters. |
Per-Request State
Cross-hook state goes through get_request_data_plugin_store(request). It returns a per-request, per-plugin-class scratch dict keyed by cls.__name__, so sibling plugins don't collide:
from django.utils.safestring import mark_safe
from django_smartbase_admin.plugins.base import SBAdminPlugin
class SoftDeleteStrikethroughPlugin(SBAdminPlugin):
@classmethod
def modify_base_queryset(cls, action, request, qs, values, **kwargs):
# Store-only — observe, don't reshape.
if "deleted_at" in values:
cls.get_request_data_plugin_store(request)["had_deleted_at"] = True
return qs
@classmethod
def modify_final_data(cls, action, request, data, **kwargs):
store = cls.get_request_data_plugin_store(request)
if not store.get("had_deleted_at"):
return data
# Mutate the visible ``name`` column; the hidden ``deleted_at`` is
# available here (filter pulled it into ``.values()``) but is then
# stripped from the JSON by ``_strip_to_visible_keys``.
for row in data:
if row.get("deleted_at") and row.get("name"):
row["name"] = mark_safe(f"<s>{row['name']}</s>")
return data
Why not
row["_css_class"]? SBAdmin ships no row formatter that reads it, and_strip_to_visible_keysdrops anything not inaction.allowed_framework_keys. For per-row CSS, wrap a visible column in<span class="...">…</span>, or register your own key + TabulatorrowFormatterin your project.
Hook Signature Conventions
All hooks take request as a keyword-style argument and accept **kwargs — call sites stay forward-compatible when new args are added. All hooks are @classmethod.
Key points:
- Plugins are classmethod-only; never instantiate them.
- Self-guard by inspecting admin config — a plugin returns its input unchanged when it doesn't apply, so it's safe to register globally.
modify_base_querysetis store-only; reshaping there changes the visible page silently. Usemodify_data_querysetif you want reshape to affect the slice.- Plugins that need to re-enter
build_final_data_{count_,}querysetpassapply_plugins=Falseto avoid recursion. - Cross-hook state via
get_request_data_plugin_store; the action never writes into a plugin's slot.
Source: django_smartbase_admin/plugins/base.py — SBAdminPlugin, PLUGIN_DATA_KEY
List-View AJAX Notifications
Django messages queued during a list-action request (messages.add_message(request, level, "…")) are auto-embedded in the JSON response and rendered into the standard notification slot — same slot used for HTMX swaps. Works for anything running inside action_list_json: restrict_queryset, plugins, row-action hooks, signal handlers. No template / JS wiring needed.
Failing Soft on Errors
Without this, a list-query failure bubbles a 500 and Tabulator renders a generic "Ajax Error" with no explanation. To replace that with an actual informative notification, subclass SBAdminListAction, catch in get_json_data, queue a message, and return an empty payload — the base flow embeds the queued message automatically so the user sees why the list is empty.
import logging
from django.contrib import admin, messages
from django.utils.translation import gettext_lazy as _
from django_smartbase_admin.actions.admin_action_list import SBAdminListAction
from django_smartbase_admin.admin.admin_base import SBAdmin
from django_smartbase_admin.admin.site import sb_admin_site
from blog.models import Comment
logger = logging.getLogger(__name__)
class FailSoftListAction(SBAdminListAction):
def get_json_data(self):
try:
return super().get_json_data()
except Exception:
logger.exception("List query failed for %s", self.view.get_id())
messages.warning(
self.threadsafe_request,
_("The list could not be loaded. Try again."),
)
return {"last_page": 0, "data": [], "last_row": 0}
@admin.register(Comment, site=sb_admin_site)
class CommentAdmin(SBAdmin):
sbadmin_list_action_class = FailSoftListAction
Key points:
self.threadsafe_requestis the active request — use it withmessages.add_message.- Return shape matches
get_data():{"last_page", "data", "last_row"}. - Wire on a shared project base admin to enable fail-soft for every list view at once. Scope the
exceptto whichever exception class fits your case.
Fieldset Options
Use fieldsets or sbadmin_fieldsets to control form layout in normal admin add/change views, stacked inlines, and modal/action forms. Each fieldset is a (name, options) tuple. name may be None to omit the title text, but names should be unique when the form has multiple fieldsets.
from django.contrib import admin
from django_smartbase_admin.admin.admin_base import SBAdmin
from django_smartbase_admin.admin.site import sb_admin_site
from blog.models import Article
@admin.register(Article, site=sb_admin_site)
class ArticleAdmin(SBAdmin):
sbadmin_fieldsets = (
(
"Content",
{
"fields": ("title", "body", ("category", "author")),
"description": "Editorial content and ownership.",
},
),
(
"Advanced",
{
"fields": ("status",),
"classes": ("wide",),
"collapsible": True,
"default_collapsed": True,
"hide_if_empty": True,
},
),
)
Supported option keys:
| Key | Type | Description |
|---|---|---|
fields | iterable | Field names to render. Group a row with a nested tuple/list such as ("category", "author"). SBAdmin also accepts SBDynamicRegion markers here. |
classes | list/tuple/string | Extra classes on the rendered <fieldset>. Use DETAIL_STRUCTURE_RIGHT_CLASS here for sidebar fieldsets. |
description | string | Tooltip/help text next to the fieldset title. Escape user-controlled HTML before passing it here. |
actions | iterable | Header actions rendered next to the fieldset title. Actions use the same SBAdminCustomAction / SBAdminFormViewAction objects and has_permission_for_action filtering as detail actions. |
collapsible | bool | Render the fieldset body inside Bootstrap collapse and add a header toggle. Defaults to False. |
default_collapsed | bool | Initial closed state for a collapsible fieldset. Defaults to False; ignored unless collapsible=True. |
hide_if_empty | bool | Hide the rendered <fieldset> with the existing hidden class when it has no visible fields. Defaults to False. Useful for fieldsets whose SBDynamicRegion may currently render no active fields. |
Key points:
- Use
sbadmin_fieldsetswhen you need SBAdmin-only behavior such as dynamic regions or collapsible fieldsets. Plain Djangofieldsetsstill works for basicfields,classes, anddescription. collapsible=Trueuses Bootstrap collapse; do not activate it withclasses=["collapse"].default_collapsed=Truestarts the fieldset closed. Existing JS opens collapsed ancestors when focusing validation errors.hide_if_empty=Trueis visual only: it does not disable, clear, or remove fields from form submission. Fieldsets with validation errors stay visible, and dynamic fieldsets are re-checked after HTMX swaps.- Fieldset actions are detail-style links. URL-callable fieldset actions receive the current object id through the
object_idargument. - SBAdmin looks up fieldset metadata by the tuple's first value (
name). Avoid multiple fieldsets with the same name, including multipleNonefieldsets, when using dynamic regions or collapse metadata. - The collapse id is generated from
sbadmin-fieldset, the form prefix when present, and the fieldset name when present. Prefixed inline rows therefore get separate collapse ids.
Source: django_smartbase_admin/admin/admin_base.py, django_smartbase_admin/engine/admin_base_view.py, django_smartbase_admin/engine/dynamic_forms.py, django_smartbase_admin/templates/sb_admin/includes/fieldset_header.html
Detail View Layout (Sidebar)
The detail/change view in SBAdmin supports a two-column layout: main content on the left and a sidebar on the right. Use this for metadata, status info, or secondary fields that shouldn't take up full width.
Using DETAIL_STRUCTURE_RIGHT_CLASS
Add the DETAIL_STRUCTURE_RIGHT_CLASS to a fieldset's classes to place it in the right sidebar:
from django_smartbase_admin.admin.admin_base import SBAdmin
from django_smartbase_admin.engine.const import DETAIL_STRUCTURE_RIGHT_CLASS
@admin.register(Article, site=sb_admin_site)
class ArticleAdmin(SBAdmin):
readonly_fields = ["created_at", "updated_at", "word_count"]
sbadmin_fieldsets = [
# Main content (left/center)
(
"Content",
{
"fields": ["title", "body", "category"],
},
),
# Sidebar (right)
(
"Metadata",
{
"fields": ["author", "status", "created_at", "updated_at"],
"classes": [DETAIL_STRUCTURE_RIGHT_CLASS],
},
),
(
"Statistics",
{
"fields": ["word_count"],
"classes": [DETAIL_STRUCTURE_RIGHT_CLASS],
},
),
]
Custom HTML Fields in Sidebar
For rich content like formatted cards, create readonly methods that return HTML. Use short_description to set a clean label:
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from django_smartbase_admin.engine.const import DETAIL_STRUCTURE_RIGHT_CLASS
@admin.register(Article, site=sb_admin_site)
class ArticleAdmin(SBAdmin):
readonly_fields = ["title", "body", "category", "status_card"]
sbadmin_fieldsets = [
(
"Content",
{
"fields": ["title", "body", "category"],
},
),
(
None, # No header for this fieldset
{
"fields": ["status_card"],
"classes": [DETAIL_STRUCTURE_RIGHT_CLASS],
},
),
]
def status_card(self, obj):
"""Render a status card as HTML."""
if not obj:
return "-"
color = {"draft": "warning", "published": "success", "archived": "secondary"}.get(obj.status, "info")
return mark_safe(
f'<div class="card">'
f'<div class="card-header fw-bold">{_("Status")}</div>'
f'<div class="card-body">'
f'<span class="badge bg-{color}">{escape(obj.status)}</span>'
f'</div></div>'
)
status_card.short_description = _("Status") # Sets the field label
Hiding Labels (Optional)
The short_description sets a reasonable label (e.g., "Status:"). If you want to hide labels entirely for custom HTML fields, override the template:
{# templates/sb_admin/blog/article/change_form.html #}
{% extends "sb_admin/actions/change_form.html" %}
{% block js_init %}
{{ block.super }}
<style>
/* Hide labels for custom HTML fields */
label[for="id_status_card"] {
display: none;
}
</style>
{% endblock %}
Then set change_form_template in your admin:
@admin.register(Article, site=sb_admin_site)
class ArticleAdmin(SBAdmin):
change_form_template = "sb_admin/blog/article/change_form.html"
# ... rest of config
Note: This is optional — the default label from short_description is usually sufficient.
Combining with Collapse
Sidebar fieldsets can also be collapsible. Keep sidebar placement in classes, and use the explicit collapse metadata for Bootstrap collapse:
sbadmin_fieldsets = [
# ... main content ...
(
"Advanced Options",
{
"fields": ["seo_title", "seo_description"],
"classes": [DETAIL_STRUCTURE_RIGHT_CLASS],
"collapsible": True,
"default_collapsed": True,
},
),
]
Key points:
- Fieldsets are rendered in order - put main content fieldsets first, then sidebar fieldsets
- Use
Noneas fieldset title to omit the title text; fieldsets with a description, actions, or collapse button still render a header row for those controls - Import
DETAIL_STRUCTURE_RIGHT_CLASSfromdjango_smartbase_admin.engine.const - Use
collapsible=True, notclasses=["collapse"], to enable fieldset collapse - Custom HTML fields need
mark_safe()and should escape user content withescape() - Sidebar is hidden in modal views (only shown in full page detail view)
Source: django_smartbase_admin/engine/const.py, django_smartbase_admin/templates/sb_admin/actions/change_form.html
Detail View Tabs (sbadmin_tabs)
Use sbadmin_tabs to organize fieldsets and inlines into separate tabs on the detail/change view. Without tabs, all fieldsets and inlines render on a single page. With tabs, users switch between logical groups.
Usage
sbadmin_tabs is a dict where:
- Keys are tab label strings (displayed as tab headers)
- Values are lists of fieldset names and/or inline classes
from django.contrib import admin
from django_smartbase_admin.admin.admin_base import SBAdmin, SBAdminTableInline
from django_smartbase_admin.admin.site import sb_admin_site
from django_smartbase_admin.engine.const import DETAIL_STRUCTURE_RIGHT_CLASS
from blog.models import Article, ArticleTag, Comment
class ArticleTagInline(SBAdminTableInline):
model = ArticleTag
extra = 0
verbose_name = "Tag"
verbose_name_plural = "Tags"
class CommentInline(SBAdminTableInline):
model = Comment
extra = 0
verbose_name = "Comment"
verbose_name_plural = "Comments"
@admin.register(Article, site=sb_admin_site)
class ArticleAdmin(SBAdmin):
model = Article
inlines = [ArticleTagInline, CommentInline]
fieldsets = (
(
None,
{
"fields": ("title", "body", "category"),
},
),
(
"Publishing",
{
"fields": ("author", "status"),
"classes": [DETAIL_STRUCTURE_RIGHT_CLASS],
},
),
(
"SEO Settings",
{
"fields": ("seo_title", "seo_description"),
},
),
)
sbadmin_tabs = {
"Content": [None, "Publishing", ArticleTagInline],
"Comments": [CommentInline],
"SEO": ["SEO Settings"],
}
How Tab Keys Map to Content
The template tag resolves each value in the list against two maps:
| Value type | Matched against | Example |
|---|---|---|
| Fieldset name (first element of fieldset tuple) | {fieldset.name: fieldset} | None, "Publishing", "SEO Settings" |
| Inline class | {inline.opts.__class__: inline} | ArticleTagInline, CommentInline |
A fieldset with None as its name (no header) is referenced as None in the tabs dict.
Fieldset Names Must Be Unique
Because fieldsets are looked up by name, each fieldset must have a unique first element. Two fieldsets both named None would conflict — only the last one would be found in the lookup.
# ❌ BAD - Two fieldsets with the same name (None)
fieldsets = (
(None, {"fields": ("title", "body")}),
(None, {"fields": ("author", "status")}),
)
# ✅ GOOD - Give at least one a name
fieldsets = (
(None, {"fields": ("title", "body")}),
("Status", {"fields": ("author", "status")}),
)
Default Behavior (No Tabs)
When sbadmin_tabs is None (the default), all fieldsets and inlines render sequentially on a single page — no tab UI is shown.
Error Handling
When a form has validation errors, the tab containing the error is automatically activated and highlighted so the user sees the problem immediately. If multiple tabs have errors, the first one with errors is shown.
Combining with Sidebar
Tabs work with DETAIL_STRUCTURE_RIGHT_CLASS. A fieldset with the sidebar class renders in the right column within its tab:
fieldsets = (
(
None,
{"fields": ("title", "body")},
),
(
"Metadata",
{
"fields": ("author", "status", "created_at"),
"classes": [DETAIL_STRUCTURE_RIGHT_CLASS],
},
),
(
"SEO Settings",
{"fields": ("seo_title", "seo_description")},
),
)
sbadmin_tabs = {
"Content": [None, "Metadata", ArticleTagInline],
"SEO": ["SEO Settings"],
}
In this example, the "Content" tab has a two-column layout (main fields on the left, metadata sidebar on the right, tags inline below), while the "SEO" tab is a simple single-column form.
Key points:
sbadmin_tabsis adict, not a list (the attribute reference table type is approximate)- Keys are tab labels (strings), values are lists of fieldset names and/or inline classes
- Fieldsets are referenced by their name (first element of the tuple) — use
Nonefor unnamed fieldsets - Inlines are referenced by their class (e.g.,
ArticleTagInline), not by a string - Every fieldset and inline should appear in exactly one tab — items not listed in any tab are not rendered
- The first tab is active by default (unless there are validation errors in another tab)
- Works with
DETAIL_STRUCTURE_RIGHT_CLASSfor sidebar layout within a tab - Override
get_sbadmin_tabs(request, object_id)for dynamic tab configuration
Source: django_smartbase_admin/admin/admin_base.py — SBAdmin.sbadmin_tabs, get_sbadmin_tabs(), get_tabs_context(); django_smartbase_admin/templatetags/sb_admin_tags.py — get_tabular_context()
Dashboard Widgets
Use SBAdminDashboardView in registered_views to build standalone dashboard pages. Dashboard widgets are regular SBAdmin views: each widget has a stable widget_id, permission checks, optional settings/filters, a template, media, and an AJAX action_get_data endpoint.
Dashboard widgets are usually built in one of four ways:
| Pattern | Use when |
|---|---|
| Simple widget | The widget is independent and owns its own HTML/chart/list data. |
| Lightweight chart subwidgets | One chart has small metric tabs/aggregates over the same queryset. |
| Real subwidgets with separate AJAX | A parent owns common layout/settings, but each child should load independently. |
| Real subwidgets with one AJAX | A parent/group owns common settings and one AJAX response refreshes all children together. |
Register dashboards from the configuration:
# blog/sbadmin_config.py
from django.utils.translation import gettext_lazy as _
from django_smartbase_admin.engine.configuration import SBAdminRoleConfiguration
from django_smartbase_admin.engine.menu_item import SBAdminMenuItem
from django_smartbase_admin.views.dashboard_view import SBAdminDashboardView
from blog.dashboard_widgets import ArticleSummaryWidget
class AdminConfiguration(SBAdminRoleConfiguration):
default_view = SBAdminMenuItem(view_id="dashboard")
menu_items = [
SBAdminMenuItem(label=_("Dashboard"), icon="All-application", view_id="dashboard"),
]
registered_views = [
SBAdminDashboardView(
widgets=[
ArticleSummaryWidget(),
],
title=_("Dashboard"),
),
]
All examples below assume the demo Article model from Demo Schema Reference.
1. Simple Widget
Use a simple top-level widget when it does not need a parent. This is the default choice for independent counters, cards, charts, lists, and HTML blocks.
# blog/dashboard_widgets.py
from django.db.models import Count
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from django_smartbase_admin.engine.dashboard import SBAdminDashboardHtmlWidget
from blog.models import Article
class ArticleSummaryWidget(SBAdminDashboardHtmlWidget):
widget_id = "article_summary"
name = _("Article summary")
def has_view_permission(self, request, obj=None) -> bool:
return True
def get_html(self, request):
counts = dict(
Article.objects.values_list("status").annotate(total=Count("id"))
)
return format_html(
"""
<div class="card p-16 col-span-12">
<h3 class="text-16 font-semibold mb-16">{title}</h3>
<div class="grid grid-cols-3 gap-16">
<div><div class="text-12">{draft_label}</div><div class="text-24 font-semibold">{draft}</div></div>
<div><div class="text-12">{published_label}</div><div class="text-24 font-semibold">{published}</div></div>
<div><div class="text-12">{archived_label}</div><div class="text-24 font-semibold">{archived}</div></div>
</div>
</div>
""",
title=_("Article summary"),
draft_label=_("Draft"),
published_label=_("Published"),
archived_label=_("Archived"),
draft=counts.get("draft", 0),
published=counts.get("published", 0),
archived=counts.get("archived", 0),
)
Register it as a top-level widget:
SBAdminDashboardView(
widgets=[ArticleSummaryWidget()],
title=_("Dashboard"),
)
2. Lightweight Chart Subwidgets
Use SBAdminChartAggregateSubWidget when the children are not real dashboard widgets. These are lightweight metric selectors rendered inside one chart widget. They do not get their own AJAX URLs; the parent chart owns the query and response.
# blog/dashboard_widgets.py
from django.db.models import Count, Q
from django.db.models.functions import TruncMonth
from django.utils.translation import gettext_lazy as _
from django_smartbase_admin.engine.dashboard import (
SBAdminChartAggregateSubWidget,
SBAdminDashboardChartWidget,
)
from blog.models import Article
class ArticleStatusChartWidget(SBAdminDashboardChartWidget):
widget_id = "article_status_chart"
name = _("Articles")
model = Article
chart_type = "line"
x_axis_annotate = TruncMonth("created_at")
y_axis_annotate = Count("id")
order_by = "x_axis"
sub_widgets = [
SBAdminChartAggregateSubWidget(
title=_("All"),
aggregate=Count("id"),
),
SBAdminChartAggregateSubWidget(
title=_("Published"),
aggregate=Count("id", filter=Q(status="published")),
),
SBAdminChartAggregateSubWidget(
title=_("Draft"),
aggregate=Count("id", filter=Q(status="draft")),
),
]
def has_view_permission(self, request, obj=None) -> bool:
return True
def process_label(self, request, label, data, labels, dataset_data):
return label.strftime("%Y-%m") if label else _("No date")
Register it as a top-level widget:
SBAdminDashboardView(
widgets=[ArticleStatusChartWidget()],
title=_("Dashboard"),
)
3. Real Subwidgets With Separate AJAX
Use a normal SBAdminDashboardWidget parent with real dashboard widgets in sub_widgets when the parent owns common layout/settings, but every child should keep its own AJAX endpoint. This is useful when children may be slow or independent.
The parent template renders each child. Chart children use the parent filter form id, so common parent settings are sent with every child AJAX request.
# blog/dashboard_widgets.py
from django.db.models import Count, F
from django.db.models.functions import TruncMonth
from django.utils.translation import gettext_lazy as _
from django_smartbase_admin.engine.dashboard import (
SBAdminDashboardChartWidget,
SBAdminDashboardWidget,
)
from django_smartbase_admin.engine.field import SBAdminField
from django_smartbase_admin.engine.filter_widgets import ChoiceFilterWidget
from blog.models import Article
class ArticleOverviewWidget(SBAdminDashboardWidget):
widget_id = "article_overview"
name = _("Article overview")
template_name = "dashboard/article_overview.html"
settings = [
SBAdminField(
title=_("Status"),
name="status",
filter_widget=ChoiceFilterWidget(
choices=[
("", _("All")),
("draft", _("Draft")),
("published", _("Published")),
("archived", _("Archived")),
],
default_value="",
),
)
]
def has_view_permission(self, request, obj=None) -> bool:
return True
def get_data(self, request):
return {}
class ArticleMonthlyChartWidget(SBAdminDashboardChartWidget):
name = _("Articles by month")
model = Article
chart_type = "bar"
x_axis_annotate = TruncMonth("created_at")
y_axis_annotate = Count("id")
order_by = "x_axis"
def has_view_permission(self, request, obj=None) -> bool:
return True
def get_queryset(self, request=None):
qs = super().get_queryset(request)
status = request.request_data.request_get.get("status")
if status:
qs = qs.filter(status=status)
return qs
def process_label(self, request, label, data, labels, dataset_data):
return label.strftime("%Y-%m") if label else _("No date")
class ArticleAuthorChartWidget(SBAdminDashboardChartWidget):
name = _("Articles by author")
model = Article
chart_type = "bar"
x_axis_annotate = F("author__name")
y_axis_annotate = Count("id")
order_by = "x_axis"
def has_view_permission(self, request, obj=None) -> bool:
return True
def get_queryset(self, request=None):
qs = super().get_queryset(request)
status = request.request_data.request_get.get("status")
if status:
qs = qs.filter(status=status)
return qs
{# templates/dashboard/article_overview.html #}
{% extends "sb_admin/dashboard/widget_base.html" %}
{% load sb_admin_tags %}
{% block content_inner %}
<section id="{{ widget_id }}" class="col-span-12 grid grid-cols-12 gap-24">
{% for sub_widget in sub_widgets %}
{% render_widget sub_widget request %}
{% endfor %}
</section>
{% endblock %}
SBAdminDashboardView(
widgets=[
ArticleOverviewWidget(
sub_widgets=[
ArticleMonthlyChartWidget(),
ArticleAuthorChartWidget(),
],
),
],
title=_("Dashboard"),
)
4. Real Subwidgets With One AJAX
Use SBAdminDashboardGroupWidget when the parent is a group container and one parent AJAX response should refresh all children. The parent calls each child get_data(request) and returns:
{
"sub_widget": {
"article_group_0": {...},
"article_group_1": {...},
}
}
Charts and HTML widgets already know how to register with the group and update themselves from their slice of the response. List widgets can render inside a group, but their Tabulator data still uses the list widget's own table AJAX endpoint.
# blog/dashboard_widgets.py
from django.db.models import Count
from django.db.models.functions import TruncMonth
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from django_smartbase_admin.engine.dashboard import (
SBAdminDashboardChartWidget,
SBAdminDashboardGroupWidget,
SBAdminDashboardHtmlWidget,
)
from django_smartbase_admin.engine.field import SBAdminField
from django_smartbase_admin.engine.filter_widgets import ChoiceFilterWidget
from blog.models import Article
class ArticleGroupWidget(SBAdminDashboardGroupWidget):
widget_id = "article_group"
name = _("Article dashboard")
settings = [
SBAdminField(
title=_("Status"),
name="status",
filter_widget=ChoiceFilterWidget(
choices=[
("", _("All")),
("draft", _("Draft")),
("published", _("Published")),
("archived", _("Archived")),
],
default_value="",
),
)
]
def has_view_permission(self, request, obj=None) -> bool:
return True
def get_filtered_queryset(self, request):
qs = Article.objects.all()
status = request.request_data.request_get.get("status")
if status:
qs = qs.filter(status=status)
return qs
def get_data(self, request):
request.article_group_queryset = self.get_filtered_queryset(request)
return super().get_data(request)
class GroupedArticleMonthlyChartWidget(SBAdminDashboardChartWidget):
name = _("Articles by month")
chart_type = "bar"
def has_view_permission(self, request, obj=None) -> bool:
return True
def get_data(self, request):
qs = getattr(request, "article_group_queryset", Article.objects.none())
rows = (
qs.annotate(month=TruncMonth("created_at"))
.values("month")
.annotate(total=Count("id"))
.order_by("month")
)
labels = [
row["month"].strftime("%Y-%m") if row["month"] else str(_("No date"))
for row in rows
]
values = [row["total"] for row in rows]
return {
"main": {
"labels": labels,
"datasets": [
{
"label": str(_("Articles")),
"data": values,
"backgroundColor": "#2368A9",
}
],
}
}
class GroupedArticleSummaryWidget(SBAdminDashboardHtmlWidget):
name = _("Summary")
def has_view_permission(self, request, obj=None) -> bool:
return True
def get_html(self, request):
qs = getattr(request, "article_group_queryset", Article.objects.none())
return format_html(
'<div class="card p-16 col-span-12"><div class="text-12">{label}</div>'
'<div class="text-24 font-semibold">{count}</div></div>',
label=_("Articles"),
count=qs.count(),
)
SBAdminDashboardView(
widgets=[
ArticleGroupWidget(
sub_widgets=[
GroupedArticleMonthlyChartWidget(),
GroupedArticleSummaryWidget(),
],
),
],
title=_("Dashboard"),
)
Grouped AJAX rules:
- Put common settings/filters on the parent group.
- Child widgets should return their normal widget data shape. Chart widgets return
{"main": {"labels": ..., "datasets": ...}}; HTML widgets return{"html": "..."}. - If the parent preloads shared data on
request, use a project-specific attribute such asrequest.article_group_querysetand guard child initial render withgetattr(...). - Prefer this for chart/card/summary widgets. Use separate AJAX for expensive children or full list/table widgets.
Local Graph Settings
Use settings for UI controls that affect how a dashboard widget displays data but are not ordinary queryset filters. Read them with get_settings_from_request(request).
# blog/dashboard_widgets.py
from django.db.models import Count
from django.db.models.functions import TruncDay, TruncMonth
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django_smartbase_admin.engine.dashboard import SBAdminDashboardChartWidget
from django_smartbase_admin.engine.field import SBAdminField
from django_smartbase_admin.engine.filter_widgets import ChoiceFilterWidget
from blog.models import Article
class ArticleResolutionChartWidget(SBAdminDashboardChartWidget):
widget_id = "article_resolution_chart"
name = _("Articles")
model = Article
chart_type = "line"
y_axis_annotate = Count("id")
order_by = "x_axis"
RESOLUTION_KEY = "article_resolution"
settings = [
SBAdminField(
title=_("Resolution"),
name=RESOLUTION_KEY,
filter_widget=ChoiceFilterWidget(
choices=[
("day", _("Day")),
("month", _("Month")),
],
default_value="month",
allow_clear=False,
),
)
]
def has_view_permission(self, request, obj=None) -> bool:
return True
def get_resolution(self, request):
return self.get_settings_from_request(request).get(self.RESOLUTION_KEY) or "month"
def get_x_axis_annotate(self, request):
if self.get_resolution(request) == "day":
return TruncDay("created_at")
return TruncMonth("created_at")
def process_label(self, request, label, data, labels, dataset_data):
if not label:
return _("No date")
if self.get_resolution(request) == "day":
return label.strftime("%Y-%m-%d")
return label.strftime("%Y-%m")
For a child chart in the same module that has a local setting but should submit through the parent form together with common parent settings, retarget only that child's setting widgets after static initialization:
class ParentFormSettingMixin:
def init_widget_static(self, configuration):
super().init_widget_static(configuration)
if not self.parent_view:
return
for setting in self.get_settings():
setting.filter_widget.view_id = self.parent_view.get_id()
class ArticleCostChartWidget(ParentFormSettingMixin, SBAdminDashboardChartWidget):
name = _("Cost")
model = Article
chart_type = "bar"
y_axis_annotate = Count("id")
order_by = "x_axis"
TIME_FRAME_KEY = "article_cost_time_frame"
settings = [
SBAdminField(
title=_("Time frame"),
name=TIME_FRAME_KEY,
filter_widget=ChoiceFilterWidget(
choices=[
("year", _("Year")),
("all", _("All")),
],
default_value="year",
allow_clear=False,
),
)
]
def has_view_permission(self, request, obj=None) -> bool:
return True
def get_x_axis_annotate(self, request):
return TruncMonth("created_at")
def get_queryset(self, request=None):
qs = super().get_queryset(request)
if request.request_data.request_get.get(self.TIME_FRAME_KEY, "year") == "year":
qs = qs.filter(created_at__year=timezone.now().year)
return qs
Use this child-local setting pattern only when the parent renders one shared filter form and one child needs an extra control. Keep setting names unique (article_cost_time_frame, not time_frame) to avoid collisions between sibling widgets.
Source: django_smartbase_admin/engine/dashboard.py; django_smartbase_admin/templates/sb_admin/dashboard/widget_base.html; django_smartbase_admin/templates/sb_admin/dashboard/chart_widget.html; django_smartbase_admin/templates/sb_admin/dashboard/group_widget.html; django_smartbase_admin/static/sb_admin/src/js/chart.js; django_smartbase_admin/static/sb_admin/src/js/dashboard_group.js
Detail View Widgets
SBAdmin admins can register dashboard-style widgets and place them inside detail/change fieldsets. Use this when related data belongs on the object page but should stay read-only and self-contained, for example a compact related-row list or a small aggregate chart.
Prefer SBAdminDashboardListWidget for detail pages when users need to inspect related records. Use chart widgets only for compact summaries that answer one question; do not turn a detail page into a dashboard wall.
from django.contrib import admin
from django.db.models import Count, F
from django_smartbase_admin.admin.admin_base import SBAdmin
from django_smartbase_admin.admin.site import sb_admin_site
from django_smartbase_admin.engine.dashboard import (
SBAdminDashboardChartWidget,
SBAdminDashboardListWidget,
)
from blog.models import Article, ArticleTag, Comment
class ArticleCommentsWidget(SBAdminDashboardListWidget):
widget_id = "comments"
name = "Comments"
model = Comment
path_to_parent_instance_id = "article_id"
sbadmin_list_display = ("id", "text", "created_at")
search_fields = ("text",)
class ArticleTagBreakdownWidget(SBAdminDashboardChartWidget):
widget_id = "tag_breakdown"
name = "Tags"
model = ArticleTag
chart_type = "pie"
path_to_parent_instance_id = "article_id"
x_axis_annotate = F("tag__name")
y_axis_annotate = Count("id")
order_by = "x_axis"
@admin.register(Article, site=sb_admin_site)
class ArticleAdmin(SBAdmin):
widgets = [ArticleCommentsWidget, ArticleTagBreakdownWidget]
sbadmin_fieldsets = (
(
"Content",
{
"fields": ("title", "status", "category"),
},
),
(
"Related",
{
"fields": (ArticleCommentsWidget, ArticleTagBreakdownWidget),
},
),
)
How Parent Scoping Works
When a widget is rendered on an admin detail page, SBAdmin prefixes the widget id with the parent admin id and registers the widget in the configuration view_map. The widget AJAX URL includes the current detail object's object_id, and dashboard widgets use path_to_parent_instance_id to filter their queryset.
For the example above, the ArticleCommentsWidget queryset is filtered with article_id=<current article pk>.
Widget Placement Contract
The fieldset fields entry uses the widget class, not a string. The same class must be declared in widgets so SBAdmin can initialize it, register its URL actions, collect its media, and pass the current request context.
# ❌ BAD - no registered widget instance exists for the fieldset marker.
class ArticleAdmin(SBAdmin):
sbadmin_fieldsets = (
("Related", {"fields": (ArticleCommentsWidget,)}),
)
# ✅ GOOD - registered in widgets and placed by class in the fieldset.
class ArticleAdmin(SBAdmin):
widgets = [ArticleCommentsWidget]
sbadmin_fieldsets = (
("Related", {"fields": (ArticleCommentsWidget,)}),
)
Key points:
- Every detail widget must define
widget_id; SBAdmin raisesImproperlyConfiguredwhen it is missing. - Admin-owned widgets get parent-scoped ids such as
blog_article_comments, avoiding collisions with standalone dashboard widgets. path_to_parent_instance_idshould point from the widget model to the parent detail object pk, for example"article_id"or"article__id".SBAdminDashboardListWidgetbrings table media and list behavior;SBAdminDashboardChartWidgetbrings chart media. SBAdmin collects widget media for the change view automatically.- Widget action methods follow the same
@sbadmin_actioncontract as other actions:(request, modifier, object_id).
Source: django_smartbase_admin/engine/admin_base_view.py — widget registration on admins; django_smartbase_admin/engine/dynamic_forms.py — fieldset widget placement; django_smartbase_admin/engine/dashboard.py — dashboard list/chart widgets and parent filtering; django_smartbase_admin/admin/admin_base.py — change-view widget media collection
Logo Customization
Override default logo by placing files in your static directory:
static/sb_admin/images/logo.svg- Light modestatic/sb_admin/images/logo_light.svg- Dark mode
URL-Callable Action Methods (@sbadmin_action)
Mark view methods as URL-callable with @sbadmin_action. All URL-routed actions go through delegate_to_action, which checks for this decorator and runs has_permission_for_action before dispatching.
Action URLs always use /<view>/<action>/<modifier>/ with an optional /<object_id>/ segment. The method signature is always (request, modifier, object_id). Keep non-object action state in modifier; row/detail object primary keys arrive as object_id.
Permission defaults
The default SBAdminRoleConfiguration.has_action_permission() requires the change model permission for every @sbadmin_action or SBAdminCustomAction that does not declare otherwise. Read-only endpoints opt out by passing permission="view". Actions that need stronger permissions declare them directly, for example the built-in bulk delete action carries permission="delete".
| Action kind | Declaration | Default check |
|---|---|---|
| Read-only (list/json/autocomplete/xlsx/config/dashboard data) | @sbadmin_action(permission="view") | has_permission(..., "view") |
| State-changing custom action | @sbadmin_action (bare) | has_permission(..., "change") |
| Mutation needing a stronger permission | @sbadmin_action(permission="delete") (or "add") | has_permission(..., <kwarg>) |
| Custom action object needing stronger permission | SBAdminCustomAction(..., permission="delete") | has_permission(..., "delete") |
The permission value is propagated end-to-end: delegate_to_action reads _sbadmin_action_attrs.permission, copies it onto the synthetic SBAdminCustomAction.permission, and has_action_permission forwards it into has_permission(). Actions declared directly with SBAdminCustomAction(..., permission=...) use the same path. Override has_action_permission on your role configuration if you need a different policy — action.permission is still readable there.
Built-in read-only endpoints already carry permission="view": action_list, action_list_json, action_autocomplete (on admins, filter widgets, and the tree widget), action_xlsx_export, action_config, dashboard widget action_get_data, audit log action_list_json, translations list. The reorder family (action_table_reorder, action_table_data_edit, action_list_json_reorder, action_enter_reorder) is intentionally left at the default — entering reorder mode is treated as a mutation.
MCP-exposed method actions
Add mcp_components to explicitly expose a method under the view's
mcp_actions discovery entry. The provider receives the current request and
returns a named dictionary of Django forms/formsets, or None when unavailable.
Invoke the discovered method with the MCP invoke_action tool; it encodes
component_values and routes through
delegate_to_action, which remains the permission boundary.
@sbadmin_action(mcp_components="get_recalculate_form_components")
def action_recalculate(self, request, modifier, object_id=None):
...
def get_recalculate_form_components(self, request):
return {"main": RecalculateForm()}
Usage
from django.contrib import admin, messages
from django.utils.translation import gettext_lazy as _
from django_smartbase_admin.admin.admin_base import SBAdmin
from django_smartbase_admin.admin.site import sb_admin_site
from django_smartbase_admin.engine.actions import SBAdminCustomAction, sbadmin_action
from blog.models import Article
# ❌ BAD — method is not decorated, returns 404 when called via URL
@admin.register(Article, site=sb_admin_site)
class ArticleAdmin(SBAdmin):
def action_archive_drafts(self, request, modifier, object_id):
Article.objects.filter(status="draft").update(status="archived")
messages.success(request, _("Draft articles archived."))
return self.build_action_response(request)
def get_sbadmin_list_actions(self, request):
return [
SBAdminCustomAction(
title=_("Archive Drafts"),
view=self,
action_id="action_archive_drafts",
),
]
# ✅ GOOD — method is decorated
@admin.register(Article, site=sb_admin_site)
class ArticleAdmin(SBAdmin):
@sbadmin_action
def action_archive_drafts(self, request, modifier, object_id):
Article.objects.filter(status="draft").update(status="archived")
messages.success(request, _("Draft articles archived."))
return self.build_action_response(request)
def get_sbadmin_list_actions(self, request):
return [
SBAdminCustomAction(
title=_("Archive Drafts"),
view=self,
action_id="action_archive_drafts",
),
]
# ✅ GOOD — decorator with keyword arguments
@admin.register(Article, site=sb_admin_site)
class ArticleAdmin(SBAdmin):
@sbadmin_action(permission="delete")
def action_delete_archived(self, request, modifier, object_id):
Article.objects.filter(status="archived").delete()
messages.success(request, _("Archived articles deleted."))
return self.build_action_response(request)
Key points:
- Import from
django_smartbase_admin.engine.actions - All examples, tutorials, and overrides must use the three-argument action signature:
def action_name(self, request, modifier, object_id=None). Do not document the old two-argument(request, modifier)form except in explicit migration "before" examples. - All built-in action methods (
action_list_json,action_autocomplete,action_config, etc.) are already decorated — read-only ones carrypermission="view"(see Permission defaults) - When overriding a built-in decorated action, accept
object_id=Noneand pass it through tosuper(), for examplereturn super().action_bulk_delete(request, modifier, object_id). - Bare
@sbadmin_actionon a custom method requires thechangepermission by default; passpermission="view"for read-only endpoints SBAdminFormViewActionmodal views (see Selection Actions) are automatically marked — no decorator neededSBAdminCustomActionorSBAdminRowActionwith directaction_idrequires the decorator on the target method- Non-modal methods that mutate data should usually return
self.build_action_response(request)so notifications render and the table reloads - Subclasses that override decorated methods inherit the marker
delegate_to_actioncheckshas_permission_for_actionfor every dispatched action, which delegates toSBAdminRoleConfiguration.has_action_permission()(see Custom Permission System)
Source: django_smartbase_admin/engine/actions.py — sbadmin_action, SBAdminCustomAction.permission; django_smartbase_admin/services/views.py — SBAdminViewService.delegate_to_action; django_smartbase_admin/engine/configuration.py — SBAdminRoleConfiguration.has_action_permission
SBAdmin Attribute Reference
Quick reference for all sbadmin_ prefixed class attributes available in SBAdmin and related classes.
List View Attributes (SBAdmin)
| Attribute | Type | Description |
|---|---|---|
sbadmin_list_display | tuple | Define columns using SBAdminField or field names |
sbadmin_list_display_data | tuple | Field names always fetched (even if column hidden) |
sbadmin_list_filter | tuple | Default visible filters - accepts SBAdminField names |
sbadmin_list_view_config | list[dict] | Pre-filtered view tabs configuration |
sbadmin_nested | dict | Opt-in one-level tree rendering via TabulatorNestedPlugin (see Nested List View) |
sbadmin_list_selection_actions | list | Custom bulk actions (override get_sbadmin_list_selection_actions()) |
sbadmin_list_actions | list | List-level actions (not selection-based) |
sbadmin_row_actions | list | Per-row icon actions rendered in the list table (override get_sbadmin_row_actions()) |
sbadmin_list_reorder_field | str | Field name for drag-and-drop row reordering |
sbadmin_xlsx_options | dict | Excel export configuration options |
sbadmin_table_history_enabled | bool | Enable/disable table state history (default: True) |
sbadmin_list_sticky_header_and_footer | bool | None | Enable sticky Tabulator column header together with sticky pagination footer and synced horizontal scrollbar. None falls back to SBAdminRoleConfiguration.default_list_sticky_header_and_footer; explicit True/False overrides the global setting. |
Detail/Change View Attributes (SBAdmin)
| Attribute | Type | Description |
|---|---|---|
sbadmin_fieldsets | tuple | Custom fieldset configuration for change form |
sbadmin_tabs | dict | Organize fieldsets and inlines into tabs (see Detail View Tabs) |
sbadmin_detail_actions | list | Actions shown on detail/change page |
widgets | list | Dashboard-style widgets registered on this admin; place them in fieldsets by widget class (see Detail View Widgets) |
sbadmin_previous_next_buttons_enabled | bool | Show prev/next navigation buttons (default: False) |
sbadmin_is_generic_model | bool | Mark as generic model for special handling (default: False) |
Inline Attributes (SBAdminTableInline/SBAdminStackedInline)
| Attribute | Type | Description |
|---|---|---|
sbadmin_fake_inlines | list | Additional inline classes to include |
sbadmin_sortable_field_options | list | Field names for inline row ordering (default: ["order_by"]) |
sbadmin_inline_list_actions | list | Actions available for inline rows |
Source: django_smartbase_admin/engine/admin_base_view.py, django_smartbase_admin/admin/admin_base.py
Audit Logging
Built-in optional app that automatically tracks all admin operations (create, update, delete, bulk) with field-level diffs, object snapshots, and request grouping. Works by patching Django's Model.save(), Model.delete(), QuerySet.update(), QuerySet.delete(), QuerySet.bulk_create(), and QuerySet.bulk_update() — only active inside SBAdmin request context.
Installation
- Add to
INSTALLED_APPS(afterdjango_smartbase_admin):
INSTALLED_APPS = [
# your apps...
"django_smartbase_admin",
"django_smartbase_admin.audit",
]
- Run migrations:
python manage.py migrate
- Add to your menu in
sbadmin_config.py:
from django_smartbase_admin.services.views import SBAdminViewService
from django_smartbase_admin.audit.models import AdminAuditLog
_role_config = SBAdminRoleConfiguration(
menu_items=[
# ... other menu items ...
SBAdminMenuItem(
label="Audit Log",
icon="Time",
view_id=SBAdminViewService.get_model_path(AdminAuditLog),
),
],
# ...
)
The admin view is auto-registered by the audit app — no @admin.register needed.
What Gets Recorded
Each AdminAuditLog entry contains:
| Field | Description |
|---|---|
timestamp | When the change happened |
user | Who made the change |
request_id | UUID grouping all changes from the same request |
content_type + object_id | The changed object |
object_repr | String representation of the object |
action_type | create, update, delete, bulk_create, bulk_update, bulk_delete |
snapshot_before | Full object state before the change (JSON) |
changes | Field-level diffs: {"field": {"old": ..., "new": ..., "old_display": ..., "new_display": ...}} |
parent_content_type + parent_object_id | Parent object context (for inline edits) |
affected_objects | FK targets referenced in changes (JSON array) |
is_bulk / bulk_count | Whether it was a bulk operation and how many items |
Skipping Models and Fields
By default, the audit app skips admin.LogEntry, sessions.Session, and contenttypes.ContentType. It also skips auto_now / auto_now_add fields and last_login on auth.User.
Add project-specific skip rules in settings.py:
# Skip entire models from auditing
SB_ADMIN_AUDIT_SKIP_MODELS = {
("blog", "comment"), # (app_label, model_name) tuples
}
# Skip specific fields per model
SB_ADMIN_AUDIT_SKIP_FIELDS = {
("blog", "article"): {"internal_score", "cache_key"},
}
History Button — Detail View (Object History)
When django_smartbase_admin.audit is in INSTALLED_APPS, the "History" button on any detail page automatically redirects to the audit log filtered for that object. No mixin or per-model configuration is needed — it's built into SBAdmin.history_view().
When a user clicks "History" on an Article detail page, they are redirected to the audit log filtered to show all changes for that specific article (including inline/related changes).
History Button — List View (Model History)
When django_smartbase_admin.audit is in INSTALLED_APPS, a "History" button automatically appears on every model's list view. Clicking it redirects to the audit log filtered by that model's content type, showing all changes for that model.
Disabling for specific models:
Set sbadmin_list_history_enabled = False on any admin class to hide the History button from its list view:
@admin.register(Comment, site=sb_admin_site)
class CommentAdmin(SBAdmin):
sbadmin_list_history_enabled = False # No History button on list view
The audit log admin itself automatically disables this to avoid circular navigation.
Programmatic Audit Entries (_create_audit_log)
For custom actions that call external APIs or perform operations outside Django's ORM (where automatic auditing doesn't apply), create audit log entries manually using _create_audit_log.
Use case: A bulk "Publish" action calls an external CMS API. The ORM is not involved, so automatic auditing doesn't capture the change. Create entries manually so the audit trail is complete.
import logging
from django_smartbase_admin.audit.manager import _create_audit_log
from blog.models import Article
logger = logging.getLogger(__name__)
def _audit_publish_action(articles: list[dict], published_by: str) -> None:
"""Create one audit log entry per article for an external publish action."""
for article in articles:
try:
_create_audit_log(
action_type="update",
model=Article,
object_id=str(article["id"]),
object_repr=f"Publish: {article['title']}",
changes={
"status": {"old": "draft", "new": "published"},
"published_by": {"old": None, "new": published_by},
"author": {"old": None, "new": article["author_name"]},
},
affected_objects=[
{"ct": "blog.author", "id": article["author_id"], "repr": article["author_name"]},
{"ct": "blog.category", "id": article["category_id"], "repr": article["category_name"]},
],
)
except Exception:
logger.exception("Failed to create audit log for article #%s", article["id"])
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
action_type | str | Yes | "create", "update", "delete", "bulk_create", "bulk_update", "bulk_delete" |
model | Model class | Yes | The Django model class |
object_id | str | No | Primary key of the affected object (as string) |
object_repr | str | No | Human-readable description shown in the audit log |
snapshot_before | dict | No | Full object state before the change (JSON-serializable) |
changes | dict | No | Field-level diffs: {"field": {"old": ..., "new": ...}} |
is_bulk | bool | No | Whether this is a bulk operation |
bulk_count | int | No | Number of affected items in bulk operation |
affected_objects | list | No | Related objects: [{"ct": "app_label.model_name", "id": pk, "repr": "display name"}] |
Key points:
- The
userfield is automatically populated from the current request viaSBAdminThreadLocalService - The
request_idis automatically populated, grouping all entries from the same request - Wraps in
transaction.atomic()so audit failures never break the main transaction - Use for external API calls, cross-service operations, or any action not captured by ORM patching
- For bulk operations, you can create one entry per item (individually traceable) or one summary entry with
is_bulk=True - The
ctinaffected_objectsuses format"app_label.model_name"(lowercase)
Programmatic Audit URLs
Generate audit history URLs from Python code (e.g., for links or redirects):
from django_smartbase_admin.audit.views import get_audit_history_url, get_audit_model_history_url
# Get URL to audit history for a specific object
article = Article.objects.get(pk=42)
url = get_audit_history_url(article)
# Returns: /sb-admin/sb_admin_audit/adminauditlog/?params=...
# Get URL to audit history for an entire model
from blog.models import Article
url = get_audit_model_history_url(Article)
# Returns: /sb-admin/sb_admin_audit/adminauditlog/?params=... (filtered by content_type)
How Auditing Works
The audit app patches Django's ORM methods at startup (AppConfig.ready()):
| Method | What it captures |
|---|---|
Model.save() | Create (new object) or update (existing object) with full diff |
Model.delete() | Delete with full snapshot before |
QuerySet.update() | Single update (with diff) or bulk update (with aggregated changes) |
QuerySet.delete() | Single delete or bulk delete |
QuerySet.bulk_create() | Bulk create with count and IDs |
QuerySet.bulk_update() | Bulk update with aggregated before/after |
Key behaviors:
- Only audits inside SBAdmin request context (uses
SBAdminThreadLocalService) - Never audits the
AdminAuditLogmodel itself (prevents infinite recursion) - Uses
transaction.atomic()so audit failures never break the main transaction - Groups all changes in a single request via
request_id(stored on the request object) - Auto-detects parent context from SBAdmin's
request_data(for inline edits) - Captures FK display values (
old_display,new_display) for human-readable diffs - M2M changes are tracked via the through/junction table (create/delete on junction rows)
Access Control
The audit log access control is implemented in AdminAuditLogAdmin.get_queryset() and _apply_restricted_queryset_for_filters(). The full logic flow:
Step 1 — User-based filtering (get_queryset):
| User type | No filter active (global view) | object_history filter active | content_type filter active |
|---|---|---|---|
| Superuser | All entries | All entries | All entries |
| Non-superuser | Own entries only (user=request.user) | All users' entries (filter skipped) | Own entries only (user=request.user) |
Step 2 — Restricted queryset permissions (_apply_restricted_queryset_for_filters):
Applies after Step 1, for all users (including superusers). When content_type or object_history filters are active:
- Collects content type IDs from active
object_historyand/orcontent_typefilters - For each content type, calls
SBAdminViewService.get_restricted_queryset()on the target model — this invokes the project'srestrict_querysetfromSBAdminRoleConfiguration - Filters audit entries so only entries with
object_idin the restricted queryset are shown - Entries for non-filtered content types (e.g., parent context, affected objects in the same audit view) are not restricted
- If the model class is unknown or restriction fails → entries for that content type are excluded (fail-closed)
Result by scenario:
| User type | Global view | Object history (History button on detail) | Model history (History button on list) |
|---|---|---|---|
| Superuser | All entries | Entries for that object, restricted by restrict_queryset | Entries for that model, restricted by restrict_queryset |
| Non-superuser | Own entries only | All users' entries for that object, restricted by restrict_queryset | Own entries for that model, restricted by restrict_queryset |
Example: If restrict_queryset limits Article to published articles only, a non-superuser clicking "History" on the Article list view will see only their own audit entries for published articles — not drafts, and not other users' entries.
Projects can further restrict access by:
- Not adding the audit log
SBAdminMenuItemfor non-admin roles - Overriding
has_permissionin the role configuration to deny access to theAdminAuditLogmodel - Overriding
restrict_querysetto apply additional filters onAdminAuditLogitself
Messaging
Built-in optional app (django_smartbase_admin.messaging) for sending in-app messages to users. It ships a Message model (title, type, rich-HTML content, file attachments, JSON targeting), a per-recipient MessageRecipient join (with notified_at / read_at), two auto-registered admin views, and a notification poller that surfaces new messages as a toast or an acknowledge-modal on every page load. Disabled until a project attaches an SBAdminMessagingConfig to its role configuration.
The two auto-registered views (no @admin.register needed):
MessageAdmin(messaging_message) — authoring/management list + add form, gated by Django model permissions onMessage. The detail of a created message is read-only.MessageInboxAdmin(messaging_messagerecipient) — per-user inbox overMessageRecipient, visible to any authenticated user and scoped to their own rows; opening a message marks it read.
Installation
- Add to
INSTALLED_APPS(afterdjango_smartbase_admin):
INSTALLED_APPS = [
"django_smartbase_admin",
"django_smartbase_admin.messaging",
]
-
Run migrations:
python manage.py migrate -
Attachments use a plain Django
FileField, so ensureMEDIA_ROOT/MEDIA_URLare configured (nofilerdependency).
Attachment upload path & storage
MessageAttachment.file resolves both its upload directory and its storage backend through stable module-level callables (message_attachment_upload_to / message_attachment_storage). Django serializes a callable upload_to/storage into migrations as its import path and never inspects what it returns, so a project can override either via settings without generating a migration — only the callables' runtime results change, not the field definition. Both default to messaging/attachments/ and the project's default storage when unset.
# settings.py — all optional, all migration-free
# upload directory: a string prefix, "" for the storage/container root,
# or an (instance, filename) -> path callable
SB_ADMIN_MESSAGING_ATTACHMENT_UPLOAD_TO = "tenant/attachments/"
# storage backend: a STORAGES alias, a Storage instance, or a callable returning one
SB_ADMIN_MESSAGING_ATTACHMENT_STORAGE = "messaging" # -> storages["messaging"]
Caveat: the override must flow through these fixed callables via the settings. Pointing the field at a different callable changes the serialized import path and would require a migration.
Enabling — messaging_config
Set messaging_config on the role configuration. With no arguments it uses sensible defaults (info→toast, warning→ack-modal; users/groups/all-users audiences); pass message_types / audiences to override.
from django_smartbase_admin.messaging.config import (
SBAdminMessagingConfig, SBAdminMessageType, NotificationStyle,
UsersAudience, GroupsAudience, AllUsersAudience,
)
class _RoleConfig(SBAdminRoleConfiguration):
messaging_config = SBAdminMessagingConfig(
message_types=[
SBAdminMessageType(
key="info", label=_("Info"),
notification_style=NotificationStyle.TOAST,
icon="Info", color="notice",
),
SBAdminMessageType(
key="warning", label=_("Warning"),
notification_style=NotificationStyle.MODAL,
icon="Attention", color="warning", require_acknowledge=True,
),
],
audiences=[UsersAudience(), GroupsAudience(), AllUsersAudience()],
poll_interval_seconds=60, # how often the page polls for new messages
scope_by_author=False, # True → MessageAdmin lists only the current user's sent messages
)
SBAdminMessageType — key (stored on Message.type), label, notification_style (TOAST or MODAL), icon (sb_admin sprite id), color (badge/alert colour token, e.g. "notice"/"warning"/"primary"), require_acknowledge (modal-only; forces an explicit Acknowledge click).
Menu wiring
The framework only registers the views — the project adds the menu entries. Typical split: senders get a submenu (received + sent), receive-only users get just the inbox. Show the inbox unread count with SBAdminMessagingService.get_unread_count as the menu item's badge.
from django_smartbase_admin.messaging.services import SBAdminMessagingService
SBAdminMenuItem(
view_id="messaging_messagerecipient", # the inbox
label=_("My messages"), icon="Mail",
badge=SBAdminMessagingService.get_unread_count,
)
SBAdminMenuItem(view_id="messaging_message", label=_("Sent")) # management view
Recipient audiences
An audience is a pluggable recipient source. On the add form each audience contributes one field under "Recipients"; on save the selections are stored in Message.targeting ({audience_key: value}) and resolved to MessageRecipient rows. Built-ins: UsersAudience (autocomplete), GroupsAudience (Django groups), AllUsersAudience (no selection).
Add a custom audience by subclassing SBAdminMessageAudience — implement get_form_field (return None for no per-message selection), serialize (cleaned value → JSON-safe), get_initial (stored → form initial), and resolve_users (stored value → auth user queryset). Do model imports lazily (the module loads during settings).
from django_smartbase_admin.messaging.config import SBAdminMessageAudience
class StaffAudience(SBAdminMessageAudience):
"""No selection — targets all staff users whenever chosen."""
key = "staff"
label = _("Staff users")
def get_form_field(self, request):
from django import forms
return forms.BooleanField(required=False, label=self.label)
def serialize(self, cleaned_value):
return bool(cleaned_value)
def resolve_users(self, stored_value, request):
from django.contrib.auth import get_user_model
model = get_user_model()
return model.objects.filter(is_staff=True) if stored_value else model.objects.none()
For a selection-based custom audience, return a forms.MultipleChoiceField/ModelMultipleChoiceField from get_form_field and resolve the stored ids in resolve_users (see UsersAudience / GroupsAudience for the pattern).
Contributing to the messaging app
- Where logic lives: query/badge/read-state helpers go on
SBAdminMessagingService(classmethods) inmessaging/services.py— don't inline.objects.filter(...)or<span class="badge">markup insb_admin.py. Reuseformat_badgefor badges anddatetime_formatterfor dates. - Templates: the message card (
templates/sb_admin/messaging/detail_card.html), toast/modal poller (poller.html,poll_response.html) are shared by the inbox detail, admin detail, and modal — change them in one place. The per-model submit row override lives attemplates/sb_admin/sb_admin_messaging/message/submit_line.html. - Type-badge filter: the type badge in lists comes from
SBAdminMessagingService.render_message_type_badgeand thesb_admin_messaging_tagstemplate filter (resolves the activemessaging_configfrom the thread-local request). - Strings: wrap user-facing text in
_()and recompile catalogs (see Internationalization). Aftermakemessages, clear any#, fuzzyflags on new strings or they render untranslated.
Internationalization
SBAdmin now ships locale catalogs for multiple languages and includes helper scripts to regenerate them in one pass.
Supported locales
Translation helper scripts are configured for:
sk,en,de,cs,hu,ro,sl,hr,fr,pl,it
Updating translation catalogs
Run these from the repository root:
# Extract strings for all configured locales
python src/django_smartbase_admin/makemessages.py
# Compile all locale catalogs
python src/django_smartbase_admin/compilemessages.py
Key points:
makemessages.pyloops through every locale insettings.LANGUAGES; it is no longer Slovak-only.compilemessages.pycompiles the same full locale list.- After adding new UI strings, regenerate
.pofiles first, then compile.mofiles.
JavaScript translation strings
When adding client-side text, expose it through templates/sb_admin/sb_admin_js_trans.html with {% trans %} instead of hardcoding English inside JavaScript.
<script>
window.sb_admin_translation_strings["search"] = '{% trans "Search" %}';
window.sb_admin_translation_strings["no_results"] = '{% trans "No results found" %}';
</script>
This is the preferred pattern for autocomplete placeholders, empty states, and other JS-rendered UI labels.
MCP (AI agents)
Optional django-mcp-server integration. Requires normal SBAdmin setup (SB_ADMIN_CONFIGURATION, sb_admin_site.urls).
Install
pip install "django-smartbase-admin[mcp]"
Installed Apps
INSTALLED_APPS = [
# ... your apps with SBAdmin model admins ...
"django_smartbase_admin",
"oauth2_provider", # MCP OAuth (skip if custom auth)
"rest_framework",
"mcp_server",
"django_smartbase_admin.mcp",
]
URL Setup
Mount before catch-all routes:
urlpatterns = [
path("", include("django_smartbase_admin.mcp.urls")),
path("", include("django_smartbase_admin.mcp.oauth.urls")), # OAuth AS; omit if custom auth
path("sb-admin/", sb_admin_site.urls),
# ...
]
Exposed endpoints:
- MCP JSON-RPC:
{SITE_ROOT}mcp. SetDJANGO_MCP_ENDPOINT = "mcp"without a trailing slash; remote clients such as Claude canonicalize the URL to/mcpand do not replay protocol POSTs across Django's slash redirect. - Optional REST
list_rows:{SITE_ROOT}{DJANGO_MCP_ENDPOINT}/rest/tools/list_rows/(forDJANGO_MCP_ENDPOINT = "mcp", this is{SITE_ROOT}mcp/rest/tools/list_rows/). This route exists indjango_smartbase_admin.mcp.urls, but only works whenSBADMIN_MCP_REST_AUTHENTICATORis configured.
MCP Settings
from django_smartbase_admin.mcp.instructions import SBADMIN_MCP_SERVER_INSTRUCTIONS
DJANGO_MCP_AUTHENTICATION_CLASSES = [
# DOT bearer-token auth backed by the AccessToken table. Clients
# discover OAuth via the /.well-known/* metadata documents (they fall
# back to probing those when the 401's WWW-Authenticate carries no
# resource_metadata pointer, per the MCP authorization spec).
"oauth2_provider.contrib.rest_framework.OAuth2Authentication",
]
DJANGO_MCP_GLOBAL_SERVER_CONFIG = {
"name": "sbadmin",
"instructions": SBADMIN_MCP_SERVER_INSTRUCTIONS, # optional: + deployment appendix
"stateless": True,
}
DJANGO_MCP_ENDPOINT = "mcp"
This targets native/CLI MCP clients (Claude Code, Cursor desktop). Browser-hosted clients (claude.ai Cowork, cursor.com web) additionally need CORS on the MCP + OAuth paths — not bundled; add your own CORS handling if you target them.
OAuth Auth
Use the bundled OAuth 2.1 Authorization Server by adding oauth2_provider, including django_smartbase_admin.mcp.oauth.urls, and configuring DOT:
OAUTH2_PROVIDER = {
"SCOPES": {"sbadmin:write": "SBAdmin MCP access"},
"PKCE_REQUIRED": True,
# Restrict plain-http redirect URIs to loopback (RFC 8252) so a local
# http://localhost dashboard can register one without opening http to
# arbitrary hosts. Subclass it if you also customise grant/token handling.
"OAUTH2_VALIDATOR_CLASS": "django_smartbase_admin.mcp.oauth.validators.SBAdminMCPOAuth2Validator",
# Keep "http" only for that loopback case; everything else stays https
# (plus any native custom schemes like cursor:// / claude://).
"ALLOWED_REDIRECT_URI_SCHEMES": ["https", "http"],
}
Custom auth: drop oauth2_provider + mcp.oauth.urls; set DJANGO_MCP_AUTHENTICATION_CLASSES to your DRF BaseAuthentication subclass.
REST list_rows
django_smartbase_admin.mcp.urls exposes only POST {DJANGO_MCP_ENDPOINT}rest/tools/list_rows/ for REST. It calls the same guarded list_rows MCP tool and accepts the same JSON arguments, for example:
{
"view_id": "app_model",
"fields": ["id", "name"],
"page": 1,
"page_size": 20
}
Authentication is configured once via SBADMIN_MCP_REST_AUTHENTICATOR; host projects should not subclass the REST API views just to attach an authenticator. The authenticator may be an import path, class, or instance extending SBAdminMCPRestAuthenticator / DRF BaseAuthentication. Return (user, auth) when credentials are valid and None when invalid.
SBADMIN_MCP_REST_AUTHENTICATOR = "my_project.mcp_auth.MyMCPRestAuthenticator"
Dashboard Usage
POST {DJANGO_MCP_ENDPOINT}rest/tools/list_rows/ can back local live dashboards when SBADMIN_MCP_REST_AUTHENTICATOR is configured and an authenticated probe succeeds. A 401 response means the REST authenticator rejected the request or is not configured for that deployment.
Current User / Whoami
MCP requests know the authenticated Django user internally, but agents should not guess the matching admin row by scanning user lists. Configure SBAdminRoleConfiguration.mcp_whoami_sbadmin to expose the current user's profile detail target through list_admins()["whoami"] and the fetch_whoami MCP tool.
For a user-profile admin, keep the profile admin project-side. A common setup is a User proxy model whose changelist URL renders the logged-in user's change form:
# accounts/models.py
from django.contrib.auth import get_user_model
BaseUser = get_user_model()
class MyProfile(BaseUser):
class Meta:
proxy = True
verbose_name = "my profile"
verbose_name_plural = "my profile"
# accounts/sb_admin.py
from django.contrib import admin
from django.core.exceptions import PermissionDenied
from django.http import HttpResponseRedirect
from accounts.models import MyProfile
from django_smartbase_admin.admin.admin_base import SBAdmin
from django_smartbase_admin.admin.site import sb_admin_site
from django_smartbase_admin.engine.configuration import (
SBAdminWhoamiConfig,
SBAdminRoleConfiguration,
)
@admin.register(MyProfile, site=sb_admin_site)
class MyProfileAdmin(SBAdmin):
model = MyProfile
fieldsets = (
(None, {"fields": ("username", "first_name", "last_name", "email")}),
)
readonly_fields = ("username",)
def has_add_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
def has_view_permission(self, request, obj=None):
if obj is None:
return request.user.is_authenticated
return obj.pk == request.user.pk
def has_change_permission(self, request, obj=None):
if obj is None:
return request.user.is_authenticated
return obj.pk == request.user.pk
def get_object(self, request, object_id, from_field=None):
obj = super().get_object(request, object_id, from_field=from_field)
if obj and obj.pk != request.user.pk:
raise PermissionDenied
return obj
def changelist_view(self, request, extra_context=None):
profile_url = self.get_menu_view_url(request)
return self.change_view(
request,
str(request.user.pk),
form_url=profile_url,
extra_context=extra_context,
)
def change_view(self, request, object_id, form_url="", extra_context=None):
profile_url = self.get_menu_view_url(request)
if str(object_id) != str(request.user.pk):
return HttpResponseRedirect(profile_url)
return super().change_view(request, object_id, form_url, extra_context)
_role_config = SBAdminRoleConfiguration(
default_view=SBAdminMenuItem(view_id="dashboard"),
menu_items=[
SBAdminMenuItem(view_id="accounts_myprofile", label="My profile", icon="User-business"),
...
],
registered_views=[...],
mcp_whoami_sbadmin=SBAdminWhoamiConfig(
view_id="accounts_myprofile",
),
)
SBAdminWhoamiConfig defaults to request.user.pk as the detail object_id, which matches proxy-user profile admins. Use object_id_getter only when the profile object has a different key:
mcp_whoami_sbadmin=SBAdminWhoamiConfig(
view_id="accounts_customerprofile",
object_id_getter=lambda request: request.user.customer_profile_id,
)
Agents can either call fetch_whoami(fields=[...]) directly, or read list_admins()["whoami"] and pass its view_id / object_id to fetch_detail or update_detail. The target is still checked through normal SBAdmin view and object permissions.
The browser-only extra
The discovery endpoints (authorization_server_metadata, protected_resource_metadata in mcp.oauth.urls) are always required — every client reads them to find the auth/token endpoints. One bundled class, however, only matters for browser-hosted clients (claude.ai Cowork, cursor.com web):
-
SBAdminMCPCorsMiddleware(mcp/middleware.py) — path-scoped CORS so browsers don't block cross-origin/mcp/calls. Native/CLI clients send noOrigin; omit it if you only target them. Wire it intoMIDDLEWAREbefore anything that 401/403s the MCP path (Django'sSecurityMiddleware, then this, then session/auth) so the preflight short-circuits, and list the browser origins it may reflect inSBADMIN_MCP_ALLOWED_ORIGINS(defaults:https://claude.ai,https://cursor.com):MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django_smartbase_admin.mcp.middleware.SBAdminMCPCorsMiddleware", # ... session / locale / common / auth ... ] # Override/extend the default allowlist; values are matched exactly. SBADMIN_MCP_ALLOWED_ORIGINS = [ "https://claude.ai", "https://cursor.com", ] # Optional: extend when a project REST authenticator needs custom headers. # Defaults to Authorization, Content-Type, MCP-Protocol-Version, MCP-Session-Id. SBADMIN_MCP_ALLOWED_HEADERS = [ "Authorization", "Content-Type", "MCP-Protocol-Version", "MCP-Session-Id", "X-Project-Auth", ]
Use the stock DOT oauth2_provider.contrib.rest_framework.OAuth2Authentication as your DJANGO_MCP_AUTHENTICATION_CLASSES (as shown above) for all clients. Its 401 WWW-Authenticate header carries no resource_metadata pointer, but every client — browser-hosted and native alike — falls back to probing /.well-known/* directly, so header-driven discovery isn't needed. (An earlier SBAdminMCPOAuth2Authentication subclass that added the pointer was removed once probing proved sufficient.)
(Verified: a Claude Code CLI connection completed OAuth with the plain auth class and no CORS middleware.)
Local custom dashboards
A ready-to-use single-page dashboard blueprint is exposed as the MCP resource dashboard://blueprint (read it; full setup is in its description). It logs in via OAuth PKCE and reads live data through an api(tool, args) helper over the MCP tools. To run one locally against a deployed (HTTPS) admin:
- Serve over http on localhost — fine as-is: loopback is a browser secure context (so
crypto.subtlePKCE works) and the OAuth/MCP traffic to the admin is HTTPS regardless. Serve on a spare port (python -m http.server 8010). - Allow the origin — add both
http://localhost:8010andhttp://127.0.0.1:8010toSBADMIN_MCP_ALLOWED_ORIGINS(8010is the blueprint's canonical serve port; the two loopback hosts aren't interchangeable to a browser, so list both).SBAdminMCPCorsMiddlewarereflects them withoutAccess-Control-Allow-Credentials(the flow is Bearer + PKCE, cookieless), so an allowed local page can't ride the user's admin session — which is why it's safe to keep these loopback origins allowed in every environment, including production, so one locally-served page can target any deployed backend without per-environment config. - Connect — the page self-registers an OAuth client via DCR (
POST <issuer>/oauth/register) on first connect and caches theclient_idinlocalStorage; no manual registration. The DCR endpoint andSBAdminMCPOAuth2Validatoraccept the page's plain-http redirect because it's loopback (localhost,127.0.0.1,::1); any other http host is rejected.
No HTTPS is needed locally; only if you serve the page on a non-loopback host (LAN IP / custom domain), which isn't a secure context over http — then use a TLS static server (e.g. mkcert) and register an https:// redirect URI.
Cursor — .cursor/mcp.json
.cursor/mcp.json or ~/.cursor/mcp.json — streamable HTTP url only (use the canonical slashless URL). Prod: HTTPS origin; oauthlib enforces TLS (no OAUTHLIB_INSECURE_TRANSPORT). Local http:// only: set that env on Django. Cursor Connect for bundled OAuth (PKCE + DCR).
{
"mcpServers": {
"sbadmin": {
"url": "https://admin.example.com/mcp"
}
}
}
MCP action form components
ActionModalView exposes its normal get_form() as the main component by
default. Compound modals override get_form_components() explicitly:
class AddPriceRowsView(ActionModalView):
form_class = PriceRowForm
def get_fixed_form(self, data=None):
return FixedPriceFieldsForm(data=data, prefix="fixed")
def get_formset(self, data=None):
return build_price_formset(data=data, prefix="rows")
def get_form_components(self):
return {
"fixed": self.get_fixed_form(),
"rows": self.get_formset(),
}
fetch_action_form returns the same names under components. Use those names
under component_values when invoking the action:
{
"component_values": {
"fixed": {"currency": "EUR"},
"rows": [
{"weight_from": 0, "weight_to": 5, "price": "3.50"},
{"weight_from": 5, "weight_to": 10, "price": "4.20"}
]
}
}
MCP derives prefixes and Django management-form fields from the live formset.
Validation still runs through the modal's regular post() implementation;
row errors are returned under errors.formsets.<name>.rows and formset-wide
errors under errors.formsets.<name>.non_form.
get_form_components() is an MCP adapter; normal autocomplete dispatch does
not inspect it. ActionModalView initializes autocomplete widgets from its
main form by default. A custom modal that places additional forms or formsets
in its context must initialize those widgets explicitly:
class AddPriceRowsView(ActionModalView):
...
def initialize_autocomplete_views(self, action_id):
common_form_kwargs = {
"request": self.request,
"view": self.view,
"sbadmin_action_id": action_id,
}
FixedPriceFieldsForm(prefix="fixed", **common_form_kwargs)
formset = build_price_formset(
prefix="rows",
form_kwargs=common_form_kwargs,
)
# Django constructs formset row forms lazily.
formset.empty_form
The hook is procedural intentionally: custom action modals may build and process arbitrary context rather than following the MCP component contract.
Object-dependent fieldset actions are discovered from fetch_detail, not the
global list_admins response. Pass that object's id to fetch_action_form and
invoke_detail_action.
Verify
# List tool names + descriptions (Django must be configured)
python manage.py mcp_inspect
# Package tests
python runtests.py django_smartbase_admin.mcp.tests
Testing
Setup
Tests use SQLite in-memory and Django's built-in auth.User / Group models — no external database required.
Install test dependencies into the project virtualenv:
source .venv/bin/activate
pip install -e .
The virtualenv already has all runtime dependencies. No extra test-only packages are needed — tests use unittest and Django's built-in test runner.
Running Tests
source .venv/bin/activate
# All tests
python runtests.py
# Audit tests only
python runtests.py django_smartbase_admin.audit.tests
# Specific test class
python runtests.py django_smartbase_admin.audit.tests.test_audit_integration.TestAdminCRUD
# Specific test method
python runtests.py django_smartbase_admin.audit.tests.test_audit_integration.TestAdminCRUD.test_create_logs_new_values
Test Structure
| File | What it tests |
|---|---|
src/django_smartbase_admin/tests/test_dynamic_forms.py | Dynamic regions, fieldset layout splitting, inactive field policies, modal dynamic-region responses |
src/django_smartbase_admin/audit/tests/test_diff.py | Unit tests for compute_diff, compute_bulk_diff, compute_bulk_snapshot |
src/django_smartbase_admin/audit/tests/test_audit_integration.py | Integration tests for audit logging (CRUD, bulk ops, inlines, M2M, request grouping) |
tests/settings.py | Minimal Django settings for standalone test runs |
runtests.py | Test runner entry point |
Adding New Tests
- Place feature-level tests near the feature, e.g. dynamic-region tests in
src/django_smartbase_admin/tests/test_dynamic_forms.py. - Place audit tests in
src/django_smartbase_admin/audit/tests/. - For audit integration tests, use
BaseAuditTestfromtest_audit_integration.pyas base class (installs/uninstalls manager hooks). - Use
MockSBAdminContextandNoAdminContextcontext managers for SBAdmin request simulation. - Audit integration tests use
TransactionTestCasebecause audit hooks patchModel.save()/QuerySet.update()globally.
SBAdminWizardView
Multi-step wizard outside the change_form. The view is a thin dispatcher — each step owns its form/formset creation, validation, context building, and save logic.
Architecture
SBAdminWizardView(TemplateView+SBAdminView) — holds the ordered step classes, dispatchesget()/post()to the current step, builds base context.SBAdminWizardStep— one step per class. The wizard instantiates a new step object per request. Steps definetitle,model,form_class,formset_classes, and override lifecycle hooks.
SBAdminWizardStep — Attributes
| Attribute | Type | Description |
|---|---|---|
title | str | Step title shown in the template |
heading | str | None | Heading above the step title. Falls back to wizard.wizard_step_heading, then model._meta.verbose_name |
model | Model class | Required. Used for permission checks |
form_class | Form class | Main form for the step |
formset_classes | list[type[BaseFormSet]] | Formset factory classes. Used for autocomplete widget registration |
requires_wizard_object | bool | If True, missing wizard object redirects to step 1 |
template_name | str | None | Override the default wizard template for this step |
submit_button_label | str | None | Custom submit button text. None = "Next step" / "Finish" |
SBAdminWizardStep — Methods
| Method | Description |
|---|---|
get_form_kwargs(**kwargs) | Returns kwargs for the main form. Default includes request and view=wizard. Override to add instance |
get_form(data, files) | Creates the main form instance. Bound when data is provided |
get_formsets(data, files) | Returns [(title, formset_instance), ...]. Override to declare formsets. On GET data is None |
get_context_data(context, **kwargs) | Builds step-specific template context. Formsets are auto-injected into wizard_formsets |
form_valid(form, formsets) | Called after successful validation. Must return HttpResponse. Raises NotImplementedError by default |
form_invalid(form, formsets) | Re-renders the page with bound form/formsets and errors |
get_blocked_get_response() | Return a redirect to block entry to this step (e.g. pending background tasks) |
adjust_navigation(nav) | Modify back_url, wizard_footer_back_url, prev_step_url dict |
check_permission(request) | Raises PermissionDenied. Default: requires_wizard_object → check change, otherwise add |
Step Lifecycle
GET:
get() → get_blocked_get_response() → _check_requires_wizard_object()
→ get_form() → wizard.get_context_data() → step.get_context_data()
→ get_formsets() injected into context → render
POST:
post() → _check_requires_wizard_object()
→ get_form(POST) + get_formsets(POST)
→ validate all → form_valid(form, formsets)
OR form_invalid(form, formsets)
SBAdminWizardView — Required
| Attribute / Method | Description |
|---|---|
wizard_steps | Tuple of SBAdminWizardStep classes |
build_wizard_url(step, object_id=None) | Returns URL with ?step=N for the given step |
get_wizard_object() | Returns the wizard's current object from session (or None) |
update_object_wizard_state(obj, step, completed) | Persists the wizard progress on the object |
Example
from django import forms
from django.forms import formset_factory
from django.http import HttpResponseRedirect
from django.urls import reverse
from django_smartbase_admin.admin.admin_base import SBAdminBaseForm, SBAdminBaseFormInit
from django_smartbase_admin.admin.widgets import SBAdminAutocompleteWidget
from django_smartbase_admin.views.sbadmin_wizard_step import SBAdminWizardStep
from django_smartbase_admin.views.sbadmin_wizard_view import SBAdminWizardView
from blog.models import Article, Tag
# -- Step 1: create the article --
class ArticleStep1Form(SBAdminBaseForm):
class Meta:
model = Article
fields = ("title", "category")
class ArticleStep1(SBAdminWizardStep):
title = "Basic Info"
model = Article
form_class = ArticleStep1Form
def form_valid(self, form, formsets):
obj = form.save()
self.wizard.update_object_wizard_state(obj, step=1, completed=False)
return HttpResponseRedirect(self.wizard.build_wizard_url(2, obj.pk))
# -- Step 2: assign tags via formset --
class TagRowForm(SBAdminBaseFormInit, forms.Form):
tag = forms.ModelChoiceField(
queryset=Tag.objects.all(),
widget=SBAdminAutocompleteWidget(
model=Tag, multiselect=False,
label_lambda=lambda request, item: item.name,
),
)
TagRowFormSet = formset_factory(TagRowForm, extra=1, can_delete=True)
class ArticleStep2(SBAdminWizardStep):
title = "Tags"
model = Article
form_class = ArticleStep1Form
formset_classes = [TagRowFormSet]
requires_wizard_object = True
def get_form_kwargs(self, **kwargs):
kwargs = super().get_form_kwargs(**kwargs)
kwargs["instance"] = self.wizard.get_wizard_object()
return kwargs
def get_formsets(self, data=None, files=None):
kwargs = {
"prefix": "tags",
"form_kwargs": {"view": self.wizard, "request": self.request},
}
if data is not None:
kwargs["data"] = data
return [("Tags", TagRowFormSet(**kwargs))]
def form_valid(self, form, formsets):
article = self.wizard.get_wizard_object()
fs = formsets[0][1]
# ... save tag associations from fs.cleaned_data ...
self.wizard.update_object_wizard_state(article, step=2, completed=True)
return HttpResponseRedirect("...")
# -- Wizard view --
class ArticleWizard(SBAdminWizardView):
wizard_steps = (ArticleStep1, ArticleStep2)
def build_wizard_url(self, step, object_id=None):
url = reverse("sb_admin:blog_article_wizard")
return f"{url}?step={step}"
Register in SBAdminRoleConfiguration.registered_views:
# blog/sbadmin_config.py
from django_smartbase_admin.engine.configuration import SBAdminConfigurationBase, SBAdminRoleConfiguration
from django_smartbase_admin.engine.menu_item import SBAdminMenuItem
from django_smartbase_admin.views.dashboard_view import SBAdminDashboardView
from blog.wizard_views import ArticleWizard
_role_config = SBAdminRoleConfiguration(
default_view=SBAdminMenuItem(view_id="dashboard"),
menu_items=[
SBAdminMenuItem(label="Dashboard", icon="All-application", view_id="dashboard"),
SBAdminMenuItem(label="Articles", icon="Box", view_id="blog_article"),
],
registered_views=[
SBAdminDashboardView(widgets=[], title="Dashboard"),
ArticleWizard(title="Create Article"),
],
)
class SBAdminConfiguration(SBAdminConfigurationBase):
def get_configuration_for_roles(self, user_roles):
return _role_config
The wizard view is automatically routed via view_map — no manual URL registration needed.
Formsets in Steps
Steps declare formsets via get_formsets() which returns [(title, formset_instance), ...]. The base class handles:
- Context injection: formsets are automatically added to
wizard_formsetsin the template context - Multipart detection:
form_is_multipartis set if any form or formset form has file fields - POST validation: all formsets are validated alongside the main form; on failure,
form_invalidre-renders with bound formsets and errors - Autocomplete registration:
formset_classesare iterated duringregister_autocomplete_viewsto instantiate each formset's row form class for widget initialization
Key points:
formset_classesis used only for autocomplete widget registration (happens duringinit_view_dynamicwhen no wizard object is available)get_formsets()is used for actual formset instance creation (happens per-request with full wizard state)- Row forms should extend
SBAdminBaseFormInitfor autocomplete widgets to work - Pass
form_kwargs={"view": self.wizard, "request": self.request}when creating formset instances
Navigation
back_url(top arrow): if a wizard object exists in session and the user haschangepermission, points to the object's change page; otherwise points to the changelist.wizard_footer_back_url: on step > 1, points to the previous wizard step; on step 1 with an existing object, points to the change page (same as the arrow); otherwise the footer "Back" button is hidden.- Override
adjust_navigation(nav)on the step to customize these URLs.
Template
Default template: sb_admin/wizard/wizard_step.html
| Context variable | Description |
|---|---|
wizard_heading | Heading from step.get_heading() |
sbadmin_wizard_step_title | Step title |
sbadmin_wizard_submit_label | Submit button text |
wizard_formsets | List of (title, formset) tuples |
form_is_multipart | True if any form has file fields |
wizard_primary_section_title | Optional title above the main form fields |
sbadmin_wizard_step_banner | Optional HTML banner shown at the top of the step |
sbadmin_wizard_poll_seconds | If set, the page auto-refreshes at this interval |
Formset rendering in the template: each formset in wizard_formsets is rendered inside a .sbadmin-formset-dynamic wrapper with data-prefix and data-max-forms. Rows live in .sbadmin-formset-forms. If the formset allows adding rows, a <template> with the empty form and a .sbadmin-formset-add button are rendered. The script sb_admin/dist/sbadmin_formset.js clones the template row, replaces __prefix__ in attributes, increments TOTAL_FORMS, and fires formset:added on the new row element (matching Django's native event, used by autocomplete and other SBAdmin widgets to re-initialize).
Return Navigation (back_url)
Back / Save on a change form or custom view (translations, …) returns to the
page the user came from instead of always the model changelist. The
originating URL is carried explicitly through a back_url query param
(outgoing cross-model links) and a hidden field (POST forms), resolved by
SBAdminViewService (services/views.py).
HTTP_REFERER is not used at all. It is unreliable on POST (points at the
form itself) and on a navigation target it points wherever the user last came
from (detail → translations → back-to-detail would leave the referer on
translations and loop back there). So the originating URL must be put on the
link that navigates to the other model.
from django_smartbase_admin.services.views import SBAdminViewService
# On a link that navigates to another model, carry a back_url back to here:
url = SBAdminViewService.url_with_current_back_url(request, target_url)
# Resolve where Back/Save should go (priority: POST hidden field > GET param > default):
back_url = SBAdminViewService.resolve_back_url(
request, default_url, current_path=request.path
)
validate_back_url rejects external hosts (open-redirect guard), non-sb_admin
paths, and self-loops. The change form / translations templates render the
hidden field straight from the back_url context value (the same value the Back
button uses), so a custom form participates just by having back_url in context:
<input type="hidden" name="back_url" value="{{ back_url }}">
Adding a new cross-model link: append the param on the outgoing link — in
Python via url_with_current_back_url(request, target_url), or in a template
with ?back_url={{ request.get_full_path|urlencode }}.
Wired globally in change_form.html, translations-detail.html,
response_change/response_add (terminal Save only — _continue/_addanother
keep Django defaults), get_change_view_context, SBAdminView.get_back_url
(+ _continue keeps back_url), the sbadmin_translation_status Edit link,
and the inline "Change" links (stacked_inline.html, table_inline.html).
Out of scope: modal/popup links (autocomplete add/edit, filer — _popup=1,
they don't navigate away) and the wizard.
Contributing to This Document
When adding new information to this document, follow these guidelines:
Use the Demo Schema
All examples must use models from the Demo Schema Reference:
Article,Category,Tag,Author,Comment,ArticleTag- App name:
blog - Admin classes:
ArticleAdmin,CategoryAdmin, etc. - View IDs:
blog_article,blog_category, etc.
If the demo schema doesn't cover your use case, extend it in the Demo Schema section first.
Structure for New Sections
## Section Title
Brief explanation of what this covers and when to use it.
### Subsection (if needed)
Code example:
```python
# Complete, runnable example using demo schema
Key points:
- Important gotcha or tip
- Another key point
Add ❌ BAD vs ✅ GOOD blocks **only when there's a non-obvious mistake worth calling out** — most reference / "how to wire X" sections don't need them. For pure feature descriptions, a single ✅ working example plus key points is enough.
### Checklist Before Adding
- [ ] Uses demo schema models (Article, Category, Tag, Author, Comment)
- [ ] Code examples are complete and runnable (not fragments)
- [ ] Added entry to Table of Contents with brief description
- [ ] No generic names like `MyModel`, `RelatedModel`, `SomeField`
- [ ] Includes "Key points" or gotchas if there are non-obvious behaviors
- [ ] *(Optional)* ❌ BAD vs ✅ GOOD blocks **only** when readers commonly trip on a specific footgun
### Naming Conventions
| Type | Pattern | Example |
|------|---------|---------|
| Admin class | `{Model}Admin` | `ArticleAdmin` |
| Form class | `{Action}Form` | `AssignCategoryForm` |
| View class | `{Action}View` | `AssignCategoryView` |
| Filter widget | `{Model}FilterWidget` | `TagFilterWidget` |
| Autocomplete widget | `{Model}AutocompleteWidget` | `CategoryAutocompleteWidget` |
| Lambda functions | `{model}_{purpose}` | `author_label`, `author_search` |
| Annotation keys | `{field}_val` or `{field}_arr` | `author_name_val`, `tag_names_arr` |
### What NOT to Add
- Implementation details that change frequently
- Workarounds for bugs (fix the bug instead)
- Features not yet released
- Duplicate information already covered elsewhere