Model admin¶
The model admin turns a SQLAlchemy model into a set of REST routes plus a schema that a generic frontend uses to draw the table and the form, without writing code per model. It is optional and covers the common case: list, search, filter, sort, create, edit and delete rows, each action behind its own permission.
It does not render HTML. The frontend (for example the Quasar pages of your project) reads the schema and builds the screens.
Enable it¶
Add AdminModule to the project's modules:
# project_modules.py
from pachifast.admin import AdminModule
from pachifast.presets import core_module
MODULES = (core_module, AdminModule, billing_module)
At startup the module imports every apps/**/admin.py and common/**/admin.py and creates the missing permissions (see below). The models must live in the authentication database (the one behind AUTH_DATABASE_URL); the model admin is asynchronous only.
Register a model¶
# apps/billing/admin.py
from pachifast.admin import ModelAdmin, register_admin
from .models import Invoice
@register_admin(Invoice)
class InvoiceAdmin(ModelAdmin):
list_display = ["number", "customer_id", "status", "total"]
search_fields = ["number"]
list_filter = ["status", "customer_id"]
ordering = ["-id"]
readonly_fields = ["created_at"]
exclude = ["internal_notes"]
fields_meta = {"status": {"label": "State"}}
Every attribute is optional. With none of them the admin lists every column, searches nothing, filters nothing and orders by the primary key, newest first.
| Attribute | Meaning |
|---|---|
app |
Prefix of the routes and of the permissions. Default: the package that holds admin.py (apps.billing.admin gives billing). |
name |
Name of the model in routes and permissions. Default: the table name. |
title |
Human title. Default: the class name spaced out. |
list_display |
Columns of the table, in order. |
search_fields |
Columns the search text looks into (case insensitive, substring). |
list_filter |
Columns that can be filtered by exact value (?status=paid). Others are ignored. |
ordering |
Default order ("-id" style). |
order_fields |
Columns a client may sort by. Default: list_display plus the primary key. |
readonly_fields |
Columns shown but never written. The primary key is always read-only. |
exclude |
Columns that never leave the server: not listed, not read, not written. Binary columns are always excluded. |
fields_meta |
Per column overrides of the schema, for example {"status": {"label": "State"}}. |
app and name must be lowercase identifiers (they appear in URLs and permission codenames) and two models cannot use the same pair. The model needs a single primary key column; unknown column names are refused when the class is defined.
Routes¶
All routes are under /api/admin/models and answer inside the standard envelope.
| Route | Does | Permission |
|---|---|---|
GET / |
The models the user can list (the menu of the admin), with the user's permissions on each. | view |
GET /{app}/{model}/_schema |
The schema of the model. | view |
GET /{app}/{model}/_options |
Rows as {value, label} pairs for a select that points at the model: search, ids=1,2, page, pageSize; answers {items, total, page, pageSize, pages}. The label is label_field or the first text column of the table. |
view |
GET /{app}/{model} |
Paginated rows: page, pageSize, orderBy, search and the list_filter columns. |
view |
GET /{app}/{model}/{pk} |
One row. | view |
POST /{app}/{model} |
Create a row. Answers 201 with the row. |
add |
PATCH /{app}/{model}/{pk} |
Update only the columns sent. | change |
DELETE /{app}/{model}/{pk} |
Delete a row. Answers 204. |
delete |
GET /{app}/{model}/{pk}/history |
The changes made to the row through the admin, newest first, paginated (works for a deleted row too). | view |
POST /{app}/{model}/_actions/{name} |
Run a bulk action on {"ids": [...]}: {affected, message}. |
delete for delete_selected, change or the one the action declares |
Details worth knowing:
orderBytakes one or more columns separated by commas,-for descending:orderBy=-status,number(camelCase or snake_case names, onlyorder_fields).- Filters of
list_filtermatch exactly (?status=paid; a foreign key takes the primary key of the other row). Columns of a numeric, date, datetime or time type can also be filtered by a range with<column>__gteand<column>__lte(inclusive;?dueDate__gte=2026-01-01T00:00:00Z); the schema marks these filters with"range": true. - Datetimes are ISO 8601. Send an offset or
Z(2026-03-01T10:00:00+02:00): for a column without time zone the value is converted to UTC and stored naive, so what is read back is UTC. A datetime without offset is taken as it is. - A row has no title in the data; to show "delete invoice 12?" ask
_options?ids=12,13(it answers the labels of those rows). - Without
pageandpageSizethe list answers page 1 of 25;pageSizeis capped at 200. The admin never returns an unbounded list. - The body of
POSTandPATCHis validated against the model's types,Literaloptions,String(n)lengths and nullability. Errors are the usual422withloc: ["body", "<field>"](and"query"for parameters); the field is always the camelCase name of the schema, even if the client sent the snake_case one. Keys are camelCase (snake_case is accepted too). Keys of read-only, excluded or unknown columns are ignored, never written. delete_selectedanswers a localizedmessage(3 deleted).- A write refused by the database (duplicate, foreign key, not null) answers
409 admin.integrity_error. Unknown models and rows answer404 admin.model_not_foundandadmin.object_not_found. - Writes are recorded by the activity log when it is enabled (
{username} changed shop.invoice #3).
History¶
Every write made through the admin (create, update, delete and bulk actions) adds a row to the history table, in the same transaction as the change: either both are saved or neither. A row records who (userId, and the username copied so it survives the account), when, the action (add, change, delete, action:<name>) and changes (always an object, empty when nothing differs), a map from each changed column to {"old": ..., "new": ...} (an update that changes nothing records nothing). Excluded columns never appear.
{"id": 12, "timestamp": "2026-10-07T10:30:00+00:00", "userId": "...", "username": "boss",
"action": "change", "changes": {"total": {"old": 5.0, "new": 7.0}, "status": {"old": "open", "new": "paid"}}}
The history only sees changes made through the admin; writes from your own routes or scripts are not recorded (the activity log records HTTP requests in general). The table is pachifast_admin_change (name follows table_prefix). AdminModule creates it at startup if it does not exist, and declares the migration target admin, so a project that manages its schema with migrations can generate a revision with pachifast migrations revision --target-name admin before the first start in production.
Bulk actions¶
delete_selected exists for every model. To add your own, mark a method with @admin_action and list its name in actions:
from pachifast.admin import ModelAdmin, admin_action, register_admin
@register_admin(Invoice)
class InvoiceAdmin(ModelAdmin):
actions = ["mark_paid"]
@admin_action(label="Mark as paid") # permission="change" and confirm=True by default
async def mark_paid(self, session, rows) -> str:
for row in rows:
row.status = "paid"
return f"{len(rows)} paid"
The method receives the selected ORM rows, changes them and may return a text for the user (message). It must not commit: the admin commits once, with the history rows. The action runs on 1 to 500 ids, all or nothing: an id that does not exist answers 404 and nothing changes. An action needs the model permission it declares (permission="delete", for example); a name that is not in actions answers 404 admin.action_not_found. Declaring a name that is not marked with @admin_action is refused when the class is defined.
Permissions¶
Each model has four permissions: <app>.<model>.view, .add, .change and .delete. A superuser has them all; other users get them through groups or directly, as usual.
At startup AdminModule creates the missing ones in the permissions table so they can be assigned from the groups page. It only adds: existing permissions are left alone and none is removed. If the authentication tables do not exist yet (migrations not applied) it logs a warning and the next start creates them.
The schema¶
GET /{app}/{model}/_schema describes the model for the user who asks:
{
"app": "billing", "model": "invoice", "title": "Invoice", "titleKey": "admin.billing.invoice.title",
"primaryKey": "id", "primaryKeyType": "int",
"permissions": {"view": true, "add": true, "change": true, "delete": false},
"list": {
"display": ["number", "status", "total"], "search": true, "ordering": ["-id"],
"orderFields": ["number", "status", "total", "id"],
"filters": [{"field": "status", "component": "select", "label": "State", "properties": {"options": [...]}}]
},
"fields": {
"number": {"component": "input", "label": "Number", "labelKey": "admin.billing.invoice.number.label",
"type": "string", "default": null, "required": true, "readonly": false, "nullable": false,
"primaryKey": false, "maxlength": 20},
"status": {"component": "select", "required": false, "properties": {"options": [{"label": "Open", "value": "open"}], "emitValue": true},
"optionKeys": {"open": "admin.billing.invoice.status.options.open"}}
}
}
The component comes from the Python type of the column:
| Type | Component |
|---|---|
Literal[...], enum.Enum |
select with options (and optionKeys) |
bool |
toggle |
int, float, Decimal |
number |
str |
input (with maxlength for String(n)), textarea for text without a length |
datetime, date, time |
datetime, date, time |
dict, list (JSON) |
json |
| foreign key to a registered model | lookup, with "lookup": {"app", "model"}: fill it from that model's _options |
type is the JSON data type (string, integer, number, boolean, date, datetime, time or json), default the plain default a new row gets (null for generated or computed ones), and A foreign key column whose table is administered by another registered model gets component: "lookup" and lookup: {"app": ..., "model": ...} (also in list.filters); a client reads the choices from GET /{app}/{model}/_options of that model and resolves a stored value with ?ids=. A foreign key to a table nobody administers stays a plain number. required is true when a row cannot be created without the value (not nullable, no default). Number fields carry properties.step (1 for integers, "any" otherwise); min, max and other component props can be added per column with fields_meta = {"price": {"properties": {"min": 0}}}. primaryKeyType is int, uuid or str, to build the URLs. list.display names columns that are always in fields: take the label and type from there.
Data in responses is JSON: dates and times are ISO 8601 strings, UUIDs and decimals (Decimal) are strings, floats are numbers. Binary columns are not handled: they are always left out. actions lists the bulk actions (name, label, labelKey, permission, confirm, and allowed for the asking user) and history: true tells the client that the history route exists. Labels and option texts follow the same translation rule as the user settings: the keys admin.<app>.<model>.title, admin.<app>.<model>.<column>.label, admin.<app>.<model>.<column>.options.<value> and admin.<app>.<model>.actions.<name>.label can be added to the catalogs of the project, and the schema falls back to a readable text made from the names.
Security notes¶
- Every route checks a permission; a model that is not registered has no route.
- The columns that may be written are exactly the model's columns minus
readonly_fields,excludeand the primary key: a body can never reach other attributes. A model with a sensitive column (a role, a flag such asis_superuser) must list it inreadonly_fieldsorexclude, otherwise whoever has theaddorchangepermission can set it. - Sorting is limited to
order_fields, searching tosearch_fields, filtering tolist_filter.
pachifast locale admin lists every text of the registered models with its default and the languages that translate it (--missing shows what is untranslated), like pachifast locale settings does for the user settings.
Not included (yet)¶
Inline related rows (child rows inside the parent's form), CSV export and live updates of open lists are not part of this version.