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 exposescurrent_student(request) -> Student | None.learners/_template/layout is stable (CLAUDE.md §3).
Deliverables¶
- Alembic migration creating
studentstable 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
progresstable:(student_id, phase_no, status, last_updated), status enum:not_started|in_progress|done|attested. src/miniportal/models.py— SQLModel classes forStudent,Progress, plus the role enum.src/miniportal/auth/rbac.py—require_role(...)dependency.src/miniportal/routes/admin_students.py—POST /admin/students,GET /admin/students.src/miniportal/routes/profile.py—GET /profile/{username}.src/miniportal/services/progress.py—compute_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/studentscreates 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_progressis unit-tested across the four(db, disk)quadrants. - Drift rows surface on the admin list (lab 05 will use the same data).
-
mypy --strictandbanditclean for the new modules. - No path-traversal possible through the username field (test enforced).
Common pitfalls¶
- 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."
- Trusting the disk OR the DB. The portal must render the conservative conjunction. Pick the one that says "not-done" louder.
- Running the
mkdiroflearners/<u>/as root. The portal must drop privileges before any filesystem write. If the deploy uses systemd, setUser=and verify withps -o user,cmd. - 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.
- Letting
bio_mdreach 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. - 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.