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
Usermodel 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-isrender_template— raw markup stored on the slug itselfjson— 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_templateparses as JSON, its keys merge into the context as a legacy blocks mechanism, andtemplate_namerenders with that context. - Otherwise
render_templateis compiled as raw template markup. Iftemplate_nameis 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
Noneand 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 rawrender_templatemarkup, for{{ }}and{% %}references. A key the template references but that is missing fromjsonstill 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, andjsonreachable, 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 inSlug.clean()and called fromsave()— 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.pygenerates a create/edit form from that schema.- Routes are
slug_parent/<parent_id>/create/andslug_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.SlugDeleteViewcleans 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 checkis_executive_manager(user)— root, or any title in Executivesis_manager_of_group(user, group)— root, executive, or anis_manager_roletitle in that exact departmentcan_access_group(user, group)— staff who are manager-tier for the department or a plain member of itget_managed_groups(user)— every department they manageget_all_groups_for_user_with_extended_rbac(user)— every department they belong to in any capacityget_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-validatedStaffRequiredMixin— authenticated andis_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.
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
Contents
- Architecture
- One app, not many
- What ships
- Request path
- Storage layers
- PostgreSQL — and FerretDB on top of it
- Redis
- The Slug CMS
- Path resolution
- The rendering trio
- The self-generating editor
- Dynamic content types
- Known limits
- RBAC
- The model
- Two things that are not roles
- The permission surface
- View guards
- Finer-grained composition
- Subsystems
- Analytics
- SEO
- File storage
- Security
- Code patterns
- Stack and license
- Next
Pages Here
No sub-pages yet.
Page Info
Wiki: Docs
Created on Sep 20, 2026 by Tobu Pengin, L.L.C.
Maintainers
| Editor | Last Activity |
|---|---|
| Tobu Pengin, L.L.C. creator | Sep 20, 2026 |