diff --git a/backend/src/routes/access.js b/backend/src/routes/access.js index ce849e1..cb72412 100644 --- a/backend/src/routes/access.js +++ b/backend/src/routes/access.js @@ -29,7 +29,16 @@ router.get('/catalog', (_req, res) => { WHERE enabled = 1 ORDER BY sort_order, exam_key `).all(); - res.json({ textbooks, exams }); + let sims = []; + try { + sims = db.prepare(` + SELECT id, title, cat AS subject, grade + FROM lab_sims + WHERE enabled = 1 + ORDER BY sort_order, id + `).all(); + } catch (_e) { /* lab_sims может отсутствовать на старом инстансе — деградация */ } + res.json({ textbooks, exams, sims }); }); /* ── Цели назначения: классы (+ученики) и отдельные ученики ────────────── */ @@ -99,12 +108,9 @@ router.get('/summary', (req, res) => { 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 }); + const out = { textbook: {}, exam: {}, sim: {}, course: {} }; + for (const r of rows) { (out[r.content_type] || (out[r.content_type] = {}))[r.content_ref] = r.n; } + res.json({ totalClasses, textbooks: out.textbook, exams: out.exam, sims: out.sim, courses: out.course }); }); /* ── Матрица класс × контент (обзор и правка одним экраном) ────────────── */ @@ -116,7 +122,7 @@ router.get('/matrix', (req, res) => { ? 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: [] }; }); + classes.forEach(c => { open[c.id] = { textbook: [], exam: [], sim: [], course: [] }; }); const ids = classes.map(c => c.id); if (ids.length) { const ph = ids.map(() => '?').join(','); @@ -169,9 +175,9 @@ router.get('/class/:id', requireRole('admin', 'teacher'), (req, res) => { 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 }); + const out = { textbook: [], exam: [], sim: [], course: [] }; + for (const r of rows) (out[r.content_type] || (out[r.content_type] = [])).push(r.content_ref); + res.json({ textbooks: out.textbook, exams: out.exam, sims: out.sim, courses: out.course }); }); function teacherCanManageStudent(teacherId, studentId) { const inClass = db.prepare(` diff --git a/backend/tests/content-access.test.js b/backend/tests/content-access.test.js index 8bb7763..573666e 100644 --- a/backend/tests/content-access.test.js +++ b/backend/tests/content-access.test.js @@ -110,6 +110,12 @@ describe('contentAccess', () => { assert.equal(r.status, 403); }); + it('GET /api/access/catalog включает симуляции', async () => { + const r = await inject('GET', '/api/access/catalog', null, teacher.token); + assert.equal(r.status, 200); + assert.ok(Array.isArray(r.body.sims) && r.body.sims.length >= 1, 'каталог содержит симуляции'); + }); + 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 07e8444..fd443d5 100644 --- a/frontend/js/admin/sections/access.js +++ b/frontend/js/admin/sections/access.js @@ -34,9 +34,14 @@ russian: 'Русский язык', english: 'Английский', geography: 'География', history: 'История' }; const esc = (s) => (window.LS && LS.esc ? LS.esc(s) : String(s == null ? '' : s)); - const bucket = (type) => (type === 'textbook' ? 'textbooks' : 'exams'); - const keyName = (type) => (type === 'textbook' ? 'slug' : 'exam_key'); - const itemsOf = (type) => (type === 'textbook' ? _catalog.textbooks : _catalog.exams) || []; + const BUCKET = { textbook: 'textbooks', exam: 'exams', sim: 'sims', course: 'courses' }; + const KEYNAME = { textbook: 'slug', exam: 'exam_key', sim: 'id', course: 'id' }; + const TYPE_LABEL = { textbook: 'Учебники', exam: 'Экзамены', sim: 'Симуляции', course: 'Курсы' }; + const TYPE_BADGE = { textbook: 'Учебник', exam: 'Экзамен', sim: 'Симуляция', course: 'Курс' }; + const CONTENT_TYPES = ['textbook', 'exam', 'sim']; // course добавим отдельным шагом + const bucket = (type) => BUCKET[type] || (type + 's'); + const keyName = (type) => KEYNAME[type] || 'id'; + const itemsOf = (type) => (_catalog && _catalog[bucket(type)]) || []; function contentTitle(type, ref) { const it = itemsOf(type).find(x => x[keyName(type)] === ref); return it ? it.title : ref; @@ -105,25 +110,25 @@ const total = _summary.totalClasses || 0; const term = _leftSearch.trim().toLowerCase(); const match = (it) => !term || (it.title || '').toLowerCase().includes(term); - const tbs = (_catalog.textbooks || []).filter(match); - const exs = (_catalog.exams || []).filter(match); let html = ''; - if (tbs.length) { - html += `
Загрузка…
'; try { const open = await LS.accessClassOpen(id); - _classOpen = { textbooks: new Set(open.textbooks || []), exams: new Set(open.exams || []) }; + _classOpen = {}; + CONTENT_TYPES.forEach(t => { _classOpen[bucket(t)] = new Set(open[bucket(t)] || []); }); renderRight(); } catch (e) { right.innerHTML = `Ошибка: ${esc(e.message)}
`; } } @@ -329,7 +335,7 @@ return `