Skip to content

Building Blocks (v0.4)

Reusable admin patterns shipped in kloudmint 0.4 -- related parent/child tables, custom-page RBAC, filters, mixins, file storage, column formatters, audit trails, and extra detail pages. Each section is a self-contained procedure you can copy into a host project.

Requires sqladmin>=0.31,<0.32 (see Installation.md).

Use these when one model "has many" of another and you want filtered child lists, links from the parent list, and a parent picker on the child form without re-writing the same filter/query code every time.

1. Define the child view

from kloudmint import ChildOf, RBACModelView
from models import Certificate, Candidate

class CertificateAdmin(ChildOf, RBACModelView, model=Certificate):
    parent = Candidate
    parent_display_field = "full_name"   # column name on Candidate -- a string, not Candidate.full_name
    column_list = [Certificate.name, Certificate.expiry_date]
    form_columns = [Certificate.candidate, Certificate.name, Certificate.expiry_date]

ChildOf automatically:

  • Adds a multi-select parent filter on the child list (same query param name sqladmin derives for the FK, e.g. ?candidate_id=12 or ?candidate_id=1,7,12).
  • Hides child rows whose parent is soft-deleted (when the parent model has a deleted_at column and you use SoftDeleteMixin on the parent view).
  • Prefills the parent field on create when the list is filtered to a single parent id in the URL.
  • Returns to the filtered list after Save -- sqladmin 0.31 preserves the create URL's query string on redirect, and kloudmint's layout template copies filter params onto the list "New" button so the create URL carries them.

Set parent_fk and parent_relationship_name explicitly when the child model has more than one FK to the same parent table (otherwise ChildOf raises at import time).

from kloudmint import Related, RelatedRecordsMixin, RBACModelView

class CandidateAdmin(RelatedRecordsMixin, RBACModelView, model=Candidate):
    column_list = [Candidate.full_name, "related_records"]
    column_labels = {"related_records": "Related"}
    related = [
        Related(CertificateAdmin, label="Certificates", count=True),
    ]

Add "related_records" to column_list yourself (same as any other computed column). Each Related(...) entry renders as a link to that child's filtered list. count=True shows a live row count next to the label (one grouped query per related entry per list render). Links only appear when the current user has index on that child resource.

3. Filtered create flow

  1. Open the child list filtered to one parent, e.g. /admin/certificate/list?candidate_id=12.
  2. Click New -- kloudmint's layout script copies the filter query onto the create link, so you land on /admin/certificate/create?candidate_id=12.
  3. The parent picker is pre-filled from that query param.
  4. Click Save -- sqladmin redirects back to /admin/certificate/list?candidate_id=12.

Limitations:

  • Multi-id filters do not prefill -- ?candidate_id=1,2 keeps the parent picker manual (the filter still works; only single-id prefill is supported).
  • "Save and add another" opens an unfiltered create form (sqladmin opens /admin/.../create with no query). Use plain Save when you want to stay in the filtered list.

See examples/basic_app/ for a minimal Product / ProductNote pair.


Custom pages with RBAC (RBACBaseView)

For non-CRUD sidebar pages, subclass RBACBaseView instead of sqladmin's plain BaseView + manual has_permission() checks:

from kloudmint import RBACBaseView
from sqladmin import expose
from starlette.requests import Request

class PdfExtractorAdmin(RBACBaseView):
    name = "PDF Extractor"
    icon = "fa-solid fa-file-pdf"
    # resource_name defaults to "PdfExtractor" (class name minus trailing "Admin")
    # rbac_actions defaults to ["use"]

    @expose("/pdf-extractor", methods=["GET", "POST"])
    async def pdf_extractor_page(self, request: Request):
        ...

Register with the engine like any model view:

admin.register(PdfExtractorAdmin)

Kloudmint.register() mounts the page and calls register_resource() with rbac_actions (default ["use"]), so it appears in the permission grid.

Shared resource names: when two pages should share one permission resource (e.g. a wizard and its follow-up screen), set the same resource_name on both:

class ResumeDraftsAdmin(RBACBaseView):
    resource_name = "ResumeIntake"
    rbac_actions = ["use"]

is_accessible / is_visible must stay plain sync def methods, not async def -- sqladmin calls them without await.

See CustomPages.md for the full custom-page walkthrough.


Filters

Import from kloudmint:

Class Use when
MultiSelectForeignKeyFilter Filter a FK column by one or more ids (?candidate_id=1,7,12) with a searchable dropdown
MultiSelectValueFilter Filter a plain string/enum column by multiple values
ExactValueFilter Pre-filter a list from a link with no dropdown UI (exact match on a query param)

ChildOf adds MultiSelectForeignKeyFilter for the parent FK automatically. Reach for these directly on other views:

from kloudmint import MultiSelectForeignKeyFilter, ExactValueFilter

class NoteAdmin(RBACModelView, model=Note):
    column_filters = [
        MultiSelectForeignKeyFilter(
            Note.candidate_id, Candidate.full_name, foreign_model=Candidate,
            title="Candidate",
        ),
        ExactValueFilter(Note.id, title="Note ID"),
    ]

Templates live under kloudmint's sqladmin/filters/ overrides (Select2 multi-select and exact-value chips).


Mixins

Compose mixins before RBACModelView in the class bases (same MRO rule as ProtectedRolesMixin):

SoftDeleteMixin

"Delete" stamps a timestamp column instead of removing the row; list/count/edit/ detail queries hide stamped rows.

class CandidateAdmin(SoftDeleteMixin, RBACModelView, model=Candidate):
    soft_delete_field = "deleted_at"   # default

Your model must have that column (add it in a migration).

DynamicChoicesMixin

Keeps dropdown choices fresh across requests (sqladmin caches scaffolded form classes on the view class).

class CandidateAdmin(DynamicChoicesMixin, RBACModelView, model=Candidate):
    def dynamic_form_args(self, db):
        statuses = db.query(Status).all()
        return {"status_id": {"data": [(str(s.id), s.name) for s in statuses]}}

FileUploadMixin + FileSpec

Adds validated file fields to create/edit forms; uploads go through a storage backend (see below).

from kloudmint import FileUploadMixin, FileSpec

class CertificateAdmin(FileUploadMixin, RBACModelView, model=Certificate):
    file_fields = {
        "certificate_file": FileSpec(
            key_attr="file_s3_key",
            name_attr="file_filename",
            extensions=(".pdf", ".png"),
            max_bytes=10_000_000,
            label="Certificate file",
        ),
    }

Pass storage= to Kloudmint(...) or set CertificateAdmin.storage directly.


Storage (storage=)

from kloudmint import Kloudmint, LocalStorage

storage = LocalStorage(directory="./uploads", public_path="/uploads")
admin = Kloudmint(app, engine, auth_backend=..., storage=storage)

Mount static files in your FastAPI app:

from starlette.staticfiles import StaticFiles
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")

For S3, use S3Storage (pip install kloudmint[s3]). Any object with upload(), delete(), and url() methods works (StorageBackend protocol).

Views using FileUploadMixin or DetailPage file fields pick up the engine's storage= automatically when they don't define their own.


Column formatters (kloudmint.formatters)

Factories that return sqladmin-compatible (model, attribute) -> Markup callables:

from kloudmint import formatters

class CandidateAdmin(RBACModelView, model=Candidate):
    column_formatters = {
        Candidate.status: formatters.badge(color_attr="status_color", label_attr="status_label"),
        Candidate.email_verified: formatters.bool_badge("Verified", "Unverified"),
        Candidate.expiry_date: formatters.expiry_badge("expiry_date"),
    }
    column_formatters_detail = {
        Candidate.notes: formatters.pre(max_height="300px"),
        Certificate.file_s3_key: formatters.file_preview(
            "file_s3_key", "file_filename", storage,
        ),
    }

Also available: formatters.date, formatters.json_pretty, formatters.link, formatters.action_links.


Audit trail

1. Create the ActivityLog model

from kloudmint.audit import create_activity_log_model

ActivityLog = create_activity_log_model(Base)
# Run an Alembic migration to create the table.

2. Wire the audit backend

from kloudmint.audit import KloudmintAuditBackend

admin = Kloudmint(
    app, engine, auth_backend=...,
    audit_backend=KloudmintAuditBackend(SessionLocal, ActivityLog),
    activity_log_model=ActivityLog,
)

Every RBACModelView create/update/delete is logged via sqladmin's built-in audit hook.

3. Field-level diffs on specific views

from kloudmint.audit import AuditDiffMixin

class CandidateAdmin(AuditDiffMixin, RBACModelView, model=Candidate):
    audit_fields = ["full_name", "email", "phone_number"]
    activity_log_model = ActivityLog

AuditDiffMixin logs {field: {old, new}} JSON instead of only the submitted snapshot.

4. Read-only Activity Log admin tab

from kloudmint.audit import ActivityLogAdminBase

class ActivityLogAdmin(ActivityLogAdminBase, model=ActivityLog):
    pass

admin.register(ActivityLogAdmin)

Detail pages (DetailPage, Section)

Extra view/edit pages for a subset of a model's fields, mounted automatically when you list them on the parent view:

from kloudmint import DetailPage, Section, FileSpec

class CandidateAdmin(RBACModelView, model=Candidate):
    detail_pages = [
        DetailPage(
            "more-details",
            label="More details",
            sections=[
                Section("Next of kin", [Candidate.next_of_kin, Candidate.next_of_kin_relationship]),
                Section("Bank", [Candidate.bank_name, Candidate.bank_account_number]),
            ],
            file_fields={
                "attachment": FileSpec(
                    "attachment_s3_key", "attachment_filename",
                    extensions=(".pdf", ".png", ".jpg"),
                ),
            },
        ),
    ]

Kloudmint.register() builds and mounts one sidebar entry per DetailPage. RBAC reuses the parent view's resource name: show to view, update to save.

form_overrides / form_args on a DetailPage take plain WTForms classes and kwargs -- they are separate from the parent ModelView's form scaffolding.


Engine options (v0.4)

admin = Kloudmint(
    app,
    engine,
    auth_backend=...,
    audit_backend=...,              # optional KloudmintAuditBackend
    hide_empty_categories=True,     # hide sidebar categories with no visible children
    storage=...,                    # default for FileUploadMixin / DetailPage uploads
    activity_log_model=ActivityLog, # passed to AuditDiffMixin when set on views
)

admin.register() accepts both RBACModelView subclasses and RBACBaseView custom pages in one call.