feat(access): Фаза 1b — управление доступом к симуляциям в админке
Бэкенд /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) <noreply@anthropic.com>
This commit is contained in:
@@ -29,7 +29,16 @@ router.get('/catalog', (_req, res) => {
|
|||||||
WHERE enabled = 1
|
WHERE enabled = 1
|
||||||
ORDER BY sort_order, exam_key
|
ORDER BY sort_order, exam_key
|
||||||
`).all();
|
`).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 = ?)
|
AND target_id IN (SELECT id FROM classes WHERE teacher_id = ?)
|
||||||
GROUP BY content_type, content_ref`).all(req.user.id);
|
GROUP BY content_type, content_ref`).all(req.user.id);
|
||||||
|
|
||||||
const textbooks = {}, exams = {};
|
const out = { textbook: {}, exam: {}, sim: {}, course: {} };
|
||||||
for (const r of rows) {
|
for (const r of rows) { (out[r.content_type] || (out[r.content_type] = {}))[r.content_ref] = r.n; }
|
||||||
if (r.content_type === 'textbook') textbooks[r.content_ref] = r.n;
|
res.json({ totalClasses, textbooks: out.textbook, exams: out.exam, sims: out.sim, courses: out.course });
|
||||||
else exams[r.content_ref] = r.n;
|
|
||||||
}
|
|
||||||
res.json({ totalClasses, textbooks, exams });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ── Матрица класс × контент (обзор и правка одним экраном) ────────────── */
|
/* ── Матрица класс × контент (обзор и правка одним экраном) ────────────── */
|
||||||
@@ -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 ORDER BY name').all()
|
||||||
: db.prepare('SELECT id, name FROM classes WHERE teacher_id = ? ORDER BY name').all(req.user.id);
|
: db.prepare('SELECT id, name FROM classes WHERE teacher_id = ? ORDER BY name').all(req.user.id);
|
||||||
const open = {};
|
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);
|
const ids = classes.map(c => c.id);
|
||||||
if (ids.length) {
|
if (ids.length) {
|
||||||
const ph = ids.map(() => '?').join(',');
|
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
|
SELECT content_type, content_ref FROM content_access
|
||||||
WHERE scope = 'class' AND target_id = ? AND allow = 1
|
WHERE scope = 'class' AND target_id = ? AND allow = 1
|
||||||
`).all(cid);
|
`).all(cid);
|
||||||
const textbooks = [], exams = [];
|
const out = { textbook: [], exam: [], sim: [], course: [] };
|
||||||
for (const r of rows) (r.content_type === 'textbook' ? textbooks : exams).push(r.content_ref);
|
for (const r of rows) (out[r.content_type] || (out[r.content_type] = [])).push(r.content_ref);
|
||||||
res.json({ textbooks, exams });
|
res.json({ textbooks: out.textbook, exams: out.exam, sims: out.sim, courses: out.course });
|
||||||
});
|
});
|
||||||
function teacherCanManageStudent(teacherId, studentId) {
|
function teacherCanManageStudent(teacherId, studentId) {
|
||||||
const inClass = db.prepare(`
|
const inClass = db.prepare(`
|
||||||
|
|||||||
@@ -110,6 +110,12 @@ describe('contentAccess', () => {
|
|||||||
assert.equal(r.status, 403);
|
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 () => {
|
it('DELETE /api/classes/:id чистит правила класса (через purgeAccessFor)', async () => {
|
||||||
setRule('class', classId, 1);
|
setRule('class', classId, 1);
|
||||||
const del = await inject('DELETE', `/api/classes/${classId}`, null, teacher.token);
|
const del = await inject('DELETE', `/api/classes/${classId}`, null, teacher.token);
|
||||||
|
|||||||
@@ -34,9 +34,14 @@
|
|||||||
russian: 'Русский язык', english: 'Английский', geography: 'География', history: 'История' };
|
russian: 'Русский язык', english: 'Английский', geography: 'География', history: 'История' };
|
||||||
|
|
||||||
const esc = (s) => (window.LS && LS.esc ? LS.esc(s) : String(s == null ? '' : s));
|
const esc = (s) => (window.LS && LS.esc ? LS.esc(s) : String(s == null ? '' : s));
|
||||||
const bucket = (type) => (type === 'textbook' ? 'textbooks' : 'exams');
|
const BUCKET = { textbook: 'textbooks', exam: 'exams', sim: 'sims', course: 'courses' };
|
||||||
const keyName = (type) => (type === 'textbook' ? 'slug' : 'exam_key');
|
const KEYNAME = { textbook: 'slug', exam: 'exam_key', sim: 'id', course: 'id' };
|
||||||
const itemsOf = (type) => (type === 'textbook' ? _catalog.textbooks : _catalog.exams) || [];
|
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) {
|
function contentTitle(type, ref) {
|
||||||
const it = itemsOf(type).find(x => x[keyName(type)] === ref);
|
const it = itemsOf(type).find(x => x[keyName(type)] === ref);
|
||||||
return it ? it.title : ref;
|
return it ? it.title : ref;
|
||||||
@@ -105,25 +110,25 @@
|
|||||||
const total = _summary.totalClasses || 0;
|
const total = _summary.totalClasses || 0;
|
||||||
const term = _leftSearch.trim().toLowerCase();
|
const term = _leftSearch.trim().toLowerCase();
|
||||||
const match = (it) => !term || (it.title || '').toLowerCase().includes(term);
|
const match = (it) => !term || (it.title || '').toLowerCase().includes(term);
|
||||||
const tbs = (_catalog.textbooks || []).filter(match);
|
|
||||||
const exs = (_catalog.exams || []).filter(match);
|
|
||||||
let html = '';
|
let html = '';
|
||||||
if (tbs.length) {
|
CONTENT_TYPES.forEach(type => {
|
||||||
html += `<div style="font-weight:600;font-size:13px;color:var(--text-3);padding:6px 8px">Учебники</div>`;
|
const items = itemsOf(type).filter(match);
|
||||||
let lastSubj = null;
|
if (!items.length) return;
|
||||||
tbs.forEach(it => {
|
html += `<div style="font-weight:600;font-size:13px;color:var(--text-3);padding:12px 8px 6px">${TYPE_LABEL[type]}</div>`;
|
||||||
const sj = it.subject || '';
|
if (type === 'textbook') {
|
||||||
if (sj !== lastSubj) {
|
let lastSubj = null;
|
||||||
lastSubj = sj;
|
items.forEach(it => {
|
||||||
html += `<div style="font-size:10.5px;color:var(--muted);padding:6px 10px 2px;text-transform:uppercase;letter-spacing:.04em">${esc(SUBJ_LABEL[sj] || sj || 'Прочее')}</div>`;
|
const sj = it.subject || '';
|
||||||
}
|
if (sj !== lastSubj) {
|
||||||
html += contentItemBtn('textbook', it, total);
|
lastSubj = sj;
|
||||||
});
|
html += `<div style="font-size:10.5px;color:var(--muted);padding:6px 10px 2px;text-transform:uppercase;letter-spacing:.04em">${esc(SUBJ_LABEL[sj] || sj || 'Прочее')}</div>`;
|
||||||
}
|
}
|
||||||
if (exs.length) {
|
html += contentItemBtn('textbook', it, total);
|
||||||
html += `<div style="font-weight:600;font-size:13px;color:var(--text-3);padding:12px 8px 6px">Экзамены</div>`;
|
});
|
||||||
exs.forEach(it => { html += contentItemBtn('exam', it, total); });
|
} else {
|
||||||
}
|
items.forEach(it => { html += contentItemBtn(type, it, total); });
|
||||||
|
}
|
||||||
|
});
|
||||||
return html || empty('Ничего не найдено');
|
return html || empty('Ничего не найдено');
|
||||||
}
|
}
|
||||||
function leftSearch(v) {
|
function leftSearch(v) {
|
||||||
@@ -250,7 +255,7 @@
|
|||||||
right.innerHTML = `
|
right.innerHTML = `
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px">
|
<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(_selContent.title)}</div>
|
<div style="font-size:16px;font-weight:700;color:var(--text-1)">${esc(_selContent.title)}</div>
|
||||||
<span class="badge ${_selContent.type === 'exam' ? 'badge-warn' : 'badge-info'}" style="font-size:12px">${_selContent.type === 'exam' ? 'Экзамен' : 'Учебник'}</span>
|
<span class="badge ${_selContent.type === 'exam' ? 'badge-warn' : 'badge-info'}" style="font-size:12px">${TYPE_BADGE[_selContent.type] || 'Контент'}</span>
|
||||||
</div>
|
</div>
|
||||||
${classes.length ? `
|
${classes.length ? `
|
||||||
<div style="display:flex;gap:8px;margin-bottom:14px">
|
<div style="display:flex;gap:8px;margin-bottom:14px">
|
||||||
@@ -318,7 +323,8 @@
|
|||||||
right.innerHTML = '<p style="color:var(--muted);font-size:13px">Загрузка…</p>';
|
right.innerHTML = '<p style="color:var(--muted);font-size:13px">Загрузка…</p>';
|
||||||
try {
|
try {
|
||||||
const open = await LS.accessClassOpen(id);
|
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();
|
renderRight();
|
||||||
} catch (e) { right.innerHTML = `<p style="color:var(--danger);font-size:13px">Ошибка: ${esc(e.message)}</p>`; }
|
} catch (e) { right.innerHTML = `<p style="color:var(--danger);font-size:13px">Ошибка: ${esc(e.message)}</p>`; }
|
||||||
}
|
}
|
||||||
@@ -329,7 +335,7 @@
|
|||||||
return `
|
return `
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 12px;border:1px solid var(--border);border-radius:9px;margin-bottom:6px">
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 12px;border:1px solid var(--border);border-radius:9px;margin-bottom:6px">
|
||||||
<span style="font-size:13.5px;color:var(--text-1)">${esc(it.title)}
|
<span style="font-size:13.5px;color:var(--text-1)">${esc(it.title)}
|
||||||
<span class="badge ${type === 'exam' ? 'badge-warn' : 'badge-info'}" style="font-size:10.5px;margin-left:6px">${type === 'exam' ? 'Экзамен' : 'Учебник'}</span></span>
|
<span class="badge ${type === 'exam' ? 'badge-warn' : 'badge-info'}" style="font-size:10.5px;margin-left:6px">${TYPE_BADGE[type] || 'Контент'}</span></span>
|
||||||
<label style="display:inline-flex;align-items:center;gap:8px;font-size:12.5px;color:var(--text-3);cursor:pointer">
|
<label style="display:inline-flex;align-items:center;gap:8px;font-size:12.5px;color:var(--text-3);cursor:pointer">
|
||||||
<span>${open ? 'Открыт' : 'Закрыт'}</span>
|
<span>${open ? 'Открыт' : 'Закрыт'}</span>
|
||||||
<input type="checkbox" ${open ? 'checked' : ''} onchange="accClassToggle('${type}','${esc(ref)}', this.checked)">
|
<input type="checkbox" ${open ? 'checked' : ''} onchange="accClassToggle('${type}','${esc(ref)}', this.checked)">
|
||||||
@@ -338,17 +344,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderClassDetail(right) {
|
function renderClassDetail(right) {
|
||||||
const tb = _catalog.textbooks || [], ex = _catalog.exams || [];
|
let html = `
|
||||||
right.innerHTML = `
|
|
||||||
<div style="font-size:16px;font-weight:700;color:var(--text-1);margin-bottom:14px">Класс «${esc(_selClass.name)}»</div>
|
<div style="font-size:16px;font-weight:700;color:var(--text-1);margin-bottom:14px">Класс «${esc(_selClass.name)}»</div>
|
||||||
<div style="display:flex;gap:8px;margin-bottom:14px">
|
<div style="display:flex;gap:8px;margin-bottom:14px">
|
||||||
<button class="adm-btn adm-btn-small" onclick="accClassBulk(1)">Открыть весь контент</button>
|
<button class="adm-btn adm-btn-small" onclick="accClassBulk(1)">Открыть весь контент</button>
|
||||||
<button class="adm-btn adm-btn-small" style="background:var(--border-h);color:var(--text-3)" onclick="accClassBulk(0)">Закрыть весь</button>
|
<button class="adm-btn adm-btn-small" style="background:var(--border-h);color:var(--text-3)" onclick="accClassBulk(0)">Закрыть весь</button>
|
||||||
</div>
|
</div>`;
|
||||||
<div style="font-weight:600;font-size:13px;color:var(--text-3);margin:4px 0 8px">Учебники</div>
|
CONTENT_TYPES.forEach(type => {
|
||||||
${tb.length ? tb.map(it => classContentRow('textbook', it)).join('') : empty('Нет учебников')}
|
const items = itemsOf(type);
|
||||||
<div style="font-weight:600;font-size:13px;color:var(--text-3);margin:14px 0 8px">Экзамены</div>
|
html += `<div style="font-weight:600;font-size:13px;color:var(--text-3);margin:14px 0 8px">${TYPE_LABEL[type]}</div>`;
|
||||||
${ex.length ? ex.map(it => classContentRow('exam', it)).join('') : empty('Нет экзаменов')}`;
|
html += items.length ? items.map(it => classContentRow(type, it)).join('') : empty('Нет');
|
||||||
|
});
|
||||||
|
right.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
function bumpSummary(type, ref, delta) {
|
function bumpSummary(type, ref, delta) {
|
||||||
@@ -371,8 +378,7 @@
|
|||||||
|
|
||||||
async function classBulk(allow) {
|
async function classBulk(allow) {
|
||||||
if (!allow && !confirm(`Закрыть весь контент у класса «${_selClass.name}»?`)) return;
|
if (!allow && !confirm(`Закрыть весь контент у класса «${_selClass.name}»?`)) return;
|
||||||
const all = [...(_catalog.textbooks || []).map(it => ['textbook', it[keyName('textbook')]]),
|
const all = CONTENT_TYPES.flatMap(t => itemsOf(t).map(it => [t, it[keyName(t)]]));
|
||||||
...(_catalog.exams || []).map(it => ['exam', it[keyName('exam')]])];
|
|
||||||
try {
|
try {
|
||||||
await Promise.all(all.map(([type, ref]) =>
|
await Promise.all(all.map(([type, ref]) =>
|
||||||
LS.accessSetRule(type, ref, 'class', _selClass.id, allow ? 1 : null)));
|
LS.accessSetRule(type, ref, 'class', _selClass.id, allow ? 1 : null)));
|
||||||
@@ -407,10 +413,9 @@
|
|||||||
return `<tr><th scope="row" style="text-align:left;padding:6px 10px;font-size:13px;font-weight:500;color:var(--text-1);white-space:nowrap;position:sticky;left:0;background:var(--card,#fff);border-bottom:1px solid var(--border-soft,#f0f0f0)">${esc(it.title)}</th>${cells}</tr>`;
|
return `<tr><th scope="row" style="text-align:left;padding:6px 10px;font-size:13px;font-weight:500;color:var(--text-1);white-space:nowrap;position:sticky;left:0;background:var(--card,#fff);border-bottom:1px solid var(--border-soft,#f0f0f0)">${esc(it.title)}</th>${cells}</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
if (!rows) return '';
|
if (!rows) return '';
|
||||||
const label = type === 'textbook' ? 'Учебники' : 'Экзамены';
|
return `<tr><th colspan="${classes.length + 1}" style="text-align:left;padding:10px 10px 4px;font-size:12px;font-weight:700;color:var(--text-3)">${TYPE_LABEL[type] || type}</th></tr>${rows}`;
|
||||||
return `<tr><th colspan="${classes.length + 1}" style="text-align:left;padding:10px 10px 4px;font-size:12px;font-weight:700;color:var(--text-3)">${label}</th></tr>${rows}`;
|
|
||||||
};
|
};
|
||||||
const body = section('textbook', (_catalog || {}).textbooks) + section('exam', (_catalog || {}).exams);
|
const body = CONTENT_TYPES.map(t => section(t, itemsOf(t))).join('');
|
||||||
return body || `<tr><td colspan="${classes.length + 1}" style="padding:10px">${empty('Ничего не найдено')}</td></tr>`;
|
return body || `<tr><td colspan="${classes.length + 1}" style="padding:10px">${empty('Ничего не найдено')}</td></tr>`;
|
||||||
}
|
}
|
||||||
async function renderMatrix() {
|
async function renderMatrix() {
|
||||||
|
|||||||
Reference in New Issue
Block a user