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, so 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 |
(required) | 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 are listed below.
| 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 through the query API below.
| 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(), with one section per group and 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