English · Español
Lab 05 — Admin / teacher dashboard¶
🇪🇸 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.
Goal¶
Build /admin and its sub-views. The dashboard renders one row per student with a 41-cell progress sparkline (phase 0..40). Clicking a student opens a deep-dive: journal feed, quiz attempts, exam attempts, notes count, time-on-task per phase. The teacher role gets the read-only version; admin gets the same view plus the lab-02 student-management actions and visible audit log. All admin-mutating actions are themselves audited.
Why this lab exists¶
Phase 41 only earns its keep if Borja (acting as teacher) can answer "where is each learner stuck?" in under a minute. The data exists from labs 02–04; lab 05 composes it into a single view. The teacher persona is deliberately read-only — the portal's contract is that a teacher sees but never acts on behalf of the student. Acting on behalf would let a teacher (intentionally or not) mark someone's quiz pass when the work was never done; that breaks the integrity of the progress sparkline downstream.
Prerequisites¶
- Labs 00–04 done.
compute_progressfrom lab 02 is in place.quiz_attempts,review_cards,journal_entries,notestables exist.
Deliverables¶
src/miniportal/routes/admin.py— admin dashboard routes.src/miniportal/services/dashboard.py— aggregators (one per panel: sparklines, time-on-task, quiz history).src/miniportal/services/time_on_task.py— derive minutes-per-phase from journal entries + quiz attempt timestamps.src/miniportal/templates/admin_dashboard.html.jinja,admin_student_detail.html.jinja,_audit_log_panel.html.jinja.src/miniportal/static/sparkline.css— the 41-cell strip styles.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.
Step 1 — /admin index¶
# 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.")
The list is ordered by last_active DESC so the teacher's eye lands first on the actively engaged learners. Stale rows (last_active > 14 days ago) get a CSS .stale class for a soft visual cue.
Step 2 — Per-student deep dive¶
@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.")
The admin_student_detail.html.jinja template uses {% if viewer.role == 'admin' %} guards around edit buttons; the teacher sees the same data without those buttons. Server-side enforcement of read-only is still required — relying on template guards alone is a bandit-flaggable pattern (template guards are display, not authorization).
Step 3 — Sparkline aggregator¶
# 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.")
The aggregator must produce O(students) DB queries, not O(students × phases). Use a single GROUP BY query per panel; assemble in Python. Lab 05's test asserts query count.
Step 4 — 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.")
The heuristic is documented honestly on the rendered chart: "estimated, derived from quiz duration + journal cadence." No pretense of accuracy beyond the input signals.
Step 5 — Audit log panel¶
# 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.")
The audit panel is the only place where admin actions become visible to other admins/teachers. Without it, an admin could quietly do something destructive and the loop closes only through git history (too low-level).
Every admin route in lab 02 (and any future admin-only mutation) must emit an audit event with at minimum: actor_id, action, target_student_id, request_id, ts. The convention is already in place from lab 01.
Step 6 — The 41-cell strip CSS¶
src/miniportal/static/sparkline.css (illustrative, Borja writes the actual rules):
/* 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; }
Color choices are deliberate: a single glance distinguishes the four statuses + drift. Accessibility: each cell carries a title (tooltip) and aria-label so screen readers do not depend on color.
Step 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.")
What "done" looks like¶
-
/adminreturns 200 foradminandteacher, 403 forlearner, 401 for unauthenticated. - Dashboard renders the full student list with sparkline + last-active + pending-reviews + notes count.
- Per-student detail page renders all six sections (header, strip, time chart, journal feed, attempts tables, audit panel).
- Teacher sees no edit buttons AND mutation endpoints reject teachers server-side.
- Drift rows are visually surfaced (red outline) and counted in the dashboard.
- Audit log panel reads the last 30 days of JSONL files; new admin actions appear within seconds.
-
mypy --strict,bandit -rclean. - Query budget: dashboard renders with ≤ 6 DB queries regardless of student count (verified by a
pytestcount fixture).
Common pitfalls¶
- Template-only guards. A teacher sees no edit button but can hit the endpoint directly via
curl. Server-siderequire_role(Role.admin)is the contract; template guards are just UX. - N+1 queries on the dashboard. Computing per-student aggregates in a loop kills the page. One JOIN per panel; assemble in Python.
- Reading the entire audit log into memory. 30 days × thousands of events. Scan by file, stop early, never load the full corpus.
- Color-only status. Add tooltips + ARIA labels so a colorblind teacher can read the strip too.
- Pretending time-on-task is precise. It is a heuristic. The chart's caption says so. Do not let the UI imply otherwise.
- Teacher able to delete a note "as the student." Lab 03's CRUD is owner-only; lab 05 must not introduce a sneaky
admin/notes/{id}/deleteroute. Audit visibility is the teacher's lever, not edit power.
Next: lab/06-deploy-and-backup.md — single-VPS deploy, daily backups, disaster-recovery drill.