feat: best-effort moodle completion sync
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { modulesToSync } from './moodle-sync';
|
||||
import type { ReadMark } from './grist';
|
||||
|
||||
let nextId = 100;
|
||||
const mark = (module_n: number, chapitre_n: number, moodle_synced = false): ReadMark => ({
|
||||
id: nextId++, course_slug: 'kicad-makers', module_n, chapitre_n, moodle_synced,
|
||||
});
|
||||
const mod = (id: number, chapterCount: number) => ({
|
||||
id,
|
||||
chapters: Array.from({ length: chapterCount }, (_, i) => ({ title: `c${i + 1}`, fileUrl: '' })),
|
||||
});
|
||||
|
||||
describe('modulesToSync', () => {
|
||||
it('includes a complete module with unsynced rows', () => {
|
||||
const reads = [mark(1, 1), mark(1, 2)];
|
||||
const out = modulesToSync(reads, [mod(27, 2)]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].mi).toBe(0);
|
||||
expect(out[0].cmid).toBe(27);
|
||||
expect(out[0].rowIds.sort()).toEqual(reads.map((r) => r.id).sort());
|
||||
});
|
||||
|
||||
it('excludes a complete module whose rows are all synced', () => {
|
||||
expect(modulesToSync([mark(1, 1, true), mark(1, 2, true)], [mod(27, 2)])).toEqual([]);
|
||||
});
|
||||
|
||||
it('excludes an incomplete module even with unsynced rows', () => {
|
||||
expect(modulesToSync([mark(1, 1)], [mod(27, 2)])).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles multiple modules independently', () => {
|
||||
const reads = [
|
||||
mark(1, 1, true), mark(1, 2, true), // M1 complete, all synced -> out
|
||||
mark(2, 1), mark(2, 2), // M2 complete, unsynced -> in
|
||||
mark(3, 1), // M3 incomplete -> out
|
||||
];
|
||||
const out = modulesToSync(reads, [mod(10, 2), mod(20, 2), mod(30, 2)]);
|
||||
expect(out.map((t) => t.cmid)).toEqual([20]);
|
||||
expect(out[0].mi).toBe(1);
|
||||
});
|
||||
});
|
||||
+103
-4
@@ -1,5 +1,104 @@
|
||||
/** Best-effort Moodle completion sync — STUB, implemented in SP2 Task 4. */
|
||||
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';
|
||||
|
||||
/** Token of the dedicated `formations_sync` WS service (wssync user).
|
||||
* Allowed functions ONLY: core_user_get_users_by_field, core_user_create_users,
|
||||
* core_user_update_users, enrol_manual_enrol_users, core_course_get_contents,
|
||||
* core_completion_override_activity_completion_status. */
|
||||
const TOKEN = () => process.env.MOODLE_SYNC_TOKEN || '';
|
||||
|
||||
const sync = (fn: string, params: Record<string, string>) => wsCall(TOKEN(), fn, params);
|
||||
|
||||
const userIdCache = new Map<string, number>(); // email -> moodle user id
|
||||
const enrolCache = new Set<string>(); // `${userId}:${courseId}`
|
||||
|
||||
export async function ensureMoodleUser(email: string, name: string): Promise<number> {
|
||||
const cached = userIdCache.get(email);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const found = (await sync('core_user_get_users_by_field', {
|
||||
field: 'email', 'values[0]': email,
|
||||
})) as { id: number }[];
|
||||
if (found.length > 0) {
|
||||
userIdCache.set(email, found[0].id);
|
||||
return found[0].id;
|
||||
}
|
||||
|
||||
const local = (email.split('@')[0] ?? '').toLowerCase().replace(/[^a-z0-9._-]/g, '') || 'apprenant';
|
||||
const username = `${local}-${randomBytes(3).toString('hex')}`;
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
const firstname = parts[0] || 'Apprenant';
|
||||
const lastname = parts.slice(1).join(' ') || 'X';
|
||||
const password = `${randomBytes(12).toString('base64url')}aA1!`; // random + policy classes
|
||||
const created = (await sync('core_user_create_users', {
|
||||
'users[0][username]': username,
|
||||
'users[0][password]': password,
|
||||
'users[0][firstname]': firstname,
|
||||
'users[0][lastname]': lastname,
|
||||
'users[0][email]': email,
|
||||
'users[0][auth]': 'manual',
|
||||
})) as { id: number }[];
|
||||
userIdCache.set(email, created[0].id);
|
||||
return created[0].id;
|
||||
}
|
||||
|
||||
export async function ensureEnrolled(userId: number, courseId: number): Promise<void> {
|
||||
const key = `${userId}:${courseId}`;
|
||||
if (enrolCache.has(key)) return;
|
||||
await sync('enrol_manual_enrol_users', {
|
||||
'enrolments[0][roleid]': '5', // student
|
||||
'enrolments[0][userid]': String(userId),
|
||||
'enrolments[0][courseid]': String(courseId),
|
||||
});
|
||||
enrolCache.add(key);
|
||||
}
|
||||
|
||||
export interface SyncTarget { mi: number; cmid: number; rowIds: number[]; }
|
||||
|
||||
/** Pure decision logic: modules fully read whose Grist rows aren't all synced.
|
||||
* `BookModule.id` IS the cmid for completion override. */
|
||||
export function modulesToSync(
|
||||
reads: ReadMark[],
|
||||
modules: Pick<BookModule, 'id' | 'chapters'>[],
|
||||
): SyncTarget[] {
|
||||
const out: SyncTarget[] = [];
|
||||
modules.forEach((mod, mi) => {
|
||||
const rows = reads.filter((r) => r.module_n === mi + 1);
|
||||
if (!rows.some((r) => !r.moodle_synced)) return; // nothing new to sync
|
||||
if (!isBookComplete(reads, mi + 1, mod.chapters.length)) return;
|
||||
out.push({ mi, cmid: mod.id, rowIds: rows.map((r) => r.id) });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Best-effort: mirror fully-read books into Moodle completion. Never throws. */
|
||||
export async function syncToMoodle(
|
||||
_u: { sub: string; email: string; name: string },
|
||||
_slug: string,
|
||||
): Promise<void> {}
|
||||
user: { sub: string; email: string; name: string },
|
||||
courseSlug: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const course = bySlug(courseSlug);
|
||||
if (!course) return;
|
||||
const [reads, modules] = await Promise.all([
|
||||
getReads(user.sub, courseSlug),
|
||||
getCourseBooks(course.id),
|
||||
]);
|
||||
const targets = modulesToSync(reads, modules);
|
||||
if (!targets.length) return;
|
||||
|
||||
const userId = await ensureMoodleUser(user.email, user.name);
|
||||
await ensureEnrolled(userId, course.id);
|
||||
for (const t of targets) {
|
||||
await sync('core_completion_override_activity_completion_status', {
|
||||
cmid: String(t.cmid),
|
||||
userid: String(userId),
|
||||
newstate: '1',
|
||||
});
|
||||
await markSynced(t.rowIds);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[moodle-sync]', (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
+5
-2
@@ -7,9 +7,10 @@ const BASE = process.env.MOODLE_BASE_URL || 'https://moodle.saillant.cc';
|
||||
const TOKEN = process.env.MOODLE_WS_TOKEN || '';
|
||||
const TTL = Number(process.env.MOODLE_CACHE_TTL_MS || 5 * 60_000);
|
||||
|
||||
async function ws(fn: string, params: Record<string, string>): Promise<unknown> {
|
||||
/** Low-level Moodle REST call with an explicit token (shared with moodle-sync). */
|
||||
export async function wsCall(token: string, fn: string, params: Record<string, string>): Promise<unknown> {
|
||||
const body = new URLSearchParams({
|
||||
wstoken: TOKEN, wsfunction: fn, moodlewsrestformat: 'json', ...params,
|
||||
wstoken: token, wsfunction: fn, moodlewsrestformat: 'json', ...params,
|
||||
});
|
||||
const res = await fetch(`${BASE}/webservice/rest/server.php`, {
|
||||
method: 'POST',
|
||||
@@ -23,6 +24,8 @@ async function ws(fn: string, params: Record<string, string>): Promise<unknown>
|
||||
return data;
|
||||
}
|
||||
|
||||
const ws = (fn: string, params: Record<string, string>) => wsCall(TOKEN, fn, params);
|
||||
|
||||
interface StructureItem { title: string; href: string; level: number; subitems?: StructureItem[]; }
|
||||
|
||||
interface ModContent {
|
||||
|
||||
@@ -5,6 +5,8 @@ import Sommaire from '../../../components/Sommaire.astro';
|
||||
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';
|
||||
|
||||
const user = verifySession(Astro.cookies.get(SESSION_COOKIE)?.value);
|
||||
const course = bySlug(Astro.params.slug ?? '');
|
||||
@@ -16,6 +18,15 @@ try {
|
||||
} catch {
|
||||
return new Response('Formation momentanément indisponible — réessayez dans quelques minutes.', { status: 503, headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
|
||||
}
|
||||
|
||||
let reads: ReadMark[] = [];
|
||||
if (user) {
|
||||
try { reads = await getReads(user.sub, course.slug); } catch { reads = []; }
|
||||
// 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 hubData = {
|
||||
slug: course.slug,
|
||||
modules: modules.map((m, mi) => ({
|
||||
|
||||
Reference in New Issue
Block a user