diff --git a/src/components/Sommaire.astro b/src/components/Sommaire.astro index 7f2c76b..ec21554 100644 --- a/src/components/Sommaire.astro +++ b/src/components/Sommaire.astro @@ -1,7 +1,7 @@ --- import type { BookModule } from '../lib/moodle'; -interface Props { slug: string; modules: BookModule[]; readSet?: Set; } -const { slug, modules, readSet } = Astro.props; +interface Props { slug: string; modules: BookModule[]; readSet?: Set; quizPassed?: Set; } +const { slug, modules, readSet, quizPassed } = Astro.props; ---
{modules.map((m, mi) => ( @@ -27,6 +27,22 @@ const { slug, modules, readSet } = Astro.props; ))} + {mi + 1 <= 4 && ( + + {quizPassed?.has(mi + 1) ? ( + <> + + Quiz du module → + + ) : ( + <> + ? + Quiz du module → + + )} + + )} ))}
diff --git a/src/lib/moodle-sync.ts b/src/lib/moodle-sync.ts index ea445a4..8dde39d 100644 --- a/src/lib/moodle-sync.ts +++ b/src/lib/moodle-sync.ts @@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto'; import { wsCall, getCourseBooks, type BookModule } from './moodle'; import { getReads, markSynced, isBookComplete, type ReadMark } from './grist'; import { bySlug } from '../config/courses'; +import { getQuizzes, getAttempts, isPassed, markAttemptsSynced } from './quiz'; /** Token of the dedicated `formations_sync` WS service (wssync user). * Allowed functions ONLY: core_user_get_users_by_field, core_user_create_users, @@ -102,3 +103,32 @@ export async function syncToMoodle( console.error('[moodle-sync]', (err as Error).message); } } + +/** Best-effort: mirror a passed quiz into Moodle completion (quiz cmid) + * and mark the user's passed attempts on that quiz as synced. Never throws. */ +export async function syncQuizToMoodle( + user: { sub: string; email: string; name: string }, + courseSlug: string, + quizN: number, +): Promise { + try { + const course = bySlug(courseSlug); + if (!course) return; + const quiz = (await getQuizzes(courseSlug)).find((q) => q.quizN === quizN); + if (!quiz) return; + const pending = (await getAttempts(user.sub, courseSlug)) + .filter((a) => a.quiz_n === quizN && !a.moodle_synced && isPassed(quiz, a.score, a.total)); + if (!pending.length) return; + + const userId = await ensureMoodleUser(user.email, user.name); + await ensureEnrolled(userId, course.id); + await sync('core_completion_override_activity_completion_status', { + cmid: String(quiz.cmid), + userid: String(userId), + newstate: '1', + }); + await markAttemptsSynced(pending.map((a) => a.id)); + } catch (err) { + console.error('[moodle-sync quiz]', (err as Error).message); + } +} diff --git a/src/pages/api/quiz.ts b/src/pages/api/quiz.ts new file mode 100644 index 0000000..f587942 --- /dev/null +++ b/src/pages/api/quiz.ts @@ -0,0 +1,62 @@ +import type { APIRoute } from 'astro'; +import { verifySession, SESSION_COOKIE } from '../../lib/session'; +import { bySlug } from '../../config/courses'; +import { getQuizzes, gradeQuiz, isPassed, addAttempt, type Quiz } from '../../lib/quiz'; +import { syncQuizToMoodle } from '../../lib/moodle-sync'; + +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +export const POST: APIRoute = async ({ request, cookies, redirect }) => { + const user = verifySession(cookies.get(SESSION_COOKIE)?.value); + if (!user) return json(401, { error: 'auth' }); + + const ct = request.headers.get('content-type') ?? ''; + const isForm = ct.includes('application/x-www-form-urlencoded'); + + let slug = '', quizRaw = ''; + const answers: Record = {}; + try { + if (isForm) { + const form = await request.formData(); + slug = String(form.get('slug') ?? ''); + quizRaw = String(form.get('quiz') ?? ''); + for (const key of new Set(form.keys())) + if (/^q\d+$/.test(key)) answers[key.slice(1)] = form.getAll(key).map(String); + } else { + const body = (await request.json()) as Record; + slug = String(body.slug ?? ''); + quizRaw = String(body.quiz ?? ''); + for (const [key, val] of Object.entries(body)) + if (/^q\d+$/.test(key)) answers[key.slice(1)] = (Array.isArray(val) ? val : [val]).map(String); + } + } catch { + return json(400, { error: 'body' }); + } + + const course = bySlug(slug); + const n = Number(quizRaw); + if (!course || !course.published || !Number.isInteger(n) || n < 1 || n > 4) + return json(400, { error: 'params' }); + + let quiz: Quiz | undefined; + try { + quiz = (await getQuizzes(slug)).find((q) => q.quizN === n); + } catch { + return json(503, { error: 'grist' }); + } + if (!quiz) return json(400, { error: 'params' }); + + const { score, total } = gradeQuiz(quiz, answers); + const passed = isPassed(quiz, score, total); + const back = `/cours/${slug}/quiz-${n}`; + try { + await addAttempt({ sub: user.sub, email: user.email }, slug, n, score, total, answers); + } catch (err) { + console.error('[quiz] grist addAttempt failed:', (err as Error).message); + return isForm ? redirect(`${back}?erreur=progression`, 303) : json(502, { error: 'grist' }); + } + if (passed) + syncQuizToMoodle({ sub: user.sub, email: user.email, name: user.name }, slug, n).catch(() => {}); + return isForm ? redirect(`${back}?fait=1`, 303) : json(200, { ok: true, score, total, passed }); +}; diff --git a/src/pages/cours/[slug]/index.astro b/src/pages/cours/[slug]/index.astro index 61e22ed..93fea83 100644 --- a/src/pages/cours/[slug]/index.astro +++ b/src/pages/cours/[slug]/index.astro @@ -6,7 +6,8 @@ import { bySlug } from '../../../config/courses'; import { getCourseBooks } from '../../../lib/moodle'; import { verifySession, SESSION_COOKIE } from '../../../lib/session'; import { getReads, type ReadMark } from '../../../lib/grist'; -import { syncToMoodle } from '../../../lib/moodle-sync'; +import { getQuizzes, getAttempts, isPassed, type Quiz, type QuizAttempt } from '../../../lib/quiz'; +import { syncToMoodle, syncQuizToMoodle } from '../../../lib/moodle-sync'; const user = verifySession(Astro.cookies.get(SESSION_COOKIE)?.value); const course = bySlug(Astro.params.slug ?? ''); @@ -20,14 +21,30 @@ try { } let reads: ReadMark[] = []; +let quizzes: Quiz[] = []; +let attempts: QuizAttempt[] = []; if (user) { try { reads = await getReads(user.sub, course.slug); } catch { reads = []; } + try { quizzes = await getQuizzes(course.slug); } catch { quizzes = []; } + if (quizzes.length) { + try { attempts = await getAttempts(user.sub, course.slug); } catch { attempts = []; } + } // Best-effort retry of pending Moodle syncs — fire-and-forget, never blocks render. if (reads.some((r) => !r.moodle_synced)) syncToMoodle({ sub: user.sub, email: user.email, name: user.name }, course.slug).catch(() => {}); } const readSet = new Set(reads.map((r) => `${r.module_n}-${r.chapitre_n}`)); +const quizPassed = new Set(); +for (const qz of quizzes) { + const passedAttempts = attempts.filter((a) => a.quiz_n === qz.quizN && isPassed(qz, a.score, a.total)); + if (!passedAttempts.length) continue; + quizPassed.add(qz.quizN); + // Retry unsynced passed quiz attempts — fire-and-forget too. + if (user && passedAttempts.some((a) => !a.moodle_synced)) + syncQuizToMoodle({ sub: user.sub, email: user.email, name: user.name }, course.slug, qz.quizN).catch(() => {}); +} + const hubData = { slug: course.slug, modules: modules.map((m, mi) => ({ @@ -51,9 +68,10 @@ const pct = total ? Math.round((nbLus / total) * 100) : 0;

{course.title}

{course.blurb}

{user &&

{nbLus} / {total} chapitres lus · {pct} %

} + {user && quizzes.length > 0 &&

Quiz : {quizPassed.size}/{quizzes.length} réussis

} - + diff --git a/src/pages/cours/[slug]/quiz-[n].astro b/src/pages/cours/[slug]/quiz-[n].astro new file mode 100644 index 0000000..a6d2042 --- /dev/null +++ b/src/pages/cours/[slug]/quiz-[n].astro @@ -0,0 +1,149 @@ +--- +import Base from '../../../layouts/Base.astro'; +import Nav from '../../../components/Nav.astro'; +import { bySlug } from '../../../config/courses'; +import { verifySession, SESSION_COOKIE } from '../../../lib/session'; +import { sanitizeHtml } from '../../../lib/sanitize'; +import { + getQuizzes, getAttempts, toPublic, gradeQuiz, isPassed, + type Quiz, type QuizAttempt, type QuestionResult, +} from '../../../lib/quiz'; + +const course = bySlug(Astro.params.slug ?? ''); +const nRaw = Astro.params.n ?? ''; +if (!course || !course.published || !/^[1-4]$/.test(nRaw)) return Astro.rewrite('/404'); +const quizN = Number(nRaw); + +const user = verifySession(Astro.cookies.get(SESSION_COOKIE)?.value); +if (!user) return Astro.redirect('/connexion?next=' + encodeURIComponent(Astro.url.pathname)); + +let quizzes: Quiz[]; +try { + quizzes = await getQuizzes(course.slug); +} catch { + return new Response('Quiz momentanément indisponible — réessayez dans quelques minutes.', { status: 503, headers: { 'Content-Type': 'text/plain; charset=utf-8' } }); +} +const quiz = quizzes.find((q) => q.quizN === quizN); +if (!quiz) return Astro.rewrite('/404'); + +const progressionErreur = Astro.url.searchParams.get('erreur') === 'progression'; +const fait = Astro.url.searchParams.get('fait') === '1'; + +// ?fait=1 → correction view of the newest attempt (falls back to the form +// when no attempt is found, e.g. direct navigation). +let attempt: QuizAttempt | null = null; +let detail: QuestionResult[] = []; +let passed = false; +if (fait) { + try { + attempt = (await getAttempts(user.sub, course.slug)) + .filter((a) => a.quiz_n === quizN) + .sort((a, b) => b.date - a.date || b.id - a.id)[0] ?? null; + } catch { + attempt = null; + } + if (attempt) { + let answers: Record = {}; + try { answers = JSON.parse(attempt.reponses || '{}') as Record; } catch { answers = {}; } + detail = gradeQuiz(quiz, answers).detail; + passed = isPassed(quiz, attempt.score, attempt.total); + } +} +const byQuestion = new Map(detail.map((d) => [d.questionId, d])); +const pub = toPublic(quiz); // never ship correct flags / feedback to the form +--- + +