feat: exporteren van resultaten per klas (CSV) (v0.4.29-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 51s

- Nieuwe sectie "Exporteren per klas" in de bestaande Voortgang-tab: klas
  kiezen + "Exporteer als CSV" downloadt de volledige activiteitenlijst
  van die klas (leerling, widget, pogingen, goed, sterren, datum/tijd).
- Bouwt voort op de bestaande route GET /progress/class/:id, die tot nu
  toe geen enkele frontend-consument had.
- Server: nieuwe optionele ?limit=-queryparameter (begrensd op 5000) -
  de export vraagt expliciet meer dan de standaard activiteitenweergave
  (200) op, zodat een drukke klas niet stilzwijgend wordt afgekapt in het
  exportbestand. Zonder de parameter blijft het bestaande gedrag (200)
  ongewijzigd.
- Eerste CSV-export in deze codebase: kleine lokale csvValue()/
  downloadCsv()-helpers in admin.js (komma/quote/newline-escaping,
  BOM zodat Excel-NL accenten en komma-scheiding herkent) - bewust nog
  niet gedeeld/verplaatst naar core.js, want er is nu nog maar één
  gebruiker (de geplande wachtwoorden-export kan dit later hergebruiken).
- Tests: test/progress-export.test.js (limit wordt doorgegeven, te hoge
  waarde wordt afgekapt op 5000, standaardgedrag zonder parameter blijft
  200) + volledige testsuite (112/112). Ook live geverifieerd: klas
  kiezen zonder selectie toont een hint i.p.v. te crashen, een echte
  CSV-download met correcte escaping van komma's/aanhalingstekens in
  een leerlingnaam, en de vertaalde widget-namen in de kolommen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014EPxzBVRXZZnBPvSAaPAbJ
This commit is contained in:
Ramon 2026-07-19 12:54:48 +02:00
parent 09a1326c7e
commit 8fe6fc10cc
5 changed files with 116 additions and 3 deletions

View file

@ -1 +1 @@
0.4.28-beta
0.4.29-beta

View file

@ -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 */
function progressRow(ev){
const row = h("div","am-row");
@ -766,6 +785,32 @@
moreBtn.onclick = 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"));
});
}
/* =========================================================================

View file

@ -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.28-beta";
const VERSION = "0.4.29-beta";
(function(){
const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION;
@ -349,6 +349,10 @@ const I18N = {
castNoClasses:"Geen klassen in deze school.", castNoPupils:"Geen leerlingen in deze school.",
castActive:"✓ Actief",
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?",
amCopy:"kopieer", amCopyAll:"kopieer alles",
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.",
castActive:"✓ Active",
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?",
amCopy:"copy", amCopyAll:"copy all",
amResetClass:"reset class passwords", amResetSchool:"reset all pupil passwords",

View file

@ -984,11 +984,15 @@ export default async function api(app) {
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');
/* 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(
`SELECT pe.*, u.display_name, u.username FROM progress_events pe
JOIN users u ON u.id = pe.pupil_id
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 })) };
});

View 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);
});