From 4549b4e8195222407c851188e6ee3c5c3f969ba8 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Wed, 3 Jun 2026 13:24:08 +0300 Subject: [PATCH] =?UTF-8?q?feat(access):=20=D0=A4=D0=B0=D0=B7=D0=B0=201b?= =?UTF-8?q?=20=E2=80=94=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF=D0=BE=D0=BC?= =?UTF-8?q?=20=D0=BA=20=D1=81=D0=B8=D0=BC=D1=83=D0=BB=D1=8F=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=D0=BC=20=D0=B2=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D0=BA?= =?UTF-8?q?=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Бэкенд /api/access обобщён на тип 'sim': catalog отдаёт симуляции (lab_sims), summary/matrix/class — карты по всем типам. Админ-секция «Доступ» теперь показывает «Симуляции» во всех трёх режимах (по контенту / по классу / матрица) + поиск; helpers (bucket/keyName/itemsOf) обобщены через карты типов (CONTENT_TYPES=textbook,exam,sim; course зарезервирован). Теперь админ/учитель могут открывать/закрывать конкретные симуляции классам и ученикам — закрыт UX- разрыв из 1a (новые классы без UI-управления). Тест: каталог включает sims; 210 pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/routes/access.js | 28 ++++++---- backend/tests/content-access.test.js | 6 +++ frontend/js/admin/sections/access.js | 77 +++++++++++++++------------- 3 files changed, 64 insertions(+), 47 deletions(-) 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 += `
Учебники
`; - let lastSubj = null; - tbs.forEach(it => { - const sj = it.subject || ''; - if (sj !== lastSubj) { - lastSubj = sj; - html += `
${esc(SUBJ_LABEL[sj] || sj || 'Прочее')}
`; - } - html += contentItemBtn('textbook', it, total); - }); - } - if (exs.length) { - html += `
Экзамены
`; - exs.forEach(it => { html += contentItemBtn('exam', it, total); }); - } + CONTENT_TYPES.forEach(type => { + const items = itemsOf(type).filter(match); + if (!items.length) return; + html += `
${TYPE_LABEL[type]}
`; + if (type === 'textbook') { + let lastSubj = null; + items.forEach(it => { + const sj = it.subject || ''; + if (sj !== lastSubj) { + lastSubj = sj; + html += `
${esc(SUBJ_LABEL[sj] || sj || 'Прочее')}
`; + } + html += contentItemBtn('textbook', it, total); + }); + } else { + items.forEach(it => { html += contentItemBtn(type, it, total); }); + } + }); return html || empty('Ничего не найдено'); } function leftSearch(v) { @@ -250,7 +255,7 @@ right.innerHTML = `
${esc(_selContent.title)}
- ${_selContent.type === 'exam' ? 'Экзамен' : 'Учебник'} + ${TYPE_BADGE[_selContent.type] || 'Контент'}
${classes.length ? `
@@ -318,7 +323,8 @@ right.innerHTML = '

Загрузка…

'; 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 `
${esc(it.title)} - ${type === 'exam' ? 'Экзамен' : 'Учебник'} + ${TYPE_BADGE[type] || 'Контент'}
`; + CONTENT_TYPES.forEach(type => { + const items = itemsOf(type); + html += `
${TYPE_LABEL[type]}
`; + html += items.length ? items.map(it => classContentRow(type, it)).join('') : empty('Нет'); + }); + right.innerHTML = html; } function bumpSummary(type, ref, delta) { @@ -371,8 +378,7 @@ async function classBulk(allow) { if (!allow && !confirm(`Закрыть весь контент у класса «${_selClass.name}»?`)) return; - const all = [...(_catalog.textbooks || []).map(it => ['textbook', it[keyName('textbook')]]), - ...(_catalog.exams || []).map(it => ['exam', it[keyName('exam')]])]; + const all = CONTENT_TYPES.flatMap(t => itemsOf(t).map(it => [t, it[keyName(t)]])); try { await Promise.all(all.map(([type, ref]) => LS.accessSetRule(type, ref, 'class', _selClass.id, allow ? 1 : null))); @@ -407,10 +413,9 @@ return `${esc(it.title)}${cells}`; }).join(''); if (!rows) return ''; - const label = type === 'textbook' ? 'Учебники' : 'Экзамены'; - return `${label}${rows}`; + return `${TYPE_LABEL[type] || type}${rows}`; }; - const body = section('textbook', (_catalog || {}).textbooks) + section('exam', (_catalog || {}).exams); + const body = CONTENT_TYPES.map(t => section(t, itemsOf(t))).join(''); return body || `${empty('Ничего не найдено')}`; } async function renderMatrix() {