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).
Related records (ChildOf, Related, RelatedRecordsMixin)¶
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=12or?candidate_id=1,7,12). - Hides child rows whose parent is soft-deleted (when the parent model has a
deleted_atcolumn and you useSoftDeleteMixinon 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).
2. Add related links on the parent list¶
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¶
- Open the child list filtered to one parent, e.g.
/admin/certificate/list?candidate_id=12. - Click New -- kloudmint's layout script copies the filter query onto the
create link, so you land on
/admin/certificate/create?candidate_id=12. - The parent picker is pre-filled from that query param.
- Click Save -- sqladmin redirects back to
/admin/certificate/list?candidate_id=12.
Limitations:
- Multi-id filters do not prefill --
?candidate_id=1,2keeps 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/.../createwith 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.