English · Español
Lab 05 — Dashboard de admin / profesor¶
🇪🇸 La vista que abre el profesor. Lista todos los estudiantes con un sparkline de 41 casillas (fase 00..40), un panel por estudiante con journal feed, intentos de quiz/exam, número de notas y tiempo dedicado por fase. El profesor puede leer todo; el admin puede además editar. Cada acción de admin queda en el log de auditoría visible en la misma página.
Objetivo¶
Construir /admin y sus subvistas. El dashboard renderiza una fila por estudiante con un sparkline de progreso de 41 celdas (fase 0..40). Al hacer clic en un estudiante se abre un deep-dive: journal feed, intentos de quiz, intentos de exam, número de notas, tiempo dedicado por fase. El rol teacher recibe la versión de solo lectura; admin obtiene la misma vista más las acciones de gestión de estudiantes del lab 02 y el audit log visible. Todas las acciones mutadoras del admin se auditan a su vez.
Por qué existe este lab¶
La Fase 41 solo se justifica si Borja (actuando como profesor) puede responder "¿dónde está atascado cada learner?" en menos de un minuto. Los datos existen desde los labs 02–04; el lab 05 los compone en una sola vista. La persona profesor es deliberadamente de solo lectura — el contrato del portal es que un profesor ve pero nunca actúa en nombre de el estudiante. Actuar en nombre permitiría a un profesor (intencionalmente o no) marcar el aprobado del quiz de alguien cuando el trabajo nunca se hizo; eso rompe la integridad del sparkline de progreso aguas abajo.
Prerrequisitos¶
- Labs 00–04 hechos.
compute_progressdel lab 02 está en su sitio.- Las tablas
quiz_attempts,review_cards,journal_entries,notesexisten.
Entregables¶
src/miniportal/routes/admin.py— rutas del admin dashboard.src/miniportal/services/dashboard.py— agregadores (uno por panel: sparklines, tiempo dedicado, historial de quiz).src/miniportal/services/time_on_task.py— deriva minutos-por-fase desde journal entries + timestamps de quiz attempts.src/miniportal/templates/admin_dashboard.html.jinja,admin_student_detail.html.jinja,_audit_log_panel.html.jinja.src/miniportal/static/sparkline.css— los estilos de la tira de 41 celdas.tests/portal/test_admin_access_gate.py.tests/portal/test_teacher_readonly.py.tests/portal/test_progress_sparkline.py.tests/portal/test_time_on_task.py.
Paso 1 — Índice /admin¶
# src/miniportal/routes/admin.py
from fastapi import APIRouter, Depends
from miniportal.auth.rbac import require_role
from miniportal.models import Role
router = APIRouter(prefix="/admin")
@router.get("")
async def dashboard(viewer = Depends(require_role(Role.admin, Role.teacher))):
"""Render admin_dashboard.html. Both roles see the same table:
| username | display | role | phase-strip(41) | last-active | pending-reviews | notes |
Difference admin vs teacher:
- admin: each row has a 'manage' link to admin_student_detail with edit affordances.
- teacher: 'manage' link replaced by a 'view' link with no edit affordances rendered.
"""
raise NotImplementedError("Lab 05 step 1 — list students, attach aggregates, render.")
La lista se ordena por last_active DESC para que la mirada del profesor caiga primero en los learners activamente involucrados. Las filas obsoletas (last_active > hace 14 días) reciben una clase CSS .stale como pista visual suave.
Paso 2 — Deep dive por estudiante¶
@router.get("/students/{username}")
async def student_detail(username: str, viewer = Depends(require_role(Role.admin, Role.teacher))):
"""Renders admin_student_detail.html with sections:
- Header: avatar, display name, role, locale, last_active.
- Progress strip (41 cells), each cell hover-tooltipped with phase title + status.
- Time-on-task bar chart (one bar per phase, minutes).
- Journal feed (latest 14 entries, paginated).
- Quiz attempts table (latest 20).
- Exam attempts table.
- Notes count + 'view notes' link (read-only listing reusing lab 03's renderer).
- Audit log panel (last 50 admin actions affecting this student).
"""
raise NotImplementedError("Lab 05 step 2 — aggregate via services/dashboard.py, render.")
La plantilla admin_student_detail.html.jinja usa guardas {% if viewer.role == 'admin' %} alrededor de los botones de edición; el profesor ve los mismos datos sin esos botones. La aplicación del solo-lectura del lado del servidor sigue siendo obligatoria — depender únicamente de las guardas de plantilla es un patrón flaggable por bandit (las guardas de plantilla son display, no autorización).
Paso 3 — Agregador del sparkline¶
# src/miniportal/services/dashboard.py
from dataclasses import dataclass
from miniportal.services.progress import compute_progress
@dataclass(frozen=True)
class StudentRow:
username: str
display_name: str
role: str
phase_strip: list[str] # 41 status strings
last_active: str | None # ISO date or None
pending_reviews: int # SM-2 cards due today
notes_count: int
drift_count: int # number of phases where DB and disk disagree
def build_student_rows(db, learners_root) -> list[StudentRow]:
raise NotImplementedError("Lab 05 step 3 — JOIN students, progress, journal, notes, review_cards; one row per student.")
El agregador debe producir O(students) consultas a la DB, no O(students × phases). Usa una sola consulta GROUP BY por panel; ensambla en Python. El test del lab 05 asevera el conteo de consultas.
Paso 4 — Tiempo dedicado (time-on-task)¶
# src/miniportal/services/time_on_task.py
from datetime import timedelta
def time_per_phase(student_id: int, db) -> dict[int, timedelta]:
"""Derive a coarse minutes-per-phase estimate from:
- quiz_attempts: (submitted_at - started_at), bucketed by phase.
- journal_entries: 30 min per entry credited to the most-recently-active phase
(heuristic; documented as such — not a true time tracker).
- notes: 5 min per note credited to the page's phase.
Returns a dict {phase_no: timedelta}. Phases with 0 minutes are omitted.
"""
raise NotImplementedError("Lab 05 step 4 — three SQL aggregates, sum into a dict.")
La heurística se documenta honestamente en el gráfico renderizado: "estimado, derivado de la duración del quiz + cadencia del journal." Sin pretensión de precisión más allá de las señales de entrada.
Paso 5 — Panel del audit log¶
# src/miniportal/services/audit_query.py
def recent_admin_actions(student_id: int, limit: int = 50) -> list[dict]:
"""Read JSONL audit files for the last 30 days; filter to rows where target_student_id matches.
Return newest first."""
raise NotImplementedError("Lab 05 step 5 — scan files, parse, filter, sort, limit.")
El panel de auditoría es el único lugar donde las acciones de admin se hacen visibles para otros admins/profesores. Sin él, un admin podría hacer silenciosamente algo destructivo y el bucle solo se cierra a través del historial de git (demasiado bajo nivel).
Cada ruta de admin en el lab 02 (y cualquier mutación futura solo-admin) debe emitir un evento de auditoría con como mínimo: actor_id, action, target_student_id, request_id, ts. La convención ya está en su sitio desde el lab 01.
Paso 6 — CSS de la tira de 41 celdas¶
src/miniportal/static/sparkline.css (ilustrativo, Borja escribe las reglas reales):
/* one strip = 41 inline-block cells, fixed width, color-coded by status */
.phase-strip { display: inline-flex; gap: 1px; font-size: 0.65rem; line-height: 1; }
.phase-strip .phase { width: 1em; height: 1em; display: inline-block; }
.phase-strip .phase.phase-not_started { background: #eee; }
.phase-strip .phase.phase-in_progress { background: #fc0; }
.phase-strip .phase.phase-done { background: #6c6; }
.phase-strip .phase.phase-attested { background: #393; }
.phase-strip .phase.phase-drift { outline: 1px solid red; }
Las elecciones de color son deliberadas: una sola mirada distingue los cuatro estados + drift. Accesibilidad: cada celda lleva un title (tooltip) y aria-label para que los lectores de pantalla no dependan del color.
Paso 7 — Tests¶
# tests/portal/test_admin_access_gate.py
def test_learner_gets_403_on_admin():
raise NotImplementedError("Authenticated as learner -> GET /admin -> 403.")
def test_teacher_gets_200_on_admin():
raise NotImplementedError("Authenticated as teacher -> GET /admin -> 200.")
def test_admin_gets_200_on_admin():
raise NotImplementedError("Authenticated as admin -> GET /admin -> 200.")
def test_unauth_gets_401_on_admin():
raise NotImplementedError("No session cookie -> GET /admin -> 401 with WWW-Authenticate.")
# tests/portal/test_teacher_readonly.py
def test_teacher_cannot_create_student():
raise NotImplementedError("Authenticated as teacher -> POST /admin/students -> 403 (server-side, NOT just template).")
def test_teacher_can_view_student_detail():
raise NotImplementedError("Authenticated as teacher -> GET /admin/students/<u> -> 200; response HTML has no 'edit' button.")
def test_teacher_cannot_post_grade_on_behalf():
raise NotImplementedError("If lab 04 exposes any 'grade exam' endpoint, teacher access is 200; admin can mutate, teacher cannot. Verify the mutation endpoint returns 403 for teacher.")
# tests/portal/test_progress_sparkline.py
def test_strip_has_41_cells():
raise NotImplementedError("Render dashboard with one student; assert the response HTML contains exactly 41 phase cells for that student.")
def test_drift_flagged_in_strip():
raise NotImplementedError("Seed a drift row; assert the corresponding cell carries the 'phase-drift' class.")
# tests/portal/test_time_on_task.py
def test_quiz_attempts_contribute():
raise NotImplementedError("Seed quiz_attempts with duration 12 min for phase 3; assert time_per_phase()[3] >= 12 min.")
def test_journal_heuristic_credited_to_recent_phase():
raise NotImplementedError("Seed a journal entry on day D where student was in phase 5; assert 30 min credited to phase 5.")
Cómo es "done"¶
-
/admindevuelve 200 paraadminyteacher, 403 paralearner, 401 para no autenticado. - El dashboard renderiza la lista completa de estudiantes con sparkline + last-active + pending-reviews + número de notas.
- La página de detalle por estudiante renderiza las seis secciones (header, strip, gráfico de tiempo, journal feed, tablas de attempts, panel de auditoría).
- El profesor no ve botones de edición Y los endpoints de mutación rechazan a los profesores del lado del servidor.
- Las filas con drift se sacan visualmente (contorno rojo) y se cuentan en el dashboard.
- El panel del audit log lee los últimos 30 días de archivos JSONL; las acciones nuevas de admin aparecen en segundos.
-
mypy --strict,bandit -rlimpios. - Presupuesto de consultas: el dashboard renderiza con ≤ 6 consultas a DB independientemente del número de estudiantes (verificado por un fixture de
pytestque cuenta).
Trampas comunes¶
- Guardas solo-plantilla. Un profesor no ve botón de editar pero puede pegar al endpoint directamente vía
curl.require_role(Role.admin)del lado del servidor es el contrato; las guardas de plantilla son solo UX. - Consultas N+1 en el dashboard. Calcular agregados por estudiante en un bucle mata la página. Un JOIN por panel; ensambla en Python.
- Leer todo el audit log en memoria. 30 días × miles de eventos. Escanea por archivo, para temprano, nunca cargues el corpus completo.
- Estado solo-color. Añade tooltips + ARIA labels para que un profesor daltónico también pueda leer la tira.
- Pretender que time-on-task es preciso. Es una heurística. El pie de gráfico lo dice. No dejes que la UI implique lo contrario.
- Profesor capaz de borrar una nota "como el estudiante". El CRUD del lab 03 es solo-dueño; el lab 05 no debe introducir una ruta sigilosa
admin/notes/{id}/delete. La visibilidad de auditoría es la palanca del profesor, no el poder de edición.
Siguiente: lab/06-deploy-and-backup.md — deploy en un único VPS, copias diarias, simulacro de disaster-recovery.