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 @@
+
`;
+ 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 `
- `;
}
@@ -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}`);