Architecture

How Pengin-Pi-3 fits together: the single-app convention, the RBAC framework, the Slug CMS and its dynamic content types, and the subsystems that run on every request.

For what the platform does, see Info. To get it running, see Install.


One app, not many

Pengin-Pi-3 does not use separate Django apps per feature. Everything lives inside main, organized into submodules — main/models/slug.py, main/views/auth.py, main/auth/permissions.py — rather than as slug/, accounts/, cms/ apps with their own configs.

pengin-pi-3/
├── main/              the entire project
│   ├── auth/          the RBAC framework — see below
│   ├── models/        every model, one app_label
│   ├── views/
│   ├── forms/
│   ├── migrations/    one directory for the whole project
│   ├── management/    manage.py commands
│   ├── settings.py, urls.py, sitemaps.py, admin.py
├── util/             generic infrastructure, no auth logic
├── templates/
├── static/

This is a deliberate departure from convention. The payoff is that every model shares the main app label, so there is exactly one migrations directory and no cross-app migration coordination. Permissions, models, and views for the whole project stay consolidated instead of scattered and duplicated across a dozen small apps.

The util/ package holds generic infrastructure — mail, file storage, rate limiting, reCAPTCHA, pagination, the FerretDB client. The rule is that nothing in util/ implements auth, permission, or user-framework logic. That all belongs in main/auth/.

What this means for contributors: adding a feature means adding modules under main/, not starting an app. See Contributing.


What ships

Bones only. There are no business applications in the box — no blog, no job board, no ticket system. What every project inherits is the foundation those things would otherwise be rebuilt on top of each time:

  • a custom User model and the RBAC framework around it
  • the Slug CMS and its self-generating content editor
  • dynamic content types backed by FerretDB
  • analytics, SEO, mail, file storage, and bot defense
  • a hardened Docker, Nginx, and Traefik deployment shape

Business features are built by the project using it, through the dynamic content-type system or as new modules under main/.


Request path

Client
  ↓
Traefik      TLS termination, host routing — external, not in compose
  ↓
Nginx        static/media serving, rate limits, blocklist
  ↓
uWSGI        4 processes, 2 threads
  ↓
Django       HardenedBlocklistMiddleware → ... → PageViewLoggerMiddleware

Traefik is expected to already be running on an external Docker network named root_proxy. The compose file joins it rather than starting it. If that network does not exist, docker compose up fails — see Deployment.

Nginx serves static and media directly from shared volumes; those requests never reach a Django worker.


Storage layers

PostgreSQL — and FerretDB on top of it

There is one Postgres instance, running FerretDB's DocumentDB image (ghcr.io/ferretdb/postgres-documentdb). This matters: a plain postgres:17-alpine will not work. The image bakes in the initialization scripts that install the documentdb_api extension, and without them every document operation fails with schema "documentdb_api" does not exist.

The DocumentDB extension can only be installed into one database, named by FERRETDB_DB and defaulting to postgres. The application's own database (DB_NAME) is created alongside it, in the same instance and volume, by an init script on first boot — which is what keeps this a one-command deploy.

The ferretdb service then speaks the MongoDB wire protocol against that same Postgres. So relational and document data share one instance and one backup, reached through two protocols.

Relational data (users, roles, slugs, events, subscriptions, page view logs) uses ordinary Django models and migrations. Document data is described below.

Redis

The Django cache backend, via django_redis, and the buffer that absorbs analytics writes.


The Slug CMS

Every page in a project built on this is a Slug (main/models/slug.py). A single catch-all SlugView at the bottom of urlpatterns resolves every unmatched path against the slug tree.

Path resolution

A slug is a name/parent pair. get_absolute_url() walks the ancestry to build the path, so a slug named history with parent about lives at /about/history/. Names are lowercased on save.

The root page is a slug named home with no parent. That is the special case in get_absolute_url() — it returns / rather than /home/. A fresh install has no such slug, which is why a new site renders blank.

The rendering trio

A slug carries three fields that together decide what renders:

  • template_name — a named template to extend or render as-is
  • render_template — raw markup stored on the slug itself
  • json — the data plugged into whichever of the above is used

Resolution lives in util/dynamic_render.py, shared with any model that wants the same behavior — Event already uses it. The algorithm:

  • If render_template parses as JSON, its keys merge into the context as a legacy blocks mechanism, and template_name renders with that context.
  • Otherwise render_template is compiled as raw template markup. If template_name is also set and the markup doesn't already start with its own {% extends %}, one is prepended — so naming a template actually fills its blocks rather than being silently ignored.
  • If neither field is set, the function returns None and the caller picks its own fallback.

Page bodies are Django template language. This is Django, not Flask: {% static 'path.svg' %}, not url_for(...).

The self-generating editor

Editing a slug is not hand-editing JSON in a textarea. util/slug_content_form.py builds a real per-field HTML form from the slug's current content every time the edit page loads. There is no stored form schema, so nothing can drift out of sync with the content.

  • _field_kind() sniffs each JSON value for its likely widget — boolean, number, image, video, HTML, plain text, or a list of strings — from the value itself.
  • extract_referenced_keys() scans the actual rendering surface, meaning both the named template's source and any raw render_template markup, for {{ }} and {% %} references. A key the template references but that is missing from json still gets a field, instead of staying unreachable.
  • Image and video fields get a real upload widget backed by util/file (local or S3), with a path field and live preview.
  • An Advanced section keeps raw template_name, render_template, and json reachable, so you can reshape the page itself. Save from there and the per-field form regenerates from the new shape on next load.

Full detail in Slugs and Pages.


Dynamic content types

This is the piece that replaces writing a model and a migration.

Set is_dynamic on a slug and it stops being a page and becomes a content type definition. Its json holds a JSON Schema instead of content, and its children become instances of that type.

  • The schema is validated as a structurally valid JSON Schema (Draft 2020-12) through util/json_schema.py, enforced in Slug.clean() and called from save() — so the invariant holds whether the save comes from the staff form, the admin, a script, or a data migration. A dynamic slug must also have a parent.
  • util/dynamic_forms.py generates a create/edit form from that schema.
  • Routes are slug_parent/<parent_id>/create/ and slug_parent/<parent_id>/<slug_id>/edit/ (main/views/slug_dynamic.py).
  • Submitted values are stored in FerretDB by util/slug_dynamic_data.py: one collection, one document per dynamic child slug, keyed by that slug's own id as a string. No second id scheme to keep synchronized.

The data is stored separately from Slug.json on purpose. That field already has three jobs — a datasource query descriptor, a static content dict, and the schema definition on a parent — and mixing in a fourth shape would make all four harder to reason about.

Known limits

Documented in the source and worth repeating here rather than letting someone discover them in production:

  • A slug row in Postgres and its document in FerretDB are never in the same transaction. Saves and deletes are ordered carefully, but a crash between the two steps can orphan either side. Acceptable at current scale — a staff-only feature with low write volume — but reconciliation tooling is not built.
  • Deleting a parent content-type slug cascades at the SQL level, which bypasses Model.delete() entirely. Children's FerretDB documents are orphaned. SlugDeleteView cleans up only the single slug it is given; a tree-wide cleanup pass does not exist yet.

RBAC

All authorization lives in main/auth/. Nothing outside it should implement its own permission check, group logic, or role framework — features extend or call into it rather than keeping a private copy.

The model

Three levels, built on Django's own Group:

| Concept | Implementation | Meaning | |---|---|---| | Department | django.contrib.auth.models.Group | Sales, Engineering, Executives | | Title | TeamRole | A position scoped to one department | | Assignment | TeamUserRole | The user-to-title join |

A TeamRole is scoped to exactly one Group, unique on (group, name). "Manager" in Sales is a different object than "Manager" in Engineering. The is_manager_role flag marks a title as manager-tier, granting department-wide authority to anyone holding it.

TeamUserRole is unique on (user, role) and stamps an assignment date. Both models carry history through HistoryMixin.

Two things that are not roles

Administrator is not a stored role. It means User.is_superuser — real Django root. It is a display label, not something anyone picks from a dropdown. display_title_for_user() returns "Administrator" for a superuser regardless of what titles they actually hold.

Executives is a department, not root. Holding any title in the department named Executives grants manager-tier authority across every other department — but never superuser. It is a cross-department evaluator.

The permission surface

main/auth/permissions.py is the single import point:

  • is_root(user) — superuser; satisfies every other check
  • is_executive_manager(user) — root, or any title in Executives
  • is_manager_of_group(user, group) — root, executive, or an is_manager_role title in that exact department
  • can_access_group(user, group) — staff who are manager-tier for the department or a plain member of it
  • get_managed_groups(user) — every department they manage
  • get_all_groups_for_user_with_extended_rbac(user) — every department they belong to in any capacity
  • get_users_with_extended_rbac_to_group(department) — staff eligible to own an item routed to a department, filtered to validated staff

View guards

Class-based, in main/auth/mixins.py:

  • LoginAndValidationRequiredMixin — authenticated and email-validated
  • StaffRequiredMixin — authenticated and is_staff

Function-based, in main/auth/decorators.py: group_required, is_admin_required, is_admin_provider, user_group_provider.

The decorators set a requires_auth marker on the wrapped view. This is not decoration — main/sitemaps.py reads it to decide whether a route is public. MRO introspection alone cannot see a guard applied via @method_decorator(..., name='dispatch'), so the marker is how gated pages stay out of sitemap.xml.

Finer-grained composition

main/auth/principals.py provides a Flask-Principal-style toolkit — Need, UserNeed, RoleNeed, TypeNeed, ActionNeed, ItemNeed, and PermissionDenied — for permission composition finer than department-and- title covers. ItemNeed('update', <id>, 'posts') is the shape.

Seed departments with manage.py seed_departments; verify a configuration with manage.py check_auth.

Full detail in RBAC.


Subsystems

Analytics

PageViewLoggerMiddleware runs late in the chain and logs only successful GETs. get_client_ip() resolves the real visitor through the proxy chain in trust order — CloudFront's viewer address header with port stripping, then the first element of X-Forwarded-For, then REMOTE_ADDR. Without it every visitor appears to originate from an internal container address.

Hits are pushed onto a Redis list rather than written directly. A background command pops them in batches and bulk-inserts, keeping traffic spikes off the Postgres write path.

is_trackable_path() filters static assets, favicons, and empty redirects. Unauthorized access attempts are still recorded, which is what gives visibility into scrapers and scanners.

SEO

Crawler control is database-driven rather than file-based — writing sitemap and robots files to disk caused synchronization and permission conflicts across multi-worker containers, so both are generated in memory and cached.

A RobotsRule model, managed from Django Admin, drives the dynamic robots.txt view and is checked inside sitemap generation, so disallowed paths are stripped from sitemap.xml rather than drifting out of sync. Static routes are auto-discovered by walking Django's URL resolver; login-gated routes are excluded via the requires_auth marker described above.

Mail

Transactional mail goes through the Boto3 SES client using send_raw_email(), not SMTP. Messages are built as MIME multipart in util/mail/config.py, with typed templates for account activation, password reset, and subscription confirmation.

Note the naming wart: the credentials come from SES_USERNAME_SMTP and SES_PASSWORD_SMTP, but they are passed to Boto3 as an IAM access key and secret, not SMTP credentials. The names are historical.

If those variables are blank, AWS_SES_ENABLED stays false and mail is skipped with a console notice rather than crashing.

File storage

util/file abstracts local and S3 storage behind one interface, selected by FILE_STORAGE_BACKEND. If S3 credentials are missing, it falls back to local automatically even when s3 is requested.

Security

HardenedBlocklistMiddleware runs first in the middleware chain, reading an IP blocklist from the same nginx_blocklist.conf that an edge Fail2ban setup writes to. A banned IP is rejected at the application layer even if a request somehow reaches it.

util/security/ratelimit.py provides RateLimitedPostMixin and RateLimitedGetMixin, applied by default to login, signup, password reset, profile edit, and slug create/edit/delete. Staff-only forms included — being logged in doesn't rule out scripted abuse.

util/security/recaptcha.py provides verify_recaptcha_token and RecaptchaRequiredMixin. The Slug.requires_recaptcha flag extends the same protection into the CMS: SlugView.dispatch() checks it on any POST a slug's own embedded content sends back to itself, so the protection exists before any form-embedding feature is built on it.

reCAPTCHA degrades gracefully. With keys blank, the widget is hidden and verification is skipped rather than rejecting every submission.

At the edge, Nginx applies per-path rate and connection limits and blocks hidden-file, vulnerability-scanner, and CMS-probe requests — with access logging deliberately left on for that traffic, since blocking without logging means Fail2ban can never see the IP to ban it.


Code patterns

UUID primary keys on every model, non-editable, defaulting to uuid4. URL patterns capture them with the <uuid:...> converter so malformed input fails at routing rather than raising a database error inside a view.

History and auditing. HistoryMixin on the tracked model paired with an AbstractHistory subclass records edits with user attribution and timestamp. Slugs, team roles, and role assignments all carry it.

Generic content linkage. Slug has a GenericForeignKey, and the subscription routes address any model by ContentType app label, model name, and object id — so a feature becomes subscribable without those routes knowing anything about it.

Graceful degradation throughout. SES, S3, and reCAPTCHA all no-op when unconfigured, and settings fall back to SQLite when no DB_* variables are present. A contributor can clone, install, and runserver without standing up any infrastructure.


Stack and license

Python 3.14 · Django 5.x · uWSGI · PostgreSQL with DocumentDB · FerretDB 2.7 · Redis · Nginx · Traefik · Bootstrap 5.

Dependencies: django-markdownit, django-macros, django-ratelimit, django-redis, boto3, pymongo, jsonschema, psycopg2-binary, pillow, python-decouple, python-dotenv, werkzeug.

Licensed GPLv3.


Next

  • Slugs and Pages — content resolution and the editor in detail
  • RBAC — the permission framework
  • Deployment — service configuration and the Traefik prerequisite
  • Contributing — the single-app convention in practice