Verbeter anatomie en plaatsing van avataraccessoires (v0.4.46-beta) #1

Open
bes-r wants to merge 192 commits from bes-r/avatar-realism into main AGit
5 changed files with 45 additions and 14 deletions
Showing only changes of commit 7287e49e0c - Show all commits

View file

@ -1 +1 @@
0.3.37-beta 0.3.38-beta

View file

@ -198,15 +198,13 @@
if(u.role==="pupil"){ if(u.role==="pupil"){
const cls = CLASSES.find(c=>Number(c.id)===Number(u.classId)); const cls = CLASSES.find(c=>Number(c.id)===Number(u.classId));
row.appendChild(h("span","am-role", T(roleKey(u.role)) + (cls ? ` · ${cls.name}` : ""))); row.appendChild(h("span","am-role", T(roleKey(u.role)) + (cls ? ` · ${cls.name}` : "")));
}else if(u.role==="teacher"){ }else{
const names = (u.classIds||[]) const names = (u.classIds||[])
.map(cid=>CLASSES.find(c=>Number(c.id)===Number(cid))?.name) .map(cid=>CLASSES.find(c=>Number(c.id)===Number(cid))?.name)
.filter(Boolean); .filter(Boolean);
row.appendChild(h("span","am-role", T(roleKey(u.role)) row.appendChild(h("span","am-role", T(roleKey(u.role))
+ (names.length ? ` · ${names.join(", ")}` : "") + (names.length ? ` · ${names.join(", ")}` : "")
+ (u.pending ? ` · ${T("amPending")}` : ""))); + (u.pending ? ` · ${T("amPending")}` : "")));
}else{
row.appendChild(h("span","am-role", T(roleKey(u.role)) + (u.pending ? ` · ${T("amPending")}` : "")));
} }
if(u.role!=="pupil" && can("users.role.change") && u.id!==currentUser.id){ if(u.role!=="pupil" && can("users.role.change") && u.id!==currentUser.id){
/* één functie per gebruiker: alleen de hoofdrol is te wijzigen (super); /* één functie per gebruiker: alleen de hoofdrol is te wijzigen (super);
@ -220,9 +218,10 @@
}); });
tools.appendChild(field("amFieldRole", rs)); tools.appendChild(field("amFieldRole", rs));
} }
/* groepsleiding eenvoudig aan klassen koppelen: huidige klassen als /* elke staf-rol eenvoudig aan klassen koppelen (groepsleiding,
chips met , en een select om een klas toe te voegen */ schoolbeheerder én systeemmanager): huidige klassen als chips met ,
if(u.role==="teacher" && can("classes.manage") && CLASSES.length){ en een select om een klas toe te voegen */
if(u.role!=="pupil" && can("classes.manage") && CLASSES.length){
const wrap = h("div","am-classes"); const wrap = h("div","am-classes");
(u.classIds||[]).forEach(cid=>{ (u.classIds||[]).forEach(cid=>{
const c = CLASSES.find(x=>Number(x.id)===Number(cid)); const c = CLASSES.find(x=>Number(x.id)===Number(cid));

View file

@ -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.3.37-beta"; const VERSION = "0.3.38-beta";
(function(){ (function(){
const tag = document.getElementById("verTag"); const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION; tag.textContent = "v"+VERSION;

View file

@ -331,19 +331,22 @@ export default async function api(app) {
return { ok: true }; return { ok: true };
}); });
// groepsleiding aan klas koppelen/loskoppelen // groepsleiding aan klas koppelen/loskoppelen
// Elke staf-rol (groepsleiding, schoolbeheerder, systeemmanager) kan aan
// een klas gekoppeld worden; alleen leerlingen niet (die horen al bij één
// klas via users.class_id). Een systeemmanager is school-onafhankelijk en
// mag dus aan klassen van elke school gekoppeld worden.
app.post('/admin/classes/:id/teachers', async (req, reply) => { app.post('/admin/classes/:id/teachers', async (req, reply) => {
need(req, reply, PERMISSIONS['classes.manage']); need(req, reply, PERMISSIONS['classes.manage']);
const { userId, remove } = req.body ?? {}; const { userId, remove } = req.body ?? {};
if (!userId) return fail(reply, 400, 'gebruiker verplicht'); if (!userId) return fail(reply, 400, 'gebruiker verplicht');
const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0]; const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0];
const u = (await pool.query(`SELECT u.*, EXISTS ( const u = (await pool.query('SELECT * FROM users WHERE id = $1', [userId])).rows[0];
SELECT 1 FROM user_roles ur WHERE ur.user_id = u.id AND ur.role = 'teacher'
) AS extra_teacher FROM users u WHERE u.id = $1`, [userId])).rows[0];
if (!c || !u) return fail(reply, 404, 'klas of gebruiker onbekend'); if (!c || !u) return fail(reply, 404, 'klas of gebruiker onbekend');
if (!req.user.allRoles.includes('super') && Number(c.school_id) !== Number(req.user.school_id)) if (!req.user.allRoles.includes('super') && Number(c.school_id) !== Number(req.user.school_id))
return fail(reply, 403, 'geen rechten'); return fail(reply, 403, 'geen rechten');
if (Number(c.school_id) !== Number(u.school_id)) return fail(reply, 400, 'klas en groepsleiding moeten bij dezelfde school horen'); if (u.role === 'pupil') return fail(reply, 400, 'leerlingen horen al bij één klas');
if (u.role !== 'teacher' && !u.extra_teacher) return fail(reply, 400, 'gebruiker is geen groepsleiding'); if (u.role !== 'super' && Number(c.school_id) !== Number(u.school_id))
return fail(reply, 400, 'klas en gebruiker moeten bij dezelfde school horen');
if (remove) await pool.query('DELETE FROM class_teachers WHERE class_id = $1 AND user_id = $2', [c.id, u.id]); if (remove) await pool.query('DELETE FROM class_teachers WHERE class_id = $1 AND user_id = $2', [c.id, u.id]);
else await pool.query('INSERT INTO class_teachers (class_id, user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [c.id, u.id]); else await pool.query('INSERT INTO class_teachers (class_id, user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [c.id, u.id]);
return { ok: true }; return { ok: true };

View file

@ -5,7 +5,7 @@ import cookie from '@fastify/cookie';
import rateLimit from '@fastify/rate-limit'; import rateLimit from '@fastify/rate-limit';
import api from '../src/api.js'; import api from '../src/api.js';
function makeApp(user) { function makeApp(user, targetUser) {
const calls = []; const calls = [];
const pool = { const pool = {
async query(sql, params = []) { async query(sql, params = []) {
@ -13,6 +13,8 @@ function makeApp(user) {
if (sql.includes('FROM sessions s JOIN users u')) return { rows: [user] }; if (sql.includes('FROM sessions s JOIN users u')) return { rows: [user] };
if (sql.includes('FROM user_roles')) return { rows: [] }; if (sql.includes('FROM user_roles')) return { rows: [] };
if (sql.startsWith('UPDATE schools SET name')) return { rows: [{ id: params[1], name: params[0] }] }; if (sql.startsWith('UPDATE schools SET name')) return { rows: [{ id: params[1], name: params[0] }] };
if (sql.startsWith('SELECT * FROM classes WHERE id')) return { rows: [{ id: 100, school_id: 2, name: 'Groep 4' }] };
if (sql.startsWith('SELECT * FROM users WHERE id') && targetUser) return { rows: [targetUser] };
return { rows: [] }; return { rows: [] };
}, },
}; };
@ -50,6 +52,33 @@ test('schoolbeheerder mag geen scholen hernoemen', async () => {
await app.close(); await app.close();
}); });
test('schoolbeheerder en systeemmanager kunnen aan een klas gekoppeld worden', async () => {
// schoolbeheerder van dezelfde school
const { app, calls } = await makeApp(superUser, { id: 3, role: 'admin', school_id: 2 });
const res = await app.inject({ method: 'POST', url: '/api/admin/classes/100/teachers', cookies,
payload: { userId: 3 } });
assert.equal(res.statusCode, 200, res.body);
assert.ok(calls.some((c) => c.sql.includes('INSERT INTO class_teachers')));
await app.close();
// systeemmanager (geen school) mag ook
const { app: app2 } = await makeApp(superUser, { id: 1, role: 'super', school_id: null });
const res2 = await app2.inject({ method: 'POST', url: '/api/admin/classes/100/teachers', cookies,
payload: { userId: 1 } });
assert.equal(res2.statusCode, 200, res2.body);
await app2.close();
// leerlingen niet, en staf van een andere school niet
const { app: app3 } = await makeApp(superUser, { id: 9, role: 'pupil', school_id: 2 });
const res3 = await app3.inject({ method: 'POST', url: '/api/admin/classes/100/teachers', cookies,
payload: { userId: 9 } });
assert.equal(res3.statusCode, 400);
await app3.close();
const { app: app4 } = await makeApp(superUser, { id: 4, role: 'admin', school_id: 3 });
const res4 = await app4.inject({ method: 'POST', url: '/api/admin/classes/100/teachers', cookies,
payload: { userId: 4 } });
assert.equal(res4.statusCode, 400);
await app4.close();
});
test('hernoemen weigert een lege of te lange naam', async () => { test('hernoemen weigert een lege of te lange naam', async () => {
const { app } = await makeApp(superUser); const { app } = await makeApp(superUser);
const res = await app.inject({ method: 'PATCH', url: '/api/admin/schools/5', cookies, const res = await app.inject({ method: 'PATCH', url: '/api/admin/schools/5', cookies,