Verbeter grafische kwaliteit van kleurplaten (v0.4.67-beta) #5
5 changed files with 116 additions and 3 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.4.28-beta
|
0.4.29-beta
|
||||||
|
|
@ -716,6 +716,25 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* CSV-download zonder library: waarden met komma/quote/newline krijgen
|
||||||
|
quotes (Excel-conventie), een BOM vooraan zodat Excel-NL accenten en de
|
||||||
|
komma-scheiding correct herkent. Eerste CSV-export in deze codebase -
|
||||||
|
bewust hier lokaal gehouden (nog maar één gebruiker); als de geplande
|
||||||
|
wachtwoorden-export dit ook nodig heeft, dan pas verplaatsen. */
|
||||||
|
function csvValue(v){
|
||||||
|
const s = String(v ?? "");
|
||||||
|
return /[",\n]/.test(s) ? '"' + s.replace(/"/g,'""') + '"' : s;
|
||||||
|
}
|
||||||
|
function downloadCsv(filename, rows){
|
||||||
|
const csv = rows.map(r=>r.map(csvValue).join(",")).join("\r\n");
|
||||||
|
const blob = new Blob([""+csv], {type:"text/csv;charset=utf-8;"});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url; a.download = filename;
|
||||||
|
document.body.appendChild(a); a.click(); a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
/* één regel in de voortgangstijdlijn van een leerling: widgettype, score en tijdstip */
|
/* één regel in de voortgangstijdlijn van een leerling: widgettype, score en tijdstip */
|
||||||
function progressRow(ev){
|
function progressRow(ev){
|
||||||
const row = h("div","am-row");
|
const row = h("div","am-row");
|
||||||
|
|
@ -766,6 +785,32 @@
|
||||||
moreBtn.onclick = showMore;
|
moreBtn.onclick = showMore;
|
||||||
showMore();
|
showMore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
panel.appendChild(h("div","am-group", T("amExportClass")));
|
||||||
|
const classSelExport = classSel();
|
||||||
|
panel.appendChild(classSelExport);
|
||||||
|
const exportBtn = h("button","am-btn", T("amExportCsv"));
|
||||||
|
exportBtn.type = "button";
|
||||||
|
panel.appendChild(exportBtn);
|
||||||
|
exportBtn.addEventListener("click", async ()=>{
|
||||||
|
if(!classSelExport.value){ msg(T("amPickClassHint")); return; }
|
||||||
|
let events = [];
|
||||||
|
try{ events = (await api("/progress/class/"+classSelExport.value+"?limit=5000")).events || []; }
|
||||||
|
catch(e){ msg(e.message); return; }
|
||||||
|
if(!events.length){ msg(T("amProgressEmpty")); return; }
|
||||||
|
const cls = CLASSES.find(c=>String(c.id)===classSelExport.value);
|
||||||
|
const header = [T("amExportColPupil"), T("amExportColWidget"), T("amExportColAttempts"),
|
||||||
|
T("amExportColCorrect"), T("amExportColStars"), T("amExportColDate")];
|
||||||
|
const rows = [header, ...events.map(ev=>[
|
||||||
|
ev.pupilName,
|
||||||
|
T("wg_"+ev.widgetType) || ev.widgetType,
|
||||||
|
ev.attempts, ev.correct, ev.stars,
|
||||||
|
new Date(ev.createdAt).toLocaleString(LANG==="nl" ? "nl-NL" : "en-GB"),
|
||||||
|
])];
|
||||||
|
const safeName = (cls ? cls.name : classSelExport.value).replace(/[^a-z0-9]+/gi, "-");
|
||||||
|
downloadCsv(`voortgang-${safeName}.csv`, rows);
|
||||||
|
msg(T("amExportDone"));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =========================================================================
|
/* =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -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.28-beta";
|
const VERSION = "0.4.29-beta";
|
||||||
(function(){
|
(function(){
|
||||||
const tag = document.getElementById("verTag");
|
const tag = document.getElementById("verTag");
|
||||||
tag.textContent = "v"+VERSION;
|
tag.textContent = "v"+VERSION;
|
||||||
|
|
@ -349,6 +349,10 @@ const I18N = {
|
||||||
castNoClasses:"Geen klassen in deze school.", castNoPupils:"Geen leerlingen in deze school.",
|
castNoClasses:"Geen klassen in deze school.", castNoPupils:"Geen leerlingen in deze school.",
|
||||||
castActive:"✓ Actief",
|
castActive:"✓ Actief",
|
||||||
amProgress:"Voortgang", amProgressEmpty:"Nog geen voortgang voor deze leerling.",
|
amProgress:"Voortgang", amProgressEmpty:"Nog geen voortgang voor deze leerling.",
|
||||||
|
amExportClass:"Exporteren per klas", amExportCsv:"Exporteer als CSV", amPickClassHint:"Kies eerst een klas.",
|
||||||
|
amExportColPupil:"Leerling", amExportColWidget:"Widget", amExportColAttempts:"Pogingen",
|
||||||
|
amExportColCorrect:"Goed", amExportColStars:"Sterren", amExportColDate:"Datum",
|
||||||
|
amExportDone:"Export gedownload ✓",
|
||||||
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",
|
||||||
|
|
@ -683,6 +687,10 @@ const I18N = {
|
||||||
castNoClasses:"No classes in this school.", castNoPupils:"No pupils in this school.",
|
castNoClasses:"No classes in this school.", castNoPupils:"No pupils in this school.",
|
||||||
castActive:"✓ Active",
|
castActive:"✓ Active",
|
||||||
amProgress:"Progress", amProgressEmpty:"No progress for this pupil yet.",
|
amProgress:"Progress", amProgressEmpty:"No progress for this pupil yet.",
|
||||||
|
amExportClass:"Export by class", amExportCsv:"Export as CSV", amPickClassHint:"Pick a class first.",
|
||||||
|
amExportColPupil:"Pupil", amExportColWidget:"Widget", amExportColAttempts:"Attempts",
|
||||||
|
amExportColCorrect:"Correct", amExportColStars:"Stars", amExportColDate:"Date",
|
||||||
|
amExportDone:"Export downloaded ✓",
|
||||||
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",
|
||||||
|
|
|
||||||
|
|
@ -984,11 +984,15 @@ export default async function api(app) {
|
||||||
if (!c) return fail(reply, 404, 'klas onbekend');
|
if (!c) return fail(reply, 404, 'klas onbekend');
|
||||||
if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten');
|
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');
|
if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten');
|
||||||
|
/* standaard blijft de korte activiteitenweergave (200) - de resultaten-
|
||||||
|
export vraagt expliciet een hogere limiet op om niet stilzwijgend af
|
||||||
|
te kappen bij een drukke klas; begrensd op 5000 tegen misbruik. */
|
||||||
|
const limit = Math.max(1, Math.min(5000, Number(req.query.limit) || PROGRESS_LIMIT));
|
||||||
const rows = (await pool.query(
|
const rows = (await pool.query(
|
||||||
`SELECT pe.*, u.display_name, u.username FROM progress_events pe
|
`SELECT pe.*, u.display_name, u.username FROM progress_events pe
|
||||||
JOIN users u ON u.id = pe.pupil_id
|
JOIN users u ON u.id = pe.pupil_id
|
||||||
WHERE u.class_id = $1 ORDER BY pe.created_at DESC LIMIT $2`,
|
WHERE u.class_id = $1 ORDER BY pe.created_at DESC LIMIT $2`,
|
||||||
[c.id, PROGRESS_LIMIT])).rows;
|
[c.id, limit])).rows;
|
||||||
return { events: rows.map((e) => ({ ...describeProgressEvent(e), pupilId: Number(e.pupil_id), pupilName: e.display_name || e.username })) };
|
return { events: rows.map((e) => ({ ...describeProgressEvent(e), pupilId: Number(e.pupil_id), pupilName: e.display_name || e.username })) };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
56
test/progress-export.test.js
Normal file
56
test/progress-export.test.js
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import cookie from '@fastify/cookie';
|
||||||
|
import rateLimit from '@fastify/rate-limit';
|
||||||
|
import api from '../src/api.js';
|
||||||
|
|
||||||
|
function makeApp(user) {
|
||||||
|
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: [{ id: 100, school_id: 2, name: 'Groep 4' }] };
|
||||||
|
if (sql.includes('FROM progress_events pe')) 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 admin = { id: 3, username: 'beheer', role: 'admin', school_id: 2, class_id: null, data: {}, data_rev: 0 };
|
||||||
|
|
||||||
|
function progressCall(calls) {
|
||||||
|
return calls.find(c => c.sql.includes('FROM progress_events pe'));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('resultaten-export: ?limit= wordt doorgegeven aan de query', async () => {
|
||||||
|
const { app, calls } = await makeApp(admin);
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/progress/class/100?limit=3000', cookies });
|
||||||
|
assert.equal(res.statusCode, 200, res.body);
|
||||||
|
assert.equal(progressCall(calls).params[1], 3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resultaten-export: een te hoge limit wordt afgekapt op het veilige maximum', async () => {
|
||||||
|
const { app, calls } = await makeApp(admin);
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/progress/class/100?limit=999999', cookies });
|
||||||
|
assert.equal(res.statusCode, 200, res.body);
|
||||||
|
assert.equal(progressCall(calls).params[1], 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resultaten-export: zonder limit-param blijft het standaardgedrag (200) behouden', async () => {
|
||||||
|
const { app, calls } = await makeApp(admin);
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/progress/class/100', cookies });
|
||||||
|
assert.equal(res.statusCode, 200, res.body);
|
||||||
|
assert.equal(progressCall(calls).params[1], 200);
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue