From 8fe6fc10ccac94897870b452219532f419e4849e Mon Sep 17 00:00:00 2001 From: Ramon Date: Sun, 19 Jul 2026 12:54:48 +0200 Subject: [PATCH] feat: exporteren van resultaten per klas (CSV) (v0.4.29-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_014EPxzBVRXZZnBPvSAaPAbJ --- VERSION | 2 +- public/js/admin.js | 45 +++++++++++++++++++++++++++++ public/js/core.js | 10 ++++++- src/api.js | 6 +++- test/progress-export.test.js | 56 ++++++++++++++++++++++++++++++++++++ 5 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 test/progress-export.test.js diff --git a/VERSION b/VERSION index f63d1be..c91d8a6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.28-beta \ No newline at end of file +0.4.29-beta \ No newline at end of file diff --git a/public/js/admin.js b/public/js/admin.js index 877e828..8df4c2a 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -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")); + }); } /* ========================================================================= diff --git a/public/js/core.js b/public/js/core.js index b18968c..94c4377 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.4.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", diff --git a/src/api.js b/src/api.js index 234dc20..45131d1 100644 --- a/src/api.js +++ b/src/api.js @@ -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 })) }; }); diff --git a/test/progress-export.test.js b/test/progress-export.test.js new file mode 100644 index 0000000..f396523 --- /dev/null +++ b/test/progress-export.test.js @@ -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); +});