From e7bb76b76d5cb5dd239ab3d76e545ec151b45f2f Mon Sep 17 00:00:00 2001 From: Ramon Date: Sun, 19 Jul 2026 15:31:26 +0200 Subject: [PATCH] feat: klassenmanagement - niveaugroep + niveau per leerling per vakgebied (v0.4.34-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Grootste openstaande roadmap-punt. Nieuwe tabel pupil_levels (db/019_pupil_levels.sql, zelfde class_id/pupil_id-vorm als assignments) legt per vakgebied (taal/rekenen/world) een niveaugroep vast (groep 1-8, gebaseerd op de leerjaren van de basisschool waaraan de leerdoelen gekoppeld zijn) + niveau (1-3, fijnmazige verfijning BINNEN die groep). Klas-standaard met individuele leerling-overrides, net zo laagdrempelig instelbaar als de standaard - het speciaal- onderwijs-geval waar dat vanaf het begin de norm is. - Server: GET/PUT/DELETE /levels/class/:id en /levels/pupil/:id (src/api.js) hergebruiken exact het assignments-patroon (upsert via partial-unique-index, pupil-eerst-dan-klas-resolutie), plus GET /my/level voor de leerling-kant. Zelfde permissie (assignments.manage) en sameSchool/teacherOnly/pupilAccessible- guards als de bestaande toewijzingsroutes. - Nieuwe admin-tab "Klassenmanagement": klas + vakgebied kiezen, een klas-standaard instellen, en per leerling de effectieve groep/niveau zien (overerft van de klas tenzij een eigen afwijking is ingesteld) met bewerk- en terug-naar-standaard-knoppen. - ECHTE inhoudskoppeling, niet alleen een standaardwaarde: rekenen is het eerste vakgebied waarvan de SLO/TULE-leerlijn direct in getalgrenzen te vertalen is (groep 3 t/m 20, groep 4 t/m 100, groep 5 t/m 1000, groep 6 t/m 10.000). Nieuwe SLO_MATH_RANGES/mathRangeFor() in data.js; math.js's gen() gebruikt dit voor de daadwerkelijke getallen zodra een leerling een groep heeft (S.yearGroup), met het bestaande niveau 1-3 als verfijning daarbinnen. Zonder groep blijft het oude vaste 10/20/100-gedrag exact bestaan. - pupil.js geeft groep/niveau door aan taal/rekenen-widgets bij het mounten: niveau is een standaardwaarde (een al opgeslagen bord-niveau blijft leidend), groep wordt voor rekenen-widgets altijd meegegeven (er is geen "oud" groep-concept om te respecteren). Terzijde ook de stille .catch() op de nieuwe async renderPupilWidgets() opgelost. - Taal/wereldoriëntatie krijgen deze ronde nog geen eigen inhoudskoppeling (vraagt een eigen contentbron per groep, nog niet uitgezocht) - eerlijk benoemd als vervolgstap, geen loze belofte. - Tests: test/pupil-levels.test.js (10 tests) dekt de server-routes (mocked-pool-patroon) én - via dezelfde vm-techniek als elders in test/widgets.test.js voor MONEY_LEVELS - een ECHTE (niet regex-)test van mathRangeFor die bevestigt dat elke groep de juiste getalgrens oplevert en niveau nooit boven de groep-grens uitkomt. Twee bugs gevonden en gefixt tijdens het schrijven van deze tests: null-yearGroup werd per ongeluk als groep 1 behandeld i.p.v. "geen groep", en de testrol had geen klas-eigenaarschap waardoor 403 in plaats van 200 terugkwam. Volledige testsuite: 128/128. - Live geverifieerd in de sandbox: klas-standaard + leerling-override instellen in de nieuwe admin-tab, en drie scenario's in de leerling-omgeving die bevestigen dat de daadwerkelijk gegenereerde sommen binnen de juiste groep-grens blijven (geen klassenmanagement → ongewijzigd oud gedrag; groep zonder eigen bord-niveau → volledige groep-grens; groep mét een al opgeslagen bord-niveau → dat niveau blijft leidend, geschaald naar de juiste groep-grens). - public/js/data.js bevatte al niet-gerelateerde, nog niet gecommitte wijzigingen van vóór deze sessie (extra taalthema's/woorden) - alleen de SLO_MATH_RANGES/mathRangeFor-hunk is hier meegenomen (git add -p), de rest blijft bewust ongemoeid staan, net als test/language-library.test.js. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014EPxzBVRXZZnBPvSAaPAbJ --- VERSION | 2 +- db/019_pupil_levels.sql | 24 ++++++ public/js/admin.js | 160 +++++++++++++++++++++++++++++++++++++- public/js/core.js | 14 +++- public/js/data.js | 31 ++++++++ public/js/pupil.js | 24 +++++- public/js/widgets/math.js | 20 +++-- src/api.js | 117 ++++++++++++++++++++++++++++ test/pupil-levels.test.js | 146 ++++++++++++++++++++++++++++++++++ 9 files changed, 527 insertions(+), 11 deletions(-) create mode 100644 db/019_pupil_levels.sql create mode 100644 test/pupil-levels.test.js diff --git a/VERSION b/VERSION index 429af07..98ff426 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.33-beta \ No newline at end of file +0.4.34-beta \ No newline at end of file diff --git a/db/019_pupil_levels.sql b/db/019_pupil_levels.sql new file mode 100644 index 0000000..9e2fd4f --- /dev/null +++ b/db/019_pupil_levels.sql @@ -0,0 +1,24 @@ +-- Klassenmanagement: per vakgebied (taal/rekenen/world) een niveaugroep +-- (groep 1-8, gebaseerd op de leerjaren van de basisschool, waaraan de +-- leerdoelen gekoppeld zijn) en een niveau (1-3, fijnmazige verfijning +-- BINNEN die groep - niet aan 3 geklemd in het datamodel zelf, widgets +-- klemmen voorlopig zelf nog wel). Zelfde vorm als assignments: precies +-- één van class_id/pupil_id gezet (klas-standaard, of een individuele +-- afwijking - in het speciaal onderwijs vaak vanaf het begin de norm), +-- nu met vakgebied als extra dimensie, dus één rij per (klas|leerling, +-- vakgebied) i.p.v. één rij per klas|leerling. +CREATE TABLE IF NOT EXISTS pupil_levels ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + class_id BIGINT REFERENCES classes(id) ON DELETE CASCADE, + pupil_id BIGINT REFERENCES users(id) ON DELETE CASCADE, + subject TEXT NOT NULL, + year_group INT NOT NULL, + level INT NOT NULL DEFAULT 1, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK (num_nonnulls(class_id, pupil_id) = 1), + CHECK (subject IN ('taal','rekenen','world')), + CHECK (year_group BETWEEN 1 AND 8), + CHECK (level >= 1) +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_pupil_levels_class ON pupil_levels(class_id, subject) WHERE class_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_pupil_levels_pupil ON pupil_levels(pupil_id, subject) WHERE pupil_id IS NOT NULL; diff --git a/public/js/admin.js b/public/js/admin.js index f9ef761..25ff9ea 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -21,6 +21,10 @@ let resetResults = null; /* actief tabblad, blijft behouden over her-renders heen (na elke actie) */ let adminTab = "school"; + /* Klassenmanagement: welke klas/vakgebied nu getoond wordt, blijft behouden + over her-renders heen zoals adminTab hierboven. */ + let kmClass = null, kmSubject = "rekenen"; + const KM_SUBJECTS = ["taal", "rekenen", "world"]; /* navigatie binnen "School": welk niveau van de boom je ziet. {type:"overview"} - tegels (scholen op "alle scholen", anders @@ -229,6 +233,10 @@ { tab:"voortgang", icon:"📈", group:"school", order:13, labelKey:"amProgress", subKey:"stVoortgangSub", show:()=>can("progress.view"), keywords:()=>["voortgang","progress","score","resultaten","results","tijdlijn","timeline"] }, + { tab:"klassenmanagement", icon:"🎓", group:"school", order:14, labelKey:"amLevels", subKey:"amLevelsSub", + show:()=>can("assignments.manage"), + keywords:()=>[T("amLevelYearGroup"), T("amLevelLevel"), "niveau","level","groep","niveaugroep", + "leerjaar","year group","differentiatie"] }, { tab:"opslag", icon:"🖼", group:"system", order:32, labelKey:"stOpslag", subKey:"stOpslagSub", show:()=>currentUser.role==="super", keywords:()=>["opslag","storage","afbeeldingen","images","quota"] }, @@ -1080,6 +1088,156 @@ return box; } + function yearGroupSel(current){ + const sel = h("select","am-sel"); + for(let g=1; g<=8; g++) sel.appendChild(new Option(T("amYearGroupN").replace("{n}", g), g)); + sel.value = current ?? 4; + return sel; + } + function levelSel(current){ + const sel = h("select","am-sel"); + [1,2,3].forEach(n=>sel.appendChild(new Option(T("lvl"+n), n))); + sel.value = current ?? 1; + return sel; + } + function describeLevel(effective, isOverride){ + if(!effective) return T("amLevelNone"); + const base = `${T("amYearGroupN").replace("{n}", effective.yearGroup)} · ${T("lvl"+effective.level)}`; + return isOverride ? base : `${base} (${T("amLevelInherited")})`; + } + + /* klas-standaard: geldt voor iedereen in de klas tenzij een leerling een + eigen afwijking heeft (zie renderPupilLevelRow hieronder). */ + function renderClassDefaultCard(classId, subject, current, onSaved){ + const box = h("div","am-card"); + box.appendChild(h("div","am-card-title", T("amLevelClassDefault"))); + const row = h("div","am-inline"); + const ygSel = yearGroupSel(current && current.yearGroup); + const lvlSel = levelSel(current && current.level); + row.append(field("amLevelYearGroup", ygSel), field("amLevelLevel", lvlSel)); + const saveBtn = h("button","am-btn", T("save")); + saveBtn.type = "button"; + saveBtn.addEventListener("click", async ()=>{ + try{ + await api(`/levels/class/${classId}`, {method:"PUT", body:{subject, yearGroup:+ygSel.value, level:+lvlSel.value}}); + msg(T("amLevelSaved")); + await onSaved(); + }catch(e){ msg(e.message); } + }); + row.appendChild(saveBtn); + box.appendChild(row); + return box; + } + + /* één leerling: toont de EFFECTIEVE groep/niveau (eigen afwijking, anders de + klas-standaard, gedempt met "erft van klas"). Instellen van een eigen + afwijking is even laagdrempelig als de klas-standaard - het + speciaal-onderwijs-geval waar dat vanaf het begin de norm is, mag geen + verstopt uitzonderingspad zijn. */ + function renderPupilLevelRow(pupil, subject, override, classDefault, onSaved){ + const effective = override || classDefault; + const wrap = h("div"); + const row = h("div","am-row am-person"); + const info = h("div","am-person-info"); + info.appendChild(h("span","am-name", pupil.displayName)); + info.appendChild(h("span","am-role", describeLevel(effective, !!override))); + row.appendChild(info); + + const editBox = h("div","am-inline"); + editBox.style.display = "none"; + const ygSel = yearGroupSel(effective && effective.yearGroup); + const lvlSel = levelSel(effective && effective.level); + editBox.append(field("amLevelYearGroup", ygSel), field("amLevelLevel", lvlSel)); + const saveBtn = h("button","am-btn", T("save")); + saveBtn.type = "button"; + saveBtn.addEventListener("click", async ()=>{ + try{ + await api(`/levels/pupil/${pupil.id}`, {method:"PUT", body:{subject, yearGroup:+ygSel.value, level:+lvlSel.value}}); + msg(T("amLevelSaved")); + await onSaved(); + }catch(e){ msg(e.message); } + }); + editBox.appendChild(saveBtn); + + const tools = h("div","am-tools"); + const editBtn = h("button","am-btn", T("amEdit")); + editBtn.type = "button"; + editBtn.addEventListener("click", ()=>{ + editBox.style.display = editBox.style.display==="none" ? "flex" : "none"; + }); + tools.appendChild(editBtn); + if(override){ + const resetBtn = h("button","am-btn", T("amLevelReset")); + resetBtn.type = "button"; + resetBtn.addEventListener("click", async ()=>{ + try{ + await api(`/levels/pupil/${pupil.id}?subject=${subject}`, {method:"DELETE"}); + msg(T("amLevelSaved")); + await onSaved(); + }catch(e){ msg(e.message); } + }); + tools.appendChild(resetBtn); + } + row.appendChild(tools); + wrap.append(row, editBox); + return wrap; + } + + /* klassenmanagement: per vakgebied een niveaugroep (leerjaar 1-8, waaraan + de leerdoelen gekoppeld zijn) + niveau (1-3) instellen, met een + klas-standaard en individuele afwijkingen - zie roadmap-memo. */ + function renderKlassenmanagementPanel(panel){ + const schoolChosen = currentUser.role!=="super" || !!selSchool; + if(!schoolChosen){ + panel.appendChild(h("div","guestnote", T("amPickSchoolHint"))); + return; + } + panel.appendChild(h("div","am-group", T("amLevels"))); + const clsSel = classSel(kmClass ?? ""); + panel.appendChild(clsSel); + + const subjectRow = h("div","am-inline"); + panel.appendChild(subjectRow); + const bodyHolder = h("div"); + panel.appendChild(bodyHolder); + + async function loadBody(){ + bodyHolder.innerHTML = ""; + if(!kmClass) return; + bodyHolder.appendChild(h("div","guestnote", "…")); + let data; + try{ data = await api(`/levels/class/${kmClass}?subject=${kmSubject}`); } + catch(e){ bodyHolder.innerHTML = ""; bodyHolder.appendChild(h("div","am-msg", e.message)); return; } + bodyHolder.innerHTML = ""; + bodyHolder.appendChild(renderClassDefaultCard(kmClass, kmSubject, data.classDefault, loadBody)); + bodyHolder.appendChild(h("div","am-group", T("amLevelPupils"))); + const list = h("div","am-list"); + const overrideMap = new Map(data.pupilOverrides.map(o=>[o.pupilId, o])); + const pupilsInClass = USERS.filter(u=>u.role==="pupil" && String(u.classId)===String(kmClass)); + if(!pupilsInClass.length) list.appendChild(h("div","guestnote", T("amNoClass"))); + pupilsInClass.forEach(u=>{ + list.appendChild(renderPupilLevelRow(u, kmSubject, overrideMap.get(u.id), data.classDefault, loadBody)); + }); + bodyHolder.appendChild(list); + } + + KM_SUBJECTS.forEach(s=>{ + const b = h("button","am-btn", T("amSubject_"+s)); + b.type = "button"; + b.classList.toggle("active", s===kmSubject); + b.addEventListener("click", ()=>{ + kmSubject = s; + subjectRow.querySelectorAll("button").forEach(x=>x.classList.remove("active")); + b.classList.add("active"); + loadBody(); + }); + subjectRow.appendChild(b); + }); + + clsSel.addEventListener("change", ()=>{ kmClass = clsSel.value || null; loadBody(); }); + if(kmClass) loadBody(); + } + /* niveau 0: "alle scholen" - alleen systeemmanager. Tegel per school + Systeemmanagers/Ouders/Zonder school + "+ School". */ function renderRootOverview(panel){ @@ -1392,7 +1550,7 @@ const tabDefs = adminTabDefs(); if(!tabDefs.some(([t])=>t===adminTab)) adminTab = tabDefs[0][0]; - const panels = { school: renderSchoolNavigator, toewijzingen: renderToewijzingenPanel, voortgang: renderVoortgangPanel, opslag: renderImageStoragePanel }; + const panels = { school: renderSchoolNavigator, toewijzingen: renderToewijzingenPanel, voortgang: renderVoortgangPanel, klassenmanagement: renderKlassenmanagementPanel, opslag: renderImageStoragePanel }; tabDefs.forEach(([t])=>{ const panel = h("div","am-tabpanel"); panel.dataset.panel = t; diff --git a/public/js/core.js b/public/js/core.js index 16f881f..92405c0 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.4.33-beta"; +const VERSION = "0.4.34-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -354,6 +354,12 @@ const I18N = { amExportColCorrect:"Goed", amExportColStars:"Sterren", amExportColDate:"Datum", amExportColUsername:"Gebruikersnaam", amExportDone:"Export gedownload ✓", + amLevels:"Klassenmanagement", amLevelsSub:"Stel per vakgebied een niveaugroep en niveau in.", + amLevelYearGroup:"Groep", amLevelLevel:"Niveau", amYearGroupN:"Groep {n}", + amLevelClassDefault:"Klas-standaard", amLevelPupils:"Leerlingen", + amLevelInherited:"erft van klas", amLevelNone:"nog niet ingesteld", + amLevelReset:"terug naar klas-standaard", amLevelSaved:"Opgeslagen ✓", + amSubject_taal:"Taal", amSubject_rekenen:"Rekenen", amSubject_world:"Wereldoriëntatie", amConfirmDel:"Weet je zeker dat je deze gebruiker wilt verwijderen?", amCopy:"kopieer", amCopyAll:"kopieer alles", amResetClass:"reset wachtwoorden klas", amResetSchool:"reset alle leerlingwachtwoorden", @@ -693,6 +699,12 @@ const I18N = { amExportColCorrect:"Correct", amExportColStars:"Stars", amExportColDate:"Date", amExportColUsername:"Username", amExportDone:"Export downloaded ✓", + amLevels:"Class management", amLevelsSub:"Set a year group and level per subject.", + amLevelYearGroup:"Year group", amLevelLevel:"Level", amYearGroupN:"Year {n}", + amLevelClassDefault:"Class default", amLevelPupils:"Pupils", + amLevelInherited:"inherited from class", amLevelNone:"not set yet", + amLevelReset:"reset to class default", amLevelSaved:"Saved ✓", + amSubject_taal:"Language", amSubject_rekenen:"Maths", amSubject_world:"World orientation", amConfirmDel:"Are you sure you want to delete this user?", amCopy:"copy", amCopyAll:"copy all", amResetClass:"reset class passwords", amResetSchool:"reset all pupil passwords", diff --git a/public/js/data.js b/public/js/data.js index 8f60d37..01bca88 100644 --- a/public/js/data.js +++ b/public/js/data.js @@ -16,6 +16,37 @@ const DIGIT_DOTCODE = { "0":[], "1":[2], "2":[2,5], "3":[1,2,3], "4":[1,3,4,6], "5":[1,2,3,4,5], "6":[1,2,3,4,5,6], "7":[1,2,4,5], "8":[1,2,3,4,6], "9":[1,4,6] }; +/* Klassenmanagement (v0.4.34-beta e.v.): rekenen is het eerste vakgebied waarvan + de leerlijn direct in getalgrenzen te vertalen is (SLO/TULE: groep 3 + automatiseert optellen/aftrekken t/m 20, groep 4 t/m 100, groep 5 t/m 1000, + groep 6 t/m 10.000 met de tafels verder uitgebouwd, groep 7-8 bouwen daarop + voort). math.js gebruikt dit als de daadwerkelijke inhoud zodra een leerling + een groep heeft (zie mathRangeFor hieronder) - zonder groep blijft het oude, + vaste 10/20/100-gedrag exact bestaan. */ +const SLO_MATH_RANGES = { + 1:{addSub:10, timesTable:5}, + 2:{addSub:10, timesTable:5}, + 3:{addSub:20, timesTable:5}, + 4:{addSub:100, timesTable:10}, + 5:{addSub:1000, timesTable:10}, + 6:{addSub:10000,timesTable:12}, + 7:{addSub:10000,timesTable:12}, + 8:{addSub:10000,timesTable:12}, +}; +/* niveau (1-3) is de verfijning BINNEN een groep - een breukdeel van de + groep-grens, nooit onder de kleinste bestaande standaardwaarde (10) of + onder tafel 3. Levert bij ontbrekend yearGroup expres geen waarde (math.js + valt dan terug op zijn eigen oude vaste 10/20/100-logica). */ +function mathRangeFor(yearGroup, level){ + if(yearGroup == null) return null; + const g = SLO_MATH_RANGES[Math.max(1, Math.min(8, +yearGroup || 1))]; + if(!g) return null; + const frac = level===1 ? .25 : level===2 ? .6 : 1; + return { + addSub: Math.max(10, Math.round(g.addSub*frac/5)*5), + timesTable: Math.max(3, Math.round(g.timesTable*frac)), + }; +} const VOWELS = "aeiou"; function dotGrid(letter){ const code = (Object.prototype.hasOwnProperty.call(DIGIT_DOTCODE, letter) ? DIGIT_DOTCODE[letter] : DOTCODE[letter]) || []; diff --git a/public/js/pupil.js b/public/js/pupil.js index fd9536a..1c34223 100644 --- a/public/js/pupil.js +++ b/public/js/pupil.js @@ -18,7 +18,7 @@ function pupilAllowedDefs(){ return REGISTRY.filter(d => PUPIL_WIDGET_CATS.includes(d.cat)); } -function renderPupilWidgets(widgets, mode){ +async function renderPupilWidgets(widgets, mode){ pupilView.innerHTML = ""; const defs = pupilAllowedDefs(); const watch = mode === "kijken"; @@ -32,6 +32,16 @@ function renderPupilWidgets(widgets, mode){ pupilView.appendChild(hint); return; } + /* klassenmanagement: groep/niveau per vakgebied ophalen vóór het mounten, + zodat een widget zonder eigen opgeslagen niveau met de door de + groepsleiding ingestelde waarde start i.p.v. altijd stilzwijgend niveau 1 + - één keer per vakgebied dat hier daadwerkelijk voorkomt, niet per widget. */ + const subjects = [...new Set(usable.map(x => x.def.cat))]; + const levels = {}; + await Promise.all(subjects.map(async subject=>{ + try{ levels[subject] = await api("/my/level?subject="+subject); } + catch(e){ levels[subject] = null; } + })); usable.forEach(({ w, def }, idx)=>{ const card = document.createElement("div"); card.className = "widget pupil-card" + (watch ? " watch" : ""); @@ -71,7 +81,15 @@ function renderPupilWidgets(widgets, mode){ api("/my/progress", { method:"POST", body: { widgetId, widgetType: def.id, ...p } }) .catch(e=>console.warn("voortgang loggen mislukt:", e.message)); }}; - def.mount(body, w.state || null, opts); + /* alleen aanvullen wat het bord zelf nog niet expliciet heeft ingesteld - + een leerkracht die zelf al een niveau op dit bord koos, blijft leidend. */ + const lvl = levels[def.cat]; + const state = w.state ? { ...w.state } : {}; + if(lvl){ + if(state.level == null) state.level = lvl.level; + if(def.cat === "rekenen" && lvl.yearGroup != null) state.yearGroup = lvl.yearGroup; + } + def.mount(body, Object.keys(state).length ? state : null, opts); }); } @@ -84,7 +102,7 @@ async function pupilPoll(){ const sig = mode + "|" + JSON.stringify(res.widgets || []); if(sig !== pupilLastSig){ pupilLastSig = sig; - renderPupilWidgets(res.widgets || [], mode); + await renderPupilWidgets(res.widgets || [], mode); } if(mode !== pupilMode){ pupilMode = mode; diff --git a/public/js/widgets/math.js b/public/js/widgets/math.js index d461bbe..022f92d 100644 --- a/public/js/widgets/math.js +++ b/public/js/widgets/math.js @@ -5,9 +5,13 @@ function mountMath(root, initState, mode, opts){ const ROUND = 10; const onProgress = opts && opts.onProgress; - const S = { level:1, snd:true, amode:"blocks", q:null, tries:0, done:0, good:0, input:"", busy:false }; + const S = { level:1, yearGroup:null, snd:true, amode:"blocks", q:null, tries:0, done:0, good:0, input:"", busy:false }; if(initState){ S.level = initState.level||1; + /* yearGroup komt van klassenmanagement (pupil.js), niet van het bord zelf + - bewust niet in getState() opgenomen, dit is een per-leerling waarde, + geen door de leerkracht op het bord ingestelde inhoud. */ + S.yearGroup = initState.yearGroup || null; S.snd = initState.snd!==false; S.amode = initState.amode||"blocks"; } @@ -161,21 +165,27 @@ function mountMath(root, initState, mode, opts){ function gen(){ const L = S.level; + /* klassenmanagement geeft een groep (leerjaar) mee via yearGroup - dan + sturen de SLO-gebaseerde getalgrenzen (data.js: mathRangeFor) de + inhoud, niet meer de vaste 10/20/100-klem. Zonder groep (geen + klassenmanagement ingesteld voor deze leerling/dit bord) blijft het + oude gedrag exact hetzelfde. */ + const slo = mathRangeFor(S.yearGroup, L); let a, b, ans, txt; if(mode==="add"){ - const max = L===1 ? 10 : L===2 ? 20 : 100; + const max = slo ? slo.addSub : (L===1 ? 10 : L===2 ? 20 : 100); a = rnd(max+1); b = rnd(max-a+1); ans = a+b; txt = `${a} + ${b} =`; }else if(mode==="sub"){ - const max = L===1 ? 10 : L===2 ? 20 : 100; + const max = slo ? slo.addSub : (L===1 ? 10 : L===2 ? 20 : 100); a = rnd(max+1); b = rnd(a+1); ans = a-b; txt = `${a} − ${b} =`; }else if(mode==="mul"){ - const tmax = L===1 ? 5 : L===2 ? 10 : 12; + const tmax = slo ? slo.timesTable : (L===1 ? 5 : L===2 ? 10 : 12); a = 1+rnd(tmax); b = 1+rnd(L===3 ? 12 : 10); ans = a*b; txt = `${a} × ${b} =`; }else if(mode==="div"){ - const tmax = L===1 ? 5 : L===2 ? 10 : 12; + const tmax = slo ? slo.timesTable : (L===1 ? 5 : L===2 ? 10 : 12); const t = 1+rnd(tmax), q = 1+rnd(L===3 ? 12 : 10); a = t*q; ans = q; txt = `${a} ${S.lang==="nl" || LANG==="nl" ? ":" : "÷"} ${t} =`; diff --git a/src/api.js b/src/api.js index 45131d1..278a693 100644 --- a/src/api.js +++ b/src/api.js @@ -917,6 +917,123 @@ export default async function api(app) { return { widgets, mode: a.mode || 'werken', updatedAt: a.updated_at }; }); + // ---- klassenmanagement: niveaugroep + niveau per leerling per vakgebied ------- + // Zelfde klas-standaard-met-leerling-override-vorm als assignments hierboven, + // nu met vakgebied als extra dimensie (subject: taal/rekenen/world - dezelfde + // waarden als REGISTRY's cat in board.js). "groep" (year_group, 1-8) is de + // niveaugroep gebaseerd op de leerjaren van de basisschool waaraan de + // leerdoelen gekoppeld zijn; "level" (1-3, niet geklemd in het datamodel) is + // de fijnmazige verfijning daarbinnen die widgets nu al kennen. + const LEVEL_SUBJECTS = ['taal', 'rekenen', 'world']; + const describePupilLevel = (row) => ({ yearGroup: Number(row.year_group), level: Number(row.level) }); + const parseLevelBody = (req, reply) => { + const { subject, yearGroup, level } = req.body ?? {}; + if (!LEVEL_SUBJECTS.includes(subject)) return fail(reply, 400, 'ongeldig vakgebied'); + const yg = Number(yearGroup); + if (!Number.isInteger(yg) || yg < 1 || yg > 8) return fail(reply, 400, 'ongeldige groep'); + const lvl = Number(level); + if (!Number.isInteger(lvl) || lvl < 1) return fail(reply, 400, 'ongeldig niveau'); + return { subject, yearGroup: yg, level: lvl }; + }; + + app.get('/levels/class/:id', async (req, reply) => { + need(req, reply, PERMISSIONS['assignments.manage']); + const subject = req.query.subject; + if (!LEVEL_SUBJECTS.includes(subject)) return fail(reply, 400, 'ongeldig vakgebied'); + const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0]; + if (!c) return fail(reply, 404, 'klas onbekend'); + if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten'); + if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten'); + const cls = (await pool.query( + 'SELECT * FROM pupil_levels WHERE class_id = $1 AND subject = $2', [c.id, subject])).rows[0]; + const overrides = (await pool.query( + `SELECT pl.* FROM pupil_levels pl JOIN users u ON u.id = pl.pupil_id + WHERE u.class_id = $1 AND pl.subject = $2`, [c.id, subject])).rows; + return { + classDefault: cls ? describePupilLevel(cls) : null, + pupilOverrides: overrides.map((r) => ({ pupilId: Number(r.pupil_id), ...describePupilLevel(r) })), + }; + }); + app.put('/levels/class/:id', async (req, reply) => { + need(req, reply, PERMISSIONS['assignments.manage']); + const body = parseLevelBody(req, reply); + if (body.error) return body; + const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0]; + if (!c) return fail(reply, 404, 'klas onbekend'); + if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten'); + if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten'); + await pool.query( + `INSERT INTO pupil_levels (class_id, subject, year_group, level) VALUES ($1,$2,$3,$4) + ON CONFLICT (class_id, subject) WHERE class_id IS NOT NULL + DO UPDATE SET year_group = $3, level = $4, updated_at = now()`, + [c.id, body.subject, body.yearGroup, body.level]); + return { ok: true }; + }); + app.delete('/levels/class/:id', async (req, reply) => { + need(req, reply, PERMISSIONS['assignments.manage']); + const subject = req.query.subject; + if (!LEVEL_SUBJECTS.includes(subject)) return fail(reply, 400, 'ongeldig vakgebied'); + const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0]; + if (!c) return fail(reply, 404, 'klas onbekend'); + if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten'); + if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten'); + await pool.query('DELETE FROM pupil_levels WHERE class_id = $1 AND subject = $2', [c.id, subject]); + return { ok: true }; + }); + + app.get('/levels/pupil/:id', async (req, reply) => { + need(req, reply, PERMISSIONS['assignments.manage']); + const subject = req.query.subject; + if (!LEVEL_SUBJECTS.includes(subject)) return fail(reply, 400, 'ongeldig vakgebied'); + const u = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0]; + if (!u) return fail(reply, 404, 'gebruiker onbekend'); + if (!(await pupilAccessible(req, u))) return fail(reply, 403, 'geen rechten'); + const row = (await pool.query( + 'SELECT * FROM pupil_levels WHERE pupil_id = $1 AND subject = $2', [u.id, subject])).rows[0]; + return { level: row ? describePupilLevel(row) : null }; + }); + app.put('/levels/pupil/:id', async (req, reply) => { + need(req, reply, PERMISSIONS['assignments.manage']); + const body = parseLevelBody(req, reply); + if (body.error) return body; + const u = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0]; + if (!u) return fail(reply, 404, 'gebruiker onbekend'); + if (!(await pupilAccessible(req, u))) return fail(reply, 403, 'geen rechten'); + await pool.query( + `INSERT INTO pupil_levels (pupil_id, subject, year_group, level) VALUES ($1,$2,$3,$4) + ON CONFLICT (pupil_id, subject) WHERE pupil_id IS NOT NULL + DO UPDATE SET year_group = $3, level = $4, updated_at = now()`, + [u.id, body.subject, body.yearGroup, body.level]); + return { ok: true }; + }); + app.delete('/levels/pupil/:id', async (req, reply) => { + need(req, reply, PERMISSIONS['assignments.manage']); + const subject = req.query.subject; + if (!LEVEL_SUBJECTS.includes(subject)) return fail(reply, 400, 'ongeldig vakgebied'); + const u = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0]; + if (!u) return fail(reply, 404, 'gebruiker onbekend'); + if (!(await pupilAccessible(req, u))) return fail(reply, 403, 'geen rechten'); + await pool.query('DELETE FROM pupil_levels WHERE pupil_id = $1 AND subject = $2', [u.id, subject]); + return { ok: true }; + }); + + // Leerling-kant: effectieve groep/niveau (eigen override wint van de klas), + // zelfde resolutievorm als /my/assignment hierboven. Geen instelling -> + // {yearGroup:null, level:1}, gelijk aan de bestaande widget-standaardwaarde. + app.get('/my/level', async (req, reply) => { + need(req, reply); + if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen'); + const subject = req.query.subject; + if (!LEVEL_SUBJECTS.includes(subject)) return fail(reply, 400, 'ongeldig vakgebied'); + let row = (await pool.query( + 'SELECT * FROM pupil_levels WHERE pupil_id = $1 AND subject = $2', [req.user.id, subject])).rows[0]; + if (!row && req.user.class_id) { + row = (await pool.query( + 'SELECT * FROM pupil_levels WHERE class_id = $1 AND subject = $2', [req.user.class_id, subject])).rows[0]; + } + return row ? describePupilLevel(row) : { yearGroup: null, level: 1 }; + }); + // ---- voortgang (leerling-omgeving) --------------------------------------------- // Leerlingen loggen elk afgerond "moment" in een widget (ronde sommen, voltooid // woord, geknapte ballon). teacher_id/board_id komen server-side uit de effectieve diff --git a/test/pupil-levels.test.js b/test/pupil-levels.test.js new file mode 100644 index 0000000..16aee04 --- /dev/null +++ b/test/pupil-levels.test.js @@ -0,0 +1,146 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { runInNewContext } from 'node:vm'; +import Fastify from 'fastify'; +import cookie from '@fastify/cookie'; +import rateLimit from '@fastify/rate-limit'; +import api from '../src/api.js'; + +/* Klassenmanagement: niveaugroep (groep 1-8) + niveau (1-3) per leerling per + vakgebied. Zelfde soort dekking als test/progress-logging.test.js voor het + server-pad, plus een echte (niet regex-)test van de SLO-getalgrenzen die + math.js gebruikt zodra een leerling een groep heeft. */ + +function makeApp(user, { classRow = { id: 100, school_id: 2, name: 'Groep 4' }, classDefault = null, pupilOverride = null, classPupilOverrides = [] } = {}) { + const calls = []; + const query = async (sql, params = []) => { + calls.push({ sql, params }); + if (sql.includes('FROM sessions s JOIN users u')) return { rows: [user] }; + if (sql.includes('FROM user_roles')) return { rows: [] }; + if (sql.startsWith('SELECT * FROM classes WHERE id')) return { rows: classRow ? [classRow] : [] }; + if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [user] }; + if (sql === 'SELECT * FROM pupil_levels WHERE class_id = $1 AND subject = $2') return { rows: classDefault ? [classDefault] : [] }; + if (sql === 'SELECT * FROM pupil_levels WHERE pupil_id = $1 AND subject = $2') return { rows: pupilOverride ? [pupilOverride] : [] }; + if (sql.includes('FROM pupil_levels pl JOIN users u')) return { rows: classPupilOverrides }; + if (sql.startsWith('INSERT INTO pupil_levels')) return { rows: [] }; + if (sql.startsWith('DELETE FROM pupil_levels')) return { rows: [] }; + return { rows: [] }; + }; + const pool = { query, async connect() { return { query, release() {} }; } }; + return (async () => { + const app = Fastify({ trustProxy: 2 }); + await app.register(cookie); + await app.register(rateLimit, { global: false }); + app.decorate('pg', pool); + await app.register(api, { prefix: '/api' }); + await app.ready(); + return { app, calls }; + })(); +} + +const cookies = { teach_session: 'x'.repeat(64) }; +const teacher = { id: 3, username: 'juf', role: 'teacher', school_id: 2, class_id: null, data: {}, data_rev: 0 }; +const admin = { id: 4, username: 'beheer', role: 'admin', school_id: 2, class_id: null, data: {}, data_rev: 0 }; +const pupil = { id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 100, data: {}, data_rev: 0 }; + +test('klassenmanagement: klas-standaard opslaan doet de juiste upsert-query', async () => { + const { app, calls } = await makeApp(admin); + const res = await app.inject({ + method: 'PUT', url: '/api/levels/class/100', cookies, + payload: { subject: 'rekenen', yearGroup: 4, level: 2 }, + }); + assert.equal(res.statusCode, 200, res.body); + const ins = calls.find(c => c.sql.startsWith('INSERT INTO pupil_levels')); + assert.ok(ins); + assert.deepEqual(ins.params, [100, 'rekenen', 4, 2]); +}); + +test('klassenmanagement: ongeldig vakgebied geeft 400', async () => { + const { app } = await makeApp(teacher); + const res = await app.inject({ + method: 'PUT', url: '/api/levels/class/100', cookies, + payload: { subject: 'gym', yearGroup: 4, level: 2 }, + }); + assert.equal(res.statusCode, 400, res.body); +}); + +test('klassenmanagement: groep buiten 1-8 geeft 400', async () => { + const { app } = await makeApp(teacher); + const res = await app.inject({ + method: 'PUT', url: '/api/levels/class/100', cookies, + payload: { subject: 'rekenen', yearGroup: 12, level: 2 }, + }); + assert.equal(res.statusCode, 400, res.body); +}); + +test('klassenmanagement: GET class levert klas-standaard én leerling-overrides in één keer', async () => { + const { app } = await makeApp(admin, { + classDefault: { year_group: 4, level: 2 }, + classPupilOverrides: [{ pupil_id: 9, year_group: 6, level: 3 }], + }); + const res = await app.inject({ method: 'GET', url: '/api/levels/class/100?subject=rekenen', cookies }); + assert.equal(res.statusCode, 200, res.body); + const body = res.json(); + assert.deepEqual(body.classDefault, { yearGroup: 4, level: 2 }); + assert.deepEqual(body.pupilOverrides, [{ pupilId: 9, yearGroup: 6, level: 3 }]); +}); + +test('klassenmanagement: /my/level - leerling-override wint van klas-standaard', async () => { + const { app } = await makeApp(pupil, { pupilOverride: { year_group: 6, level: 3 } }); + const res = await app.inject({ method: 'GET', url: '/api/my/level?subject=rekenen', cookies }); + assert.equal(res.statusCode, 200, res.body); + assert.deepEqual(res.json(), { yearGroup: 6, level: 3 }); +}); + +test('klassenmanagement: /my/level - valt terug op de klas-standaard zonder eigen override', async () => { + const { app } = await makeApp(pupil, { classDefault: { year_group: 4, level: 2 } }); + const res = await app.inject({ method: 'GET', url: '/api/my/level?subject=rekenen', cookies }); + assert.equal(res.statusCode, 200, res.body); + assert.deepEqual(res.json(), { yearGroup: 4, level: 2 }); +}); + +test('klassenmanagement: /my/level - niets ingesteld geeft de bestaande widget-standaardwaarde', async () => { + const { app } = await makeApp(pupil); + const res = await app.inject({ method: 'GET', url: '/api/my/level?subject=rekenen', cookies }); + assert.equal(res.statusCode, 200, res.body); + assert.deepEqual(res.json(), { yearGroup: null, level: 1 }); +}); + +test('klassenmanagement: leerkracht-rol wordt geweigerd op /my/level (alleen leerlingen)', async () => { + const { app } = await makeApp(teacher); + const res = await app.inject({ method: 'GET', url: '/api/my/level?subject=rekenen', cookies }); + assert.equal(res.statusCode, 403, res.body); +}); + +/* Echte (niet regex-)test van de SLO-getalgrenzen: data.js's SLO_MATH_RANGES/ + mathRangeFor zijn pure functies zonder DOM-afhankelijkheid, dus rechtstreeks + uitvoerbaar in een geïsoleerde VM-context - zelfde techniek als elders in + test/widgets.test.js voor MONEY_LEVELS/createMoneyTask. */ +test('SLO-rekengrenzen: groep bepaalt de daadwerkelijke getalgrens, niet meer de vaste 10/20/100-klem', async () => { + const source = await readFile('public/js/data.js', 'utf8'), context = {}; + runInNewContext(source + ';globalThis.__mathRange = mathRangeFor', context); + const mathRangeFor = context.__mathRange; + assert.equal(mathRangeFor(3, 3).addSub, 20, 'groep 3 t/m 20'); + assert.equal(mathRangeFor(4, 3).addSub, 100, 'groep 4 t/m 100'); + assert.equal(mathRangeFor(5, 3).addSub, 1000, 'groep 5 t/m 1000'); + assert.equal(mathRangeFor(6, 3).addSub, 10000, 'groep 6 t/m 10.000'); + // niveau is een verfijning BINNEN de groep: nooit hoger dan de groep-grens + for (let g = 1; g <= 8; g++) { + const r1 = mathRangeFor(g, 1), r2 = mathRangeFor(g, 2), r3 = mathRangeFor(g, 3); + assert.ok(r1.addSub <= r2.addSub && r2.addSub <= r3.addSub, `groep ${g}: niveau moet oplopen`); + assert.ok(r1.addSub >= 10, `groep ${g} niveau 1 nooit onder de kleinste bestaande standaardwaarde`); + } + // zonder groep (geen klassenmanagement ingesteld) geeft mathRangeFor niets terug - + // math.js valt dan terug op zijn eigen oude vaste logica + assert.equal(mathRangeFor(null, 2), null); + assert.equal(mathRangeFor(undefined, 2), null); +}); + +test('math.js: gen() gebruikt mathRangeFor als er een groep is, anders de oude vaste klem', async () => { + const source = await readFile('public/js/widgets/math.js', 'utf8'); + assert.match(source, /const slo = mathRangeFor\(S\.yearGroup, L\)/); + assert.match(source, /const max = slo \? slo\.addSub : \(L===1 \? 10 : L===2 \? 20 : 100\)/); + assert.match(source, /const tmax = slo \? slo\.timesTable : \(L===1 \? 5 : L===2 \? 10 : 12\)/); + assert.ok(source.includes('S.yearGroup = initState.yearGroup || null')); +});