8
Admin CRUD Interface
Create, edit and delete subjects, subtopics, pages and content sections — with a TinyMCE HTML editor for page content
By the end of this chapter you can log into /admin and create, edit or
delete any subject, subtopic, page, or content section without touching the database
directly. The TinyMCE rich-text editor handles the HTML formatting.
All admin routes are protected by Depends(require_admin) from Chapter 7.
1 The full route map for app/routers/admin.py
All routes sit under the /admin prefix. They follow a consistent
GET-form / POST-action pattern — the same convention as Chapter 7's login routes.
| URL | Method | What it does |
|---|---|---|
| /admin/subjects | GET | List all subjects with edit/delete links |
| /admin/subjects/new | GET | Show blank subject form |
| /admin/subjects/new | POST | Create subject, redirect to list |
| /admin/subjects/{id}/edit | GET | Show subject form pre-filled |
| /admin/subjects/{id}/edit | POST | Save changes, redirect to list |
| /admin/subjects/{id}/delete | POST | Delete subject (and cascade), redirect |
| /admin/subtopics/{subject_id} | GET | List subtopics for one subject |
| /admin/subtopics/{subject_id}/new | GET / POST | Create subtopic under subject |
| /admin/subtopics/{id}/edit | GET / POST | Edit subtopic name / slug / order |
| /admin/subtopics/{id}/delete | POST | Delete subtopic (and cascade) |
| /admin/pages/{subtopic_id} | GET | List pages for one subtopic |
| /admin/pages/{subtopic_id}/new | GET / POST | Create page |
| /admin/pages/{id}/edit | GET / POST | Edit page metadata |
| /admin/pages/{id}/delete | POST | Delete page (and content sections) |
| /admin/content/{page_id} | GET | List content sections for a page |
| /admin/content/{page_id}/new | GET / POST | Create content section with TinyMCE |
| /admin/content/{id}/edit | GET / POST | Edit content section with TinyMCE |
| /admin/content/{id}/delete | POST | Delete content section |
<form method="delete">. A small confirmation form
with method="post" is the standard browser-compatible approach. FastAPI
can handle DELETE via fetch()/AJAX, but that requires
JavaScript. Sticking with plain forms keeps the admin usable even with JS disabled.
2 Shared helpers — slug generation and get-or-404
Add two utility functions to app/database.py. Every CRUD form uses
them.
import re from fastapi import HTTPException def slugify(text: str) -> str: """Convert 'My Cool Title' → 'my-cool-title'. Lowercases, replaces spaces and non-alphanumeric chars with hyphens, collapses multiple hyphens, strips leading/trailing hyphens. """ text = text.lower() text = re.sub(r"[^\w\s-]", "", text) text = re.sub(r"[\s_]+", "-", text) text = re.sub(r"-+", "-", text) return text.strip("-") def get_or_404(db, model, id_: int): """Fetch a record by primary key or raise HTTP 404.""" obj = db.get(model, id_) if not obj: raise HTTPException(status_code=404) return obj
SELECT * FROM table WHERE id = ? but uses the ORM's unit-of-work
cache. In Java terms: like entityManager.find(Model.class, id).
3 app/routers/admin.py — subjects CRUD (full), others abbreviated
The subject routes are shown in full — subtopics, pages, and content sections follow the exact same pattern. The abbreviated versions at the bottom show the structure; fill them in by analogy.
from fastapi import APIRouter, Depends, Form, Request from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates from sqlalchemy import select from app.dependencies import CommonContext, require_admin from app.models import Subject, Subtopic, Page, PageContent from app.database import slugify, get_or_404 router = APIRouter(prefix="/admin") templates = Jinja2Templates(directory="app/templates") # Shorthand — all admin routes share these two dependencies AUTH = Depends(require_admin) CTX = Depends() # Depends(CommonContext) — class-based deps use Depends() shorthand # ════════════════════════════════════════════════════════════════════════ # SUBJECTS # ════════════════════════════════════════════════════════════════════════ @router.get("/subjects", response_class=HTMLResponse) async def subjects_list(_=AUTH, ctx: CommonContext = CTX): subjects = ctx.db.execute( select(Subject).order_by(Subject.display_order) ).scalars().all() return templates.TemplateResponse( "admin/subjects.html", ctx.get(subjects=subjects) ) @router.get("/subjects/new", response_class=HTMLResponse) async def subject_new_form(_=AUTH, ctx: CommonContext = CTX): return templates.TemplateResponse( "admin/subject_form.html", ctx.get(subject=None, error=None), ) @router.post("/subjects/new") async def subject_create( _=AUTH, ctx: CommonContext = CTX, name: str = Form(...), slug: str = Form(""), description: str = Form(""), display_order: int = Form(0), ): slug = slug.strip() or slugify(name) # Check slug uniqueness existing = ctx.db.execute( select(Subject).where(Subject.slug == slug) ).scalar_one_or_none() if existing: return templates.TemplateResponse( "admin/subject_form.html", ctx.get(subject=None, error=f"Slug '{slug}' is already in use."), status_code=422, ) subject = Subject( name=name, slug=slug, description=description, display_order=display_order, ) ctx.db.add(subject) ctx.db.commit() return RedirectResponse("/admin/subjects", status_code=302) @router.get("/subjects/{id}/edit", response_class=HTMLResponse) async def subject_edit_form(id: int, _=AUTH, ctx: CommonContext = CTX): subject = get_or_404(ctx.db, Subject, id) return templates.TemplateResponse( "admin/subject_form.html", ctx.get(subject=subject, error=None) ) @router.post("/subjects/{id}/edit") async def subject_update( id: int, _=AUTH, ctx: CommonContext = CTX, name: str = Form(...), slug: str = Form(...), description: str = Form(""), display_order: int = Form(0), ): subject = get_or_404(ctx.db, Subject, id) # Check slug uniqueness (allow same slug on same record) conflict = ctx.db.execute( select(Subject).where(Subject.slug == slug, Subject.id != id) ).scalar_one_or_none() if conflict: return templates.TemplateResponse( "admin/subject_form.html", ctx.get(subject=subject, error=f"Slug '{slug}' is already in use."), status_code=422, ) subject.name = name subject.slug = slug subject.description = description subject.display_order = display_order ctx.db.commit() return RedirectResponse("/admin/subjects", status_code=302) @router.post("/subjects/{id}/delete") async def subject_delete(id: int, _=AUTH, ctx: CommonContext = CTX): subject = get_or_404(ctx.db, Subject, id) ctx.db.delete(subject) # cascade="all, delete-orphan" in model handles children ctx.db.commit() return RedirectResponse("/admin/subjects", status_code=302)
Subtopics, Pages, Content — same pattern
# ════════════════════════════════════════════════════════════════════════ # SUBTOPICS — same GET/POST list/new/edit/delete pattern as subjects # Key difference: subtopic_id is always scoped to a parent subject_id # ════════════════════════════════════════════════════════════════════════ @router.get("/subtopics/{subject_id}", response_class=HTMLResponse) async def subtopics_list(subject_id: int, _=AUTH, ctx: CommonContext = CTX): subject = get_or_404(ctx.db, Subject, subject_id) subtopics = ctx.db.execute( select(Subtopic) .where(Subtopic.subject_id == subject_id) .order_by(Subtopic.display_order) ).scalars().all() return templates.TemplateResponse( "admin/subtopics.html", ctx.get(subject=subject, subtopics=subtopics), ) # … new/create/edit/update/delete follow same structure as subjects # See companion code download or replicate from the subjects pattern above. # ════════════════════════════════════════════════════════════════════════ # PAGES — scoped to a parent subtopic_id # ════════════════════════════════════════════════════════════════════════ @router.get("/pages/{subtopic_id}", response_class=HTMLResponse) async def pages_list(subtopic_id: int, _=AUTH, ctx: CommonContext = CTX): subtopic = get_or_404(ctx.db, Subtopic, subtopic_id) pages = ctx.db.execute( select(Page) .where(Page.subtopic_id == subtopic_id) .order_by(Page.display_order) ).scalars().all() return templates.TemplateResponse( "admin/pages.html", ctx.get(subtopic=subtopic, pages=pages), ) # … new/create/edit/update/delete follow same structure # ════════════════════════════════════════════════════════════════════════ # CONTENT SECTIONS — scoped to a parent page_id, uses TinyMCE editor # ════════════════════════════════════════════════════════════════════════ @router.get("/content/{page_id}", response_class=HTMLResponse) async def content_list(page_id: int, _=AUTH, ctx: CommonContext = CTX): page = get_or_404(ctx.db, Page, page_id) sections = ctx.db.execute( select(PageContent) .where(PageContent.page_id == page_id) .order_by(PageContent.display_order) ).scalars().all() return templates.TemplateResponse( "admin/content.html", ctx.get(page=page, sections=sections), ) @router.get("/content/{page_id}/new", response_class=HTMLResponse) async def content_new_form(page_id: int, _=AUTH, ctx: CommonContext = CTX): page = get_or_404(ctx.db, Page, page_id) return templates.TemplateResponse( "admin/content_form.html", ctx.get(page=page, section=None), ) @router.post("/content/{page_id}/new") async def content_create( page_id: int, _=AUTH, ctx: CommonContext = CTX, content: str = Form(...), display_order: int = Form(0), ): get_or_404(ctx.db, Page, page_id) # validate parent exists section = PageContent( page_id=page_id, content=content, display_order=display_order ) ctx.db.add(section) ctx.db.commit() return RedirectResponse(f"/admin/content/{page_id}", status_code=302) @router.get("/content/section/{id}/edit", response_class=HTMLResponse) async def content_edit_form(id: int, _=AUTH, ctx: CommonContext = CTX): section = get_or_404(ctx.db, PageContent, id) return templates.TemplateResponse( "admin/content_form.html", ctx.get(page=section.page, section=section), ) @router.post("/content/section/{id}/edit") async def content_update( id: int, _=AUTH, ctx: CommonContext = CTX, content: str = Form(...), display_order: int = Form(0), ): section = get_or_404(ctx.db, PageContent, id) section.content = content section.display_order = display_order ctx.db.commit() return RedirectResponse(f"/admin/content/{section.page_id}", status_code=302) @router.post("/content/section/{id}/delete") async def content_delete(id: int, _=AUTH, ctx: CommonContext = CTX): section = get_or_404(ctx.db, PageContent, id) page_id = section.page_id ctx.db.delete(section) ctx.db.commit() return RedirectResponse(f"/admin/content/{page_id}", status_code=302)
db.delete(subject),
SQLAlchemy follows the cascade="all, delete-orphan" set on the
subtopics relationship in Chapter 2. It automatically deletes all
subtopics, which cascade to pages, which cascade to content sections — in the correct
order to satisfy foreign-key constraints. No manual joins or subqueries needed.
In Java/JPA terms: CascadeType.ALL + orphanRemoval=true.
4 Register admin.py in main.py
from app.routers import cms, special, auth, admin # ← add admin # ... middleware and static mounts unchanged ... app.include_router(auth.router) app.include_router(admin.router) # ← add before special and cms app.include_router(special.router) app.include_router(cms.router)
/{subject_slug} pattern. Without explicit ordering, a URL like
/admin/subjects could be caught by the CMS router and treated as a
subject slug lookup. Registering admin.router first means
/admin/… is intercepted before the generic CMS patterns run.
Auth router is first because it contains /admin/login and
/admin/logout which must resolve before the admin CRUD routes.
5 CSRF protection for admin forms
Chapter 7 mentioned same_site="lax" for basic CSRF protection.
For admin forms that mutate data, add a hidden CSRF token as well. The approach
uses the session to store a per-session token.
import secrets from fastapi import Form, HTTPException def get_csrf_token(request: Request) -> str: """Return (and lazily create) a CSRF token stored in the session.""" if "csrf_token" not in request.session: request.session["csrf_token"] = secrets.token_hex(16) return request.session["csrf_token"] async def verify_csrf( request: Request, csrf_token: str = Form(...), ): """Dependency: validate the hidden CSRF field matches the session token.""" expected = request.session.get("csrf_token", "") if not secrets.compare_digest(csrf_token, expected): raise HTTPException(status_code=403, detail="CSRF token invalid")
Add csrf_token = get_csrf_token(request) to every GET form route's
context dict, render it as a hidden field, and add Depends(verify_csrf)
to every POST route. The template pattern is:
<form method="post"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}"> {# rest of form fields #} </form>
6 Admin templates
All admin templates share the same dark card style. Here are the two most important ones — the subjects list and the content section form (which has TinyMCE). Subtopic and page forms follow the same structure as the subject form.
app/templates/admin/subjects.html
{% extends "base.html" %} {% block title %}Subjects — Admin{% endblock %} {% block content %} <div class="admin-wrap"> <div class="admin-hdr"> <h1>Subjects</h1> <a href="/admin/subjects/new" class="btn-add">+ New subject</a> </div> <table class="admin-table"> <thead><tr> <th>Order</th><th>Name</th><th>Slug</th><th>Actions</th> </tr></thead> <tbody> {% for s in subjects %} <tr> <td>{{ s.display_order }}</td> <td><strong>{{ s.name }}</strong></td> <td><code>{{ s.slug }}</code></td> <td class="actions"> <a href="/admin/subtopics/{{ s.id }}">Subtopics</a> <a href="/admin/subjects/{{ s.id }}/edit">Edit</a> <form method="post" action="/admin/subjects/{{ s.id }}/delete" onsubmit="return confirm('Delete {{ s.name }} and all its content?')"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <button type="submit" class="btn-del">Delete</button> </form> </td> </tr> {% else %} <tr><td colspan="4" style="color:#6e7681">No subjects yet.</td></tr> {% endfor %} </tbody> </table> <p style="margin-top:24px"> <a href="/admin" style="color:#8b949e">← Dashboard</a> </p> </div> {% endblock %}
app/templates/admin/subject_form.html
{% extends "base.html" %} {% block title %}{{ "Edit" if subject else "New" }} Subject — Admin{% endblock %} {% block content %} <div class="admin-wrap"> <h1>{{ "Edit" if subject else "New" }} Subject</h1> {% if error %} <div class="form-error">{{ error }}</div> {% endif %} <form method="post" class="admin-form"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <div class="field"> <label>Name</label> <input type="text" name="name" value="{{ subject.name if subject else '' }}" required> </div> <div class="field"> <label>Slug <span class="field-hint">(leave blank to auto-generate)</span></label> <input type="text" name="slug" value="{{ subject.slug if subject else '' }}"> </div> <div class="field"> <label>Description</label> <textarea name="description" rows="3">{{ subject.description if subject else '' }}</textarea> </div> <div class="field"> <label>Display order</label> <input type="number" name="display_order" value="{{ subject.display_order if subject else 0 }}"> </div> <div class="form-actions"> <button type="submit" class="btn-save">Save</button> <a href="/admin/subjects" class="btn-cancel">Cancel</a> </div> </form> </div> {% endblock %}
{{ subject.name if subject else '' }} pre-fills the field when editing
and leaves it blank when creating. The form action URL is set by the route
itself (both GET and POST share the same URL), so no action= attribute
is needed — the form submits to the current URL by default.
7 The content section form with TinyMCE
TinyMCE is a mature open-source rich-text editor that produces clean HTML. The free CDN version (community edition) requires no API key for self-hosted use — just include the script tag and initialise it.
{% extends "base.html" %} {% block title %}{{ "Edit" if section else "New" }} Content Section{% endblock %} {% block extra_head %} <!-- TinyMCE community edition — no API key required for self-hosted use --> <script src="https://cdn.tiny.cloud/1/no-api-key/tinymce/7/tinymce.min.js" referrerpolicy="origin"></script> {% endblock %} {% block content %} <div class="admin-wrap"> <h1>{{ "Edit" if section else "New" }} Content Section</h1> <p style="color:#8b949e;margin-bottom:20px"> Page: <strong style="color:#e6edf3">{{ page.name }}</strong> </p> <form method="post" id="content-form" class="admin-form"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <div class="field"> <label>Display order</label> <input type="number" name="display_order" value="{{ section.display_order if section else 0 }}"> </div> <div class="field"> <label>Content</label> <!-- TinyMCE replaces this textarea with the rich-text editor --> <textarea id="editor" name="content">{{ section.content | safe if section else '' }}</textarea> </div> <div class="form-actions"> <button type="submit" class="btn-save">Save</button> <a href="/admin/content/{{ page.id }}" class="btn-cancel">Cancel</a> </div> </form> </div> {% endblock %} {% block extra_scripts %} <script> tinymce.init({ selector: '#editor', skin: 'oxide-dark', // dark theme matching the site content_css: 'dark', height: 500, menubar: false, plugins: [ 'advlist', 'autolink', 'lists', 'link', 'image', 'charmap', 'searchreplace', 'visualblocks', 'code', 'fullscreen', 'table', 'wordcount' ], toolbar: 'undo redo | blocks | bold italic underline | ' + 'bullist numlist | link image table | code fullscreen', // Preserve raw HTML from the database — don't sanitise it verify_html: false, extended_valid_elements: '*[*]', // Make sure TinyMCE syncs back to the textarea before form submit setup: function(editor) { editor.on('change', function() { editor.save(); }); } }); </script> {% endblock %}
<textarea> in real time.
Without the setup callback, submitting the form would send the original
textarea content, not the edited content. editor.save() copies the
editor's current HTML back into the textarea so the form POST includes the latest
version.
no-api-key works without registration but shows a yellow warning banner
in the editor. To remove it: register for a free API key at
tiny.cloud and
replace no-api-key in the script URL. The key is free for self-hosted use.
Alternatively, self-host TinyMCE: npm install tinymce
and serve from /static/tinymce/ — no CDN, no key, no banner.
8 Admin CSS — add to app/static/css/main.css
These classes are shared across all admin templates. Add them to the bottom of
main.css.
/* ── Admin shared styles ─────────────────────────────────────────── */
.admin-wrap {
max-width: 860px;
margin: 48px auto;
padding: 0 24px;
}
.admin-hdr {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.admin-hdr h1 { font-size: 1.4rem; color: var(--clr-text); margin: 0; }
.admin-table {
width: 100%;
border-collapse: collapse;
font-size: .88rem;
border: 1px solid var(--clr-border);
border-radius: 8px;
overflow: hidden;
}
.admin-table th {
background: var(--clr-surface);
color: var(--clr-muted);
font-size: .7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .1em;
padding: 9px 14px;
text-align: left;
}
.admin-table td {
padding: 9px 14px;
border-top: 1px solid var(--clr-border);
vertical-align: middle;
color: var(--clr-muted);
}
.admin-table td strong { color: var(--clr-text); }
.admin-table td code { color: var(--clr-accent); font-size: .82rem; }
.admin-table .actions { display: flex; gap: 10px; align-items: center; }
.admin-table .actions a { color: var(--clr-accent); font-size: .82rem; text-decoration: none; }
.admin-table .actions a:hover { text-decoration: underline; }
.btn-add {
background: var(--clr-accent);
color: #000;
padding: 7px 14px;
border-radius: 6px;
font-size: .82rem;
font-weight: 700;
text-decoration: none;
}
.btn-del {
background: none;
border: 1px solid rgba(248,81,73,.4);
color: #f85149;
padding: 3px 10px;
border-radius: 5px;
font-size: .78rem;
cursor: pointer;
}
.btn-del:hover { background: rgba(248,81,73,.08); }
.admin-form .field { margin-bottom: 18px; }
.admin-form .field label { display: block; font-size: .8rem; font-weight: 600; color: var(--clr-muted); margin-bottom: 5px; letter-spacing: .05em; text-transform: uppercase; }
.admin-form .field-hint { font-weight: 400; text-transform: none; font-size: .75rem; }
.admin-form .field input,
.admin-form .field textarea {
width: 100%;
background: #0d1117;
border: 1px solid var(--clr-border);
border-radius: 6px;
padding: 9px 12px;
color: var(--clr-text);
font-size: .9rem;
box-sizing: border-box;
}
.admin-form .field input:focus,
.admin-form .field textarea:focus {
outline: none;
border-color: var(--clr-accent);
}
.form-actions { display: flex; gap: 12px; align-items: center; margin-top: 6px; }
.btn-save {
background: var(--clr-accent);
color: #000;
border: none;
border-radius: 6px;
padding: 9px 22px;
font-weight: 700;
font-size: .88rem;
cursor: pointer;
}
.btn-cancel { color: var(--clr-muted); font-size: .88rem; text-decoration: none; }
.form-error {
background: rgba(248,81,73,.08);
border: 1px solid rgba(248,81,73,.3);
color: #ff8882;
border-radius: 6px;
padding: 10px 14px;
font-size: .85rem;
margin-bottom: 16px;
}
9 Update admin/dashboard.html
Replace the placeholder cards with real links now that the routes exist.
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;margin-bottom:32px"> <a href="/admin/subjects" style="background:#161b22;border:1px solid #30363d;border-radius:8px;padding:20px;text-decoration:none;display:block"> <div style="font-size:1.5rem;margin-bottom:8px">📚</div> <strong style="color:#e6edf3">Subjects</strong> <p style="color:#00b4d8;font-size:.8rem;margin:4px 0 0"> {{ subjects | length }} subjects </p> </a> <a href="/admin/subjects" style="background:#161b22;border:1px solid #30363d;border-radius:8px;padding:20px;text-decoration:none;display:block"> <div style="font-size:1.5rem;margin-bottom:8px">🗂️</div> <strong style="color:#e6edf3">Subtopics / Pages</strong> <p style="color:#8b949e;font-size:.8rem;margin:4px 0 0">Browse via Subjects</p> </a> </div> <a href="/admin/logout" style="color:#f85149;font-size:.88rem;text-decoration:none">Sign out →</a>
10 New files this chapter
app/ ├── database.py ← + slugify(), get_or_404() ├── dependencies.py ← + get_csrf_token(), verify_csrf() ├── main.py ← + admin router ├── routers/ │ └── admin.py ← new: full CRUD for all four levels ├── static/css/ │ └── main.css ← + .admin-wrap, .admin-table, .btn-*, .admin-form └── templates/admin/ ├── dashboard.html ← updated with real links ├── subjects.html ← new ├── subject_form.html ← new ├── subtopics.html ← new (same pattern as subjects.html) ├── subtopic_form.html ← new (same pattern as subject_form.html) ├── pages.html ← new ├── page_form.html ← new (adds title/description fields) ├── content.html ← new: list sections with edit/delete/reorder └── content_form.html ← new: TinyMCE editor
✓ Chapter 8 Complete — Milestone reached
- Full CRUD routes for subjects, subtopics, pages and content sections — all protected by
Depends(require_admin) - slugify() auto-generates URL slugs from names; slug uniqueness is checked on create and update
- get_or_404() shared helper — clean 404 on invalid IDs, used throughout admin.py
- db.delete() + cascade — deleting a subject automatically removes all children in order
- CSRF tokens — per-session token stored in the signed session cookie; verified on every POST
- TinyMCE dark theme —
oxide-darkskin witheditor.save()ensuring HTML syncs to textarea on change - POST/Redirect/Get throughout — all successful mutations redirect, preventing double-submit on browser refresh
- One form template for create and edit — Jinja2 ternary pre-fills fields for edits, leaves blank for creates
- Admin CSS added to
main.css— shared table, form and button styles used across all admin templates