description: karni.admin: every model gets a derived django-unfold admin with columns, filters, search and autocompletes, and how to override any part of it.¶
karni.admin¶
No project writes admin.py for routine models: when Django starts,
karni.admin autodiscovers every unregistered concrete model and registers a
derived admin on a django-unfold UI. A model with zero admin code appears
fully configured — sensible columns, filters, search, date drilldown and
audit fields read-only.
Scope note (v0.1): third-party apps that register their own admins
(django-celery-beat/results, SimpleJWT's token blacklist, auth.Group, …)
keep their stock admin classes — autodiscovery never re-registers an
existing admin. A re-registration pass that restyles those is future work.
How registration works¶
KarniAdminConfig.ready() runs karni.admin.autodiscover() (disable with
KARNI_ADMIN_AUTODISCOVER = False). For each concrete model (proxies
included) it derives a KarniModelAdmin subclass and registers it on the
site named by KARNI_ADMIN_SITE (dotted path, default
"django.contrib.admin.site"). Skipped:
- models already registered (project
admin.pyfiles and third-party apps run first — explicit registration always wins); - models matched by
KARNI_ADMIN_EXCLUDE— app labels orapp_label.ModelNameentries; default["sessions", "contenttypes", "admin", "auth.Permission", "django_celery_results.ChordCounter"](env: comma list); - models whose
AdminMeta.excludeisTrue; AbstractBaseUsersubclasses, unless theirAdminMetasetsregister = True(the default user model is covered explicitly bykarni.accounts'KarniUserAdmin— see docs/accounts.md).
A second pass fills in autocomplete_fields for FK/M2M fields whose target
admin (on the same site) has search fields. autodiscover() is idempotent
and accepts site= for custom sites.
App ordering¶
karni_settings() places unfold before django.contrib.admin (validation
enforces this even for a hand-written INSTALLED_APPS) and karni.admin
last. Keep karni.admin last: an app listed after it whose ready()
registers admins will hit AlreadyRegistered against the auto-derived admin
— either keep such apps before karni.admin or exclude their models via
KARNI_ADMIN_EXCLUDE. (unfold.contrib.filters is not installed in v0.1;
adopting it for nicer FK filters is a noted follow-up.)
What gets derived¶
Audit fields (id, created_at, updated_at, deleted_at), UUID pks and
password-named fields are skipped everywhere; password-named fields are also
excluded from forms.
| attribute | rule | cap |
|---|---|---|
list_display |
declaration-order Char/Slug/Email/URL/numeric/Boolean/Date(Time)/FK fields (never Text/JSON/File/Image/M2M); falls back to __str__ |
KARNI_ADMIN_LIST_DISPLAY_MAX (6) |
list_filter |
booleans, choice fields, FKs (RelatedOnlyFieldListFilter), date/datetime |
KARNI_ADMIN_LIST_FILTER_MAX (6) |
search_fields |
Char/Slug/Email/Text without choices | KARNI_ADMIN_SEARCH_MAX (5) |
date_hierarchy |
first declared non-audit date field, else created_at |
— |
readonly_fields |
audit fields present on the model + AdminMeta.read_only |
— |
list_select_related |
FKs appearing in the derived list_display (no N+1 changelists) |
— |
autocomplete_fields |
FK/M2M whose target admin has search fields (second pass) | — |
Each rule is a pure function in karni.admin.derive and a classmethod hook
on KarniModelAdmin — override either per project.
Documented v0.1 costs: RelatedOnlyFieldListFilter issues a DISTINCT
subquery per FK filter on the changelist, and the created_at fallback puts
a date drilldown on every BaseModel changelist. Trim with AdminMeta if a
table is huge.
Per-model overrides — AdminMeta¶
The admin twin of APIMeta, declared on the model:
class Invoice(BaseModel):
...
class AdminMeta:
list_display = ["number", "customer", "total"]
list_filter = ["status"]
search_fields = ["number"]
read_only = ["total"] # extends derived readonly_fields
date_hierarchy = "issued_at"
ordering = ["-issued_at"]
extra = {"list_per_page": 25} # any ModelAdmin attrs, applied last
Other recognized attrs: exclude = True (skip registration),
inline_on = "fk_field" (child side: generates a TabularInline on the
parent's admin; a list of FK names is allowed; the child stays registered on
its own too), register = True (opt an AbstractBaseUser subclass in).
extra is the catch-all escape hatch — anything a ModelAdmin accepts.
Precedence: explicit admin.site.register (project admin.py) >
AdminMeta > derived defaults.
Row scope¶
The admin shows the rows the user's grants reach (ADR 0002, P2) — the same
answer the API gives for access = Granted(). Django's model permissions
still decide which screens a staff user may open; the roles' grants
(KARNI_ROLES → RolePermission, see roles as data)
decide which rows those screens show and accept:
get_queryset()keeps only the rows the user'sviewgrants reach, so the changelist, its search and list filters, the autocomplete endpoint and every action start from the same set. A branch manager sees their branch; a staff user with Django permissions but no grant sees nothing; superusers hold every grant.has_view_permission,has_change_permissionandhas_delete_permissionwith an object also require the row to be inside the matching grant's scope, so a change-form URL for another scope's row is refused (and the changelist never offered it).- FK and M2M widgets list only target rows the user may view — what the API's relation fields accept.
A soft-deleted row inside the scope stays reachable (the owner can restore it). An admin that must show everything its Django permissions admit opts out:
class SupportConsoleAdmin(KarniModelAdmin):
scope_exempt = True
scope_exempt_reason = "support staff look up any customer's rows"
The reason is required (E210 without it) and karni check prints every
exemption in the project on each run. Viewsets have the same switch
(Access control); the one-line test that a role
cannot reach another scope's rows through the admin is
assert_isolated.
Field visibility¶
Karni.field_roles (API reference) applies to
the admin too: a field the staff user may not read is absent from the
changelist columns, the change form (fields and fieldsets), search, list
filters and sortable columns, and a changelist lookup on it (?salary__gte=)
is refused. A field they may read but not change is shown read-only. No
admin class needs to know; the hooks (get_list_display, get_fields,
get_search_fields, get_sortable_by, get_readonly_fields,
lookup_allowed, …) call super() and then drop what the user's roles do
not reach, so an explicit AdminMeta or subclass still works.
Change history¶
A model that opts in with class Karni: audit = True gets its field-level
changes on the History tab of its change page — date, user, action and each
field's old → new, above Django's own admin-action log — with the same
visibility rules applied. See change history.
Permission matrix¶
Roles → Permission matrix is the screen that changes who may do what
without a deploy: roles across, models down, one scope selector per action,
bulk selectors per row and column, a confirmation diff before anything is
saved, and the matrix's own change history underneath. Effective
permissions (on the same changelist, and as a button on every user's change
page) answers "why can this person see this?" — every permission a user
holds, with the role that grants it and at which scope. Both pages are gated
on the grants (karni_accounts.rolepermission:view to look, :change to
edit) and work at tablet width. The rules, the guard rails and the API are in
accounts.
Import and export¶
Every changelist has Export selected as CSV / XLSX (the selection, or
every filtered row with "select all" — through the row scope and the role's
field visibility) and an Import button: upload, map columns to fields,
preview the dry run, commit — on one page, with progress for large files and
a downloadable template. karni_transfer = False on an admin class removes
both. See import and export.
Vetoes¶
The admin honours karni.vetoes the way the API gate does:
has_add_permission, has_change_permission and has_delete_permission
also require that no registered veto refuses the action, get_actions
drops vetoed action names (delete_selected, export_csv, …) from the
menu, and the Import page refuses when import is vetoed.
KarniAdminMixin.vetoed(request, action) is the one call behind all of
them — use it in a custom view. The karni-impersonation module registers
the veto that blocks dangerous actions while a support user acts as someone
else.
Soft-deleted models in the admin¶
For karni.models.SoftDeleteModel subclasses the derived admin is
soft-delete aware:
- The changelist defaults to alive rows only; the "deleted" filter
switches to only-deleted or everything. The change form can open deleted
rows. The admin queryset lifts the
soft_deletescope, and only that one (objects.without("soft_delete", reason=...)), so any other scope, such as a tenant, still applies. - Django's built-in delete selected action stays and is soft on soft models (it is a real delete everywhere else). Extra actions: restore selected, and hard delete selected — superuser-only and routed through Django's usual confirmation page.
- Autocomplete responses only offer alive rows; FK fields pointing at a soft-delete model still validate rows that already reference a deleted object (the current value is kept in the field's queryset; new choices stay alive-only).
Behavioural fine print (by design — see karni/models.py docstrings):
- Soft deletes send no
pre_delete/post_deletesignals and do not cascade to related rows; hard deletion is only ever the explicithard_delete()/ admin hard-delete action. - Delete confirmation pages use Django's collector, so they list cascade children that a soft delete will not actually touch.
Theming¶
Unfold reads the UNFOLD setting. Karni's default sets the site title and
header from KARNI_ADMIN_TITLE (env, default "<PROJECT_NAME> admin"), a
neutral indigo primary color scale, BORDER_RADIUS: "8px" and leaves
unfold's automatic light/dark switching on. Two ways to change it:
karni_settings(globals())
# 1. mutate the merged default
UNFOLD["COLORS"]["primary"]["600"] = "oklch(0.55 0.2 250)"
# 2. or replace it wholesale (assign after the call — your value wins)
UNFOLD = {"SITE_TITLE": "Ops", "SITE_HEADER": "Ops"}
(Defining UNFOLD before the call also works — setdefault semantics.)
The header and login screen carry no logo by default — the admin is yours,
not Karni's. KARNI_ADMIN_LOGO adds one: "karni" uses Karni's own mark
(the black-ground mark in light mode, the light-ground one in dark mode,
the small variant as the site icon), or give static paths of your own:
Each value is passed to static(); dark and icon fall back to light.
KARNI_ADMIN_LOGO = None (the default, also the env value "") leaves the
text header.
More escape hatches:
KARNI_ADMIN_BASE(default"unfold.admin.ModelAdmin") — the base classKarniModelAdminextends, resolved lazily; point it at your own subclass to inject behaviour into every derived admin.KARNI_ADMIN_SITE— dotted path to theAdminSiteinstance used for registration and by the test/project URLconf.KARNI_ADMIN_AUTODISCOVER = False— opt out entirely and usekarni.admin.KarniModelAdminas a hand-registered base class.