English · Español
Lab 02 — Perfiles multi-estudiante, roles y progreso por fase¶
🇪🇸 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.
Objetivo¶
Implementar la tabla students, el enum de rol, un endpoint "crear estudiante" solo para admin, una página de perfil que renderiza avatar + bio + tira de progreso por fase, y una capa de control de acceso basado en rol que impone la privacidad por defecto. Cruza-referencia el learners/<username>/phase-NN/checkpoint.json en disco de cada estudiante con la tabla progress en DB para que el progreso renderizado sea la conjunción de ambas señales (una fase está "done" solo si ambas están de acuerdo).
Por qué existe este lab¶
La Fase 41 hereda el patrón de directorio-por-learner de §A3. Ese patrón funciona sin portal; lo que el portal añade es la read-side — una sola página donde ves todas tus fases de un vistazo — y la write-side para las cuentas (crear una es ahora un solo formulario de admin, no una invocación cp -r learners/_template/ learners/<name>/ en un shell).
Cruzar-referenciar la DB con el checkpoint en disco es crítico: la DB es lo que el portal escribe cuando se hace un quiz; el archivo JSON es lo que los rituales locales de Borja escriben cuando se cierra una fase. Si no concuerdan, el portal debe mostrar la respuesta conservadora (gana no-hecho) y sacar un aviso en el dashboard de admin.
Prerrequisitos¶
- Labs 00, 01 hechos.
src/miniportal/auth/existe y exponecurrent_student(request) -> Student | None.- El layout de
learners/_template/es estable (CLAUDE.md §3).
Entregables¶
- Migración alembic creando la tabla
studentscon:id PK,username UNIQUE,display_name,role(enum:learner|teacher|admin),locale(default'en'),bio_md(TEXT, nullable),created_at, más las columnas del lab 01. - Migración alembic creando la tabla
progress:(student_id, phase_no, status, last_updated), status enum:not_started|in_progress|done|attested. src/miniportal/models.py— clases SQLModel paraStudent,Progress, más el enum de rol.src/miniportal/auth/rbac.py— dependenciarequire_role(...).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](la conjunción DB+disco).src/miniportal/templates/profile.html.jinja,admin_students.html.jinja.tests/portal/test_role_based_access.py.tests/portal/test_progress_cross_reference.py.
Paso 1 — Modelos¶
# 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)
Restricción del username: ^[a-z][a-z0-9_-]{1,30}$. Esto es también el nombre del directorio bajo learners/, así que debe ser un segmento de path seguro. Valida en un validator de Pydantic, no solo en SQL.
Paso 2 — Acceso basado en rol¶
# 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
La distinción 401 vs 403 importa: 401 significa "no has iniciado sesión"; 403 significa "iniciada, rol incorrecto". Confundir las dos filtra la existencia del recurso protegido.
Paso 3 — "Crear estudiante" del admin¶
# 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.")
La acción cp -r learners/_template/ learners/<username>/ ocurre del lado del servidor. Corre como el usuario del portal (no root). El umask del usuario del portal debe mantener estos archivos no legibles para otros usuarios del sistema — pónlo explícitamente, no confíes en el default del sistema.
Paso 4 — Cruz-referencia de progreso¶
# 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.")
La regla conservadora: cuando la DB dice "done" pero el disco no tiene checkpoint, el portal muestra "done" con un pequeño glifo de aviso, y el admin ve una fila de drift. Lo opuesto — disco dice done, DB dice not-started — también se saca como drift; esto puede pasar si Borja cerró una fase localmente sin hacer el quiz del portal.
Paso 5 — Página de perfil¶
# 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: URL de gravatar calculada desde md5(username + '@lynx-cortex.local'), con fallback a un disco SVG de iniciales inline-renderizado. El fetch de gravatar ocurre en el momento de render de la plantilla vía una URL pública con d=identicon — nunca vía una llamada saliente del servidor (sin superficie SSRF).
Paso 6 — La tira de progreso en la plantilla¶
Extracto de profile.html.jinja (ilustrativo, no para copiar):
<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>
El CSS vive en src/miniportal/static/portal.css; el lab 02 añade las cuatro clases (phase-not_started, phase-in_progress, phase-done, phase-attested) más .phase-drift.
Paso 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.")
Cómo es "done"¶
- Las migraciones se aplican limpias en una DB fresca y en una DB en estado lab-01.
-
POST /admin/studentscrea fila en DB + directoriolearners/<u>/atómicamente. - La página de perfil renderiza avatar, bio (sanitizado por Fase 37), y tira de progreso.
- La privacidad por defecto se cumple: peer→peer devuelve 404, no 403.
-
compute_progressestá testeado unitariamente en los cuatro cuadrantes(db, disco). - Las filas de drift aparecen en la lista del admin (el lab 05 usará los mismos datos).
-
mypy --strictybanditlimpios para los nuevos módulos. - No es posible path traversal a través del campo username (test impuesto).
Trampas comunes¶
- Usar 403 donde corresponde 404. Teoría §profile-privacy de la Fase 41: confirmar la existencia del perfil de otro learner es una fuga de privacidad en un contexto de red de amigos. 404 es la respuesta correcta a "esto no es tuyo".
- Confiar en el disco O en la DB. El portal debe renderizar la conjunción conservadora. Elige la que diga "not-done" más alto.
- Correr el
mkdirdelearners/<u>/como root. El portal debe soltar privilegios antes de cualquier escritura al sistema de archivos. Si el deploy usa systemd, ponUser=y verifica conps -o user,cmd. - Olvidar el fallback de gravatar. Algunos usuarios no tienen gravatar; la página debe seguir renderizando. Fallback SVG de iniciales en el momento de render de plantilla, sin llamada de red saliente.
- Dejar que
bio_mdllegue al renderer sin sanitización. El sanitizer de la Fase 37 aplica aquí exactamente como en el lab 03 — llámalo desde un único helper, no inline por plantilla. - Hardcodear
role='learner'en el formulario. Un typo en el form deja a un tester escalar a sí mismo. El rol debe imponerse del lado del servidor contra el enum del form.
Siguiente: lab/03-notes-and-journal.md — notas inline ancladas a párrafos y el journal diario.