docs: sp3 spec + plan (native quizzes)
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
# SP3 — Native Quizzes Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Learners take the 4 module quizzes inside the app (login required): question bank exported from Moodle into Grist at provisioning, graded server-side, attempts stored in Grist, pass → Moodle quiz-activity completion override.
|
||||
|
||||
**Architecture/Stack:** unchanged (Astro 6 SSR, zero new deps, vitest). Repo `electron-server:/home/electron/lelectron-rare/formations-app`, branch `feat/sp3-quizzes` off main. Docker runner as SP1/SP2. Deploy Tower.
|
||||
|
||||
**Conventions:** subjects ≤50 chars; files via scp (quote bracketed paths); secrets only in `/home/clems/formations-app.env` (no new vars needed — reuses GRIST_* and MOODLE_SYNC_TOKEN).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Export quiz bank → Grist (+ real fixture)
|
||||
|
||||
**Files:** Create `ops/export-quiz-bank.php`, `fixtures/quizbank-3.json` (SANITIZED: correct flags and feedback REMOVED — repo is public), update `ops/README.md`.
|
||||
|
||||
- [ ] **1.1** `ops/export-quiz-bank.php` (CLI in moodle container). For each course id>1: find quiz course-modules in section order (`course_modules` + `modules.name='quiz'` join, order by section sequence — simpler: order by quiz name which is "Quiz N : …"); for each quiz: cmid, name, `gradepass` from `grade_items` (itemmodule='quiz', iteminstance=quiz.id), and its slot questions via quiz_slots → question_references → question_bank_entries → latest question_versions → question; for each question: id, name, questiontext (html), single (qtype multichoice field `single` from `qtype_multichoice_options`), answers from `question_answers` (id, answer html, fraction, feedback). Output ONE JSON object to stdout: `{courses: {"<courseid>": {quizzes: [{quiz_n, cmid, name, gradepass, questions: [{id, text, single, choices: [{id, text, correct, feedback}]}]}]}}}` where `correct = fraction > 0`. Use `$DB` API (get_records_sql) — pure read.
|
||||
- [ ] **1.2** Run on Tower: `docker cp … && docker exec moodle php /tmp/export-quiz-bank.php > /tmp/quizbank.json`; sanity with python (4 quizzes × 5 questions × 4 choices for course 3; every question has exactly one correct=true when single=1).
|
||||
- [ ] **1.3** Push to Grist: new table `QuizBank` (course_slug Text, quiz_n Int, cmid Int, name Text, gradepass Numeric, payload Text) — one row per quiz, `payload` = JSON string of `{questions:[…]}` (full, WITH correct/feedback). Slug mapping from src/config/courses.ts (id→slug). Script the push with python from Tower (GRIST_API_KEY/DOC from env file). Verify: 6 courses × 4 = 24 rows; spot-check one payload parses and has 5 questions.
|
||||
- [ ] **1.4** Commit fixture SANITIZED ONLY (strip correct+feedback via python before writing `fixtures/quizbank-3.json`) + the php + README section (export rerun procedure, QuizBank schema, sanitization warning). `git add ops fixtures && git commit -m 'feat: quiz bank export to grist'`
|
||||
|
||||
### Task 2: Quiz BFF + grading (TDD)
|
||||
|
||||
**Files:** Create `src/lib/quiz.ts`, `src/lib/quiz.test.ts`; modify `src/lib/grist.ts` (generic records helpers if needed).
|
||||
|
||||
- [ ] **2.1** Types: `QuizChoice {id,text,correct?,feedback?}`, `QuizQuestion {id,text,single,choices}`, `Quiz {quizN,cmid,name,gradepass,questions}`, `PublicQuiz` (choices without correct/feedback). Functions:
|
||||
- `getQuizzes(slug): Promise<Quiz[]>` — Grist QuizBank rows filtered by course_slug (existing grist() helper; filter param), payload JSON.parse, sorted by quiz_n; SWR-cached 5 min.
|
||||
- `toPublic(quiz): PublicQuiz` — deep-strip correct+feedback.
|
||||
- `gradeQuiz(quiz, answers: Record<string, string[]>): {score,total,detail[]}` — per question: chosen ids set === correct ids set → 1 point else 0; detail {questionId, ok, correctIds, feedback of chosen}.
|
||||
- `isPassed(quiz, score): boolean` — gradepass>0 ? score/total*100 ≥ gradepass : score/total ≥ 0.5.
|
||||
- Grist `QuizAttempts` helpers: `addAttempt(...)` (fields per spec, moodle_synced=false), `getAttempts(sub, slug)`, `bestBySQuiz(attempts)` map quiz_n→best, `markAttemptsSynced(ids)`.
|
||||
- [ ] **2.2** Tests FIRST (red→green): gradeQuiz single correct/incorrect/multi all-or-nothing/empty; toPublic leaves no `correct` or `feedback` anywhere (JSON.stringify scan); isPassed gradepass set vs default. ≥8 new tests.
|
||||
- [ ] **2.3** Runner green → commit `feat: quiz bff and grading`
|
||||
|
||||
### Task 3: Quiz pages + API
|
||||
|
||||
**Files:** Create `src/pages/cours/[slug]/quiz-[n].astro`, `src/pages/api/quiz.ts`; modify `Sommaire.astro`, hub page.
|
||||
|
||||
- [ ] **3.1** `/cours/<slug>/quiz-<n>`: course published + n in 1..4 else 404-rewrite; login REQUIRED → `Astro.redirect('/connexion?next='+path)` if anon. GET: render PublicQuiz form (radio per question when single, checkboxes else; names `q<questionId>`), submit POST to same page? — NO: POST `/api/quiz` (PRG). If query `?fait=1`, SSR shows the LAST attempt result instead (read newest attempt from Grist for this user+quiz): score banner (copper celebration if passed: « Quiz réussi · X/5 ✓ »), per-question correction list (need correct ids: load full quiz server-side), button « Rejouer » + link back to hub.
|
||||
- [ ] **3.2** `POST /api/quiz` (form-urlencoded): session required (401 JSON / redirect); fields slug, quiz (int), q<id> values; load full quiz, gradeQuiz, addAttempt, if isPassed → fire-and-forget `syncQuizToMoodle(user, slug, quiz)` ; 303 → `/cours/<slug>/quiz-<n>?fait=1`. Grist write failure → 303 `?erreur=progression`.
|
||||
- [ ] **3.3** `syncQuizToMoodle` in moodle-sync.ts: reuse ensureMoodleUser/ensureEnrolled; completion override on quiz cmid; markAttemptsSynced. Hub retry: include unsynced passed attempts in the existing fire-and-forget.
|
||||
- [ ] **3.4** Sommaire: per module section append `<a href=…/quiz-N>` line « Quiz du module → » with copper ✓ when passed (new prop `quizPassed?: Set<number>`). Hub page: load attempts (try/catch), compute passed set, pass to Sommaire; under the reads progress line add `Quiz : {passed}/{total} réussis` when user.
|
||||
- [ ] **3.5** Runner green → commit `feat: native quiz pages` (split commits ok).
|
||||
|
||||
### Task 4: Deploy + E2E
|
||||
|
||||
- [ ] **4.1** Merge ff-only → main, push, Tower pull + `docker compose up -d --build`.
|
||||
- [ ] **4.2** E2E: quiz page anon → 302 /connexion; `POST /api/quiz` anon → 401; logged-out hub unchanged (200, data-hub); regression suite of SP1/SP2 curls (catalogue 200, chapter 200, /api/lu 401 anon).
|
||||
- [ ] **4.3** Owner manual: take a quiz logged-in, see correction + celebration, Grist QuizAttempts row, Moodle completion on the quiz cmid after pass.
|
||||
|
||||
## Self-review
|
||||
- Public repo: answers only in Grist; fixture sanitized; toPublic tested by stringify-scan.
|
||||
- Reuses: grist client, session, moodle-sync ensure*/override, SWR cache. No new deps/env.
|
||||
- gradepass semantics decided (≥ gradepass% else ≥50%); retakes unlimited, best wins.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Design: SP3 — native quizzes (Grist question bank, in-app grading)
|
||||
|
||||
Date: 2026-06-10
|
||||
Status: approved direction (program brainstorm); technical defaults below
|
||||
chosen autonomously and flagged to the owner.
|
||||
|
||||
## Ground truth (probed on the live DB)
|
||||
|
||||
125 questions, ALL `multichoice`; per course: 4 quizzes ("Quiz N : …",
|
||||
course-module activities) × 5 slots, quiz grade scale 100, sumgrades 5.
|
||||
Answers (choices + correct fraction + feedback) live in
|
||||
`mdl_question_answers`.
|
||||
|
||||
## Why not the Moodle quiz API per attempt
|
||||
|
||||
WS quiz functions execute as the token's user. Learners are SSO-only (no
|
||||
Moodle password → no per-user token via login/token.php). Running attempts
|
||||
as `wssync` would corrupt attempt ownership. And the app repo is PUBLIC,
|
||||
so question banks with answers cannot be committed to it.
|
||||
|
||||
## Architecture
|
||||
|
||||
1. **Provisioning export**: `ops/export-quiz-bank.php` (CLI in the moodle
|
||||
container) dumps per course: quizzes (id, cmid, name, module order,
|
||||
gradepass) and their questions (text html, choices, correct flag(s),
|
||||
feedback) as JSON. Pushed into the SP2 Grist doc, new table
|
||||
`QuizBank` (course_slug, quiz_n, cmid, name, payload JSON string).
|
||||
Grist is API-key-protected → answers never public. Re-export = rerun.
|
||||
2. **BFF** `src/lib/quiz.ts`: `getQuiz(slug, quizN)` reads QuizBank via
|
||||
the existing Grist client (SWR-cached); strips `correct`/`feedback`
|
||||
before anything reaches the browser. `gradeQuiz(quiz, answers)` is a
|
||||
pure function (server-side only): 1 point per fully-correct question
|
||||
(radio, single answer — bank is single-correct; multi-correct payloads
|
||||
are graded all-or-nothing on the checkbox set).
|
||||
3. **Pages**: `/cours/<slug>/quiz-<n>` (login REQUIRED — redirect to
|
||||
/connexion?next=…): one form, 5 questions (radio groups), submit →
|
||||
POST `/api/quiz` → grade server-side → store attempt in Grist table
|
||||
`QuizAttempts` (user_sub, email, course_slug, quiz_n, score, total,
|
||||
reponses JSON, date, moodle_synced) → PRG to a result view: score,
|
||||
per-question correction (your answer vs right answer + feedback),
|
||||
celebration (copper glow header) when passed.
|
||||
4. **Pass rule**: passed = score ≥ gradepass when set (>0), else ≥ 50 %.
|
||||
Retakes allowed, unlimited; best score wins for pass state.
|
||||
5. **Moodle sync**: on pass, completion override on the QUIZ cmid via the
|
||||
existing `formations_sync` machinery (`syncQuizToMoodle` mirrors
|
||||
moodle-sync.ts: ensure user+enrol, override cmid, mark synced); also
|
||||
best-effort retried from the hub. Grade values stay in Grist (Moodle
|
||||
gradebook injection out of scope — certificates key off completion).
|
||||
6. **Surfacing**: hub page — under the title progress line add quiz
|
||||
state `Quiz : 2/4 réussis`; each module section in the Sommaire gets a
|
||||
final line « Quiz du module → » linking the quiz page, with ✓ when
|
||||
passed. Atom unchanged in SP3 (no new geometry; YAGNI).
|
||||
|
||||
## Out of scope
|
||||
Certificates UI (SP4), Moodle gradebook grades, question types beyond
|
||||
multichoice, anti-cheat/time limits.
|
||||
|
||||
## Error handling
|
||||
Grist down → quiz page 503-friendly; grading idempotent re-submits OK;
|
||||
Moodle sync best-effort (flag + retry) as SP2.
|
||||
|
||||
## Testing
|
||||
vitest: gradeQuiz (single, multi, partial→0, empty), payload stripping
|
||||
(no `correct` in client payload), pass rule. E2E: quiz page anon → redirect
|
||||
connexion; POST /api/quiz anon → 401.
|
||||
Reference in New Issue
Block a user