feat(access): Фаза 2a — режим «Матрица» класс × контент в админке
GET /api/access/matrix (классы + карта открытого контента одним запросом, скоуп учителя). Клиент LS.accessMatrix. Третий режим вкладки «Доступ»: таблица контент × классы с чекбоксами (правка в один клик) + поиск по названию (обновляет только tbody — фокус ввода сохраняется), залипающие заголовки. Тест /api/access смонтирован в харнесс; content-access.test 11/11 (+матрица: учитель видит свои классы и открытый контент, ученику 403). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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} } */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user