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] = {}

_resources is populated during feature registration (init_feature) and read-only during requests. It is not thread-safe for concurrent writes — never call register_admin_resource() from a request handler, same contract as template_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.

  • scope is 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_defaults are 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

Back to top

splent. Distributed by an LGPL license v3. Contact us: drorganvidez@us.es