From 67a70c672d41c5e908843794e11a4992cfb82719 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Wed, 3 Jun 2026 12:43:00 +0300 Subject: [PATCH] =?UTF-8?q?feat(access):=20=D0=A4=D0=B0=D0=B7=D0=B0=202a?= =?UTF-8?q?=20=E2=80=94=20=D1=80=D0=B5=D0=B6=D0=B8=D0=BC=20=C2=AB=D0=9C?= =?UTF-8?q?=D0=B0=D1=82=D1=80=D0=B8=D1=86=D0=B0=C2=BB=20=D0=BA=D0=BB=D0=B0?= =?UTF-8?q?=D1=81=D1=81=20=C3=97=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=20=D0=B2=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/access/matrix (классы + карта открытого контента одним запросом, скоуп учителя). Клиент LS.accessMatrix. Третий режим вкладки «Доступ»: таблица контент × классы с чекбоксами (правка в один клик) + поиск по названию (обновляет только tbody — фокус ввода сохраняется), залипающие заголовки. Тест /api/access смонтирован в харнесс; content-access.test 11/11 (+матрица: учитель видит свои классы и открытый контент, ученику 403). Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/routes/access.js | 24 +++++++ backend/tests/content-access.test.js | 15 +++++ backend/tests/setup.js | 1 + frontend/js/admin/sections/access.js | 93 +++++++++++++++++++++++++--- js/api.js | 3 +- 5 files changed, 126 insertions(+), 10 deletions(-) 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 = ` -
- ${seg('content', 'По контенту')}${seg('class', 'По классу')} -
+ font-size:13px;padding:6px 16px;cursor:pointer;font-family:inherit;${radius}">${label}`; + }; + const tabs = `
+ ${seg('content', 'По контенту', 'first')}${seg('class', 'По классу', 'mid')}${seg('matrix', 'Матрица', 'last')} +
`; + if (_mode === 'matrix') { + root.innerHTML = tabs + `
`; + renderMatrix(); + return; + } + root.innerHTML = tabs + `
@@ -328,10 +339,72 @@ } catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); selClass(_selClass.id); } } + /* ════════ режим «Матрица» (класс × контент одним экраном) ════════ */ + function matrixHeadCells(classes) { + return classes.map(c => + `${esc(c.name)}`).join(''); + } + function matrixBody() { + const classes = _matrix.classes || []; + const term = _mSearch.trim().toLowerCase(); + const match = (it) => !term || (it.title || '').toLowerCase().includes(term); + const section = (type, items) => { + const rows = (items || []).filter(match).map(it => { + const ref = it[keyName(type)]; + const cells = classes.map(c => { + const open = ((_matrix.open[c.id] || {})[type] || []).includes(ref); + return ` + `; + }).join(''); + return `${esc(it.title)}${cells}`; + }).join(''); + if (!rows) return ''; + const label = type === 'textbook' ? 'Учебники' : 'Экзамены'; + return `${label}${rows}`; + }; + const body = section('textbook', (_catalog || {}).textbooks) + section('exam', (_catalog || {}).exams); + return body || `${empty('Ничего не найдено')}`; + } + async function renderMatrix() { + const root = document.getElementById('acc-matrix'); + if (!root) return; + if (!_matrix) { + 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)} + ${matrixBody()} +
+
`; + } + async function mxToggle(type, ref, classId, checked) { + try { + await LS.accessSetRule(type, ref, 'class', classId, checked ? 1 : null); + const o = _matrix.open[classId] || (_matrix.open[classId] = { textbook: [], exam: [] }); + const arr = o[type] || (o[type] = []); + const i = arr.indexOf(ref); + if (checked && i < 0) arr.push(ref); + if (!checked && i >= 0) arr.splice(i, 1); + } catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); _matrix = null; renderMatrix(); } + } + function mxSearch(v) { _mSearch = v; const b = document.getElementById('acc-mx-body'); if (b) b.innerHTML = matrixBody(); } + /* ── режим ── */ function setMode(m) { if (m === _mode) return; _mode = m; + if (m === 'matrix') _matrix = null; // всегда свежая матрица renderRoot(); } @@ -344,6 +417,8 @@ window.accSelClass = selClass; window.accClassToggle = classToggle; window.accClassBulk = classBulk; + window.accMx = mxToggle; + window.accMxSearch = mxSearch; window.AdminSections = window.AdminSections || {}; window.AdminSections.access = { diff --git a/js/api.js b/js/api.js index 399589a..bd0cc7d 100644 --- a/js/api.js +++ b/js/api.js @@ -1029,7 +1029,7 @@ window.LS = { getFolderAccess, clearFolderAccess, assignFolder, unassignFolder, getStudentsList, submitWork, resubmitWork, getMySubmissions, getClassSubmissions, reviewSubmission, deleteSubmission, submissionDownloadUrl, getPermissions, setPermission, getUserPermissions, setUserPermission, resetUserPermissions, - accessCatalog, accessTargets, accessSummary, accessClassOpen, accessRules, accessSetRule, + accessCatalog, accessTargets, accessSummary, accessClassOpen, accessMatrix, accessRules, accessSetRule, getCourseTemplates, saveCourseTemplate, createFromCourseTemplate, deleteCourseTemplate, getLessonTemplates, saveLessonTemplate, createFromLessonTemplate, deleteLessonTemplate, getBookmarks, addBookmark, removeBookmark, removeBookmarkByEntity, checkBookmark, @@ -1300,6 +1300,7 @@ async function accessCatalog() { return req('GET', '/access/catalog'); } async function accessTargets() { return req('GET', '/access/targets'); } async function accessSummary() { return req('GET', '/access/summary'); } async function accessClassOpen(classId) { return req('GET', `/access/class/${classId}`); } +async function accessMatrix() { return req('GET', '/access/matrix'); } async function accessRules(content_type, content_ref) { const p = new URLSearchParams({ content_type, content_ref }); return req('GET', `/access/rules?${p}`);