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 });
|
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=
|
/* GET /api/access/rules?content_type=&content_ref=
|
||||||
→ { classRules:{[class_id]:allow}, studentRules:{[user_id]:allow} } */
|
→ { 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);
|
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 () => {
|
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);
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ app.use('/api/questions', require('../src/routes/questions'));
|
|||||||
|
|
||||||
// Additional routes for integration tests
|
// Additional routes for integration tests
|
||||||
app.use('/api/permissions', require('../src/routes/permissions'));
|
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)
|
// Feature-gated routes (requireFeature checks app_settings in DB)
|
||||||
const { requireFeature } = require('../src/middleware/features');
|
const { requireFeature } = require('../src/middleware/features');
|
||||||
|
|||||||
@@ -23,6 +23,10 @@
|
|||||||
let _selClass = null; // { id, name }
|
let _selClass = null; // { id, name }
|
||||||
let _classOpen = { textbooks: new Set(), exams: new Set() };
|
let _classOpen = { textbooks: new Set(), exams: new Set() };
|
||||||
|
|
||||||
|
// matrix-mode state
|
||||||
|
let _matrix = null; // { classes:[{id,name}], open:{ [cid]:{textbook:[],exam:[]} } }
|
||||||
|
let _mSearch = '';
|
||||||
|
|
||||||
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 = (type) => (type === 'textbook' ? 'textbooks' : 'exams');
|
||||||
const keyName = (type) => (type === 'textbook' ? 'slug' : 'exam_key');
|
const keyName = (type) => (type === 'textbook' ? 'slug' : 'exam_key');
|
||||||
@@ -44,18 +48,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── каркас: переключатель режимов + две колонки ── */
|
/* ── каркас: переключатель режимов + две колонки / матрица ── */
|
||||||
function renderRoot() {
|
function renderRoot() {
|
||||||
const root = document.getElementById('acc-root');
|
const root = document.getElementById('acc-root');
|
||||||
const seg = (m, label) =>
|
const seg = (m, label, pos) => {
|
||||||
`<button onclick="accMode('${m}')" style="border:1px solid var(--border);
|
const radius = pos === 'first' ? 'border-radius:8px 0 0 8px'
|
||||||
|
: pos === 'last' ? 'border-radius:0 8px 8px 0;border-left:none' : 'border-left:none';
|
||||||
|
return `<button onclick="accMode('${m}')" style="border:1px solid var(--border);
|
||||||
background:${_mode === m ? 'var(--accent,#4f46e5)' : 'transparent'};color:${_mode === m ? '#fff' : 'var(--text-3)'};
|
background:${_mode === m ? 'var(--accent,#4f46e5)' : 'transparent'};color:${_mode === m ? '#fff' : 'var(--text-3)'};
|
||||||
font-size:13px;padding:6px 16px;cursor:pointer;font-family:inherit;
|
font-size:13px;padding:6px 16px;cursor:pointer;font-family:inherit;${radius}">${label}</button>`;
|
||||||
${m === 'content' ? 'border-radius:8px 0 0 8px' : 'border-radius:0 8px 8px 0;border-left:none'}">${label}</button>`;
|
};
|
||||||
root.innerHTML = `
|
const tabs = `<div style="margin-bottom:16px;display:inline-flex">
|
||||||
<div style="margin-bottom:16px;display:inline-flex">
|
${seg('content', 'По контенту', 'first')}${seg('class', 'По классу', 'mid')}${seg('matrix', 'Матрица', 'last')}
|
||||||
${seg('content', 'По контенту')}${seg('class', 'По классу')}
|
</div>`;
|
||||||
</div>
|
if (_mode === 'matrix') {
|
||||||
|
root.innerHTML = tabs + `<div class="adm-panel" id="acc-matrix" style="padding:14px"></div>`;
|
||||||
|
renderMatrix();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.innerHTML = tabs + `
|
||||||
<div class="acc-layout" style="display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap">
|
<div class="acc-layout" style="display:flex;gap:20px;align-items:flex-start;flex-wrap:wrap">
|
||||||
<div class="adm-panel" id="acc-left" style="flex:0 0 290px;max-width:330px;padding:10px"></div>
|
<div class="adm-panel" id="acc-left" style="flex:0 0 290px;max-width:330px;padding:10px"></div>
|
||||||
<div class="adm-panel" id="acc-right" style="flex:1;min-width:340px;padding:18px"></div>
|
<div class="adm-panel" id="acc-right" style="flex:1;min-width:340px;padding:18px"></div>
|
||||||
@@ -328,10 +339,72 @@
|
|||||||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); selClass(_selClass.id); }
|
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); selClass(_selClass.id); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ════════ режим «Матрица» (класс × контент одним экраном) ════════ */
|
||||||
|
function matrixHeadCells(classes) {
|
||||||
|
return classes.map(c =>
|
||||||
|
`<th style="padding:6px 8px;font-size:11.5px;font-weight:600;color:var(--text-3);white-space:nowrap;border-bottom:1px solid var(--border)">${esc(c.name)}</th>`).join('');
|
||||||
|
}
|
||||||
|
function matrixBody() {
|
||||||
|
const classes = _matrix.classes || [];
|
||||||
|
const term = _mSearch.trim().toLowerCase();
|
||||||
|
const match = (it) => !term || (it.title || '').toLowerCase().includes(term);
|
||||||
|
const section = (type, items) => {
|
||||||
|
const rows = (items || []).filter(match).map(it => {
|
||||||
|
const ref = it[keyName(type)];
|
||||||
|
const cells = classes.map(c => {
|
||||||
|
const open = ((_matrix.open[c.id] || {})[type] || []).includes(ref);
|
||||||
|
return `<td style="text-align:center;border-bottom:1px solid var(--border-soft,#f0f0f0)">
|
||||||
|
<input type="checkbox" ${open ? 'checked' : ''} onchange="accMx('${type}','${esc(ref)}',${c.id},this.checked)" title="${esc(c.name)} · ${esc(it.title)}"></td>`;
|
||||||
|
}).join('');
|
||||||
|
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('');
|
||||||
|
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)">${label}</th></tr>${rows}`;
|
||||||
|
};
|
||||||
|
const body = section('textbook', (_catalog || {}).textbooks) + section('exam', (_catalog || {}).exams);
|
||||||
|
return body || `<tr><td colspan="${classes.length + 1}" style="padding:10px">${empty('Ничего не найдено')}</td></tr>`;
|
||||||
|
}
|
||||||
|
async function renderMatrix() {
|
||||||
|
const root = document.getElementById('acc-matrix');
|
||||||
|
if (!root) return;
|
||||||
|
if (!_matrix) {
|
||||||
|
root.innerHTML = '<p style="color:var(--muted);font-size:13px">Загрузка…</p>';
|
||||||
|
try { _matrix = await LS.accessMatrix(); }
|
||||||
|
catch (e) { root.innerHTML = `<p style="color:var(--danger);font-size:13px">Ошибка: ${esc(e.message)}</p>`; return; }
|
||||||
|
}
|
||||||
|
const classes = _matrix.classes || [];
|
||||||
|
if (!classes.length) { root.innerHTML = empty('Нет классов'); return; }
|
||||||
|
root.innerHTML = `
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap">
|
||||||
|
<input type="text" placeholder="Поиск по названию…" value="${esc(_mSearch)}" oninput="accMxSearch(this.value)"
|
||||||
|
style="flex:1;min-width:200px;max-width:320px;padding:7px 11px;border:1px solid var(--border);border-radius:8px;background:var(--card);color:var(--text-1);font-family:inherit;font-size:13px">
|
||||||
|
<span style="font-size:12px;color:var(--muted)">отметьте, какой класс видит контент</span>
|
||||||
|
</div>
|
||||||
|
<div style="overflow:auto;max-height:70vh">
|
||||||
|
<table style="border-collapse:collapse;min-width:100%">
|
||||||
|
<thead><tr><th style="position:sticky;left:0;background:var(--card,#fff);border-bottom:1px solid var(--border);z-index:1"></th>${matrixHeadCells(classes)}</tr></thead>
|
||||||
|
<tbody id="acc-mx-body">${matrixBody()}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
async function mxToggle(type, ref, classId, checked) {
|
||||||
|
try {
|
||||||
|
await LS.accessSetRule(type, ref, 'class', classId, checked ? 1 : null);
|
||||||
|
const o = _matrix.open[classId] || (_matrix.open[classId] = { textbook: [], exam: [] });
|
||||||
|
const arr = o[type] || (o[type] = []);
|
||||||
|
const i = arr.indexOf(ref);
|
||||||
|
if (checked && i < 0) arr.push(ref);
|
||||||
|
if (!checked && i >= 0) arr.splice(i, 1);
|
||||||
|
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); _matrix = null; renderMatrix(); }
|
||||||
|
}
|
||||||
|
function mxSearch(v) { _mSearch = v; const b = document.getElementById('acc-mx-body'); if (b) b.innerHTML = matrixBody(); }
|
||||||
|
|
||||||
/* ── режим ── */
|
/* ── режим ── */
|
||||||
function setMode(m) {
|
function setMode(m) {
|
||||||
if (m === _mode) return;
|
if (m === _mode) return;
|
||||||
_mode = m;
|
_mode = m;
|
||||||
|
if (m === 'matrix') _matrix = null; // всегда свежая матрица
|
||||||
renderRoot();
|
renderRoot();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,6 +417,8 @@
|
|||||||
window.accSelClass = selClass;
|
window.accSelClass = selClass;
|
||||||
window.accClassToggle = classToggle;
|
window.accClassToggle = classToggle;
|
||||||
window.accClassBulk = classBulk;
|
window.accClassBulk = classBulk;
|
||||||
|
window.accMx = mxToggle;
|
||||||
|
window.accMxSearch = mxSearch;
|
||||||
|
|
||||||
window.AdminSections = window.AdminSections || {};
|
window.AdminSections = window.AdminSections || {};
|
||||||
window.AdminSections.access = {
|
window.AdminSections.access = {
|
||||||
|
|||||||
@@ -1029,7 +1029,7 @@ window.LS = {
|
|||||||
getFolderAccess, clearFolderAccess, assignFolder, unassignFolder, getStudentsList,
|
getFolderAccess, clearFolderAccess, assignFolder, unassignFolder, getStudentsList,
|
||||||
submitWork, resubmitWork, getMySubmissions, getClassSubmissions, reviewSubmission, deleteSubmission, submissionDownloadUrl,
|
submitWork, resubmitWork, getMySubmissions, getClassSubmissions, reviewSubmission, deleteSubmission, submissionDownloadUrl,
|
||||||
getPermissions, setPermission, getUserPermissions, setUserPermission, resetUserPermissions,
|
getPermissions, setPermission, getUserPermissions, setUserPermission, resetUserPermissions,
|
||||||
accessCatalog, accessTargets, accessSummary, accessClassOpen, accessRules, accessSetRule,
|
accessCatalog, accessTargets, accessSummary, accessClassOpen, accessMatrix, accessRules, accessSetRule,
|
||||||
getCourseTemplates, saveCourseTemplate, createFromCourseTemplate, deleteCourseTemplate,
|
getCourseTemplates, saveCourseTemplate, createFromCourseTemplate, deleteCourseTemplate,
|
||||||
getLessonTemplates, saveLessonTemplate, createFromLessonTemplate, deleteLessonTemplate,
|
getLessonTemplates, saveLessonTemplate, createFromLessonTemplate, deleteLessonTemplate,
|
||||||
getBookmarks, addBookmark, removeBookmark, removeBookmarkByEntity, checkBookmark,
|
getBookmarks, addBookmark, removeBookmark, removeBookmarkByEntity, checkBookmark,
|
||||||
@@ -1300,6 +1300,7 @@ async function accessCatalog() { return req('GET', '/access/catalog'); }
|
|||||||
async function accessTargets() { return req('GET', '/access/targets'); }
|
async function accessTargets() { return req('GET', '/access/targets'); }
|
||||||
async function accessSummary() { return req('GET', '/access/summary'); }
|
async function accessSummary() { return req('GET', '/access/summary'); }
|
||||||
async function accessClassOpen(classId) { return req('GET', `/access/class/${classId}`); }
|
async function accessClassOpen(classId) { return req('GET', `/access/class/${classId}`); }
|
||||||
|
async function accessMatrix() { return req('GET', '/access/matrix'); }
|
||||||
async function accessRules(content_type, content_ref) {
|
async function accessRules(content_type, content_ref) {
|
||||||
const p = new URLSearchParams({ content_type, content_ref });
|
const p = new URLSearchParams({ content_type, content_ref });
|
||||||
return req('GET', `/access/rules?${p}`);
|
return req('GET', `/access/rules?${p}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user