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

SectionWhat it covers
Demo Schema ReferenceModels used in all examples (Article, Category, Tag, Author, Comment)
SBAdminFieldDefining list columns, annotations, supporting_annotates, admin methods, ordering with computed fields, sbadmin_list_display_data, inline cell editing (tabulator_editor / per_cell_editable_field)
ConfigurationINSTALLED_APPS, role config, menu items, queryset restrictions, custom permissions
Filter WidgetsBuilt-in widgets, custom filters, filter_query_lambda for M2M filtering
Form Widgetsinput 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 ViewsFull SBAdmin pages backed by a regular Django form instead of a model change view
Dynamic RegionsHTMX-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 ActionsModal forms for bulk operations, ListActionModalView, confirmation modals, SBAdminCustomAction params, per-action permissions, success/error handling
Row ActionsPer-row icon buttons with SBAdminRowAction, RowActionModalView, and row-aware enablement
Field FormattersBadge formatters, array_badge_formatter, BadgeType options, automatic choice formatting
XLSX Export Field FormattingPer-column Excel cell formats via XLSXFieldOptions.cell_format (named, dict, or SBAdminXLSXFormat)
View on Site link in listList column with "View on site" icon via admin method, redirect view, view_on_site_link_formatter
Performance OptimizationSubquery patterns, ArrayAgg, avoiding N+1 queries
Common ErrorsFrequent errors and solutions
InlinesSBAdminTableInline, SBAdminStackedInline for related models
Validated Singleton Inline Creation on AddWhy default-only singleton inlines can be skipped and how SBAdmin creates them during add
Fake InlinesShow related models without a real Django FK to the parent — cross-database, unmanaged, or indirect relationships
Global Autocomplete Widget Customizationlabel_lambda, search_query_lambda, dependent dropdowns, subclassing for computed values
Pre-filtered List ViewsTab-based filtered views with sbadmin_list_view_config, default tab from menu, programmatic URL building
Nested List ViewOne-level self-referential tree rendering via Tabulator dataTree with TabulatorNestedPlugin
Tree Widget & Tree List ViewMulti-level tree rendering for django-treebeard MP_Node models — form widget, filter widget, actions/tree_list.html
List View PluginsProtocol for reshaping the list pipeline globally — queryset hooks, per-request state, Tabulator definition
List-View AJAX NotificationsSurface Django messages from list-action requests via the standard notification slot; fail-soft pattern for the list query
Fieldset OptionsSupported 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 TabsOrganizing fieldsets and inlines into tabs with sbadmin_tabs
Dashboard WidgetsStandalone dashboards: simple widgets, lightweight subwidgets, real subwidgets with separate or grouped AJAX
Detail View WidgetsEmbedding dashboard-style list/chart widgets inside detail fieldsets
Logo CustomizationOverride logo via static files
URL-Callable Action Methods (@sbadmin_action)@sbadmin_action decorator for URL-callable view methods
SBAdmin Attribute ReferenceQuick reference for all sbadmin_ prefixed attributes
Audit LoggingBuilt-in audit trail — installation, configuration, skip models/fields, history button, programmatic entries, programmatic URLs
MessagingBuilt-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
InternationalizationLocale workflow, makemessages.py, compilemessages.py, JS translation strings
MCP (AI agents)Optional MCP server: install, host wiring, Cursor config
TestingHow to install test dependencies, run tests, and add new tests
SBAdminWizardViewMulti-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 DocumentGuidelines for adding new sections and examples

Quick lookup:


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

ParameterTypeDescription
namestrRequired. Field identifier - can reference a model field OR an admin method
titlestrColumn header label
annotateExpressionDjango ORM expression (F, Concat, Case, etc.)
supporting_annotatesdictAdditional annotations passed to admin method
filter_fieldstrORM 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_widgetFilterWidgetCustom filter widget
filter_disabledboolDisable filtering for this field
python_formattercallableFormat value: (obj_id, value) -> formatted_value
list_visibleboolShow/hide column in list
tabulator_optionsTabulatorFieldOptionsPer-column Tabulator settings (width, grow, max, custom SBAdmin options)
tabulator_editorstrTabulator editor type ("number", "input", …). Makes the column editable inline. See Inline cell editing
per_cell_editable_fieldstrName 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)

OptionTypeDescription
sbadminKeepDataWidthboolKeep 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:

  1. tabulator_editor on the SBAdminField — the Tabulator editor type ("number", "input", "select", …). This is what makes the column editable; it is emitted as the column's editor. With it set and nothing else, every cell in the column is editable.

  2. Enable the dataEditModule on 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 definition
    

    The module does two things: installs the per-cell editable gate (see below) and wires cellEdited → POST to the action_table_data_edit action.

  3. Implement table_data_edit_form_valid on the view. The inherited action_table_data_edit validates the request with TableDataEditForm and calls this hook with the bound form. Its cleaned_data contains currentRowId (already decoded from JSON), columnFieldName, and cellValue. Persist the change and return an HttpResponse, commonly with render_notifications(request) (optionally with an HX-Trigger: SBAdminReloadTableData header via trigger_client_event to refresh the table after the edit). Override table_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_field needs tabulator_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 undefinedBoolean(undefined)false → the cell is read-only. Provide it via supporting_annotates (or sbadmin_list_display_data). Such non-column keys are preserved through _strip_to_visible_keys specifically because a column references them via per_cell_editable_field.
  • Needs the dataEditModule. Both the editable gate (via modifyTabulatorOptions) 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 restricts columnFieldName to fields that declare tabulator_editor.
  • Customize form responses through the dedicated hooks. Override table_data_edit_form_invalid when 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_editorper_cell_editable_fieldResult
setunsetEvery cell in the column is editable.
setsetEditable only where the row boolean is truthy.
unsetsetNothing editable (the gate has no editor to act on).
unsetunsetPlain 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 same filter_field — they'd render form inputs with the same name/id and JS only reaches the first. Caught statically as sbadmin.W001.
  • Custom widgets that hardcode their Q(...) (i.e. ignore self.field.filter_field) shouldn't set filter_field at all — it would only create a collision risk without affecting the actual filter.
  • filter_field is the key in sbadmin_list_view_config["url_params"]["filterData"], not SBAdminField.name. A mismatched key silently disables the filter on load and produces a spurious * on the tab. Caught statically as sbadmin.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:

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

  2. Shared queryset: Filters operate on the same queryset as the list display. Annotations defined in supporting_annotates can 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's get_ordering())
  • get_list_ordering() is only called for the list view, so computed fields from sbadmin_list_display are 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 ordering class 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_id for links)
  • Use for hidden data-only fields defined with list_visible=False
  • supporting_annotates are 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

  1. Settings: SB_ADMIN_CONFIGURATION = "blog.sbadmin_config.SBAdminConfiguration"
  2. URLs: Include sb_admin_site.urls
  3. 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.10 through 3.14

Format: {app_label}_{model_name} (lowercase)

Example: blog.models.Articleview_id="blog_article"

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_items don't need a view_id (they act as dropdown containers)
  • Child items in sub_items typically 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.model to check the model type - simpler than passing model as a separate parameter
  • Override restrict_queryset on SBAdminRoleConfiguration subclass to call your restriction function
  • Import apply_model_restrictions directly 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 LookupError if 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 request explicitly from restrict_queryset when available; the function falls back to thread-local when called from filter widgets or subqueries
  • User object should have properties like allowed_categories that return filtering criteria (or None for 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 your SBAdminRoleConfiguration subclass — 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's has_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's has_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:

MethodPurposeDocumented in
has_permission()Replace Django model permissions with custom systemCustom 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 dropdownsGlobal Autocomplete Widget Customization
enable_url_compressionToggle 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_readonlyMake 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_sbadminTell 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(...) or SBAdminCustomAction(...).
  • Mutating custom actions default to "change" if no permission is declared.
  • The gate runs in SBAdminViewService.has_permission() before project-specific has_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

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.

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, default True; 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

PieceLocation / name
Redirect viewViewOnSiteRedirectView in views/view_on_site_redirect_view.py
URL namesb_admin:view_on_site_redirect (kwargs: view, object_id)
Formatterview_on_site_link_formatter in engine/field_formatter.py
URL for redirectget_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 pass sbadmin_view_id and sbadmin_view_on_site to the formatter.

Filter Widgets

Built-in Widgets

WidgetUse Case
StringFilterWidgetText fields
BooleanFilterWidgetBoolean fields
DateFilterWidgetDate/DateTime fields
AutocompleteFilterWidgetForeignKey/M2M fields
FromValuesAutocompleteWidgetFilter from distinct values
ChoiceFilterWidgetStatic choices (single selection)
MultipleChoiceFilterWidgetStatic choices (multiple selection)

Recommendation: Prefer MultipleChoiceFilterWidget over ChoiceFilterWidget for 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:

ParameterTypeDefaultDescription
close_dropdown_on_changeboolFalseIf 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_clearboolTrueIf 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 StringFilterWidget and BooleanFilterWidget default to close_dropdown_on_change=True for better UX
  • AutocompleteFilterWidget defaults to close_dropdown_on_change=False to 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.
  • delimiter controls 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 / suffix are passive text affixes; prefix_icon / suffix_icon render clickable icon buttons.
  • Each side accepts only one affix shape: use either prefix or prefix_icon, and either suffix or suffix_icon.
  • prefix_button_attrs and suffix_button_attrs are normal HTML attributes. Always include title and aria-label for icon-only buttons.
  • Copy-to-clipboard behavior is globally delegated from main.js via data-sbadmin-copy-button. Copy buttons can omit data-sbadmin-copy-target; main.js falls back to the nearest input inside the affix wrapper.
  • Read-only inputs add input-affix--readonly to the wrapper so affix addons match the read-only input styling.
  • Other widgets can support the same pattern via SBAdminInputAffixMixin (mix in and forward prefix / suffix plus the icon-button args in __init__).
  • SBAdminCopyableTextInputWidget remains 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=False if 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 call widget.run_schema_validation(value) from your own field/validator if you want to keep using a plain forms.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=True reorders the root array only; nested arrays still append.
  • Multiple editors on the same page get unique input name prefixes automatically (form_name_root is 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:

FieldTypeDefaultDescription
labelstrrequiredDisplay name for the collapsible section.
optionslist[PermissionOption][]Visible permission rows in the section.
help_textstr""Help text displayed below the section header.

PermissionOption fields:

FieldTypeDefaultDescription
labelstrrequiredDisplay name for the visible checkbox.
codenameslist[str]requiredStrict permission refs in app_label.model:codename format.
help_textstr""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 by SBAdminMultipleChoiceWidget.
  • 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.codenames ref must exist in that queryset.
  • Permissions referenced by PermissionOption are marked as seen and are not duplicated in the automatic leftover groups.
  • Group options are all-or-nothing: a PermissionOption checkbox 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 PermissionGroup becomes its own collapsible section, each PermissionOption renders 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=self through ArticleReportFormView.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) into get_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, SBAdminStandaloneFormView already 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.status selection changes which publishing fields are visible.
  • An Article.category selection changes choices for related Tag or Author fields.
  • A normal SBAdmin add/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 Category widget 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 caseWhere to define the regionEndpoint behavior
Normal model admin add/change formSBAdmin.sbadmin_fieldsets, or the form's Meta.sbadmin_fieldsetsSBAdmin uses the admin sbadmin_dynamic_region/<object id> or sbadmin_dynamic_region/add action.
Stacked inline rowSBAdminStackedInline.sbadmin_fieldsets, or the inline form's Meta.sbadmin_fieldsetsSBAdmin injects the inline form prefix into hx-vals, so only the changed inline row is refreshed.
Action modalThe modal form_class.Meta.sbadmin_fieldsetsActionModalView, ListActionModalView, and RowActionModalView use the current modal URL.
Custom view + standalone formThe form's Meta.sbadmin_fieldsets, with sbadmin_dynamic_region_source=SBDynamicRegionSource.FORM passed when constructing the formYour 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:

  • status gets HTMX trigger attributes automatically.
  • The initial render shows only category for draft articles.
  • Switching status to published refreshes the publishing_fields wrapper and renders category + author on one row because get_active_fields returns a grouped tuple.
  • Hidden fields stay in form.fields, but inactive fields are handled according to inactive_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

OptionTypeDescription
namestrRequired unique region name. Used in sbadmin_dynamic_region, wrapper IDs, and client events.
trigger_fieldsiterable of field namesFields that trigger a refresh. SBAdmin binds HTMX attrs to these widgets.
fieldsiterable of field namesAll fields controlled by this region. Only these fields can become active.
is_visiblecallableOptional (form, request, region) -> bool. If false, the whole region renders empty.
get_active_fieldscallableOptional (form, request, region) -> iterable. Return active field names or grouped iterables. Defaults to all fields.
inactive_field_policySBInactiveFieldPolicyPRESERVE by default. Controls cleaning/submission behavior for inactive fields.
templatestrOptional custom template instead of the default inline fieldset renderer.
defer_triggerstr | NoneOptional 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 admin sbadmin_dynamic_region action 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:replace for field-triggered regions and this:replace for 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_region
  • form
  • region
  • region_state
  • active_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 SBDynamicRegion directly in a fieldset's fields tuple. Regions nested inside grouped fields like ("status", SBDynamicRegion(...)) are ignored.
  • Keep region.name unique within a form.
  • Every field that may render must be declared in fields=...; get_active_fields cannot activate fields outside that list.
  • Use Meta.sbadmin_fieldsets when defining regions. Meta.fieldsets is supported for normal standalone fieldsets, but sbadmin_fieldsets is the explicit SBAdmin layout hook.
  • For model admin change/add forms, the region endpoint is sbadmin_dynamic_region/<object id> or sbadmin_dynamic_region/add.
  • sbadmin_standalone_dynamic_regions is no longer supported. Use SBDynamicRegionSource.FORM when the form should read its own Meta.sbadmin_fieldsets instead 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_fields accepts prefixes (^, =, @) the same way as Django admin; SBAdmin keeps the prefix and maps only the field name part.
  • If a value in search_fields matches SBAdminField.name, SBAdmin rewrites it to that field's filter_field before 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:

AttributeUse WhenAccepts
sbadmin_list_filterYou have SBAdminField with annotate= or custom filter_widgetSBAdminField name values
list_filterAll filters are simple model fieldsModel 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

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_message is formatted with confirmation_message.format(**data), where data is returned by get_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_confirmation for action flows backed by ActionModalView, ListActionModalView, or RowActionModalView; direct SBAdminCustomAction methods are not modal form flows and should implement their own confirmation UI if needed.

SBAdminCustomAction Parameters

SBAdminFormViewAction extends SBAdminCustomAction. Available parameters:

ParameterTypeDefaultDescription
titlestrRequiredButton label
target_viewclassRequired (FormViewAction only)The ListActionModalView subclass
viewSBAdminRequiredThe admin class instance (self)
action_idstrNoneUnique identifier for this action
open_in_modalboolFalseOpen the action in a modal dialog
css_classstrNoneCSS class for the button (e.g., "btn-destructive" for red)
iconstrNoneIcon name (from sprite set)
groupstrNoneGroup actions under a dropdown menu
sub_actionslistNoneNested actions under this action
no_paramsboolFalseIf True, don't pass current list params to the action URL
open_in_new_tabboolFalseOpen action URL in a new browser tab
urlstrNoneDirect URL (for non-form actions)
templatestrNoneCustom 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_action when an action should be hidden for some users or request states
  • The default has_permission_for_action delegates to SBAdminRoleConfiguration.has_permission()
  • SBAdminFormViewAction modal views are automatically URL-callable — no extra decoration needed
  • When using SBAdminCustomAction with action_id pointing 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 let ActionModalView build the success response with notifications, modal close, and table reload events
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:

OptionUse When
enabled_ifYou need custom logic. It receives the row dict and takes precedence over enabled_field.
enabled_field + enabled_valueYou need a simple equality check like row["status"] == "draft".

Processing Contract

Framework consumers call processed getters, not raw getters:

SurfaceRaw declaration getterProcessed consumer getter
List buttonsget_sbadmin_list_actions()get_sbadmin_list_actions_processed()
Bulk selection buttonsget_sbadmin_list_selection_actions()get_sbadmin_list_selection_actions_processed()
Row buttonsget_sbadmin_row_actions()get_sbadmin_row_actions_processed()
Detail buttonsget_sbadmin_detail_actions()get_sbadmin_detail_actions_processed()
Inline row buttonsget_sbadmin_inline_list_actions()get_sbadmin_inline_list_actions_processed()

Key points:

  • Use get_sbadmin_row_actions() when the action needs view=self. URL-only actions can also be declared in the sbadmin_row_actions class attribute.
  • MODIFIER_OBJECT_ID is the literal token "__object_id__"; use it only in direct custom URLs. Row method/modal actions receive the row pk through the object_id argument.
  • RowActionModalView resolves objects with self.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. TabulatorNestedPlugin injects actions into hydrated child rows too.
  • Methods referenced by action_id must be decorated with @sbadmin_action; non-modal methods can return self.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_badge over hand-written <span class="badge …"> markup so colour/markup stays consistent (it's what boolean_formatter and the messaging badges use).
  • badge_formatter(badge_type, simple=True) returns a (object_id, value) callable — drop it straight into python_formatter.

XLSX Export Field Formatting

Use XLSXFieldOptions.cell_format to apply an Excel format to one exported column.

Supported cell_format values:

  • str — key from SBAdminXLSXOptions.cell_formats
  • dict — raw xlsxwriter format props
  • SBAdminXLSXFormat — 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 ArrayAgg from django.contrib.postgres.aggregates for PostgreSQL array aggregation
  • Always specify output_field on 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

ErrorCauseSolution
"Menu item is missing view X"view_id doesn't match registered adminUse format {app_label}_{model_name}
"The annotation 'X' conflicts with a field"supporting_annotates key matches model fieldRename annotation key
"Expression contains mixed types"Missing output_field in expressionAdd output_field=TextField() to all parts
"relation 'django_smartbase_admin_X' does not exist"Missing migrationsRun python manage.py migrate
"Cannot resolve keyword 'X' into field" on detail pageUsing computed SBAdminField name in orderingOverride 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 fieldsUse 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_displayRun 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 (like TabularInline)
  • SBAdminStackedInline - Stacked layout (like StackedInline)
  • SBAdminGenericTableInline - For GenericForeignKey relations
  • SBAdminGenericStackedInline - 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 = 1
  • max_num = 1
  • validate_min = True
  • validate_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:

  1. Annotates the queryset with a fake FK field (inline_fake_relationship) using get_fake_inline_identifier_annotate()
  2. Intercepts .filter() calls from the formset and delegates to filter_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 / MethodTypeDescription
modelModel classRequired. The inline model (can be unmanaged, cross-database)
path_to_parent_instance_idstrRequired. Lookup path from inline model to parent's PK (e.g., "article_id", "article__author_id")
get_fake_inline_identifier_annotate()method → ExpressionRequired 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()methodFilters inline queryset by parent instance. Default implementation uses path_to_parent_instance_id — override for custom logic
save_new_fake_inline_instance()methodHandles saving new inline instances. Override for writable fake inlines
get_queryset()methodOverride 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:

  • SBAdminFakeInlineMixin must be the first parent class (before SBAdminTableInline/SBAdminStackedInline) for correct MRO
  • path_to_parent_instance_id is a Django ORM lookup path (e.g., "article_id", "article__author_id")
  • get_fake_inline_identifier_annotate() must return an F() expression that maps each inline row to the parent PK it belongs to
  • Permission checks flow through SBAdminRoleConfiguration.has_permission() using the inline's original_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.pySBAdminFakeInlineMixin, 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

ParameterTypeDescription
modelModel classRequired. The Django model to query
multiselectboolAllow multiple selections (default: True)
label_lambdacallable(request, item) -> str - Format how items appear in dropdown
value_lambdacallable(request, item) -> any - Extract value from item (default: primary key)
value_fieldstrField name to use as value (alternative to value_lambda)
search_query_lambdacallable(request, qs, model, search_term, lang_code) -> QuerySet - Define which fields to search. Default searches all CharField/TextField
filter_search_lambdacallable(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_lambdacallable(request, selected_values) -> Q - How selected values filter the main list (for filter widgets only)
forwardlist[str]Field names to forward to filter_search_lambda for dependent dropdowns
allow_addboolAllow creating new items inline (default: False, not supported with multiselect)
create_value_fieldstrWhen allow_add=True, create a new model instance using the typed value as this field (e.g. "name"). Required to enable creation.
forward_to_createlist[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_buttonboolHide 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=True
  • create_value_field is 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_create pulls values from the widget’s forward data, so the field must also be present in forward=[...].
  • For FK fields, SBAdmin will try to store forwarded values under <field>_id during creation (so Model.objects.create(**data) accepts raw PKs). This requires the form to expose the model via form.model (see model = Category above).

Key points:

  • search_query_lambda should search the same fields shown in label_lambda for intuitive UX
  • filter_search_lambda runs BEFORE search - use for dependent dropdowns OR general pre-filtering (e.g., limit to items with related data)
  • search_query_lambda defines 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_queryset are available to label_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 Subquery annotations 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:

ApproachUse When
Inline lambdasSource field values are unique, duplicates are acceptable, editing existing values not needed
SubclassNo FK relationship, need .distinct(), or need to look up existing values when editing (requires get_value_field())

Key points:

  • Form field is CharField (not ModelChoiceField) since we're storing a text value
  • Form must inherit from SBAdminBaseForm for proper widget initialization
  • label_lambda and value_lambda return 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=False for single selection
  • filter_search_lambda returns a Q object - for .distinct() or complex queryset changes, subclass the widget and override get_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

KeyTypeDescription
namestrRequired. Tab label shown in the UI
url_paramsdictRequired. 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 WidgetValue Format in filterDataExample
ChoiceFilterWidgetString"published"
MultipleChoiceFilterWidgetArray 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: MultipleChoiceFilterWidget values 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_filter fields. 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

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

  1. Override get_base_config to reorder tabs so your custom tab is first
  2. Override get_menu_view_url to 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_config returns the list of tab configs. The default order is [All, ...custom tabs...]. Rotating with configs[1:] + configs[:1] puts custom tabs first and "All" last.
  • get_menu_view_url is 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_config rotation (configs[1:] + configs[:1]) works for any number of custom tabs — the first custom tab becomes the default
  • self.get_id() returns the view ID in {app_label}_{model_name} format
  • SBAdminViewService.build_list_params_url encodes filter data into URL query parameters
  • Without get_menu_view_url override, clicking the menu item would show the "All" tab even if get_base_config reorders 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:

WidgetFormatExample
AutocompleteFilterWidget[{"value": ..., "label": ...}][{"value": 5, "label": "Tech"}]
MultipleChoiceFilterWidget[{"value": ..., "label": ...}][{"value": "draft", "label": "Draft"}]
ChoiceFilterWidgetString"published"
BooleanFilterWidgetBooleanTrue or False
StringFilterWidgetString"search term"
DateFilterWidgetString (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)
  • AutocompleteFilterWidget and MultipleChoiceFilterWidget require array format with value and label keys
  • The label is displayed in the filter UI; value is 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

KeyTypeDefaultDescription
parent_fieldstrrequiredName of the self-referential FK on the model (e.g. "parent"). Must be a ForeignKey("self", null=True) — validated at request time.
element_columnstrfirst visible columnColumn that gets the tree expand/collapse chevron (dataTreeElementColumn). Accepts either a column name from sbadmin_list_display or a raw Tabulator field id.
start_expandedboolFalseRender all parent rows expanded on first load.
only_show_filtered_childrenboolTrueIf 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_rootboolFalseSet 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:

  • TabulatorNestedPlugin must be added to SBAdminRoleConfiguration.plugins and the admin must declare sbadmin_nested — the plugin self-guards and is a no-op for admins without it.
  • parent_field must be a self-referential ForeignKey; 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_page controls 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_queryset is 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.pyTabulatorNestedPlugin, 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:

PiecePurpose
SBAdminTreeWidgetForm widget to pick a node (e.g. a parent) from a searchable collapsible tree
SBAdminTreeFilterWidgetFilter widget to restrict a list view by subtree
sb_admin/actions/tree_list.htmlChangelist template that replaces the flat Tabulator table with a Fancytree tree — supports drag reorder

When to use this vs sbadmin_nested?sbadmin_nested is for one-level trees over an ordinary self-referential ForeignKey. Use MP_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 / attributePurpose
modelThe treebeard model class.
order_byOrdering 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 pointWhen 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:

ParameterDefaultDescription
relationship_pick_modeNoneSet 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.
inlineFalseRender the tree inline (always visible) instead of in a popup. Switches the template to sb_admin/widgets/tree_select_inline.html.
additional_columnsNoneList of {"field": ..., "title": ...} dicts shown as extra columns next to the node title.
tree_stringsSensible defaultsOverride 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 keyDescription
list_titleLabel of the main tree column (usually the model's verbose name).
tree_json_urlURL that returns the Fancytree JSON — the admin's @sbadmin_action endpoint.
additional_columnsExtra 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_Node are not supported; the depth math hard-codes the materialized-path encoding.
  • SBAdminTreeWidgetMixin is 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_PARENT on 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_reorder URLs or the context values; the template is a scaffold, not a turnkey changelist. Decorate URL-callable methods with @sbadmin_action.
  • Keep reorder_list_template at 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 TabulatorNestedPlugin which needs Postgres).

Source: django_smartbase_admin/engine/filter_widgets.pySBAdminTreeWidgetMixin, SBAdminTreeFilterWidget, process_treebeard_tree; django_smartbase_admin/admin/widgets.pySBAdminTreeWidget; 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:

#HookPurpose
1modify_tabulator_definition(view, request, definition)Inject Tabulator options (dataTree, custom modules, etc.) into the JSON sent to the client.
2modify_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.
3modify_base_queryset(action, request, qs, values)Store-only. Observe the unfiltered .values()-applied qs. Reshaping here leaks into the visible page — return qs unchanged.
4modify_data_queryset(action, request, qs, page_num, page_size)Reshape the unsliced, filtered, ordered data qs. Caller slices [from:to] on the return value.
5modify_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_keys drops anything not in action.allowed_framework_keys. For per-row CSS, wrap a visible column in <span class="...">…</span>, or register your own key + Tabulator rowFormatter in 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_queryset is store-only; reshaping there changes the visible page silently. Use modify_data_queryset if you want reshape to affect the slice.
  • Plugins that need to re-enter build_final_data_{count_,}queryset pass apply_plugins=False to 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.pySBAdminPlugin, 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_request is the active request — use it with messages.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 except to 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:

KeyTypeDescription
fieldsiterableField names to render. Group a row with a nested tuple/list such as ("category", "author"). SBAdmin also accepts SBDynamicRegion markers here.
classeslist/tuple/stringExtra classes on the rendered <fieldset>. Use DETAIL_STRUCTURE_RIGHT_CLASS here for sidebar fieldsets.
descriptionstringTooltip/help text next to the fieldset title. Escape user-controlled HTML before passing it here.
actionsiterableHeader actions rendered next to the fieldset title. Actions use the same SBAdminCustomAction / SBAdminFormViewAction objects and has_permission_for_action filtering as detail actions.
collapsibleboolRender the fieldset body inside Bootstrap collapse and add a header toggle. Defaults to False.
default_collapsedboolInitial closed state for a collapsible fieldset. Defaults to False; ignored unless collapsible=True.
hide_if_emptyboolHide 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_fieldsets when you need SBAdmin-only behavior such as dynamic regions or collapsible fieldsets. Plain Django fieldsets still works for basic fields, classes, and description.
  • collapsible=True uses Bootstrap collapse; do not activate it with classes=["collapse"].
  • default_collapsed=True starts the fieldset closed. Existing JS opens collapsed ancestors when focusing validation errors.
  • hide_if_empty=True is 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_id argument.
  • SBAdmin looks up fieldset metadata by the tuple's first value (name). Avoid multiple fieldsets with the same name, including multiple None fieldsets, 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 None as 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_CLASS from django_smartbase_admin.engine.const
  • Use collapsible=True, not classes=["collapse"], to enable fieldset collapse
  • Custom HTML fields need mark_safe() and should escape user content with escape()
  • 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 typeMatched againstExample
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_tabs is a dict, 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 None for 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_CLASS for sidebar layout within a tab
  • Override get_sbadmin_tabs(request, object_id) for dynamic tab configuration

Source: django_smartbase_admin/admin/admin_base.pySBAdmin.sbadmin_tabs, get_sbadmin_tabs(), get_tabs_context(); django_smartbase_admin/templatetags/sb_admin_tags.pyget_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:

PatternUse when
Simple widgetThe widget is independent and owns its own HTML/chart/list data.
Lightweight chart subwidgetsOne chart has small metric tabs/aggregates over the same queryset.
Real subwidgets with separate AJAXA parent owns common layout/settings, but each child should load independently.
Real subwidgets with one AJAXA 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 as request.article_group_queryset and guard child initial render with getattr(...).
  • 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 raises ImproperlyConfigured when 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_id should point from the widget model to the parent detail object pk, for example "article_id" or "article__id".
  • SBAdminDashboardListWidget brings table media and list behavior; SBAdminDashboardChartWidget brings chart media. SBAdmin collects widget media for the change view automatically.
  • Widget action methods follow the same @sbadmin_action contract 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 mode
  • static/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 kindDeclarationDefault 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 permissionSBAdminCustomAction(..., 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 carry permission="view" (see Permission defaults)
  • When overriding a built-in decorated action, accept object_id=None and pass it through to super(), for example return super().action_bulk_delete(request, modifier, object_id).
  • Bare @sbadmin_action on a custom method requires the change permission by default; pass permission="view" for read-only endpoints
  • SBAdminFormViewAction modal views (see Selection Actions) are automatically marked — no decorator needed
  • SBAdminCustomAction or SBAdminRowAction with direct action_id requires 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_action checks has_permission_for_action for every dispatched action, which delegates to SBAdminRoleConfiguration.has_action_permission() (see Custom Permission System)

Source: django_smartbase_admin/engine/actions.pysbadmin_action, SBAdminCustomAction.permission; django_smartbase_admin/services/views.pySBAdminViewService.delegate_to_action; django_smartbase_admin/engine/configuration.pySBAdminRoleConfiguration.has_action_permission


SBAdmin Attribute Reference

Quick reference for all sbadmin_ prefixed class attributes available in SBAdmin and related classes.

List View Attributes (SBAdmin)

AttributeTypeDescription
sbadmin_list_displaytupleDefine columns using SBAdminField or field names
sbadmin_list_display_datatupleField names always fetched (even if column hidden)
sbadmin_list_filtertupleDefault visible filters - accepts SBAdminField names
sbadmin_list_view_configlist[dict]Pre-filtered view tabs configuration
sbadmin_nesteddictOpt-in one-level tree rendering via TabulatorNestedPlugin (see Nested List View)
sbadmin_list_selection_actionslistCustom bulk actions (override get_sbadmin_list_selection_actions())
sbadmin_list_actionslistList-level actions (not selection-based)
sbadmin_row_actionslistPer-row icon actions rendered in the list table (override get_sbadmin_row_actions())
sbadmin_list_reorder_fieldstrField name for drag-and-drop row reordering
sbadmin_xlsx_optionsdictExcel export configuration options
sbadmin_table_history_enabledboolEnable/disable table state history (default: True)
sbadmin_list_sticky_header_and_footerbool | NoneEnable 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)

AttributeTypeDescription
sbadmin_fieldsetstupleCustom fieldset configuration for change form
sbadmin_tabsdictOrganize fieldsets and inlines into tabs (see Detail View Tabs)
sbadmin_detail_actionslistActions shown on detail/change page
widgetslistDashboard-style widgets registered on this admin; place them in fieldsets by widget class (see Detail View Widgets)
sbadmin_previous_next_buttons_enabledboolShow prev/next navigation buttons (default: False)
sbadmin_is_generic_modelboolMark as generic model for special handling (default: False)

Inline Attributes (SBAdminTableInline/SBAdminStackedInline)

AttributeTypeDescription
sbadmin_fake_inlineslistAdditional inline classes to include
sbadmin_sortable_field_optionslistField names for inline row ordering (default: ["order_by"])
sbadmin_inline_list_actionslistActions 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

  1. Add to INSTALLED_APPS (after django_smartbase_admin):
INSTALLED_APPS = [
    # your apps...
    "django_smartbase_admin",
    "django_smartbase_admin.audit",
]
  1. Run migrations:
python manage.py migrate
  1. 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:

FieldDescription
timestampWhen the change happened
userWho made the change
request_idUUID grouping all changes from the same request
content_type + object_idThe changed object
object_reprString representation of the object
action_typecreate, update, delete, bulk_create, bulk_update, bulk_delete
snapshot_beforeFull object state before the change (JSON)
changesField-level diffs: {"field": {"old": ..., "new": ..., "old_display": ..., "new_display": ...}}
parent_content_type + parent_object_idParent object context (for inline edits)
affected_objectsFK targets referenced in changes (JSON array)
is_bulk / bulk_countWhether 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:

ParameterTypeRequiredDescription
action_typestrYes"create", "update", "delete", "bulk_create", "bulk_update", "bulk_delete"
modelModel classYesThe Django model class
object_idstrNoPrimary key of the affected object (as string)
object_reprstrNoHuman-readable description shown in the audit log
snapshot_beforedictNoFull object state before the change (JSON-serializable)
changesdictNoField-level diffs: {"field": {"old": ..., "new": ...}}
is_bulkboolNoWhether this is a bulk operation
bulk_countintNoNumber of affected items in bulk operation
affected_objectslistNoRelated objects: [{"ct": "app_label.model_name", "id": pk, "repr": "display name"}]

Key points:

  • The user field is automatically populated from the current request via SBAdminThreadLocalService
  • The request_id is 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 ct in affected_objects uses 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()):

MethodWhat 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 AdminAuditLog model 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 typeNo filter active (global view)object_history filter activecontent_type filter active
SuperuserAll entriesAll entriesAll entries
Non-superuserOwn 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:

  1. Collects content type IDs from active object_history and/or content_type filters
  2. For each content type, calls SBAdminViewService.get_restricted_queryset() on the target model — this invokes the project's restrict_queryset from SBAdminRoleConfiguration
  3. Filters audit entries so only entries with object_id in the restricted queryset are shown
  4. Entries for non-filtered content types (e.g., parent context, affected objects in the same audit view) are not restricted
  5. If the model class is unknown or restriction fails → entries for that content type are excluded (fail-closed)

Result by scenario:

User typeGlobal viewObject history (History button on detail)Model history (History button on list)
SuperuserAll entriesEntries for that object, restricted by restrict_querysetEntries for that model, restricted by restrict_queryset
Non-superuserOwn entries onlyAll users' entries for that object, restricted by restrict_querysetOwn 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 SBAdminMenuItem for non-admin roles
  • Overriding has_permission in the role configuration to deny access to the AdminAuditLog model
  • Overriding restrict_queryset to apply additional filters on AdminAuditLog itself

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 on Message. The detail of a created message is read-only.
  • MessageInboxAdmin (messaging_messagerecipient) — per-user inbox over MessageRecipient, visible to any authenticated user and scoped to their own rows; opening a message marks it read.

Installation

  1. Add to INSTALLED_APPS (after django_smartbase_admin):
INSTALLED_APPS = [
    "django_smartbase_admin",
    "django_smartbase_admin.messaging",
]
  1. Run migrations: python manage.py migrate

  2. Attachments use a plain Django FileField, so ensure MEDIA_ROOT / MEDIA_URL are configured (no filer dependency).

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
    )

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

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) in messaging/services.py — don't inline .objects.filter(...) or <span class="badge"> markup in sb_admin.py. Reuse format_badge for badges and datetime_formatter for 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 at templates/sb_admin/sb_admin_messaging/message/submit_line.html.
  • Type-badge filter: the type badge in lists comes from SBAdminMessagingService.render_message_type_badge and the sb_admin_messaging_tags template filter (resolves the active messaging_config from the thread-local request).
  • Strings: wrap user-facing text in _() and recompile catalogs (see Internationalization). After makemessages, clear any #, fuzzy flags 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.py loops through every locale in settings.LANGUAGES; it is no longer Slovak-only.
  • compilemessages.py compiles the same full locale list.
  • After adding new UI strings, regenerate .po files first, then compile .mo files.

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. Set DJANGO_MCP_ENDPOINT = "mcp" without a trailing slash; remote clients such as Claude canonicalize the URL to /mcp and do not replay protocol POSTs across Django's slash redirect.
  • Optional REST list_rows: {SITE_ROOT}{DJANGO_MCP_ENDPOINT}/rest/tools/list_rows/ (for DJANGO_MCP_ENDPOINT = "mcp", this is {SITE_ROOT}mcp/rest/tools/list_rows/). This route exists in django_smartbase_admin.mcp.urls, but only works when SBADMIN_MCP_REST_AUTHENTICATOR is 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 no Origin; omit it if you only target them. Wire it into MIDDLEWARE before anything that 401/403s the MCP path (Django's SecurityMiddleware, then this, then session/auth) so the preflight short-circuits, and list the browser origins it may reflect in SBADMIN_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:

  1. Serve over http on localhost — fine as-is: loopback is a browser secure context (so crypto.subtle PKCE works) and the OAuth/MCP traffic to the admin is HTTPS regardless. Serve on a spare port (python -m http.server 8010).
  2. Allow the origin — add both http://localhost:8010 and http://127.0.0.1:8010 to SBADMIN_MCP_ALLOWED_ORIGINS (8010 is the blueprint's canonical serve port; the two loopback hosts aren't interchangeable to a browser, so list both). SBAdminMCPCorsMiddleware reflects them without Access-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.
  3. Connect — the page self-registers an OAuth client via DCR (POST <issuer>/oauth/register) on first connect and caches the client_id in localStorage; no manual registration. The DCR endpoint and SBAdminMCPOAuth2Validator accept 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

FileWhat it tests
src/django_smartbase_admin/tests/test_dynamic_forms.pyDynamic regions, fieldset layout splitting, inactive field policies, modal dynamic-region responses
src/django_smartbase_admin/audit/tests/test_diff.pyUnit tests for compute_diff, compute_bulk_diff, compute_bulk_snapshot
src/django_smartbase_admin/audit/tests/test_audit_integration.pyIntegration tests for audit logging (CRUD, bulk ops, inlines, M2M, request grouping)
tests/settings.pyMinimal Django settings for standalone test runs
runtests.pyTest runner entry point

Adding New Tests

  1. Place feature-level tests near the feature, e.g. dynamic-region tests in src/django_smartbase_admin/tests/test_dynamic_forms.py.
  2. Place audit tests in src/django_smartbase_admin/audit/tests/.
  3. For audit integration tests, use BaseAuditTest from test_audit_integration.py as base class (installs/uninstalls manager hooks).
  4. Use MockSBAdminContext and NoAdminContext context managers for SBAdmin request simulation.
  5. Audit integration tests use TransactionTestCase because audit hooks patch Model.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, dispatches get()/post() to the current step, builds base context.
  • SBAdminWizardStep — one step per class. The wizard instantiates a new step object per request. Steps define title, model, form_class, formset_classes, and override lifecycle hooks.

SBAdminWizardStep — Attributes

AttributeTypeDescription
titlestrStep title shown in the template
headingstr | NoneHeading above the step title. Falls back to wizard.wizard_step_heading, then model._meta.verbose_name
modelModel classRequired. Used for permission checks
form_classForm classMain form for the step
formset_classeslist[type[BaseFormSet]]Formset factory classes. Used for autocomplete widget registration
requires_wizard_objectboolIf True, missing wizard object redirects to step 1
template_namestr | NoneOverride the default wizard template for this step
submit_button_labelstr | NoneCustom submit button text. None = "Next step" / "Finish"

SBAdminWizardStep — Methods

MethodDescription
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 / MethodDescription
wizard_stepsTuple 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_formsets in the template context
  • Multipart detection: form_is_multipart is set if any form or formset form has file fields
  • POST validation: all formsets are validated alongside the main form; on failure, form_invalid re-renders with bound formsets and errors
  • Autocomplete registration: formset_classes are iterated during register_autocomplete_views to instantiate each formset's row form class for widget initialization

Key points:

  • formset_classes is used only for autocomplete widget registration (happens during init_view_dynamic when 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 SBAdminBaseFormInit for autocomplete widgets to work
  • Pass form_kwargs={"view": self.wizard, "request": self.request} when creating formset instances
  • back_url (top arrow): if a wizard object exists in session and the user has change permission, 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 variableDescription
wizard_headingHeading from step.get_heading()
sbadmin_wizard_step_titleStep title
sbadmin_wizard_submit_labelSubmit button text
wizard_formsetsList of (title, formset) tuples
form_is_multipartTrue if any form has file fields
wizard_primary_section_titleOptional title above the main form fields
sbadmin_wizard_step_bannerOptional HTML banner shown at the top of the step
sbadmin_wizard_poll_secondsIf 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