From 76df3b45943ba876991e66c493354e6301ce8650 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Sat, 30 May 2026 12:47:05 +0300 Subject: [PATCH] =?UTF-8?q?feat(access):=20=D0=B2=D0=B8=D0=B4=20=C2=AB?= =?UTF-8?q?=D0=BF=D0=BE=20=D0=BA=D0=BB=D0=B0=D1=81=D1=81=D1=83=C2=BB,=20?= =?UTF-8?q?=D0=BC=D0=B0=D1=81=D1=81=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=B4=D0=B5?= =?UTF-8?q?=D0=B9=D1=81=D1=82=D0=B2=D0=B8=D1=8F,=20=D0=B1=D0=B5=D0=B9?= =?UTF-8?q?=D0=B4=D0=B6=D0=B8=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20+=20=D1=87=D0=B8=D1=81=D1=82=D0=BA=D0=B0=20orpha?= =?UTF-8?q?n-=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit По итогам ревью системы прав: - админка: переключатель режимов «По контенту» / «По классу» - кнопки «Открыть всем классам» / «Закрыть у всех» (и зеркально по классу) - бейджи N/M (сколько классов открыто) в списке контента - эндпоинты /api/access/summary и /api/access/class/:id - вкладка «Доступ к учебникам» перенесена к «Права доступа» (группа Пользователи) - чистка content_access при удалении класса/ученика (нет FK) Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/controllers/adminController.js | 2 + backend/src/controllers/classController.js | 2 + backend/src/routes/access.js | 47 +++ frontend/admin.html | 23 +- frontend/js/admin/sections/access.js | 327 ++++++++++++++++----- js/api.js | 4 +- 6 files changed, 306 insertions(+), 99 deletions(-) diff --git a/backend/src/controllers/adminController.js b/backend/src/controllers/adminController.js index 54c9558..d251794 100644 --- a/backend/src/controllers/adminController.js +++ b/backend/src/controllers/adminController.js @@ -480,6 +480,8 @@ const _deleteUserTx = db.transaction((uid) => { // The rest cascades via ON DELETE CASCADE, but explicitly clean large tables: db.prepare('DELETE FROM notifications WHERE user_id = ?').run(uid); db.prepare('DELETE FROM test_sessions WHERE user_id = ?').run(uid); + // Персональные правила доступа к контенту (нет FK — чистим вручную): + db.prepare("DELETE FROM content_access WHERE scope = 'student' AND target_id = ?").run(uid); db.prepare('DELETE FROM users WHERE id = ?').run(uid); }); diff --git a/backend/src/controllers/classController.js b/backend/src/controllers/classController.js index 240569d..370516c 100644 --- a/backend/src/controllers/classController.js +++ b/backend/src/controllers/classController.js @@ -324,6 +324,8 @@ function deleteClass(req, res) { if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id) return res.status(403).json({ error: 'Forbidden' }); stmts.deleteClass.run(req.params.id); + // Правила доступа к контенту для этого класса (нет FK — чистим вручную): + db.prepare("DELETE FROM content_access WHERE scope = 'class' AND target_id = ?").run(req.params.id); res.json({ ok: true }); } diff --git a/backend/src/routes/access.js b/backend/src/routes/access.js index 88863e0..53279ed 100644 --- a/backend/src/routes/access.js +++ b/backend/src/routes/access.js @@ -76,6 +76,36 @@ router.get('/targets', (req, res) => { res.json({ classes: classList, looseStudents }); }); +/* ── Сводка: сколько классов открыто по каждому контенту ───────────────── */ +/* GET /api/access/summary + → { totalClasses, textbooks:{[slug]:openCount}, exams:{[key]:openCount} } */ +router.get('/summary', (req, res) => { + const admin = isAdmin(req); + const totalClasses = admin + ? db.prepare('SELECT COUNT(*) n FROM classes').get().n + : db.prepare('SELECT COUNT(*) n FROM classes WHERE teacher_id = ?').get(req.user.id).n; + + const rows = admin + ? db.prepare(` + SELECT content_type, content_ref, COUNT(DISTINCT target_id) n + FROM content_access + WHERE scope = 'class' AND allow = 1 + GROUP BY content_type, content_ref`).all() + : db.prepare(` + SELECT content_type, content_ref, COUNT(DISTINCT target_id) n + FROM content_access + WHERE scope = 'class' AND allow = 1 + AND target_id IN (SELECT id FROM classes WHERE teacher_id = ?) + GROUP BY content_type, content_ref`).all(req.user.id); + + const textbooks = {}, exams = {}; + for (const r of rows) { + if (r.content_type === 'textbook') textbooks[r.content_ref] = r.n; + else exams[r.content_ref] = r.n; + } + res.json({ totalClasses, textbooks, exams }); +}); + /* ── Текущие правила для одного контента ───────────────────────────────── */ /* GET /api/access/rules?content_type=&content_ref= → { classRules:{[class_id]:allow}, studentRules:{[user_id]:allow} } */ @@ -101,6 +131,23 @@ router.get('/rules', (req, res) => { function teacherOwnsClass(teacherId, classId) { return !!db.prepare('SELECT 1 FROM classes WHERE id = ? AND teacher_id = ?').get(classId, teacherId); } + +/* ── Открытый классу контент (для вида «по классу») ────────────────────── */ +/* GET /api/access/class/:id → { textbooks:[slug], exams:[exam_key] } (allow=1) */ +router.get('/class/:id', requireRole('admin', 'teacher'), (req, res) => { + const cid = Number(req.params.id); + if (!Number.isInteger(cid) || cid <= 0) return res.status(400).json({ error: 'неверный id' }); + if (!isAdmin(req) && !teacherOwnsClass(req.user.id, cid)) { + return res.status(403).json({ error: 'Нет прав на этот класс' }); + } + const rows = db.prepare(` + SELECT content_type, content_ref FROM content_access + WHERE scope = 'class' AND target_id = ? AND allow = 1 + `).all(cid); + const textbooks = [], exams = []; + for (const r of rows) (r.content_type === 'textbook' ? textbooks : exams).push(r.content_ref); + res.json({ textbooks, exams }); +}); function teacherCanManageStudent(teacherId, studentId) { const inClass = db.prepare(` SELECT 1 FROM class_members cm JOIN classes c ON c.id = cm.class_id diff --git a/frontend/admin.html b/frontend/admin.html index 8d9141d..f5f2d30 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -956,9 +956,6 @@ - @@ -974,6 +971,9 @@ + `; + /* ── каркас: переключатель режимов + две колонки ── */ + function renderRoot() { + const root = document.getElementById('acc-root'); + const seg = (m, label) => + ``; + root.innerHTML = ` +
+ ${seg('content', 'По контенту')}${seg('class', 'По классу')} +
+
+
+
+
`; + renderLeft(); + renderRight(); } - function renderList() { - const tb = document.getElementById('acc-textbooks'); - const ex = document.getElementById('acc-exams'); - tb.innerHTML = (_catalog.textbooks || []) - .map(t => itemBtn('textbook', t.slug, t.title, t.grade ? t.grade + ' кл.' : '')).join('') - || '

Нет учебников

'; - ex.innerHTML = (_catalog.exams || []) - .map(e => itemBtn('exam', e.exam_key, e.title, e.grade ? e.grade + ' кл.' : '')).join('') - || '

Нет экзаменов

'; + /* ── badge «N/M» ── */ + function badge(open, total) { + const has = open > 0; + return `${open}/${total}`; } - async function select(type, ref) { - const src = type === 'textbook' ? _catalog.textbooks : _catalog.exams; - const keyName = type === 'textbook' ? 'slug' : 'exam_key'; - const item = (src || []).find(x => x[keyName] === ref); - _sel = { type, ref, title: item ? item.title : ref }; - renderList(); - document.getElementById('acc-detail-empty').style.display = 'none'; - const det = document.getElementById('acc-detail'); - det.style.display = ''; - det.innerHTML = '

Загрузка…

'; + /* ── ЛЕВАЯ колонка ── */ + function renderLeft() { + const left = document.getElementById('acc-left'); + if (_mode === 'content') { + const total = _summary.totalClasses || 0; + const list = (type, items) => items.map(it => { + const ref = it[keyName(type)]; + const active = _selContent && _selContent.type === type && _selContent.ref === ref; + const open = (_summary[bucket(type)] || {})[ref] || 0; + return ``; + }).join(''); + left.innerHTML = ` +
Учебники
+ ${list('textbook', _catalog.textbooks || []) || empty('Нет учебников')} +
Экзамены
+ ${list('exam', _catalog.exams || []) || empty('Нет экзаменов')}`; + } else { + const classes = _targets.classes || []; + left.innerHTML = ` +
Классы
+ ${classes.length ? classes.map(c => { + const active = _selClass && _selClass.id === c.id; + return ``; + }).join('') : empty('Нет классов')}`; + } + } + const empty = (t) => `

${t}

`; + + /* ── ПРАВАЯ колонка ── */ + function renderRight() { + const right = document.getElementById('acc-right'); + if (_mode === 'content') { + if (!_selContent) { right.innerHTML = hint('Выберите учебник или экзамен слева, чтобы настроить доступ.'); return; } + renderContentDetail(right); + } else { + if (!_selClass) { right.innerHTML = hint('Выберите класс слева, чтобы открыть ему учебники и экзамены.'); return; } + renderClassDetail(right); + } + } + const hint = (t) => `
${t}
`; + + /* ════════ режим «По контенту» ════════ */ + async function selContent(type, ref) { + _selContent = { type, ref, title: contentTitle(type, ref) }; + renderLeft(); + const right = document.getElementById('acc-right'); + right.innerHTML = '

Загрузка…

'; try { _rules = await LS.accessRules(type, ref); - renderDetail(); - } catch (e) { - det.innerHTML = `

Ошибка: ${esc(e.message)}

`; - } + renderRight(); + } catch (e) { right.innerHTML = `

Ошибка: ${esc(e.message)}

`; } } - /* tri-state кнопки для ученика внутри класса */ function studentTri(uid) { - const v = _rules.studentRules[uid]; // 1 | 0 | undefined + const v = _rules.studentRules[uid]; const state = v === 1 ? 'open' : v === 0 ? 'closed' : 'inherit'; const btn = (val, label, on) => ``; - return ` - ${btn('null', 'Наследовать', state === 'inherit')} - ${btn(1, 'Открыт', state === 'open')} - ${btn(0, 'Закрыт', state === 'closed')} - `; + ${val === 'null' ? 'border-radius:7px 0 0 7px' : val === 0 ? 'border-radius:0 7px 7px 0;border-left:none' : 'border-left:none'}">${label}`; + return ` + ${btn('null', 'Наследовать', state === 'inherit')}${btn(1, 'Открыт', state === 'open')}${btn(0, 'Закрыт', state === 'closed')}`; } - function classRow(c) { + function classRowContent(c) { const openToClass = _rules.classRules[c.id] === 1; const expanded = _open.has(c.id); const students = c.students || []; @@ -87,25 +154,22 @@
${students.length ? students.map(s => `
- ${esc(s.name || s.email)} - ${studentTri(s.id)} -
`).join('') - : '

В классе нет учеников

'} + ${esc(s.name || s.email)}${studentTri(s.id)} +
`).join('') : '

В классе нет учеников

'} ` : ''; return ` -
+
-
- ${studentsHtml} +
${studentsHtml}
`; } @@ -121,60 +185,163 @@ `; } - function renderDetail() { - const det = document.getElementById('acc-detail'); + function renderContentDetail(right) { const classes = _targets.classes || []; const loose = _targets.looseStudents || []; - det.innerHTML = ` + right.innerHTML = `
-
${esc(_sel.title)}
- ${_sel.type === 'exam' ? 'Экзамен' : 'Учебник'} -
-
- ${classes.length ? classes.map(classRow).join('') - : '

Нет классов.

'} +
${esc(_selContent.title)}
+ ${_selContent.type === 'exam' ? 'Экзамен' : 'Учебник'}
+ ${classes.length ? ` +
+ + +
` : ''} + ${classes.length ? classes.map(classRowContent).join('') : '

Нет классов.

'} ${loose.length ? `
Отдельные ученики (без класса)
${loose.map(looseRow).join('')} -
` : ''} - `; + ` : ''}`; + } + + /* пересчёт бейджа для текущего контента по отображаемым классам */ + function recountContent() { + if (!_selContent) return; + const open = (_targets.classes || []).filter(c => _rules.classRules[c.id] === 1).length; + _summary[bucket(_selContent.type)][_selContent.ref] = open; } - /* ── handlers (optimistic update) ── */ async function setClass(classId, checked) { const allow = checked ? 1 : null; try { - await LS.accessSetRule(_sel.type, _sel.ref, 'class', classId, allow); - if (allow === 1) _rules.classRules[classId] = 1; - else delete _rules.classRules[classId]; - renderDetail(); - LS.toast(checked ? 'Открыт классу' : 'Закрыт для класса', 'success'); - } catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); renderDetail(); } + await LS.accessSetRule(_selContent.type, _selContent.ref, 'class', classId, allow); + if (allow === 1) _rules.classRules[classId] = 1; else delete _rules.classRules[classId]; + recountContent(); renderLeft(); renderRight(); + } catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); renderRight(); } + } + + async function bulk(allow) { + const classes = _targets.classes || []; + try { + await Promise.all(classes.map(c => + LS.accessSetRule(_selContent.type, _selContent.ref, 'class', c.id, allow ? 1 : null))); + for (const c of classes) { if (allow) _rules.classRules[c.id] = 1; else delete _rules.classRules[c.id]; } + recountContent(); renderLeft(); renderRight(); + LS.toast(allow ? 'Открыто всем классам' : 'Закрыто у всех классов', 'success'); + } catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); selContent(_selContent.type, _selContent.ref); } } async function setStudent(uid, allow) { - // allow: 1 | 0 | null (строка 'null' приходит из tri-кнопок) if (allow === 'null') allow = null; try { - await LS.accessSetRule(_sel.type, _sel.ref, 'student', uid, allow); + await LS.accessSetRule(_selContent.type, _selContent.ref, 'student', uid, allow); if (allow === 1) _rules.studentRules[uid] = 1; else if (allow === 0) _rules.studentRules[uid] = 0; else delete _rules.studentRules[uid]; - renderDetail(); - } catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); renderDetail(); } + renderRight(); + } catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); renderRight(); } } function toggleExpand(classId) { if (_open.has(classId)) _open.delete(classId); else _open.add(classId); - renderDetail(); + renderRight(); } - window.accSelect = select; + /* ════════ режим «По классу» ════════ */ + async function selClass(id) { + const c = (_targets.classes || []).find(x => x.id === id); + _selClass = { id, name: c ? c.name : ('#' + id) }; + renderLeft(); + const right = document.getElementById('acc-right'); + right.innerHTML = '

Загрузка…

'; + try { + const open = await LS.accessClassOpen(id); + _classOpen = { textbooks: new Set(open.textbooks || []), exams: new Set(open.exams || []) }; + renderRight(); + } catch (e) { right.innerHTML = `

Ошибка: ${esc(e.message)}

`; } + } + + function classContentRow(type, it) { + const ref = it[keyName(type)]; + const open = _classOpen[bucket(type)].has(ref); + return ` +
+ ${esc(it.title)} + ${type === 'exam' ? 'Экзамен' : 'Учебник'} + +
`; + } + + function renderClassDetail(right) { + const tb = _catalog.textbooks || [], ex = _catalog.exams || []; + right.innerHTML = ` +
Класс «${esc(_selClass.name)}»
+
+ + +
+
Учебники
+ ${tb.length ? tb.map(it => classContentRow('textbook', it)).join('') : empty('Нет учебников')} +
Экзамены
+ ${ex.length ? ex.map(it => classContentRow('exam', it)).join('') : empty('Нет экзаменов')}`; + } + + function bumpSummary(type, ref, delta) { + const b = _summary[bucket(type)]; + const cur = b[ref] || 0; + b[ref] = Math.max(0, Math.min(_summary.totalClasses || 0, cur + delta)); + } + + async function classToggle(type, ref, checked) { + try { + await LS.accessSetRule(type, ref, 'class', _selClass.id, checked ? 1 : null); + const set = _classOpen[bucket(type)]; + const was = set.has(ref); + if (checked) set.add(ref); else set.delete(ref); + if (checked && !was) bumpSummary(type, ref, +1); + if (!checked && was) bumpSummary(type, ref, -1); + renderRight(); + } catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); selClass(_selClass.id); } + } + + async function classBulk(allow) { + const all = [...(_catalog.textbooks || []).map(it => ['textbook', it[keyName('textbook')]]), + ...(_catalog.exams || []).map(it => ['exam', it[keyName('exam')]])]; + try { + await Promise.all(all.map(([type, ref]) => + LS.accessSetRule(type, ref, 'class', _selClass.id, allow ? 1 : null))); + for (const [type, ref] of all) { + const set = _classOpen[bucket(type)]; + const was = set.has(ref); + if (allow) { set.add(ref); if (!was) bumpSummary(type, ref, +1); } + else { set.delete(ref); if (was) bumpSummary(type, ref, -1); } + } + renderRight(); + LS.toast(allow ? 'Открыт весь контент классу' : 'Закрыт весь контент', 'success'); + } catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); selClass(_selClass.id); } + } + + /* ── режим ── */ + function setMode(m) { + if (m === _mode) return; + _mode = m; + renderRoot(); + } + + window.accMode = setMode; + window.accSelContent = selContent; window.accSetClass = setClass; + window.accBulk = bulk; window.accSetStudent = setStudent; window.accToggleExpand = toggleExpand; + window.accSelClass = selClass; + window.accClassToggle = classToggle; + window.accClassBulk = classBulk; window.AdminSections = window.AdminSections || {}; window.AdminSections.access = { diff --git a/js/api.js b/js/api.js index dc179b7..531eb94 100644 --- a/js/api.js +++ b/js/api.js @@ -1025,7 +1025,7 @@ window.LS = { getFolderAccess, clearFolderAccess, assignFolder, unassignFolder, getStudentsList, submitWork, resubmitWork, getMySubmissions, getClassSubmissions, reviewSubmission, deleteSubmission, submissionDownloadUrl, getPermissions, setPermission, getUserPermissions, setUserPermission, resetUserPermissions, - accessCatalog, accessTargets, accessRules, accessSetRule, + accessCatalog, accessTargets, accessSummary, accessClassOpen, accessRules, accessSetRule, getCourseTemplates, saveCourseTemplate, createFromCourseTemplate, deleteCourseTemplate, getLessonTemplates, saveLessonTemplate, createFromLessonTemplate, deleteLessonTemplate, getBookmarks, addBookmark, removeBookmark, removeBookmarkByEntity, checkBookmark, @@ -1292,6 +1292,8 @@ async function resetUserPermissions(uid, permission) { return req('DELETE' /* ── content access (учебники / экзамены: открыть-закрыть классам/ученикам) ── */ 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 accessRules(content_type, content_ref) { const p = new URLSearchParams({ content_type, content_ref }); return req('GET', `/access/rules?${p}`);