Verbeter anatomie en plaatsing van avataraccessoires (v0.4.46-beta) #1
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;
|
||||
/* 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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]) || [];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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} =`;
|
||||
|
|
|
|||
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 };
|
||||
});
|
||||
|
||||
// ---- 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
|
||||
|
|
|
|||
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