Admin 2

FastAPI Rebuild › Chapter 8

Admin CRUD Interface

Create, edit and delete subjects, subtopics, pages and content sections — with a TinyMCE HTML editor for page content

Chapter 8 of 10 Full CRUD TinyMCE display_order
Chapter Milestone

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.

URLMethodWhat it does
/admin/subjectsGETList all subjects with edit/delete links
/admin/subjects/newGETShow blank subject form
/admin/subjects/newPOSTCreate subject, redirect to list
/admin/subjects/{id}/editGETShow subject form pre-filled
/admin/subjects/{id}/editPOSTSave changes, redirect to list
/admin/subjects/{id}/deletePOSTDelete subject (and cascade), redirect
/admin/subtopics/{subject_id}GETList subtopics for one subject
/admin/subtopics/{subject_id}/newGET / POSTCreate subtopic under subject
/admin/subtopics/{id}/editGET / POSTEdit subtopic name / slug / order
/admin/subtopics/{id}/deletePOSTDelete subtopic (and cascade)
/admin/pages/{subtopic_id}GETList pages for one subtopic
/admin/pages/{subtopic_id}/newGET / POSTCreate page
/admin/pages/{id}/editGET / POSTEdit page metadata
/admin/pages/{id}/deletePOSTDelete page (and content sections)
/admin/content/{page_id}GETList content sections for a page
/admin/content/{page_id}/newGET / POSTCreate content section with TinyMCE
/admin/content/{id}/editGET / POSTEdit content section with TinyMCE
/admin/content/{id}/deletePOSTDelete content section
Why POST for deletes instead of DELETE? HTML forms only support GET and POST — there is no <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.

app/database.py — add below existing code python
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
db.get(Model, id) is SQLAlchemy 2.0's identity-map lookup — it hits the session cache first, then the database. It's equivalent to 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.

app/routers/admin.py — Part 1: subjects CRUD python
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

app/routers/admin.py — Part 2: subtopics / pages / content (structure) python
# ════════════════════════════════════════════════════════════════════════
# 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() + cascade: When you call 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

app/main.py — update import and include_router python
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)
Why before special and cms? The CMS router has a generic /{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.

app/dependencies.py — add csrf helpers python
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:

In every admin form template — hidden CSRF field html + jinja2
<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

app/templates/admin/subjects.html jinja2 + 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

app/templates/admin/subject_form.html jinja2 + 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 %}
One template for create and edit. The Jinja2 ternary {{ 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.

app/templates/admin/content_form.html jinja2 + html
{% 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 %}
editor.save() on change is critical. TinyMCE operates on a hidden iframe — it doesn't update the underlying <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.
TinyMCE CDN and the "no-api-key" warning. The CDN URL with 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.

app/static/css/main.css — append 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.

app/templates/admin/dashboard.html — updated cards section html + jinja2
<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

Project structure — additions
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 themeoxide-dark skin with editor.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

Quick Reference — Chapter 8

db.get(Model, id) Fetch by PK using identity-map cache — wrap in get_or_404()
db.add(obj); db.commit() Insert a new row — SQLAlchemy assigns the id after commit
obj.field = value; db.commit() Update — dirty tracking auto-generates UPDATE SQL on commit
db.delete(obj); db.commit() Delete with cascade — removes all child records automatically
slugify("My Topic") → "my-topic" Auto-generate URL-safe slug from display name
secrets.compare_digest(a, b) Constant-time string comparison for CSRF token check
tinymce.init({ selector: '#editor', skin: 'oxide-dark' }) Initialise TinyMCE dark editor on a textarea
editor.on('change', () => editor.save()) Sync TinyMCE HTML back to textarea before form submits