feat(access): доступ к учебникам и экзаменам по классам/ученикам из админ-панели
Модель allowlist (закрыто по умолчанию), правило ученика важнее класса. Управляют админ (все) и учителя (свои классы/ученики). - миграция 040: таблица content_access + непрерывный переход (всем существующим классам открыт текущий контент) - сервис contentAccess: резолвинг доступа, главы наследуют хаб - API /api/access (catalog/targets/rules) для admin+teacher - гейты: каталог учебников, router.param slug/examKey, фильтр tracks - клиентские редиректы на /403 (textbook-tracker, exam-prep boot) - раздел админки «Доступ к учебникам»: классы + ученики (tri-state) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
-- 040_content_access.sql
|
||||
-- Per-class / per-student access control for textbooks and exam modules.
|
||||
--
|
||||
-- Model (chosen 2026-05-30): ALLOWLIST — content is hidden by default and must
|
||||
-- be explicitly opened for a class or a student. A student-level rule always
|
||||
-- overrides the class-level rule (точечные исключения). allow = 1 → открыт,
|
||||
-- allow = 0 → закрыт (используется только как индивидуальное исключение).
|
||||
--
|
||||
-- content_ref:
|
||||
-- • content_type='textbook' → top-level textbook slug (parent_slug IS NULL).
|
||||
-- Главы (parent_slug != NULL) наследуют доступ родителя.
|
||||
-- • content_type='exam' → exam_tracks.exam_key (например 'math9').
|
||||
|
||||
CREATE TABLE IF NOT EXISTS content_access (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
content_type TEXT NOT NULL CHECK (content_type IN ('textbook','exam')),
|
||||
content_ref TEXT NOT NULL,
|
||||
scope TEXT NOT NULL CHECK (scope IN ('class','student')),
|
||||
target_id INTEGER NOT NULL, -- class_id (scope=class) или user_id (scope=student)
|
||||
allow INTEGER NOT NULL DEFAULT 1 CHECK (allow IN (0,1)),
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (content_type, content_ref, scope, target_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_content_access_lookup ON content_access (content_type, content_ref);
|
||||
CREATE INDEX IF NOT EXISTS idx_content_access_target ON content_access (content_type, scope, target_id);
|
||||
|
||||
-- ── Непрерывный переход ───────────────────────────────────────────────────
|
||||
-- До этой миграции всё было открыто всем. Чтобы переход на allowlist не отнял
|
||||
-- доступ задним числом, выдаём каждому существующему классу доступ ко всем
|
||||
-- активным учебникам верхнего уровня и ко всем включённым экзамен-трекам.
|
||||
-- Новый контент, добавленный позже, по умолчанию закрыт — его нужно открыть
|
||||
-- явно из админ-панели.
|
||||
|
||||
INSERT OR IGNORE INTO content_access (content_type, content_ref, scope, target_id, allow)
|
||||
SELECT 'textbook', t.slug, 'class', c.id, 1
|
||||
FROM textbooks t CROSS JOIN classes c
|
||||
WHERE t.is_active = 1 AND t.parent_slug IS NULL;
|
||||
|
||||
INSERT OR IGNORE INTO content_access (content_type, content_ref, scope, target_id, allow)
|
||||
SELECT 'exam', e.exam_key, 'class', c.id, 1
|
||||
FROM exam_tracks e CROSS JOIN classes c
|
||||
WHERE e.enabled = 1;
|
||||
@@ -0,0 +1,168 @@
|
||||
'use strict';
|
||||
/* /api/access — управление доступом к учебникам и экзамен-модулям.
|
||||
*
|
||||
* Доступно админам (все классы/ученики) и учителям (только свои классы и
|
||||
* ученики своих классов / привязанные ученики). Модель — allowlist,
|
||||
* правило ученика важнее правила класса (см. services/contentAccess.js). */
|
||||
const router = require('express').Router();
|
||||
const db = require('../db/db');
|
||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||
|
||||
router.use(authMiddleware);
|
||||
router.use(requireRole('admin', 'teacher'));
|
||||
|
||||
const isAdmin = (req) => req.user.role === 'admin';
|
||||
|
||||
/* ── Каталог контента, которым можно управлять ─────────────────────────── */
|
||||
/* GET /api/access/catalog → { textbooks:[...], exams:[...] } */
|
||||
router.get('/catalog', (_req, res) => {
|
||||
const textbooks = db.prepare(`
|
||||
SELECT slug, title, subject, grade, color
|
||||
FROM textbooks
|
||||
WHERE is_active = 1 AND parent_slug IS NULL
|
||||
ORDER BY sort_order, subject, grade
|
||||
`).all();
|
||||
const exams = db.prepare(`
|
||||
SELECT exam_key, title, subject_slug, grade
|
||||
FROM exam_tracks
|
||||
WHERE enabled = 1
|
||||
ORDER BY sort_order, exam_key
|
||||
`).all();
|
||||
res.json({ textbooks, exams });
|
||||
});
|
||||
|
||||
/* ── Цели назначения: классы (+ученики) и отдельные ученики ────────────── */
|
||||
/* GET /api/access/targets → { classes:[{id,name,students:[...]}], looseStudents:[...] } */
|
||||
router.get('/targets', (req, res) => {
|
||||
const admin = isAdmin(req);
|
||||
|
||||
const classes = admin
|
||||
? db.prepare(`SELECT c.id, c.name, u.name AS teacher_name
|
||||
FROM classes c LEFT JOIN users u ON u.id = c.teacher_id
|
||||
ORDER BY c.name`).all()
|
||||
: db.prepare(`SELECT id, name FROM classes WHERE teacher_id = ? ORDER BY name`)
|
||||
.all(req.user.id);
|
||||
|
||||
const membersStmt = db.prepare(`
|
||||
SELECT u.id, u.name, u.email
|
||||
FROM class_members cm JOIN users u ON u.id = cm.user_id
|
||||
WHERE cm.class_id = ?
|
||||
ORDER BY u.name
|
||||
`);
|
||||
const classList = classes.map(c => ({ ...c, students: membersStmt.all(c.id) }));
|
||||
|
||||
/* «Отдельные ученики» — без класса (admin) / привязанные не в моих классах (teacher). */
|
||||
let looseStudents;
|
||||
if (admin) {
|
||||
looseStudents = db.prepare(`
|
||||
SELECT id, name, email FROM users
|
||||
WHERE role IN ('student','free_student')
|
||||
AND id NOT IN (SELECT user_id FROM class_members)
|
||||
ORDER BY name LIMIT 500
|
||||
`).all();
|
||||
} else {
|
||||
looseStudents = db.prepare(`
|
||||
SELECT u.id, u.name, u.email
|
||||
FROM teacher_students ts JOIN users u ON u.id = ts.student_id
|
||||
WHERE ts.teacher_id = ?
|
||||
AND u.id NOT IN (
|
||||
SELECT cm.user_id FROM class_members cm
|
||||
JOIN classes c ON c.id = cm.class_id
|
||||
WHERE c.teacher_id = ?)
|
||||
ORDER BY u.name
|
||||
`).all(req.user.id, req.user.id);
|
||||
}
|
||||
|
||||
res.json({ classes: classList, looseStudents });
|
||||
});
|
||||
|
||||
/* ── Текущие правила для одного контента ───────────────────────────────── */
|
||||
/* GET /api/access/rules?content_type=&content_ref=
|
||||
→ { classRules:{[class_id]:allow}, studentRules:{[user_id]:allow} } */
|
||||
router.get('/rules', (req, res) => {
|
||||
const { content_type, content_ref } = req.query;
|
||||
if (!['textbook', 'exam'].includes(content_type) || !content_ref) {
|
||||
return res.status(400).json({ error: 'content_type и content_ref обязательны' });
|
||||
}
|
||||
const rows = db.prepare(`
|
||||
SELECT scope, target_id, allow FROM content_access
|
||||
WHERE content_type = ? AND content_ref = ?
|
||||
`).all(content_type, content_ref);
|
||||
|
||||
const classRules = {}, studentRules = {};
|
||||
for (const r of rows) {
|
||||
if (r.scope === 'class') classRules[r.target_id] = r.allow;
|
||||
else studentRules[r.target_id] = r.allow;
|
||||
}
|
||||
res.json({ classRules, studentRules });
|
||||
});
|
||||
|
||||
/* ── Проверка прав учителя на конкретную цель ──────────────────────────── */
|
||||
function teacherOwnsClass(teacherId, classId) {
|
||||
return !!db.prepare('SELECT 1 FROM classes WHERE id = ? AND teacher_id = ?').get(classId, teacherId);
|
||||
}
|
||||
function teacherCanManageStudent(teacherId, studentId) {
|
||||
const inClass = db.prepare(`
|
||||
SELECT 1 FROM class_members cm JOIN classes c ON c.id = cm.class_id
|
||||
WHERE c.teacher_id = ? AND cm.user_id = ? LIMIT 1`).get(teacherId, studentId);
|
||||
if (inClass) return true;
|
||||
return !!db.prepare('SELECT 1 FROM teacher_students WHERE teacher_id = ? AND student_id = ?')
|
||||
.get(teacherId, studentId);
|
||||
}
|
||||
|
||||
/* ── Установка / снятие правила ────────────────────────────────────────── */
|
||||
/* POST /api/access/rules
|
||||
body: { content_type, content_ref, scope, target_id, allow }
|
||||
allow: 1 (открыть) | 0 (закрыть-исключение) | null/'inherit' (удалить правило) */
|
||||
router.post('/rules', (req, res) => {
|
||||
const { content_type, content_ref, scope, target_id } = req.body || {};
|
||||
let { allow } = req.body || {};
|
||||
|
||||
if (!['textbook', 'exam'].includes(content_type)) {
|
||||
return res.status(400).json({ error: 'неверный content_type' });
|
||||
}
|
||||
if (!['class', 'student'].includes(scope)) {
|
||||
return res.status(400).json({ error: 'неверный scope' });
|
||||
}
|
||||
const tid = Number(target_id);
|
||||
if (!Number.isInteger(tid) || tid <= 0) {
|
||||
return res.status(400).json({ error: 'неверный target_id' });
|
||||
}
|
||||
|
||||
/* Валидация существования контента. */
|
||||
if (content_type === 'textbook') {
|
||||
const ok = db.prepare('SELECT 1 FROM textbooks WHERE slug = ? AND parent_slug IS NULL').get(content_ref);
|
||||
if (!ok) return res.status(404).json({ error: 'учебник не найден' });
|
||||
} else {
|
||||
const ok = db.prepare('SELECT 1 FROM exam_tracks WHERE exam_key = ?').get(content_ref);
|
||||
if (!ok) return res.status(404).json({ error: 'экзамен-трек не найден' });
|
||||
}
|
||||
|
||||
/* Скоупинг учителя. */
|
||||
if (!isAdmin(req)) {
|
||||
const allowed = scope === 'class'
|
||||
? teacherOwnsClass(req.user.id, tid)
|
||||
: teacherCanManageStudent(req.user.id, tid);
|
||||
if (!allowed) return res.status(403).json({ error: 'Нет прав на эту цель' });
|
||||
}
|
||||
|
||||
/* allow === null / 'inherit' / undefined → удалить правило (наследование). */
|
||||
if (allow === null || allow === undefined || allow === 'inherit') {
|
||||
db.prepare(`DELETE FROM content_access
|
||||
WHERE content_type = ? AND content_ref = ? AND scope = ? AND target_id = ?`)
|
||||
.run(content_type, content_ref, scope, tid);
|
||||
return res.json({ ok: true, allow: null });
|
||||
}
|
||||
|
||||
allow = (allow === 1 || allow === true || allow === '1') ? 1 : 0;
|
||||
db.prepare(`
|
||||
INSERT INTO content_access (content_type, content_ref, scope, target_id, allow, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (content_type, content_ref, scope, target_id)
|
||||
DO UPDATE SET allow = excluded.allow, created_by = excluded.created_by, created_at = datetime('now')
|
||||
`).run(content_type, content_ref, scope, tid, allow, req.user.id);
|
||||
|
||||
res.json({ ok: true, allow });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -2,9 +2,19 @@
|
||||
const router = require('express').Router();
|
||||
const db = require('../db/db');
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
const access = require('../services/contentAccess');
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
/* Гейт доступа: любой маршрут с :examKey проверяется по allowlist.
|
||||
Админ/учитель проходят всегда; ученик — только при наличии правила. */
|
||||
router.param('examKey', (req, res, next, examKey) => {
|
||||
if (!access.canAccessExam(req.user, examKey)) {
|
||||
return res.status(403).json({ error: 'Нет доступа к этому экзамен-модулю' });
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
/* ── Statements (prepared once) ────────────────────────────────── */
|
||||
const SQL = {
|
||||
listTracks: db.prepare(`
|
||||
@@ -399,8 +409,9 @@ const SQL = {
|
||||
|
||||
/* ── GET /api/exam-prep/tracks ──
|
||||
Public list of enabled exam tracks (for a future landing page). */
|
||||
router.get('/tracks', (_req, res) => {
|
||||
const tracks = SQL.listTracks.all();
|
||||
router.get('/tracks', (req, res) => {
|
||||
const tracks = SQL.listTracks.all()
|
||||
.filter(t => access.canAccessExam(req.user, t.exam_key));
|
||||
res.json({ tracks });
|
||||
});
|
||||
|
||||
|
||||
@@ -3,9 +3,19 @@ const router = require('express').Router();
|
||||
const db = require('../db/db');
|
||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||
const { emit } = require('../sse');
|
||||
const access = require('../services/contentAccess');
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
/* Гейт доступа: любой маршрут с :slug проверяется по allowlist.
|
||||
Админ/учитель проходят всегда; ученик — только при наличии правила. */
|
||||
router.param('slug', (req, res, next, slug) => {
|
||||
if (!access.canAccessTextbook(req.user, slug)) {
|
||||
return res.status(403).json({ error: 'Нет доступа к этому учебнику' });
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
/* Parse "1-5", "1,3,7", "1-3,5,7-9" → [1,2,3,...]; empty → [1..fallback] */
|
||||
function parseTextbookParas(spec, fallback) {
|
||||
if (!spec || !spec.trim()) return Array.from({ length: fallback || 0 }, (_, i) => i + 1);
|
||||
@@ -118,7 +128,8 @@ router.get('/', (req, res) => {
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ textbooks });
|
||||
/* Allowlist: ученику показываем только открытые учебники. */
|
||||
res.json({ textbooks: access.filterTextbooks(req.user, textbooks) });
|
||||
});
|
||||
|
||||
/* GET /api/textbooks/bookmarks/all — all my bookmarks across textbooks */
|
||||
|
||||
@@ -53,6 +53,7 @@ const parentRoutes = require('./routes/parent');
|
||||
const exam9Routes = require('./routes/exam9');
|
||||
const examPrepRoutes = require('./routes/exam-prep');
|
||||
const textbookRoutes = require('./routes/textbooks');
|
||||
const accessRoutes = require('./routes/access');
|
||||
const teacherStudentsRoutes = require('./routes/teacherStudents');
|
||||
|
||||
const { requestId, errorHandler } = require('./middleware/errorHandler');
|
||||
@@ -174,6 +175,7 @@ app.use('/api/parent', parentRoutes);
|
||||
app.use('/api/exam9', exam9Routes);
|
||||
app.use('/api/exam-prep', examPrepRoutes);
|
||||
app.use('/api/textbooks', textbookRoutes);
|
||||
app.use('/api/access', accessRoutes);
|
||||
app.use('/api/teacher-students', teacherStudentsRoutes);
|
||||
|
||||
/* ── Public features endpoint (merges global + per-class for authenticated students) ── */
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
'use strict';
|
||||
/* contentAccess — резолвинг доступа к учебникам / экзамен-модулям.
|
||||
*
|
||||
* Модель: ALLOWLIST. По умолчанию закрыто. Правило ученика важнее правила
|
||||
* класса. См. миграцию 040_content_access.sql.
|
||||
*
|
||||
* canAccessTextbook(user, slug) → bool
|
||||
* canAccessExam(user, examKey) → bool
|
||||
* filterTextbooks(user, rows) → отфильтрованный список (rows[*].slug)
|
||||
* allowedRefs(userId, type) → Set<content_ref> доступных пользователю
|
||||
*/
|
||||
const db = require('../db/db');
|
||||
|
||||
const PRIVILEGED = new Set(['admin', 'teacher']);
|
||||
|
||||
const _parentOf = db.prepare('SELECT parent_slug FROM textbooks WHERE slug = ?');
|
||||
/* Глава наследует доступ родителя-хаба; верхнеуровневый учебник — ключ = own slug. */
|
||||
function textbookAccessKey(slug) {
|
||||
const row = _parentOf.get(slug);
|
||||
return row && row.parent_slug ? row.parent_slug : slug;
|
||||
}
|
||||
|
||||
const _studentRule = db.prepare(`
|
||||
SELECT allow FROM content_access
|
||||
WHERE content_type = ? AND content_ref = ? AND scope = 'student' AND target_id = ?
|
||||
`);
|
||||
const _classRule = db.prepare(`
|
||||
SELECT MAX(allow) AS any_allow, COUNT(*) AS n
|
||||
FROM content_access
|
||||
WHERE content_type = ? AND content_ref = ? AND scope = 'class'
|
||||
AND target_id IN (SELECT class_id FROM class_members WHERE user_id = ?)
|
||||
`);
|
||||
|
||||
function resolve(userId, type, ref) {
|
||||
const s = _studentRule.get(type, ref, userId);
|
||||
if (s) return s.allow === 1; // правило ученика побеждает
|
||||
const c = _classRule.get(type, ref, userId);
|
||||
if (c && c.n > 0) return c.any_allow === 1; // открыт хотя бы одним классом
|
||||
return false; // allowlist — по умолчанию закрыто
|
||||
}
|
||||
|
||||
function canAccess(user, type, ref) {
|
||||
if (!user) return false;
|
||||
if (PRIVILEGED.has(user.role)) return true; // админ/учитель видят весь контент
|
||||
return resolve(user.id, type, ref);
|
||||
}
|
||||
|
||||
function canAccessTextbook(user, slug) {
|
||||
return canAccess(user, 'textbook', textbookAccessKey(slug));
|
||||
}
|
||||
function canAccessExam(user, examKey) {
|
||||
return canAccess(user, 'exam', examKey);
|
||||
}
|
||||
|
||||
/* Множество доступных пользователю content_ref для типа (bulk, для каталога). */
|
||||
const _allStudentRules = db.prepare(`
|
||||
SELECT content_ref, allow FROM content_access
|
||||
WHERE content_type = ? AND scope = 'student' AND target_id = ?
|
||||
`);
|
||||
const _allClassRules = db.prepare(`
|
||||
SELECT content_ref, MAX(allow) AS any_allow
|
||||
FROM content_access
|
||||
WHERE content_type = ? AND scope = 'class'
|
||||
AND target_id IN (SELECT class_id FROM class_members WHERE user_id = ?)
|
||||
GROUP BY content_ref
|
||||
`);
|
||||
function allowedRefs(userId, type) {
|
||||
const out = new Set();
|
||||
for (const r of _allClassRules.all(type, userId)) {
|
||||
if (r.any_allow === 1) out.add(r.content_ref);
|
||||
}
|
||||
for (const r of _allStudentRules.all(type, userId)) {
|
||||
if (r.allow === 1) out.add(r.content_ref); // ученик-разрешение добавляет
|
||||
else out.delete(r.content_ref); // ученик-запрет снимает
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* Фильтрует список учебников верхнего уровня (каждый row имеет .slug). */
|
||||
function filterTextbooks(user, rows) {
|
||||
if (!user) return [];
|
||||
if (PRIVILEGED.has(user.role)) return rows;
|
||||
const allow = allowedRefs(user.id, 'textbook');
|
||||
return rows.filter(r => allow.has(r.slug));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PRIVILEGED,
|
||||
textbookAccessKey,
|
||||
canAccess,
|
||||
canAccessTextbook,
|
||||
canAccessExam,
|
||||
allowedRefs,
|
||||
filterTextbooks,
|
||||
};
|
||||
@@ -956,6 +956,9 @@
|
||||
<button class="admin-nav-item" data-tab="topics" onclick="switchTab(this)">
|
||||
<i data-lucide="list-tree" style="width:15px;height:15px"></i> Темы
|
||||
</button>
|
||||
<button class="admin-nav-item" data-tab="access" onclick="switchTab(this)">
|
||||
<i data-lucide="book-lock" style="width:15px;height:15px"></i> Доступ к учебникам
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1509,6 +1512,30 @@
|
||||
<div id="topics-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- ── Доступ к учебникам / экзаменам ── -->
|
||||
<div class="tab-pane" id="tab-access">
|
||||
<div class="section-title">Доступ к учебникам и экзаменам</div>
|
||||
<p style="color:var(--muted);font-size:13px;margin:4px 0 16px;max-width:720px">
|
||||
По умолчанию доступ <b>закрыт</b>. Откройте учебник или экзамен-модуль нужным классам.
|
||||
Внутри класса можно сделать точечное исключение для отдельного ученика —
|
||||
индивидуальное правило важнее правила класса.
|
||||
</p>
|
||||
<div class="acc-layout" style="display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap">
|
||||
<div class="acc-list adm-panel" style="flex:0 0 280px;max-width:320px;padding:10px">
|
||||
<div class="acc-list-head" style="font-weight:600;font-size:13px;color:var(--text-3);padding:6px 8px">Учебники</div>
|
||||
<div id="acc-textbooks"></div>
|
||||
<div class="acc-list-head" style="font-weight:600;font-size:13px;color:var(--text-3);padding:12px 8px 6px">Экзамены</div>
|
||||
<div id="acc-exams"></div>
|
||||
</div>
|
||||
<div class="acc-detail adm-panel" style="flex:1;min-width:340px;padding:18px">
|
||||
<div id="acc-detail-empty" style="color:var(--muted);font-size:14px">
|
||||
Выберите учебник или экзамен слева, чтобы настроить доступ.
|
||||
</div>
|
||||
<div id="acc-detail" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Рассылка ── -->
|
||||
<div class="tab-pane" id="tab-broadcast">
|
||||
<div class="section-title">Рассылка уведомлений</div>
|
||||
@@ -2009,6 +2036,7 @@
|
||||
<script src="/js/admin/sections/sessions.js"></script>
|
||||
<script src="/js/admin/sections/user-detail.js"></script>
|
||||
<script src="/js/admin/sections/session-detail.js"></script>
|
||||
<script src="/js/admin/sections/access.js"></script>
|
||||
<script src="/js/admin/palette.js"></script>
|
||||
<script src="/js/admin/admin.js"></script>
|
||||
</div>
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
sims: 'sims',
|
||||
games: 'games',
|
||||
sublog: 'sublog',
|
||||
access: 'access',
|
||||
};
|
||||
|
||||
/* Phase 6: deep entity pages. When a route has a first param (#users/123),
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
'use strict';
|
||||
/* admin → access section — открыть/закрыть доступ к учебникам и экзаменам
|
||||
* для классов и отдельных учеников. Модель allowlist: по умолчанию закрыто,
|
||||
* правило ученика важнее правила класса. */
|
||||
(function () {
|
||||
'use strict';
|
||||
let inited = false;
|
||||
let _catalog = null; // { textbooks:[], exams:[] }
|
||||
let _targets = null; // { classes:[{id,name,students:[]}], looseStudents:[] }
|
||||
let _sel = null; // { type:'textbook'|'exam', ref, title }
|
||||
let _rules = { classRules: {}, studentRules: {} };
|
||||
const _open = new Set(); // class ids развёрнутых строк
|
||||
|
||||
const esc = (s) => (window.LS && LS.esc ? LS.esc(s) : String(s == null ? '' : s));
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
[_catalog, _targets] = await Promise.all([LS.accessCatalog(), LS.accessTargets()]);
|
||||
renderList();
|
||||
} catch (e) {
|
||||
document.getElementById('acc-textbooks').innerHTML =
|
||||
`<p style="color:var(--danger);font-size:13px">Ошибка загрузки: ${esc(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function itemBtn(type, ref, title, sub) {
|
||||
const active = _sel && _sel.type === type && _sel.ref === ref;
|
||||
return `<button class="acc-item${active ? ' active' : ''}" data-type="${type}" data-ref="${esc(ref)}"
|
||||
onclick="accSelect('${type}','${esc(ref)}')"
|
||||
style="display:block;width:100%;text-align:left;border:none;background:${active ? 'var(--accent-soft,#eef2ff)' : 'transparent'};
|
||||
padding:8px 10px;border-radius:8px;cursor:pointer;font-family:inherit;font-size:13.5px;color:var(--text-1);margin-bottom:2px">
|
||||
<span style="font-weight:${active ? 600 : 500}">${esc(title)}</span>
|
||||
${sub ? `<span style="color:var(--muted);font-size:12px"> · ${esc(sub)}</span>` : ''}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
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('')
|
||||
|| '<p style="color:var(--muted);font-size:12px;padding:6px 10px">Нет учебников</p>';
|
||||
ex.innerHTML = (_catalog.exams || [])
|
||||
.map(e => itemBtn('exam', e.exam_key, e.title, e.grade ? e.grade + ' кл.' : '')).join('')
|
||||
|| '<p style="color:var(--muted);font-size:12px;padding:6px 10px">Нет экзаменов</p>';
|
||||
}
|
||||
|
||||
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 = '<p style="color:var(--muted);font-size:13px">Загрузка…</p>';
|
||||
try {
|
||||
_rules = await LS.accessRules(type, ref);
|
||||
renderDetail();
|
||||
} catch (e) {
|
||||
det.innerHTML = `<p style="color:var(--danger);font-size:13px">Ошибка: ${esc(e.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
/* tri-state кнопки для ученика внутри класса */
|
||||
function studentTri(uid) {
|
||||
const v = _rules.studentRules[uid]; // 1 | 0 | undefined
|
||||
const state = v === 1 ? 'open' : v === 0 ? 'closed' : 'inherit';
|
||||
const btn = (val, label, on) =>
|
||||
`<button onclick="accSetStudent(${uid},${val})"
|
||||
style="border:1px solid var(--border);background:${on ? 'var(--accent,#4f46e5)' : 'transparent'};
|
||||
color:${on ? '#fff' : 'var(--text-3)'};font-size:11.5px;padding:3px 9px;cursor:pointer;font-family:inherit;
|
||||
${val === "null" ? 'border-radius:7px 0 0 7px' : val === 0 ? 'border-radius:0 7px 7px 0;border-left:none' : 'border-left:none'}">${label}</button>`;
|
||||
return `<span class="acc-tri" style="display:inline-flex">
|
||||
${btn('null', 'Наследовать', state === 'inherit')}
|
||||
${btn(1, 'Открыт', state === 'open')}
|
||||
${btn(0, 'Закрыт', state === 'closed')}
|
||||
</span>`;
|
||||
}
|
||||
|
||||
function classRow(c) {
|
||||
const openToClass = _rules.classRules[c.id] === 1;
|
||||
const expanded = _open.has(c.id);
|
||||
const students = c.students || [];
|
||||
const studentsHtml = expanded ? `
|
||||
<div style="padding:6px 0 10px 26px">
|
||||
${students.length ? students.map(s => `
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;padding:5px 0;border-top:1px solid var(--border-soft,#f0f0f0)">
|
||||
<span style="font-size:13px;color:var(--text-1)">${esc(s.name || s.email)}</span>
|
||||
${studentTri(s.id)}
|
||||
</div>`).join('')
|
||||
: '<p style="color:var(--muted);font-size:12px;margin:4px 0">В классе нет учеников</p>'}
|
||||
</div>` : '';
|
||||
return `
|
||||
<div class="acc-class" style="border:1px solid var(--border);border-radius:10px;margin-bottom:10px;padding:10px 12px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px">
|
||||
<button onclick="accToggleExpand(${c.id})"
|
||||
style="border:none;background:transparent;cursor:pointer;font-family:inherit;font-size:14px;font-weight:600;color:var(--text-1);display:flex;align-items:center;gap:6px">
|
||||
<span style="display:inline-block;transition:transform .15s;transform:rotate(${expanded ? 90 : 0}deg)">▸</span>
|
||||
${esc(c.name)}${c.teacher_name ? `<span style="font-weight:400;color:var(--muted);font-size:12px">· ${esc(c.teacher_name)}</span>` : ''}
|
||||
</button>
|
||||
<label class="acc-switch" style="display:inline-flex;align-items:center;gap:8px;font-size:12.5px;color:var(--text-3);cursor:pointer">
|
||||
<span>${openToClass ? 'Открыт' : 'Закрыт'}</span>
|
||||
<input type="checkbox" ${openToClass ? 'checked' : ''} onchange="accSetClass(${c.id}, this.checked)">
|
||||
</label>
|
||||
</div>
|
||||
${studentsHtml}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function looseRow(s) {
|
||||
const open = _rules.studentRules[s.id] === 1;
|
||||
return `
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;padding:7px 12px;border:1px solid var(--border);border-radius:9px;margin-bottom:6px">
|
||||
<span style="font-size:13px;color:var(--text-1)">${esc(s.name || s.email)} <span style="color:var(--muted);font-size:11.5px">${esc(s.email)}</span></span>
|
||||
<label style="display:inline-flex;align-items:center;gap:8px;font-size:12.5px;color:var(--text-3);cursor:pointer">
|
||||
<span>${open ? 'Открыт' : 'Закрыт'}</span>
|
||||
<input type="checkbox" ${open ? 'checked' : ''} onchange="accSetStudent(${s.id}, this.checked ? 1 : null)">
|
||||
</label>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderDetail() {
|
||||
const det = document.getElementById('acc-detail');
|
||||
const classes = _targets.classes || [];
|
||||
const loose = _targets.looseStudents || [];
|
||||
det.innerHTML = `
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px">
|
||||
<div style="font-size:16px;font-weight:700;color:var(--text-1)">${esc(_sel.title)}</div>
|
||||
<span class="badge ${_sel.type === 'exam' ? 'badge-warn' : 'badge-info'}" style="font-size:12px">${_sel.type === 'exam' ? 'Экзамен' : 'Учебник'}</span>
|
||||
</div>
|
||||
<div class="acc-classes">
|
||||
${classes.length ? classes.map(classRow).join('')
|
||||
: '<p style="color:var(--muted);font-size:13px">Нет классов.</p>'}
|
||||
</div>
|
||||
${loose.length ? `
|
||||
<div style="margin-top:18px">
|
||||
<div style="font-weight:600;font-size:13px;color:var(--text-3);margin-bottom:8px">Отдельные ученики (без класса)</div>
|
||||
${loose.map(looseRow).join('')}
|
||||
</div>` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
/* ── 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(); }
|
||||
}
|
||||
|
||||
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);
|
||||
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(); }
|
||||
}
|
||||
|
||||
function toggleExpand(classId) {
|
||||
if (_open.has(classId)) _open.delete(classId); else _open.add(classId);
|
||||
renderDetail();
|
||||
}
|
||||
|
||||
window.accSelect = select;
|
||||
window.accSetClass = setClass;
|
||||
window.accSetStudent = setStudent;
|
||||
window.accToggleExpand = toggleExpand;
|
||||
|
||||
window.AdminSections = window.AdminSections || {};
|
||||
window.AdminSections.access = {
|
||||
init: async () => { if (inited) return; inited = true; await load(); },
|
||||
reload: load,
|
||||
};
|
||||
})();
|
||||
@@ -64,6 +64,7 @@
|
||||
try {
|
||||
info = await LS.api(`/api/exam-prep/${examKey}/info`);
|
||||
} catch (e) {
|
||||
if (e && e.status === 403) { window.location.replace('/403'); return { examKey, view, info: null }; }
|
||||
console.warn('[exam-prep] info failed', e);
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,10 @@
|
||||
fetch('/api/textbooks/' + slug, {
|
||||
headers: { 'Authorization': 'Bearer ' + LS.getToken() },
|
||||
})
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(r => {
|
||||
if (r.status === 403) { window.location.replace('/403'); return null; }
|
||||
return r.ok ? r.json() : null;
|
||||
})
|
||||
.then(d => {
|
||||
if (!d || !d.progress) return;
|
||||
const serverRead = new Set(d.progress.read || []);
|
||||
|
||||
@@ -1025,6 +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,
|
||||
getCourseTemplates, saveCourseTemplate, createFromCourseTemplate, deleteCourseTemplate,
|
||||
getLessonTemplates, saveLessonTemplate, createFromLessonTemplate, deleteLessonTemplate,
|
||||
getBookmarks, addBookmark, removeBookmark, removeBookmarkByEntity, checkBookmark,
|
||||
@@ -1288,6 +1289,17 @@ async function getUserPermissions(uid) { return req('GET',
|
||||
async function setUserPermission(uid, permission, enabled) { return req('POST', `/permissions/users/${uid}`, { permission, enabled }); }
|
||||
async function resetUserPermissions(uid, permission) { return req('DELETE', `/permissions/users/${uid}/reset`, permission ? { permission } : undefined); }
|
||||
|
||||
/* ── content access (учебники / экзамены: открыть-закрыть классам/ученикам) ── */
|
||||
async function accessCatalog() { return req('GET', '/access/catalog'); }
|
||||
async function accessTargets() { return req('GET', '/access/targets'); }
|
||||
async function accessRules(content_type, content_ref) {
|
||||
const p = new URLSearchParams({ content_type, content_ref });
|
||||
return req('GET', `/access/rules?${p}`);
|
||||
}
|
||||
async function accessSetRule(content_type, content_ref, scope, target_id, allow) {
|
||||
return req('POST', '/access/rules', { content_type, content_ref, scope, target_id, allow });
|
||||
}
|
||||
|
||||
/* ── notifications ───────────────────────────────────────────────────────── */
|
||||
async function getNotifications() { return req('GET', '/notifications'); }
|
||||
async function markNotifRead(id) { return req('PATCH',`/notifications/${id}/read`); }
|
||||
|
||||
Reference in New Issue
Block a user