Admin resources
A registry where a content feature declares how its model appears in the admin panel. The analogue of WordPress register_post_type — but the data lives in real per-feature tables, not a generic CPT table.
Table of contents
What it is
WordPress has register_post_type: you describe a content type and WordPress gives you back-office screens for it. The catch is that every post type shares one generic wp_posts table — fields get smeared across post_meta.
SPLENT keeps the ergonomics and drops the generic table. A content feature owns a real SQLAlchemy model backed by its own per-feature table (Event, TeamMember, …). The admin resource registry is purely metadata: a feature calls register_admin_resource(Model, ...) to declare how that model should be administered (label, icon, menu group, list columns, per-field widgets). The admin feature reads the registry to build a curated, grouped, wp-admin-style menu and CRUD screens — instead of dumping every db.Model subclass in a flat list.
| WordPress | SPLENT | |
|---|---|---|
| Declaration | register_post_type() |
register_admin_resource() |
| Storage | shared wp_posts + post_meta |
real per-feature table, typed columns |
| Source of truth | WP core | the feature’s own model |
| Back-office | wp-admin | the admin feature |
The singleton registry
Like template hooks, the registry is a module-level singleton in splent_framework, populated once at app startup and read-only during request handling.
# splent_framework/admin/registry.py
_resources: dict[str, AdminResource] = {}
_resourcesis populated during feature registration (init_feature) and read-only during requests. It is not thread-safe for concurrent writes — never callregister_admin_resource()from a request handler, same contract astemplate_hooks.py.
Registering a resource
Call register_admin_resource from the feature’s init_feature(app), right where it registers its services. The owning content feature declares its admin surface once; the admin panel renders it.
# splent_feature_events/__init__.py
from splent_framework.admin import register_admin_resource
from splent_io.splent_feature_events.models import Event
from splent_io.splent_feature_events.services import EventsService
def init_feature(app):
register_service(app, "EventsService", EventsService)
# Surface Event in the admin panel (the wp-admin-style back-office).
register_admin_resource(
Event,
name="event",
label="Event",
label_plural="Events",
icon="calendar",
group="Programme",
order=10,
list_columns=["title", "kind", "room", "starts_at"],
field_widgets={
"summary": "textarea",
"description": "richtext",
"image": "image",
"link": "url",
"kind": "select",
"slug": "slug",
"starts_at": "datetime",
"ends_at": "datetime",
"published": "bool",
},
feature="events",
)
Another feature, another model, another menu group:
# splent_feature_team/__init__.py
def init_feature(app):
register_service(app, "TeamService", TeamService)
register_admin_resource(
TeamMember,
name="team_member",
label="Team member",
label_plural="Team",
icon="users",
group="People",
order=10,
list_columns=["name", "role", "group", "order"],
field_widgets={
"bio": "richtext",
"photo": "image",
"email": "text",
"link": "url",
"slug": "slug",
"published": "bool",
},
feature="team",
)
Registration is additive and idempotent by name: re-registering the same name overrides the previous entry (last registration wins), which lets a refinement feature curate another feature’s resource.
AdminResource fields
register_admin_resource builds and stores an AdminResource. The keyword arguments map one-to-one onto these fields.
| Field | Default | Meaning |
|---|---|---|
model |
— | The SQLAlchemy model class to administer. |
name |
model.__name__.lower() |
URL-safe key, e.g. "event". The registry key. |
label |
model.__name__ |
Singular human label, e.g. "Event". |
label_plural |
f"{label}s" |
Plural label shown in the menu, e.g. "Events". |
icon |
"file" |
Feather icon name used in the menu, e.g. "calendar". |
group |
"Content" |
Menu section this resource is filed under. |
order |
100 |
Sort order within the group. |
list_columns |
None |
Columns shown in the list view; None lets the admin auto-pick. |
search_fields |
[] |
Columns the admin search box queries. |
field_widgets |
{} |
Per-column widget hints — column -> WIDGETS. |
readonly_fields |
[] |
Columns rendered but not editable. |
scope |
{} |
Query filter — see below. |
create_defaults |
{} |
Column values forced on create — see below. |
feature |
None |
Owning feature short name (provenance). |
AdminResource.model_name exposes the SQLAlchemy class name (model.__name__) — the key the CRUD routes resolve by.
Widgets
field_widgets maps a column name to a widget hint the admin form renderer understands. Any column you do not list falls back to an introspection-derived default chosen by the admin feature. The available WIDGETS:
| Widget | Use |
|---|---|
text |
single-line string |
textarea |
multi-line plain text |
richtext |
WYSIWYG / HTML body |
number |
numeric input |
bool |
checkbox |
date |
date picker |
datetime |
date + time picker |
select |
enum / fixed choices |
fk |
foreign key — related record picker |
image |
media id / upload |
url |
URL input |
slug |
slug input |
color |
colour picker |
scope and create_defaults
These two fields let one shared table back several menu entries — the closest SPLENT comes to “several post types in one table”, but always with explicit, typed filters.
-
scopeis a query filter applied to the list view. Registering the same model twice with different scopes produces two menu entries reading the same table:register_admin_resource(Page, name="event_page", label="Event page", scope={"collection": "event"}) register_admin_resource(Page, name="info_page", label="Info page", scope={"collection": "info"}) -
create_defaultsare column values forced on create, so new records made through a scoped entry land back inside that scope:register_admin_resource(Page, name="event_page", label="Event page", scope={"collection": "event"}, create_defaults={"collection": "event"})
A resource with empty scope / create_defaults (the common case) administers its whole table.
How the admin feature consumes the registry
The admin feature does not write to the registry — it reads it to build the menu and resolve screens. The query API:
| Function | Returns |
|---|---|
get_admin_resource(name) |
A single resource by its url-safe name, or None. |
get_admin_resource_for_model(model_name) |
The resource whose model class name matches, or None. |
get_admin_resources() |
All resources keyed by name (insertion order). |
get_admin_groups() |
Resources bucketed by menu group, each sorted by (order, label). |
The sidebar is built from get_admin_groups() — one section per group, content types listed underneath, exactly the wp-admin shape:
def get_admin_groups() -> dict[str, list[AdminResource]]:
groups: dict[str, list[AdminResource]] = {}
for res in sorted(_resources.values(), key=lambda r: (r.order, r.label)):
groups.setdefault(res.group, []).append(res)
return groups
So events (group "Programme") and team (group "People") each get their own labelled section, with their models listed in order sequence — no feature ever edits the admin menu directly.
clear_admin_resources() empties the registry and exists for test teardown.
See also
- Template hooks — the same singleton-registry pattern for UI injection
- Refinement — how a feature curates another feature’s resource
- Service locator — the other thing features register from
init_feature