From 9b7585ac7b34783ef93a0b216aa0c91ed338e9f4 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Wed, 3 Jun 2026 13:31:10 +0300 Subject: [PATCH] =?UTF-8?q?feat(access):=20=D0=A4=D0=B0=D0=B7=D0=B0=201c?= =?UTF-8?q?=20=E2=80=94=20=D0=B2=D0=B8=D0=B4=D0=B8=D0=BC=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BA=D1=83=D1=80=D1=81=D0=BE=D0=B2=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D0=BA=D0=BB=D0=B0=D1=81=D1=81=D0=B0=D0=BC=20(=D0=A4=D0=B0?= =?UTF-8?q?=D0=B7=D0=B0=201=20=D0=B7=D0=B0=D0=B2=D0=B5=D1=80=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Миграция 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) --- backend/src/controllers/courseController.js | 16 +++++- .../migrations/052_course_access_bridge.sql | 14 +++++ backend/src/routes/access.js | 11 +++- backend/tests/course-access.test.js | 55 +++++++++++++++++++ backend/tests/setup.js | 1 + frontend/js/admin/sections/access.js | 2 +- 6 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 backend/src/db/migrations/052_course_access_bridge.sql create mode 100644 backend/tests/course-access.test.js diff --git a/backend/src/controllers/courseController.js b/backend/src/controllers/courseController.js index ce8cf80..79ad37d 100644 --- a/backend/src/controllers/courseController.js +++ b/backend/src/controllers/courseController.js @@ -1,6 +1,15 @@ const db = require('../db/db'); +const access = require('../services/contentAccess'); /* ── 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) 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 `).all(uid, ...args); - res.json(rows.map(courseRow)); + res.json(rows.filter(courseVisible(req.user)).map(courseRow)); } /* ── GET /api/courses/search?q=… ─────────────────────────────────────── */ @@ -59,13 +68,14 @@ function search(req, res) { const pubC = role === 'student' ? '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(` SELECT c.*, (SELECT COUNT(*) FROM lessons l WHERE l.course_id = c.id ${progressSubquery(role)}) AS lesson_count, ${DONE_COUNT_SUBQ} FROM courses c WHERE (c.title LIKE ? OR c.description LIKE ?) ${pubC} 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(` 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 = ? WHERE l.title LIKE ? ${pubL} 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 }); } diff --git a/backend/src/db/migrations/052_course_access_bridge.sql b/backend/src/db/migrations/052_course_access_bridge.sql new file mode 100644 index 0000000..32a731a --- /dev/null +++ b/backend/src/db/migrations/052_course_access_bridge.sql @@ -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; diff --git a/backend/src/routes/access.js b/backend/src/routes/access.js index cb72412..729f6b4 100644 --- a/backend/src/routes/access.js +++ b/backend/src/routes/access.js @@ -38,7 +38,16 @@ router.get('/catalog', (_req, res) => { ORDER BY sort_order, id `).all(); } 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 }); }); /* ── Цели назначения: классы (+ученики) и отдельные ученики ────────────── */ diff --git a/backend/tests/course-access.test.js b/backend/tests/course-access.test.js new file mode 100644 index 0000000..47f499b --- /dev/null +++ b/backend/tests/course-access.test.js @@ -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), 'наш курс в каталоге'); + }); +}); diff --git a/backend/tests/setup.js b/backend/tests/setup.js index b31c3de..894cebf 100644 --- a/backend/tests/setup.js +++ b/backend/tests/setup.js @@ -46,6 +46,7 @@ app.use('/api/questions', require('../src/routes/questions')); app.use('/api/permissions', require('../src/routes/permissions')); app.use('/api/access', require('../src/routes/access')); 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) const { requireFeature } = require('../src/middleware/features'); diff --git a/frontend/js/admin/sections/access.js b/frontend/js/admin/sections/access.js index fd443d5..aa26b92 100644 --- a/frontend/js/admin/sections/access.js +++ b/frontend/js/admin/sections/access.js @@ -38,7 +38,7 @@ 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 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)]) || [];