From b702b04ed2e05cd019d26c59c0f92208b60e1cb0 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Wed, 3 Jun 2026 13:55:02 +0300 Subject: [PATCH] =?UTF-8?q?feat(access):=20=D0=A4=D0=B0=D0=B7=D0=B0=202c?= =?UTF-8?q?=20=E2=80=94=20=D0=B8=D1=81=D1=82=D0=BE=D1=80=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=20+=20=D0=BF=D1=80=D0=B5?= =?UTF-8?q?=D1=81=D0=B5=D1=82=20=C2=AB=D0=BA=D0=BE=D0=BF=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D1=8C=20=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF?= =?UTF-8?q?=20=D0=B8=D0=B7=20=D0=BA=D0=BB=D0=B0=D1=81=D1=81=D0=B0=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit История: GET /api/access/log (admin-only) — кто/когда открыл/закрыл/сбросил правило для контента (из admin_audit_log, имена классов/учеников резолвятся). Клиент LS.accessLog; в режиме «По контенту» — кнопка «История изменений». Пресет: в режиме «По классу» — «Скопировать доступ из класса [выбор]» (дополняет текущие правила открытыми правилами класса-источника). Тест: история (admin видит запись, учителю 403). content-access 13/13. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/routes/access.js | 30 +++++++++++++++ backend/tests/content-access.test.js | 13 +++++++ frontend/js/admin/sections/access.js | 55 +++++++++++++++++++++++++++- js/api.js | 6 ++- 4 files changed, 102 insertions(+), 2 deletions(-) diff --git a/backend/src/routes/access.js b/backend/src/routes/access.js index 729f6b4..3918e8c 100644 --- a/backend/src/routes/access.js +++ b/backend/src/routes/access.js @@ -146,6 +146,36 @@ router.get('/matrix', (req, res) => { res.json({ classes, open }); }); +/* ── История изменений правил доступа к контенту (только админ) ────────── */ +/* GET /api/access/log?content_type=&content_ref= + → [{ action:'grant'|'deny'|'inherit', actor, targetName, at }] (последние 50) */ +router.get('/log', requireRole('admin'), (req, res) => { + const { content_type, content_ref } = req.query; + if (!['textbook', 'exam', 'sim', 'course'].includes(content_type) || !content_ref) { + return res.status(400).json({ error: 'content_type и content_ref обязательны' }); + } + const rows = db.prepare(` + SELECT a.action, a.detail, a.created_at, u.name AS actor + FROM admin_audit_log a LEFT JOIN users u ON u.id = a.admin_id + WHERE a.action LIKE 'access.%' AND a.target = ? + ORDER BY a.id DESC LIMIT 50 + `).all(content_type + ':' + content_ref); + + const out = rows.map(r => { + const [scope, tid] = String(r.detail || '').split(':'); + let targetName = r.detail || ''; + if (scope === 'class') { + const c = db.prepare('SELECT name FROM classes WHERE id = ?').get(tid); + targetName = c ? `класс «${c.name}»` : `класс #${tid}`; + } else if (scope === 'student') { + const s = db.prepare('SELECT name, email FROM users WHERE id = ?').get(tid); + targetName = s ? `ученик ${s.name || s.email}` : `ученик #${tid}`; + } + return { action: r.action.replace('access.', ''), actor: r.actor || '—', targetName, at: r.created_at }; + }); + res.json(out); +}); + /* ── Текущие правила для одного контента ───────────────────────────────── */ /* 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 573666e..b37cdd8 100644 --- a/backend/tests/content-access.test.js +++ b/backend/tests/content-access.test.js @@ -116,6 +116,19 @@ describe('contentAccess', () => { assert.ok(Array.isArray(r.body.sims) && r.body.sims.length >= 1, 'каталог содержит симуляции'); }); + it('GET /api/access/log — история (admin видит запись; учителю 403)', async () => { + const admin = await getToken('admin'); + const p = await inject('POST', '/api/access/rules', + { content_type: 'textbook', content_ref: HUB, scope: 'class', target_id: classId, allow: 1 }, admin.token); + assert.ok(p.status < 300, JSON.stringify(p.body)); + const log = await inject('GET', `/api/access/log?content_type=textbook&content_ref=${HUB}`, null, admin.token); + assert.equal(log.status, 200); + assert.ok(Array.isArray(log.body) && log.body.length >= 1, 'есть запись истории'); + assert.equal(log.body[0].action, 'grant'); + const t = await inject('GET', `/api/access/log?content_type=textbook&content_ref=${HUB}`, null, teacher.token); + assert.equal(t.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/frontend/js/admin/sections/access.js b/frontend/js/admin/sections/access.js index 98d65ce..b4c0f4c 100644 --- a/frontend/js/admin/sections/access.js +++ b/frontend/js/admin/sections/access.js @@ -269,7 +269,29 @@
Отдельные ученики (без класса)
${loose.map(looseRow).join('')} -
` : ''}`; + ` : ''} +
+ +
+
`; + } + + async function showLog() { + const box = document.getElementById('acc-log'); + if (!box || !_selContent) return; + box.innerHTML = '

Загрузка…

'; + try { + const log = await LS.accessLog(_selContent.type, _selContent.ref); + if (!log.length) { box.innerHTML = '

Изменений пока нет.

'; return; } + const A = { grant: 'открыл', deny: 'закрыл (исключение)', inherit: 'сбросил (наследование)' }; + box.innerHTML = log.map(e => ` +
+ ${esc(e.actor)} ${A[e.action] || esc(e.action)} · ${esc(e.targetName)} + · ${esc((e.at || '').replace('T', ' ').slice(0, 16))} +
`).join(''); + } catch (e) { + box.innerHTML = `

${e && e.status === 403 ? 'История доступна только администратору.' : 'Ошибка: ' + esc(e.message)}

`; + } } /* пересчёт бейджа для текущего контента по отображаемым классам */ @@ -359,6 +381,17 @@ ${subjects.map(s => ``).join('')} `; } + const others = (_targets.classes || []).filter(c => c.id !== _selClass.id); + if (others.length) { + html += `
+ Скопировать доступ из класса: + + +
`; + } CONTENT_TYPES.forEach(type => { const items = itemsOf(type); html += `
${TYPE_LABEL[type]}
`; @@ -397,6 +430,24 @@ } catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); selClass(_selClass.id); } } + /* пресет: скопировать открытый доступ из другого класса в текущий (дополняет) */ + async function copyFrom() { + const sel = document.getElementById('acc-copy-src'); + if (!sel || !sel.value) { LS.toast('Выберите класс-источник', 'error'); return; } + const srcId = Number(sel.value); + const srcName = sel.options[sel.selectedIndex].text; + if (!confirm(`Скопировать весь открытый доступ из «${srcName}» в «${_selClass.name}»? Текущие правила класса дополнятся.`)) return; + try { + const src = await LS.accessClassOpen(srcId); + const items = CONTENT_TYPES.flatMap(t => (src[bucket(t)] || []).map(ref => [t, ref])); + if (!items.length) { LS.toast('В классе-источнике нет открытого контента', 'error'); return; } + await Promise.all(items.map(([t, ref]) => LS.accessSetRule(t, ref, 'class', _selClass.id, 1))); + items.forEach(([t, ref]) => { const set = _classOpen[bucket(t)]; if (set && !set.has(ref)) { set.add(ref); bumpSummary(t, ref, +1); } }); + renderRight(); + LS.toast(`Скопировано из «${srcName}» (${items.length})`, 'success'); + } catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); selClass(_selClass.id); } + } + async function classBulk(allow) { if (!allow && !confirm(`Закрыть весь контент у класса «${_selClass.name}»?`)) return; const all = CONTENT_TYPES.flatMap(t => itemsOf(t).map(it => [t, it[keyName(t)]])); @@ -532,6 +583,8 @@ window.accClassToggle = classToggle; window.accClassBulk = classBulk; window.accClassSubj = classSubjectBulk; + window.accCopyFrom = copyFrom; + window.accShowLog = showLog; window.accMx = mxToggle; window.accMxSearch = mxSearch; window.accMxRowBulk = mxRowBulk; diff --git a/js/api.js b/js/api.js index b3299cb..75e02bf 100644 --- a/js/api.js +++ b/js/api.js @@ -1034,7 +1034,7 @@ window.LS = { getFolderAccess, clearFolderAccess, assignFolder, unassignFolder, getStudentsList, submitWork, resubmitWork, getMySubmissions, getClassSubmissions, reviewSubmission, deleteSubmission, submissionDownloadUrl, getPermissions, setPermission, getUserPermissions, setUserPermission, resetUserPermissions, - accessCatalog, accessTargets, accessSummary, accessClassOpen, accessMatrix, accessRules, accessSetRule, + accessCatalog, accessTargets, accessSummary, accessClassOpen, accessMatrix, accessLog, accessRules, accessSetRule, getCourseTemplates, saveCourseTemplate, createFromCourseTemplate, deleteCourseTemplate, getLessonTemplates, saveLessonTemplate, createFromLessonTemplate, deleteLessonTemplate, getBookmarks, addBookmark, removeBookmark, removeBookmarkByEntity, checkBookmark, @@ -1306,6 +1306,10 @@ 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 accessLog(content_type, content_ref) { + const p = new URLSearchParams({ content_type, content_ref }); + return req('GET', `/access/log?${p}`); +} async function accessRules(content_type, content_ref) { const p = new URLSearchParams({ content_type, content_ref }); return req('GET', `/access/rules?${p}`);