diff --git a/backend/src/routes/access.js b/backend/src/routes/access.js index f59bfcc..ce849e1 100644 --- a/backend/src/routes/access.js +++ b/backend/src/routes/access.js @@ -107,6 +107,30 @@ router.get('/summary', (req, res) => { res.json({ totalClasses, textbooks, exams }); }); +/* ── Матрица класс × контент (обзор и правка одним экраном) ────────────── */ +/* GET /api/access/matrix + → { classes:[{id,name}], open:{ [class_id]:{ textbook:[ref], exam:[ref] } } } */ +router.get('/matrix', (req, res) => { + const admin = isAdmin(req); + const classes = admin + ? db.prepare('SELECT id, name FROM classes ORDER BY name').all() + : db.prepare('SELECT id, name FROM classes WHERE teacher_id = ? ORDER BY name').all(req.user.id); + const open = {}; + classes.forEach(c => { open[c.id] = { textbook: [], exam: [] }; }); + const ids = classes.map(c => c.id); + if (ids.length) { + const ph = ids.map(() => '?').join(','); + const rows = db.prepare(` + SELECT content_type, content_ref, target_id FROM content_access + WHERE scope = 'class' AND allow = 1 AND target_id IN (${ph})`).all(...ids); + for (const r of rows) { + const o = open[r.target_id]; + if (o && o[r.content_type]) o[r.content_type].push(r.content_ref); + } + } + res.json({ classes, open }); +}); + /* ── Текущие правила для одного контента ───────────────────────────────── */ /* GET /api/access/rules?content_type=&content_ref= → { classRules:{[class_id]:allow}, studentRules:{[user_id]:allow} } */ diff --git a/backend/tests/content-access.test.js b/backend/tests/content-access.test.js index a49ec92..8bb7763 100644 --- a/backend/tests/content-access.test.js +++ b/backend/tests/content-access.test.js @@ -95,6 +95,21 @@ describe('contentAccess', () => { assert.equal(db.prepare("SELECT COUNT(*) c FROM content_access WHERE scope='student' AND target_id=?").get(student.userId).c, 0); }); + it('GET /api/access/matrix — учитель видит свои классы и открытый контент', async () => { + clearHub(); + setRule('class', classId, 1); + const r = await inject('GET', '/api/access/matrix', null, teacher.token); + assert.equal(r.status, 200, JSON.stringify(r.body)); + const cls = (r.body.classes || []).find(c => c.id === classId); + assert.ok(cls, 'класс учителя в матрице'); + assert.ok((r.body.open[classId].textbook || []).includes(HUB), 'открытый учебник в матрице'); + }); + + it('GET /api/access/matrix — ученику 403', async () => { + const r = await inject('GET', '/api/access/matrix', null, student.token); + assert.equal(r.status, 403); + }); + it('DELETE /api/classes/:id чистит правила класса (через purgeAccessFor)', async () => { setRule('class', classId, 1); const del = await inject('DELETE', `/api/classes/${classId}`, null, teacher.token); diff --git a/backend/tests/setup.js b/backend/tests/setup.js index 905484b..34064df 100644 --- a/backend/tests/setup.js +++ b/backend/tests/setup.js @@ -44,6 +44,7 @@ app.use('/api/questions', require('../src/routes/questions')); // Additional routes for integration tests app.use('/api/permissions', require('../src/routes/permissions')); +app.use('/api/access', require('../src/routes/access')); // Feature-gated routes (requireFeature checks app_settings in DB) const { requireFeature } = require('../src/middleware/features'); diff --git a/frontend/js/admin/sections/access.js b/frontend/js/admin/sections/access.js index 7188f2d..f8c8462 100644 --- a/frontend/js/admin/sections/access.js +++ b/frontend/js/admin/sections/access.js @@ -23,6 +23,10 @@ let _selClass = null; // { id, name } let _classOpen = { textbooks: new Set(), exams: new Set() }; + // matrix-mode state + let _matrix = null; // { classes:[{id,name}], open:{ [cid]:{textbook:[],exam:[]} } } + let _mSearch = ''; + const esc = (s) => (window.LS && LS.esc ? LS.esc(s) : String(s == null ? '' : s)); const bucket = (type) => (type === 'textbook' ? 'textbooks' : 'exams'); const keyName = (type) => (type === 'textbook' ? 'slug' : 'exam_key'); @@ -44,18 +48,25 @@ } } - /* ── каркас: переключатель режимов + две колонки ── */ + /* ── каркас: переключатель режимов + две колонки / матрица ── */ function renderRoot() { const root = document.getElementById('acc-root'); - const seg = (m, label) => - ``; - root.innerHTML = ` -
Загрузка…
'; + try { _matrix = await LS.accessMatrix(); } + catch (e) { root.innerHTML = `Ошибка: ${esc(e.message)}
`; return; } + } + const classes = _matrix.classes || []; + if (!classes.length) { root.innerHTML = empty('Нет классов'); return; } + root.innerHTML = ` +| ${matrixHeadCells(classes)} |
|---|