Skip to content

English · Español

Lab 02 — Multi-student profiles, roles, and per-phase progress

🇪🇸 El portal deja de ser para una sola persona. Aparecen filas en students, un enum de rol (learner, teacher, admin), y una vista de perfil con avatar, biografía y barra de progreso fase a fase. La regla de privacidad por defecto: cada quien ve solo su perfil; el admin lo ve todo.

Goal

Implement the students table, the role enum, an admin-only "create student" endpoint, a profile page that renders avatar + bio + per-phase progress strip, and a role-based-access layer enforcing the privacy default. Cross-reference each student's on-disk learners/<username>/phase-NN/checkpoint.json with the in-DB progress table so the rendered progress is the conjunction of both signals (a phase is "done" only if both agree).

Why this lab exists

Phase 41 inherits the directory-per-learner pattern from §A3. That pattern works without a portal; what the portal adds is the read-side — a single page where you see all your phases at a glance — and the write-side for accounts (creating one is now a single admin form, not a cp -r learners/_template/ learners/<name>/ invocation in a shell).

Cross-referencing the DB with the on-disk checkpoint is critical: the DB is what the portal writes when a quiz is taken; the JSON file is what Borja's local rituals write when a phase is closed. If they disagree, the portal must show the conservative answer (not-done wins) and surface a warning on the admin dashboard.

Prerequisites

  • Labs 00, 01 done.
  • src/miniportal/auth/ exists and exposes current_student(request) -> Student | None.
  • learners/_template/ layout is stable (CLAUDE.md §3).

Deliverables

  • Alembic migration creating students table with: id PK, username UNIQUE, display_name, role (enum: learner | teacher | admin), locale (default 'en'), bio_md (TEXT, nullable), created_at, plus the columns from lab 01.
  • Alembic migration creating progress table: (student_id, phase_no, status, last_updated), status enum: not_started | in_progress | done | attested.
  • src/miniportal/models.py — SQLModel classes for Student, Progress, plus the role enum.
  • src/miniportal/auth/rbac.pyrequire_role(...) dependency.
  • src/miniportal/routes/admin_students.pyPOST /admin/students, GET /admin/students.
  • src/miniportal/routes/profile.pyGET /profile/{username}.
  • src/miniportal/services/progress.pycompute_progress(student) -> list[PhaseProgress] (the DB+disk conjunction).
  • src/miniportal/templates/profile.html.jinja, admin_students.html.jinja.
  • tests/portal/test_role_based_access.py.
  • tests/portal/test_progress_cross_reference.py.

Step 1 — Models

# src/miniportal/models.py
import enum
from datetime import datetime
from sqlmodel import SQLModel, Field


class Role(str, enum.Enum):
    learner = "learner"
    teacher = "teacher"
    admin = "admin"


class Student(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    username: str = Field(unique=True, index=True)
    display_name: str
    role: Role = Field(default=Role.learner)
    locale: str = Field(default="en")
    bio_md: str | None = None
    argon2_hash: str | None = None  # from lab 01
    must_set_on_next_login: bool = Field(default=True)
    created_at: datetime = Field(default_factory=datetime.utcnow)


class ProgressStatus(str, enum.Enum):
    not_started = "not_started"
    in_progress = "in_progress"
    done = "done"
    attested = "attested"   # done AND checkpoint.json present AND signed


class Progress(SQLModel, table=True):
    student_id: int = Field(foreign_key="student.id", primary_key=True)
    phase_no: int = Field(primary_key=True, ge=0, le=40)
    status: ProgressStatus = Field(default=ProgressStatus.not_started)
    last_updated: datetime = Field(default_factory=datetime.utcnow)

Username constraint: ^[a-z][a-z0-9_-]{1,30}$. This is also the directory name under learners/, so it must be a safe path segment. Validate in a Pydantic validator, not in SQL alone.

Step 2 — Role-based access

# src/miniportal/auth/rbac.py
from fastapi import Depends, HTTPException, Request
from miniportal.models import Role, Student


def current_student(request: Request) -> Student:
    raise NotImplementedError("Lab 02 step 2 — read signed cookie, load student, raise 401 if absent.")


def require_role(*allowed: Role):
    def _dep(student: Student = Depends(current_student)) -> Student:
        if student.role not in allowed:
            raise HTTPException(status_code=403, detail="role_required")
        return student
    return _dep

The 401 vs 403 split matters: 401 means "you're not logged in"; 403 means "logged in, wrong role." Confusing the two leaks the existence of the gated resource.

Step 3 — Admin "create student"

# src/miniportal/routes/admin_students.py
from fastapi import APIRouter, Depends, Form

router = APIRouter(prefix="/admin/students")


@router.get("")
async def list_students(admin = Depends(require_role(Role.admin))):
    raise NotImplementedError("Lab 02 step 3 — render admin_students.html with the full list, paginated.")


@router.post("")
async def create_student(
    username: str = Form(...),
    display_name: str = Form(...),
    role: Role = Form(default=Role.learner),
    admin = Depends(require_role(Role.admin)),
):
    """Side effects:

    1. INSERT into students with argon2_hash=NULL, must_set_on_next_login=TRUE.
    2. mkdir learners/<username>/ from learners/_template/ (CLAUDE.md §A3).
    3. Audit: auth.student_created.
    4. Return 303 to /admin/students.

    Atomicity: if step 2 fails (e.g., disk full), roll back step 1.
    """
    raise NotImplementedError("Lab 02 step 3 — implement the four side effects with rollback.")

The cp -r learners/_template/ learners/<username>/ action happens server-side. It runs as the portal user (not root). The portal user's umask must keep these files unreadable to other system users — set explicitly, do not rely on the system default.

Step 4 — Progress cross-reference

# src/miniportal/services/progress.py
from dataclasses import dataclass
from pathlib import Path
import json

from miniportal.models import Student, ProgressStatus


@dataclass
class PhaseProgress:
    phase_no: int
    db_status: ProgressStatus
    disk_checkpoint_present: bool
    rendered_status: ProgressStatus
    drift: bool   # True iff DB and disk disagree


def compute_progress(student: Student, learners_root: Path) -> list[PhaseProgress]:
    """For each phase 0..40, return the conjunction:

    rendered_status =
        'attested' if db_status == 'done' AND checkpoint.json present AND validates,
        'done'     if db_status == 'done' AND checkpoint missing  (drift=True),
        'in_progress' if db_status == 'in_progress',
        'not_started' otherwise.

    The portal renders the *rendered_status*; admin dashboard surfaces drift=True rows.
    """
    raise NotImplementedError("Lab 02 step 4 — load DB rows, read disk checkpoints, compute conjunction.")

The conservative rule: when the DB says "done" but the disk has no checkpoint, the portal shows "done" with a small warning glyph, and the admin sees a drift row. The opposite — disk says done, DB says not-started — also surfaces drift; this can happen if Borja closed a phase locally without taking the portal-side quiz.

Step 5 — Profile page

# src/miniportal/routes/profile.py
from fastapi import APIRouter, Depends, HTTPException


router = APIRouter()


@router.get("/profile/{username}")
async def profile(username: str, viewer = Depends(current_student)):
    """Privacy:

    - viewer.username == username        -> full detail (bio, full journal counts, all phase data).
    - viewer.role == admin               -> full detail.
    - otherwise (viewer is another learner) -> 404 (NOT 403 — never confirm existence to peers).
    """
    raise NotImplementedError("Lab 02 step 5 — branch by viewer, render profile.html with appropriate detail level.")

Avatar: gravatar URL computed from md5(username + '@lynx-cortex.local'), with a fallback to an SVG inline-rendered initials disk. The gravatar fetch happens at template-render time via a public URL with d=identicon — never via a server-side outbound call (no SSRF surface).

Step 6 — The progress strip in the template

profile.html.jinja excerpt (illustrative, not for copy):

<section class="progress-strip">
  {% for p in phases %}
    <span
      class="phase phase-{{ p.rendered_status }}{% if p.drift %} phase-drift{% endif %}"
      title="Phase {{ p.phase_no }}{{ p.rendered_status }}{% if p.drift %} (drift: db≠disk){% endif %}"
    >{{ p.phase_no }}</span>
  {% endfor %}
</section>

CSS lives in src/miniportal/static/portal.css; lab 02 adds the four classes (phase-not_started, phase-in_progress, phase-done, phase-attested) plus .phase-drift.

Step 7 — Tests

# tests/portal/test_role_based_access.py
def test_learner_cannot_create_students():
    raise NotImplementedError("Authenticated as a learner, POST /admin/students -> 403.")


def test_admin_can_create_students():
    raise NotImplementedError("Authenticated as admin, POST /admin/students with valid form -> 303 + DB row + dir created.")


def test_learner_cannot_view_other_profile():
    raise NotImplementedError("Authenticated as learner A, GET /profile/<learner_b_username> -> 404 (not 403).")


def test_admin_can_view_any_profile():
    raise NotImplementedError("Admin GET /profile/<any> -> 200.")
# tests/portal/test_progress_cross_reference.py
def test_db_done_disk_missing_marks_drift():
    raise NotImplementedError("Seed Progress(student=A, phase=3, status=done); ensure no checkpoint.json on disk; expect rendered_status=done, drift=True.")


def test_db_done_disk_present_attested():
    raise NotImplementedError("Seed both; expect rendered_status=attested, drift=False.")


def test_path_traversal_in_username_rejected():
    raise NotImplementedError("Try compute_progress for a student whose username is '../etc'. Expect ValueError, no fs touch above learners_root.")

What "done" looks like

  • Migrations apply cleanly on a fresh DB and on a DB at lab-01 state.
  • POST /admin/students creates DB row + learners/<u>/ directory atomically.
  • Profile page renders avatar, bio (Phase 37 sanitized), and progress strip.
  • Privacy default holds: peer→peer returns 404, not 403.
  • compute_progress is unit-tested across the four (db, disk) quadrants.
  • Drift rows surface on the admin list (lab 05 will use the same data).
  • mypy --strict and bandit clean for the new modules.
  • No path-traversal possible through the username field (test enforced).

Common pitfalls

  1. Using 403 where 404 belongs. Phase 41 theory §profile-privacy: confirming existence of another learner's profile is a privacy leak in a friend-network context. 404 is the right answer to "this is not yours."
  2. Trusting the disk OR the DB. The portal must render the conservative conjunction. Pick the one that says "not-done" louder.
  3. Running the mkdir of learners/<u>/ as root. The portal must drop privileges before any filesystem write. If the deploy uses systemd, set User= and verify with ps -o user,cmd.
  4. Forgetting the gravatar fallback. Some users have no gravatar; the page must still render. SVG initials fallback at template-render time, no outbound network call.
  5. Letting bio_md reach the renderer without sanitization. Phase 37 sanitizer applies here exactly as it does in lab 03 — call it from a single helper, not inline per template.
  6. Hardcoding role='learner' in the form. A typo in the form lets a tester escalate themselves. The role must be enforced server-side against the form's enum.

Next: lab/03-notes-and-journal.md — inline notes anchored to paragraphs and the daily journal.