feat(access): Фаза 1c — видимость курсов по классам (Фаза 1 завершена)
Миграция 052: мост «открыть все опубликованные курсы всем существующим классам» (тип 'course' уже в CHECK из 051). courseController.list/search фильтруют курсы для НЕпривилегированных по allowedRefs(uid,'course') (content_ref = courses.id как TEXT); admin/teacher — все. /api/access/catalog отдаёт курсы; CONTENT_TYPES в админ-UI = textbook,exam,sim,course → курсы управляются во всех режимах «Доступ». Тест course-access 4/4 (allowlist+класс+privileged+каталог). Полный набор: 213 pass. ВАЖНО: новый опубликованный курс по умолчанию закрыт (allowlist) — открыть классам в админке. Мост сохранил видимость текущих опубликованных курсов у существующих классов. class_courses остаётся для назначений с дедлайном (сверх видимости). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,15 @@
|
|||||||
const db = require('../db/db');
|
const db = require('../db/db');
|
||||||
|
const access = require('../services/contentAccess');
|
||||||
|
|
||||||
/* ── helpers ──────────────────────────────────────────────────────────── */
|
/* ── helpers ──────────────────────────────────────────────────────────── */
|
||||||
|
/* Видимость курсов по классам (добавочная модель): ученик видит только
|
||||||
|
* разрешённые его классу/лично курсы; admin/teacher — все. Возвращает
|
||||||
|
* предикат для фильтрации строк курсов (по c.id). */
|
||||||
|
function courseVisible(user) {
|
||||||
|
if (access.PRIVILEGED.has(user.role)) return () => true;
|
||||||
|
const allowed = access.allowedRefs(user.id, 'course');
|
||||||
|
return (row) => allowed.has(String(row.id));
|
||||||
|
}
|
||||||
|
|
||||||
// Reused SQL fragment: user's completed-lesson count for a course (param: user_id)
|
// Reused SQL fragment: user's completed-lesson count for a course (param: user_id)
|
||||||
const DONE_COUNT_SUBQ = `(SELECT COUNT(*) FROM lesson_progress lp
|
const DONE_COUNT_SUBQ = `(SELECT COUNT(*) FROM lesson_progress lp
|
||||||
@@ -45,7 +54,7 @@ function list(req, res) {
|
|||||||
ORDER BY c.subject_slug, c.order_index, c.id
|
ORDER BY c.subject_slug, c.order_index, c.id
|
||||||
`).all(uid, ...args);
|
`).all(uid, ...args);
|
||||||
|
|
||||||
res.json(rows.map(courseRow));
|
res.json(rows.filter(courseVisible(req.user)).map(courseRow));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── GET /api/courses/search?q=… ─────────────────────────────────────── */
|
/* ── GET /api/courses/search?q=… ─────────────────────────────────────── */
|
||||||
@@ -59,13 +68,14 @@ function search(req, res) {
|
|||||||
const pubC = role === 'student' ? 'AND c.is_published = 1' : '';
|
const pubC = role === 'student' ? 'AND c.is_published = 1' : '';
|
||||||
const pubL = role === 'student' ? 'AND l.is_published = 1 AND c.is_published = 1' : '';
|
const pubL = role === 'student' ? 'AND l.is_published = 1 AND c.is_published = 1' : '';
|
||||||
|
|
||||||
|
const vis = courseVisible(req.user);
|
||||||
const courses = db.prepare(`
|
const courses = db.prepare(`
|
||||||
SELECT c.*,
|
SELECT c.*,
|
||||||
(SELECT COUNT(*) FROM lessons l WHERE l.course_id = c.id ${progressSubquery(role)}) AS lesson_count,
|
(SELECT COUNT(*) FROM lessons l WHERE l.course_id = c.id ${progressSubquery(role)}) AS lesson_count,
|
||||||
${DONE_COUNT_SUBQ}
|
${DONE_COUNT_SUBQ}
|
||||||
FROM courses c WHERE (c.title LIKE ? OR c.description LIKE ?) ${pubC}
|
FROM courses c WHERE (c.title LIKE ? OR c.description LIKE ?) ${pubC}
|
||||||
ORDER BY c.subject_slug, c.order_index LIMIT 20
|
ORDER BY c.subject_slug, c.order_index LIMIT 20
|
||||||
`).all(uid, like, like).map(courseRow);
|
`).all(uid, like, like).filter(vis).map(courseRow);
|
||||||
|
|
||||||
const lessons = db.prepare(`
|
const lessons = db.prepare(`
|
||||||
SELECT l.id, l.title, l.course_id, c.title AS course_title, c.subject_slug,
|
SELECT l.id, l.title, l.course_id, c.title AS course_title, c.subject_slug,
|
||||||
@@ -75,7 +85,7 @@ function search(req, res) {
|
|||||||
LEFT JOIN lesson_progress lp ON lp.lesson_id = l.id AND lp.user_id = ?
|
LEFT JOIN lesson_progress lp ON lp.lesson_id = l.id AND lp.user_id = ?
|
||||||
WHERE l.title LIKE ? ${pubL}
|
WHERE l.title LIKE ? ${pubL}
|
||||||
ORDER BY c.subject_slug, l.order_index LIMIT 30
|
ORDER BY c.subject_slug, l.order_index LIMIT 30
|
||||||
`).all(uid, like);
|
`).all(uid, like).filter(r => vis({ id: r.course_id }));
|
||||||
|
|
||||||
res.json({ courses, lessons });
|
res.json({ courses, lessons });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- 052_course_access_bridge.sql
|
||||||
|
-- Фаза 1c: видимость курсов по классам через content_access (тип 'course' уже
|
||||||
|
-- разрешён CHECK-ом из миграции 051). Мост: открываем все ОПУБЛИКОВАННЫЕ курсы
|
||||||
|
-- всем существующим классам — чтобы переход на allowlist не отнял доступ к тому,
|
||||||
|
-- что ученики видят сейчас. content_ref = courses.id (как TEXT).
|
||||||
|
--
|
||||||
|
-- После этой миграции новый опубликованный курс по умолчанию закрыт — открыть
|
||||||
|
-- классам через админ-панель «Доступ». Видимость управляется в content_access;
|
||||||
|
-- таблица class_courses остаётся для назначений с дедлайном (это сверх видимости).
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO content_access (content_type, content_ref, scope, target_id, allow)
|
||||||
|
SELECT 'course', CAST(c.id AS TEXT), 'class', cl.id, 1
|
||||||
|
FROM courses c CROSS JOIN classes cl
|
||||||
|
WHERE c.is_published = 1;
|
||||||
@@ -38,7 +38,16 @@ router.get('/catalog', (_req, res) => {
|
|||||||
ORDER BY sort_order, id
|
ORDER BY sort_order, id
|
||||||
`).all();
|
`).all();
|
||||||
} catch (_e) { /* lab_sims может отсутствовать на старом инстансе — деградация */ }
|
} catch (_e) { /* lab_sims может отсутствовать на старом инстансе — деградация */ }
|
||||||
res.json({ textbooks, exams, sims });
|
let courses = [];
|
||||||
|
try {
|
||||||
|
courses = db.prepare(`
|
||||||
|
SELECT CAST(id AS TEXT) AS id, title, subject_slug AS subject
|
||||||
|
FROM courses
|
||||||
|
WHERE is_published = 1
|
||||||
|
ORDER BY subject_slug, order_index, id
|
||||||
|
`).all();
|
||||||
|
} catch (_e) { /* деградация */ }
|
||||||
|
res.json({ textbooks, exams, sims, courses });
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ── Цели назначения: классы (+ученики) и отдельные ученики ────────────── */
|
/* ── Цели назначения: классы (+ученики) и отдельные ученики ────────────── */
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* Фаза 1c (добавочная модель): видимость курсов по классам через content_access.
|
||||||
|
* GET /api/courses фильтрует список для НЕпривилегированных по allowedRefs(uid,'course');
|
||||||
|
* admin/teacher видят все. content_ref = courses.id (как TEXT).
|
||||||
|
*/
|
||||||
|
const { describe, it, before, after } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { db, getToken, inject, cleanup } = require('./setup');
|
||||||
|
|
||||||
|
after(() => cleanup());
|
||||||
|
|
||||||
|
describe('course access (per-class)', () => {
|
||||||
|
let teacher, student, classId, courseId;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
teacher = await getToken('teacher');
|
||||||
|
student = await getToken('student');
|
||||||
|
const r = db.prepare(
|
||||||
|
`INSERT INTO courses (subject_slug, title, is_published, created_by) VALUES ('math','Acc Course',1,?)`
|
||||||
|
).run(teacher.userId);
|
||||||
|
courseId = Number(r.lastInsertRowid);
|
||||||
|
|
||||||
|
const cr = await inject('POST', '/api/classes', { name: 'CourseAcc Class' }, teacher.token);
|
||||||
|
assert.ok(cr.status < 300, JSON.stringify(cr.body));
|
||||||
|
classId = db.prepare('SELECT id FROM classes WHERE name = ?').get('CourseAcc Class').id;
|
||||||
|
await inject('POST', `/api/classes/${classId}/members`, { user_id: student.userId }, teacher.token);
|
||||||
|
});
|
||||||
|
|
||||||
|
const has = (body, id) => (Array.isArray(body) ? body : []).some(c => c.id === id);
|
||||||
|
|
||||||
|
it('ученик без правил не видит опубликованный курс (allowlist)', async () => {
|
||||||
|
const r = await inject('GET', '/api/courses', null, student.token);
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.ok(!has(r.body, courseId), 'курс скрыт без правила');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('teacher видит курс (privileged)', async () => {
|
||||||
|
const r = await inject('GET', '/api/courses', null, teacher.token);
|
||||||
|
assert.ok(has(r.body, courseId));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('открытый классу курс виден ученику', async () => {
|
||||||
|
db.prepare(`INSERT OR IGNORE INTO content_access (content_type,content_ref,scope,target_id,allow)
|
||||||
|
VALUES ('course',?, 'class',?,1)`).run(String(courseId), classId);
|
||||||
|
const r = await inject('GET', '/api/courses', null, student.token);
|
||||||
|
assert.ok(has(r.body, courseId), 'курс виден после открытия классу');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('каталог админки включает курсы', async () => {
|
||||||
|
const r = await inject('GET', '/api/access/catalog', null, teacher.token);
|
||||||
|
assert.ok(Array.isArray(r.body.courses), 'есть массив courses');
|
||||||
|
assert.ok(r.body.courses.some(c => Number(c.id) === courseId), 'наш курс в каталоге');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -46,6 +46,7 @@ app.use('/api/questions', require('../src/routes/questions'));
|
|||||||
app.use('/api/permissions', require('../src/routes/permissions'));
|
app.use('/api/permissions', require('../src/routes/permissions'));
|
||||||
app.use('/api/access', require('../src/routes/access'));
|
app.use('/api/access', require('../src/routes/access'));
|
||||||
app.use('/api/lab', require('../src/routes/lab'));
|
app.use('/api/lab', require('../src/routes/lab'));
|
||||||
|
app.use('/api/courses', require('../src/routes/courses'));
|
||||||
|
|
||||||
// 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');
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
const KEYNAME = { textbook: 'slug', exam: 'exam_key', sim: 'id', course: 'id' };
|
const KEYNAME = { textbook: 'slug', exam: 'exam_key', sim: 'id', course: 'id' };
|
||||||
const TYPE_LABEL = { textbook: 'Учебники', exam: 'Экзамены', sim: 'Симуляции', course: 'Курсы' };
|
const TYPE_LABEL = { textbook: 'Учебники', exam: 'Экзамены', sim: 'Симуляции', course: 'Курсы' };
|
||||||
const TYPE_BADGE = { textbook: 'Учебник', exam: 'Экзамен', sim: 'Симуляция', course: 'Курс' };
|
const TYPE_BADGE = { textbook: 'Учебник', exam: 'Экзамен', sim: 'Симуляция', course: 'Курс' };
|
||||||
const CONTENT_TYPES = ['textbook', 'exam', 'sim']; // course добавим отдельным шагом
|
const CONTENT_TYPES = ['textbook', 'exam', 'sim', 'course'];
|
||||||
const bucket = (type) => BUCKET[type] || (type + 's');
|
const bucket = (type) => BUCKET[type] || (type + 's');
|
||||||
const keyName = (type) => KEYNAME[type] || 'id';
|
const keyName = (type) => KEYNAME[type] || 'id';
|
||||||
const itemsOf = (type) => (_catalog && _catalog[bucket(type)]) || [];
|
const itemsOf = (type) => (_catalog && _catalog[bucket(type)]) || [];
|
||||||
|
|||||||
Reference in New Issue
Block a user