feat: klassenmanagement - niveaugroep + niveau per leerling per vakgebied (v0.4.34-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 1m9s
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 1m9s
- 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014EPxzBVRXZZnBPvSAaPAbJ
This commit is contained in:
parent
36695a06f6
commit
e7bb76b76d
9 changed files with 527 additions and 11 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.4.33-beta
|
0.4.34-beta
|
||||||
24
db/019_pupil_levels.sql
Normal file
24
db/019_pupil_levels.sql
Normal file
|
|
@ -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;
|
||||||
|
|
@ -21,6 +21,10 @@
|
||||||
let resetResults = null;
|
let resetResults = null;
|
||||||
/* actief tabblad, blijft behouden over her-renders heen (na elke actie) */
|
/* actief tabblad, blijft behouden over her-renders heen (na elke actie) */
|
||||||
let adminTab = "school";
|
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.
|
/* navigatie binnen "School": welk niveau van de boom je ziet.
|
||||||
{type:"overview"} - tegels (scholen op "alle scholen", anders
|
{type:"overview"} - tegels (scholen op "alle scholen", anders
|
||||||
|
|
@ -229,6 +233,10 @@
|
||||||
{ tab:"voortgang", icon:"📈", group:"school", order:13, labelKey:"amProgress", subKey:"stVoortgangSub",
|
{ tab:"voortgang", icon:"📈", group:"school", order:13, labelKey:"amProgress", subKey:"stVoortgangSub",
|
||||||
show:()=>can("progress.view"),
|
show:()=>can("progress.view"),
|
||||||
keywords:()=>["voortgang","progress","score","resultaten","results","tijdlijn","timeline"] },
|
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",
|
{ tab:"opslag", icon:"🖼", group:"system", order:32, labelKey:"stOpslag", subKey:"stOpslagSub",
|
||||||
show:()=>currentUser.role==="super",
|
show:()=>currentUser.role==="super",
|
||||||
keywords:()=>["opslag","storage","afbeeldingen","images","quota"] },
|
keywords:()=>["opslag","storage","afbeeldingen","images","quota"] },
|
||||||
|
|
@ -1080,6 +1088,156 @@
|
||||||
return box;
|
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 +
|
/* niveau 0: "alle scholen" - alleen systeemmanager. Tegel per school +
|
||||||
Systeemmanagers/Ouders/Zonder school + "+ School". */
|
Systeemmanagers/Ouders/Zonder school + "+ School". */
|
||||||
function renderRootOverview(panel){
|
function renderRootOverview(panel){
|
||||||
|
|
@ -1392,7 +1550,7 @@
|
||||||
const tabDefs = adminTabDefs();
|
const tabDefs = adminTabDefs();
|
||||||
if(!tabDefs.some(([t])=>t===adminTab)) adminTab = tabDefs[0][0];
|
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])=>{
|
tabDefs.forEach(([t])=>{
|
||||||
const panel = h("div","am-tabpanel");
|
const panel = h("div","am-tabpanel");
|
||||||
panel.dataset.panel = t;
|
panel.dataset.panel = t;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
/* version — shown until /api/version resolves (or if the fetch fails, e.g. offline).
|
/* 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. */
|
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(){
|
(function(){
|
||||||
const tag = document.getElementById("verTag");
|
const tag = document.getElementById("verTag");
|
||||||
tag.textContent = "v"+VERSION;
|
tag.textContent = "v"+VERSION;
|
||||||
|
|
@ -354,6 +354,12 @@ const I18N = {
|
||||||
amExportColCorrect:"Goed", amExportColStars:"Sterren", amExportColDate:"Datum",
|
amExportColCorrect:"Goed", amExportColStars:"Sterren", amExportColDate:"Datum",
|
||||||
amExportColUsername:"Gebruikersnaam",
|
amExportColUsername:"Gebruikersnaam",
|
||||||
amExportDone:"Export gedownload ✓",
|
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?",
|
amConfirmDel:"Weet je zeker dat je deze gebruiker wilt verwijderen?",
|
||||||
amCopy:"kopieer", amCopyAll:"kopieer alles",
|
amCopy:"kopieer", amCopyAll:"kopieer alles",
|
||||||
amResetClass:"reset wachtwoorden klas", amResetSchool:"reset alle leerlingwachtwoorden",
|
amResetClass:"reset wachtwoorden klas", amResetSchool:"reset alle leerlingwachtwoorden",
|
||||||
|
|
@ -693,6 +699,12 @@ const I18N = {
|
||||||
amExportColCorrect:"Correct", amExportColStars:"Stars", amExportColDate:"Date",
|
amExportColCorrect:"Correct", amExportColStars:"Stars", amExportColDate:"Date",
|
||||||
amExportColUsername:"Username",
|
amExportColUsername:"Username",
|
||||||
amExportDone:"Export downloaded ✓",
|
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?",
|
amConfirmDel:"Are you sure you want to delete this user?",
|
||||||
amCopy:"copy", amCopyAll:"copy all",
|
amCopy:"copy", amCopyAll:"copy all",
|
||||||
amResetClass:"reset class passwords", amResetSchool:"reset all pupil passwords",
|
amResetClass:"reset class passwords", amResetSchool:"reset all pupil passwords",
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,37 @@ const DIGIT_DOTCODE = {
|
||||||
"0":[], "1":[2], "2":[2,5], "3":[1,2,3], "4":[1,3,4,6],
|
"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]
|
"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";
|
const VOWELS = "aeiou";
|
||||||
function dotGrid(letter){
|
function dotGrid(letter){
|
||||||
const code = (Object.prototype.hasOwnProperty.call(DIGIT_DOTCODE, letter) ? DIGIT_DOTCODE[letter] : DOTCODE[letter]) || [];
|
const code = (Object.prototype.hasOwnProperty.call(DIGIT_DOTCODE, letter) ? DIGIT_DOTCODE[letter] : DOTCODE[letter]) || [];
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ function pupilAllowedDefs(){
|
||||||
return REGISTRY.filter(d => PUPIL_WIDGET_CATS.includes(d.cat));
|
return REGISTRY.filter(d => PUPIL_WIDGET_CATS.includes(d.cat));
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPupilWidgets(widgets, mode){
|
async function renderPupilWidgets(widgets, mode){
|
||||||
pupilView.innerHTML = "";
|
pupilView.innerHTML = "";
|
||||||
const defs = pupilAllowedDefs();
|
const defs = pupilAllowedDefs();
|
||||||
const watch = mode === "kijken";
|
const watch = mode === "kijken";
|
||||||
|
|
@ -32,6 +32,16 @@ function renderPupilWidgets(widgets, mode){
|
||||||
pupilView.appendChild(hint);
|
pupilView.appendChild(hint);
|
||||||
return;
|
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)=>{
|
usable.forEach(({ w, def }, idx)=>{
|
||||||
const card = document.createElement("div");
|
const card = document.createElement("div");
|
||||||
card.className = "widget pupil-card" + (watch ? " watch" : "");
|
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 } })
|
api("/my/progress", { method:"POST", body: { widgetId, widgetType: def.id, ...p } })
|
||||||
.catch(e=>console.warn("voortgang loggen mislukt:", e.message));
|
.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 || []);
|
const sig = mode + "|" + JSON.stringify(res.widgets || []);
|
||||||
if(sig !== pupilLastSig){
|
if(sig !== pupilLastSig){
|
||||||
pupilLastSig = sig;
|
pupilLastSig = sig;
|
||||||
renderPupilWidgets(res.widgets || [], mode);
|
await renderPupilWidgets(res.widgets || [], mode);
|
||||||
}
|
}
|
||||||
if(mode !== pupilMode){
|
if(mode !== pupilMode){
|
||||||
pupilMode = mode;
|
pupilMode = mode;
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,13 @@
|
||||||
function mountMath(root, initState, mode, opts){
|
function mountMath(root, initState, mode, opts){
|
||||||
const ROUND = 10;
|
const ROUND = 10;
|
||||||
const onProgress = opts && opts.onProgress;
|
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){
|
if(initState){
|
||||||
S.level = initState.level||1;
|
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.snd = initState.snd!==false;
|
||||||
S.amode = initState.amode||"blocks";
|
S.amode = initState.amode||"blocks";
|
||||||
}
|
}
|
||||||
|
|
@ -161,21 +165,27 @@ function mountMath(root, initState, mode, opts){
|
||||||
|
|
||||||
function gen(){
|
function gen(){
|
||||||
const L = S.level;
|
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;
|
let a, b, ans, txt;
|
||||||
if(mode==="add"){
|
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;
|
a = rnd(max+1); b = rnd(max-a+1); ans = a+b;
|
||||||
txt = `${a} + ${b} =`;
|
txt = `${a} + ${b} =`;
|
||||||
}else if(mode==="sub"){
|
}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;
|
a = rnd(max+1); b = rnd(a+1); ans = a-b;
|
||||||
txt = `${a} − ${b} =`;
|
txt = `${a} − ${b} =`;
|
||||||
}else if(mode==="mul"){
|
}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;
|
a = 1+rnd(tmax); b = 1+rnd(L===3 ? 12 : 10); ans = a*b;
|
||||||
txt = `${a} × ${b} =`;
|
txt = `${a} × ${b} =`;
|
||||||
}else if(mode==="div"){
|
}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);
|
const t = 1+rnd(tmax), q = 1+rnd(L===3 ? 12 : 10);
|
||||||
a = t*q; ans = q;
|
a = t*q; ans = q;
|
||||||
txt = `${a} ${S.lang==="nl" || LANG==="nl" ? ":" : "÷"} ${t} =`;
|
txt = `${a} ${S.lang==="nl" || LANG==="nl" ? ":" : "÷"} ${t} =`;
|
||||||
|
|
|
||||||
117
src/api.js
117
src/api.js
|
|
@ -917,6 +917,123 @@ export default async function api(app) {
|
||||||
return { widgets, mode: a.mode || 'werken', updatedAt: a.updated_at };
|
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) ---------------------------------------------
|
// ---- voortgang (leerling-omgeving) ---------------------------------------------
|
||||||
// Leerlingen loggen elk afgerond "moment" in een widget (ronde sommen, voltooid
|
// Leerlingen loggen elk afgerond "moment" in een widget (ronde sommen, voltooid
|
||||||
// woord, geknapte ballon). teacher_id/board_id komen server-side uit de effectieve
|
// woord, geknapte ballon). teacher_id/board_id komen server-side uit de effectieve
|
||||||
|
|
|
||||||
146
test/pupil-levels.test.js
Normal file
146
test/pupil-levels.test.js
Normal file
|
|
@ -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'));
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue