Skip to content
GitHub

Architecture

Internal design of the lexigram-admin package and its role in the Lexigram framework.


lexigram-admin is an async-first admin panel framework with declarative Resource definitions, CRUD routing, HTMX-driven interactions, RBAC, and a contributor system for extension packages.

flowchart TB
    subgraph Framework
        C[lexigram-contracts]
        L[lexigram]
        UI[lexigram-ui]
        Web[lexigram-web]
    end
    subgraph Admin[lexigram-admin]
        A[Admin Panel]
    end
    subgraph Extensions
        Auth[lexigram-auth]
        Cache[lexigram-cache]
        Features[lexigram-features]
        Resilience[lexigram-resilience]
    end

    C --> L
    L --> UI
    L --> Web
    UI --> Admin
    Web --> Admin
    Admin --> Auth
    Admin --> Cache
    Admin --> Features
    Admin --> Resilience

Import exceptions (documented in AGENTS.md §1.2): lexigram-admin may import directly from lexigram-ui, lexigram-web, lexigram-auth, lexigram-cache, lexigram-features, and lexigram-resilience. These are declared as explicit pyproject.toml dependencies.


flowchart BT
    Contracts[Inner Ring: lexigram-contracts<br/>Protocols · Types · Base Exceptions]
    Core[Middle Ring: lexigram<br/>DI · IoC · Container · Primitives · Result]
    Admin[Outer Ring: lexigram-admin<br/>Resources · Pages · Actions · SchemaFields · RBAC]

    Admin --> Core
    Core --> Contracts
RingPackageWhat It Provides
Innerlexigram-contractsProtocols, value types, base exceptions (LexigramError, ProviderPriority). Zero dependencies.
MiddlelexigramDI container, IoC provider pattern, primitives (clock, identity), Result[T, E], logging.
Outerlexigram-adminAdmin panel abstractions — Resource, SchemaField, Action, Page, Cluster, RelationManager, IDataSource. None hoisted to contracts — they reference admin-domain types.

sequenceDiagram
    actor User as Browser
    participant Starlette as Starlette App
    participant MW as Middleware Stack
    participant Router as AdminRouter
    participant Handler as ResourceHandler
    participant Renderer as AdminRenderer
    participant DS as IDataSource

    User->>Starlette: GET /admin/users/{id}/edit
    Starlette->>MW: Session → Auth → CSRF → AuthGuard → Tenant
    MW->>Router: Route matched
    Router->>Handler: Dispatch to action handler
    Handler->>Handler: ActionHandlerRegistry.select("edit")
    Handler->>DS: find_one(id)
    DS-->>Handler: Record
    Handler->>Renderer: render_edit(request, resource, record)
    Renderer->>Renderer: Build form via SchemaField.render_form()
    Renderer->>Renderer: Wrap in chrome (nav, layout, theme)
    Renderer-->>Handler: HTML
    Handler-->>User: HTMX-enabled HTML response

Middleware stack (inner → outer execution order):

MiddlewarePurpose
AdminErrorMiddlewareError handler for admin-specific HTTP errors
AdminAuthorizationMiddlewareRBAC enforcement at request entry
AdminAuthMiddlewareLoads user from session into request.state.user
AdminAuthGuardMiddlewareRedirects unauthenticated requests to login
AdminCsrfMiddlewareCSRF token validation
SetupMiddlewareFirst-run wizard redirect
AdminTenantMiddlewareMulti-tenant isolation (when enabled)

The core abstraction — a single class defines a model-backed admin entity with fields, actions, pages, permissions, navigation grouping, and relations.

flowchart LR
    subgraph Resource[Resource Definition]
        F[SchemaFields]
        A[Actions<br/>Row · Bulk · Header]
        P[Pages<br/>List · Create · Edit · View]
        R[RelationManagers]
        C[Cluster]
    end
    subgraph Backend
        DS[IDataSource<br/>SqlDataSource · APIDataSource · Custom]
        QS[QuerySpec]
    end
    subgraph Navigation[Navigation]
        NB[NavItemBuilder]
    end

    Resource --> DS
    DS --> QB
    Resource --> Navigation
    F --> P
    A --> P
    R --> P
    C --> NB
class UserResource(Resource):
model = User
name = "users"
cluster = "content"
icon = "users"
fields = [
TextField("name").required(),
EmailField("email").required(),
BooleanField("is_active"),
]
actions = [EditAction(), DeleteAction()]
relations = [UserPostsRelationManager]
AttributeTypePurpose
modeltype[DomainModel] | NoneDomain model this resource represents
namestr | NoneURL-safe identifier; auto-derived from class name
clusterstr | NoneNavigation grouping key (references Cluster.name)
fieldslist[SchemaField]Declarative field definitions
actionslist[Action]Row-level actions
bulk_actionslist[BulkAction]Selection-driven bulk actions
relationslist[type[RelationManager]]Related-record managers on ViewPage
pageslist[type[Page]]Page classes (defaults: List, Create, Edit, View)
permissionsResourcePermissions | NoneRBAC permissions for this resource
_data_sourceIDataSource | NoneData backend set at runtime via set_data_source()

Lifecycle hooks: before_create / after_create, before_update / after_update, before_delete / after_delete, before_clone / after_clone, before_restore / after_restore, before_purge / after_purge.

PagePathPurpose
ListPage/{resource}Table with filters, search, pagination, bulk actions
CreatePage/{resource}/createForm for a new record
EditPage/{resource}/{id}/editForm pre-filled with existing record
ViewPage/{resource}/{id}Read-only detail view with relation managers
PathMethodsHandler Mode
/{name}GETlist
/{name}/createGET, POSTcreate
/{name}/{id}GETdetail
/{name}/{id}/editGET, POSTedit
/{name}/{id}/cloneGETclone
/{name}/{id}/deleteDELETE, POSTdelete
/{name}/bulkPOSTbulk

Plus six HTMX routes per relation manager (list, create form, create, edit form, update, delete).


One field class renders in three contexts — form input, table column, and filter widget.

flowchart LR
    subgraph Field[SchemaField[T]<br/>frozen dataclass]
        F[name · label · help_text<br/>required · readonly · sortable · searchable]
    end
    Field --> Form[render_form()<br/>→ Element]
    Field --> Column[render_column()<br/>→ Element]
    Field --> Filter[render_filter()<br/>→ Element | None]
    Field --> Coerce[from_form(str) → Result[T, FieldError]<br/>to_form(T) → str]
MethodSignatureContext
render_form()(value: T | None, *, errors) -> ElementForm input on Create/Edit pages
render_column()(record: Any, value: T | None) -> ElementTable cell on ListPage
render_filter()(current_value: Any | None = None) -> Element | NoneFilter widget in sidebar

Subclass hierarchy (~30 types):

SchemaField (abstract, Generic[T])
├── TextField (str) — EmailField, PasswordField, URLField
├── TextAreaField (str) — MarkdownField, RichTextField
├── NumberField (int | float) — IntegerField, FloatField, CurrencyField
├── BooleanField (bool) — ToggleField
├── DateField (date) — DateTimeField, TimeField
├── SelectField (T from fixed set) — EnumField, MultiSelectField, RadioField
├── RelationField (related record ID) — BelongsToField, HasManyField, MorphField
├── JsonField (dict | list)
├── FileField (UploadedFile) — ImageField, AvatarField
├── ColorField (str hex), RatingField (int 1-5), TagsField (list[str]), KeyValueField, HiddenField

Stateful work units against a record, a selection, or no record.

flowchart LR
    Action[Action[R, Outcome]<br/>frozen dataclass]
    Action --> Row[RowAction[Any, Any]<br/>Single record]
    Action --> Bulk[BulkAction[list[Any], Any]<br/>Selection of records]
    Action --> Header[HeaderAction[None, Any]<br/>No record context]

    Row --> |execute()| Result[Result[Outcome, ActionError]]
    Bulk --> |execute()| Result
    Header --> |execute()| Result
HookPurpose
execute()Abstract. The business logic. Returns Result[Outcome, ActionError].
visible_for()Visibility predicate; defaults to True.
authorize()Auth check; defaults to Ok(None). Returns Result[None, PermissionDenied].
form()Optional parameter-collection form; defaults to None.
confirm()Optional confirmation dialog config; defaults to None.
render_button()Button rendering; default delegates to ActionButton.

Visual variants: ActionColor enum — GRAY, PRIMARY, SUCCESS, WARNING, DANGER, INFO.


flowchart LR
    subgraph HTMX[HTMX Patterns]
        M[Modal forms<br/>hx-get / hx-post]
        I[Inline edits<br/>hx-put / hx-delete]
        F[Filter/Search<br/>hx-trigger / hx-target]
        P[Pagination<br/>hx-get with page param]
    end
    subgraph Server
        R[ResourceHandler]
        RR[RelationManager routes]
    end
    subgraph Response
        S[Zone swap<br/>outerHTML / innerHTML]
    end

    M --> R
    I --> RR
    F --> R
    P --> R
    R --> S
    RR --> S

RelationManager HTMX inline-edit cycle:

User actionRequestServer response
Click “Add”GET /admin/{r}/{id}/relations/{rel}/newCreate form rendered into relation panel
SubmitPOST /admin/{r}/{id}/relations/{rel}Creates record; returns updated panel
Click row “Edit”GET /admin/{r}/{id}/relations/{rel}/{rid}/editReplace row with edit form
Submit editPUT /admin/{r}/{id}/relations/{rel}/{rid}Updates; replaces form with display row
Click “Delete”DELETE /admin/{r}/{id}/relations/{rel}/{rid}Removes the row

All swaps use hx-swap="outerHTML" targeted at the row or relation panel zone.


sequenceDiagram
    participant App as Application
    participant Container as DI Container
    participant BP as AdminBundleProvider
    participant SP as Sub-Providers (×9)
    participant Router as AdminRouter

    App->>Container: Create container
    App->>Container: AdminModule.configure(config, resources)
    Container->>BP: register(container)
    BP->>SP: Instantiate sub-providers
    SP->>Container: Bind singletons (core → auth → resource → ui → realtime → tenancy → dashboard → contributor → integrations)
    BP->>Container: Register NavItemBuilder, controllers, resource classes
    Container->>Container: Freeze
    Container->>BP: boot(container)
    BP->>SP: Resolve sub-provider dependencies
    App->>BP: mount_to_app(app, container)
    BP->>BP: Resolve resources + controllers + middleware
    BP->>Router: Create AdminRouter(resources, controllers, middleware)
    Router->>App: Starlette sub-app mounted at {prefix}/

AdminBundleProvider orchestrates nine focused sub-providers:

Sub-ProviderResponsibility
AdminCoreSubProviderCore admin services, renderer, config
AdminAuthSubProviderAuth integration, login/logout, session
AdminResourceSubProviderResource registration and resolution
AdminUISubProviderUI components, theme, layout
AdminRealtimeSubProviderServer-sent events, WebSocket
AdminTenancySubProviderMulti-tenant data isolation
AdminDashboardSubProviderDashboard widgets, homepage
AdminContributorSubProviderExtension registration
AdminIntegrationsSubProviderCache, search, resilience integration specs

ProtocolLocationPurpose
IDataSource[T]lexigram-admin.data.data_sourceData access contract every Resource backend satisfies. @runtime_checkable.
AdminContributorRegistryProtocollexigram.contracts.admin.protocolsExtension point for resources, pages, widgets, navigation
AdminAuthorizerProtocollexigram.contracts.admin.authorizerRBAC authorization service
AdminContributorProtocollexigram.contracts.admin.protocolsContributor surface for features
AdminUserStoreProtocoladmin/auth/store/protocols.pyUser store for admin authentication
AdminCsrfServiceProtocoladmin/auth/protocols.pyCSRF token service
AdminSessionServiceProtocoladmin/auth/protocols.pySession management

All admin-specific protocols live within lexigram-admin or lexigram.contracts.admin. They are not hoisted to top-level contracts because they reference admin-domain types (AdminUser, AdminRequest, Permission, Zone, Cluster).


Permission predicates at Resource, Page, and Action level via ResourcePermissions (CRUD role sets, field-level visibility, action-level permissions).

LayerEnforcementMechanism
RouteAdminAuthorizationMiddlewareResolves AdminAuthorizerProtocol
ResourceResourcePermissions on classCRUD role sets, field-level FieldPermission
Actionaction.authorize()Result[None, PermissionDenied]
Actionaction.visible_for()Boolean visibility predicate
Relationcan_create/edit/delete/detachResult[None, PermissionDeniedError]

ScenarioMechanism
Expected, recoverable domain failuresResult[T, E] with specific error type
Infrastructure failuresRaise exceptions
Action executionResult[Outcome, ActionError]
AuthorizationResult[None, PermissionDenied] or raise PermissionDeniedError
Data accessResult[T, DataError] from IDataSource
Form coercionResult[T | None, FieldError] from SchemaField.from_form()

Prohibited: Blind result.unwrap(), wrapping infra exceptions in Result, Any as error type.

Exception hierarchy: LexigramErrorDomainError (NotFound, PermissionDenied, Conflict, Data, Notification) / AdminError (ActionError, AdminDataError) / ValidationError (AdminValidationError). FieldError(Exception) — used as Result error type, not exception flow.


@runtime_checkable
class IDataSource(Protocol[T]):
async def find_one(self, item_id: Any) -> T | None: ...
async def find_many(self, query: QuerySpec) -> QueryResult[T]: ...
async def count(self, query: QuerySpec) -> int: ...
async def create(self, data: dict[str, Any]) -> T: ...
async def update(self, item_id: Any, data: dict[str, Any]) -> T: ...
async def delete(self, item_id: Any) -> bool: ...
async def bulk_create(self, items: list[dict]) -> list[T]: ...
async def bulk_update(self, ids: list[Any], data: dict) -> int: ...
async def bulk_delete(self, ids: list[Any]) -> int: ...

Built-in implementations: SqlDataSource[T] (SQL via DatabaseProviderProtocol), DataSourceBase[T] (ABC for custom implementations), APIDataSource[T] (external HTTP API).


PointMechanism
Custom resourceSubclass Resource, define fields/actions/pages
Custom fieldSubclass SchemaField[T], implement render_form() + render_column()
Custom actionSubclass RowAction / BulkAction / HeaderAction, override execute()
Custom pageSubclass Page, implement view(). Register via contributor or AdminBuilder.page()
Contributor systemSubclass BaseAdminContributor, override get_resources(), get_dashboard_widgets(), get_navigation_items(), etc.
Dashboard widgetsDashboardWidgetDefinition registered via contributor
Custom data sourceImplement IDataSource[T] protocol, attach via Resource.set_data_source()
Navigation entriesNavigationContribution via contributor or Cluster on Resource
ControllerImplement a Starlette HTTPEndpoint with get_routes(), pass to AdminModule.configure(controllers=...)

Contributors override methods: get_resources(), get_dashboard_widgets(), get_navigation_items(), get_health_definitions(), get_management_pages(), get_settings_panels(), get_actions(), get_routes() — all return empty sequences by default. Collision mode via AdminConfig.contributor_collision_mode ("warn" | "error").


lexigram/admin/di/bundle_provider.py
class AdminBundleProvider(Provider):
name = "admin"
priority = ProviderPriority.APPLICATION
async def register(self, container: ContainerRegistrarProtocol) -> None:
container.singleton(AdminBundleProvider, self)
container.singleton("admin_bundle", self)
container.singleton(NavItemBuilder, nav_item_builder)
container.singleton(WidgetController, WidgetController)
container.singleton(DashboardController, DashboardController)
for sp in self._sub_providers:
await sp.register(container)
async def boot(self, container: ContainerResolverProtocol) -> None:
for sp in self._sub_providers:
await sp.boot(container)
async def mount_to_app(self, app, container) -> None:
# Resolve resources, controllers, middleware
# Build AdminRouter → Starlette sub-app → mount on app
...
# Usage:
AdminModule.configure(config=admin_config, resources=[UserResource])

lexigram-admin/src/lexigram/admin/
├── __init__.py # Public API exports
├── module.py # AdminModule (DI entry point)
├── config.py # AdminConfig
├── exceptions.py # Exception hierarchy
├── constants.py # Constants
├── actions/ # Action, RowAction, BulkAction, HeaderAction
├── clusters/ # Cluster dataclass
├── core/routing.py # AdminRouter, route builder
├── data/ # IDataSource, QueryResult, SqlDataSource
├── di/
│ ├── bundle_provider.py # AdminBundleProvider
│ └── sub_providers/ # 9 sub-providers
├── pages/ # Page ABC, resource_pages (List/Create/Edit/View)
├── relations/ # RelationManager, HTMX inline CRUD routes
├── resources/ # Resource, ResourceHandler, renderers
├── schema/ # SchemaField ABC + ~30 field types
├── rbac/ # Permissions, authorization service
├── auth/ # Auth protocols, user store, session
├── contributors/ # BaseAdminContributor, registry
├── controllers/ # WidgetController, DashboardController, etc.
├── middleware/ # Auth, CSRF, tenant, error, setup middleware
├── navigation/ # NavItemBuilder
├── engine/ # AdminRenderer (HTML composition)
├── dashboard/ # Widget definitions
├── realtime/ # SSE event hub
├── services/ # Settings, search services
└── integrations/ # Cache, search, resilience wrappers