Skip to content

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_progress from lab 02 is in place.
  • quiz_attempts, review_cards, journal_entries, notes tables 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

  • /admin returns 200 for admin and teacher, 403 for learner, 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 -r clean.
  • Query budget: dashboard renders with ≤ 6 DB queries regardless of student count (verified by a pytest count fixture).

Common pitfalls

  1. Template-only guards. A teacher sees no edit button but can hit the endpoint directly via curl. Server-side require_role(Role.admin) is the contract; template guards are just UX.
  2. N+1 queries on the dashboard. Computing per-student aggregates in a loop kills the page. One JOIN per panel; assemble in Python.
  3. Reading the entire audit log into memory. 30 days × thousands of events. Scan by file, stop early, never load the full corpus.
  4. Color-only status. Add tooltips + ARIA labels so a colorblind teacher can read the strip too.
  5. Pretending time-on-task is precise. It is a heuristic. The chart's caption says so. Do not let the UI imply otherwise.
  6. 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}/delete route. 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.