CustomBlock: ERP & Headless CMS

Framework-as-a-service with Server-Driven UI

What is this project?

CustomBlock is a modular, multi-tenant B2B backend operating on a 'framework-as-a-service' model. A shared core handles authentication, audit logging, and UI generation, while individual tenant modules (like this portfolio's Headless CMS or an inventory ERP) plug into the framework by providing only their data models — eliminating all boilerplate code.

The Challenge

The goal was to build a multi-tenant architecture with strict separation between the core framework and customer code, enabling zero-downtime framework upgrades via versioned Docker images. I also wanted to eliminate manual HTML authoring by engineering a Server-Driven UI capable of generating forms, data grids, and APIs dynamically by reading database metadata.

Architecture

Framework and tenant code are kept architecturally separate at every level — in the module system, in the Docker image, and in the deployment pipeline. This makes it possible to update the core framework across all tenants by releasing a new versioned Docker image, without touching customer code.

core/
  • api/dynamic_crud.py
  • auth/ & security/rbac.py
  • ui/ (builder, components)
  • models/ (User, AuditLog)
  • gdpr.py · flash.py
  • main.py (create_app)
customers/<name>/
  • models.py
  • router.py
  • menu.py
  • migrations/
  • .env
Zero-change upgrades: Updating the framework means bumping CORE_VERSION in the tenant's .env and restarting the stack. No tenant code changes ever required.

Tech Stack

Language Python 3.14
Web Framework FastAPI (async)
ORM / Driver SQLAlchemy 2.x — aiomysql
Database MySQL 8.4
Templates Jinja2 — ChoiceLoader
UI Framework Tabler + Tabulator.js
Auth JWT HS256 — HttpOnly cookies
Password Hashing bcrypt (12 rounds)
Migrations Alembic
Containerisation Docker + Docker Compose
Settings Pydantic v2 BaseSettings

Dynamic CRUD Engine

register_crud_routes() generates 8 standard routes for any SQLAlchemy model with a single function call. A customer registers all its resources in a loop — the entire admin interface for 8 models takes approximately 10 lines of Python.

Method Route Purpose
GET /api/tabulator{path} Paginated JSON endpoint for Tabulator.js
GET {path} List page with TabulatorComponent
GET {path}/new Create form
POST {path}/new Create record → flash message → redirect
GET {path}/{pk} Read-only detail page (DetailComponent)
GET {path}/{pk}/edit Edit form with Delete + optional GDPR Anonymize
POST {path}/{pk}/edit Update record → flash message → redirect
POST {path}/{pk}/delete Delete record → flash message → redirect

Server-Driven UI Engine

No HTML is written per resource. UI is expressed as a tree of Python dataclasses attached to SQLAlchemy column info metadata. The engine reads this metadata and produces complete, interactive admin pages. Python never builds HTML strings — all rendering is delegated to Jinja2.

Component Tree

BaseComponent (ABC) ├── CardComponent — Tabler card wrapper (title + body) ├── DataGridComponent — Key/value pairs from any ORM instance ├── DetailComponent — Read-only record detail page ├── FormComponent — Full form with validation & delete ├── RowComponent — Bootstrap row of equal-width columns ├── StatsCardComponent — Single KPI metric card └── TabulatorComponent — Interactive Tabulator.js data table

Column Metadata drives everything

Adding a database column with the right info={} dict instantly updates the create/edit forms, the Tabulator list view, and the public API response schema — without touching any frontend code.

title: Mapped[str] = mapped_column( String(255), info={ "label": "Project Title", "ui": "text", # widget: text | textarea | select | checkbox | file … "visible": True, # show column in Tabulator list view "size": 6, # Bootstrap col-md-6 "placeholder": "My Project", } )

Security Architecture

JWT (HttpOnly)

HS256 tokens in HttpOnly, SameSite=Lax cookies. Dual-mode login: browser clients receive a Set-Cookie redirect; API clients receive {"access_token": ...} JSON — both from the same endpoint.

RBAC

Factory function returning FastAPI-injectable dependency functions. Pre-built guards: allow_admin, allow_authenticated. Navigation items are filtered server-side per role — no empty dropdowns for restricted users.

Automated Audit Log

SQLAlchemy event hooks (after_insert, before_update, before_delete) silently capture every data change using ContextVars. Entries are written in the same DB transaction as the change — no phantom logs on rollback.

GDPR Anonymization

5-step Article 17 engine: scramble PII fields, scrub the record's audit history, anonymize historical actions by the user, and scan all audit logs for the subject's email. Triggered via a modal-confirmed button on the edit form.

Active Tenants

demo
Inventory & ERP

Eight interconnected models: Products, Categories, Warehouses, Suppliers, Purchase Orders, Customers, Sales Orders. Three navigation dropdowns. The entire admin interface is registered in approximately 10 lines of Python.

portfolio
Headless Portfolio CMS

Eight multilingual (IT/EN) models. Public API with server-side language flattening, draft filtering, absolute URL resolution, and a dedicated cross-origin CV download endpoint. Powers this portfolio site.

Docker Architecture

The Dockerfile copies only core/ — customer directories are deliberately excluded. The result is a versioned, immutable cb-core:<version> image. Each customer's .env pins its own CORE_VERSION, allowing staged rollouts. Two customers running simultaneously share nothing: separate networks, separate volumes, separate databases.

Live Development Mode — A docker-compose override mounts ./core as a bind-mount and enables Uvicorn --reload, giving instant hot-reload of framework code inside the properly isolated Docker environment.

cbenv.sh — Developer CLI

Sourced into the shell (source cbenv.sh), it exposes the cbdev command. A pre-flight check validates that the required cb-core image exists locally before any stack starts — no silent pulls or mid-startup failures.

  • cbdev build <version> — Build a versioned cb-core image from the local Dockerfile
  • cbdev <customer> start — Render Compose template and start the tenant stack
  • cbdev <customer> migrate — Run alembic upgrade head inside the container
  • cbdev <customer> bash — Open a shell inside the running app container
  • cbdev <customer> remove — Stop the stack and delete its volumes (destructive)