Verbeter grafische kwaliteit van kleurplaten (v0.4.67-beta) #5

Open
bes-r wants to merge 208 commits from bes-r/coloring-quality-v0.4.67 into main AGit
5 changed files with 116 additions and 3 deletions
Showing only changes of commit 8fe6fc10cc - Show all commits

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