feat(tests): витрина доступных тестов ученику + флаг «доступен ученикам»

Раньше ученик видел лишь 1 тест на предмет (дефолтный). Теперь учитель/админ
может пометить любой свой тест доступным, и он появляется в каталоге на дашборде.

- Миграция 079: tests.available_to_students (default 0).
- testController: list для ученика отдаёт тесты с available_to_students=1 и вопросами;
  create/update принимают флаг; update сделан частичным (не затирает поля при toggle).
- admin «Тесты»: бейдж «Доступен ученикам» + быстрый тумблер «Ученикам/Скрыть»
  (toggleTstAvail; конструктор доступен и учителям — видят свои тесты).
- Дашборд: виджет «Тесты» → секция «Доступные тесты» (loadAvailableTests), клик
  запускает фикс-тест. Прячется, если доступных нет.

⚠️ Живой БД нужен npm run migrate (колонка).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-23 11:03:42 +03:00
parent c5d440a7a9
commit c6d323ec6d
4 changed files with 75 additions and 13 deletions
+22 -13
View File
@@ -7,13 +7,16 @@ function list(req, res) {
const args = [];
let where = '1=1';
if (subject) { where += ' AND t.subject_slug = ?'; args.push(subject); }
if (role !== 'admin') { where += ' AND t.created_by = ?'; args.push(uid); }
const isStudent = role === 'student' || role === 'free_student';
// Ученик видит каталог тестов, помеченных доступными; учитель — только свои; админ — все.
if (isStudent) { where += ' AND t.available_to_students = 1'; }
else if (role !== 'admin') { where += ' AND t.created_by = ?'; args.push(uid); }
// Экзаменационные варианты — это служебные строки в tests (см. import-exam9.js),
// не показываем их во вкладке «Тесты (шаблоны)» админки.
where += ' AND t.id NOT IN (SELECT test_id FROM exam9_variant_tests)';
const rows = db.prepare(`
SELECT t.id, t.title, t.subject_slug, t.description, t.created_at,
let rows = db.prepare(`
SELECT t.id, t.title, t.subject_slug, t.description, t.created_at, t.available_to_students,
u.name AS creator_name,
COUNT(tq.question_id) AS question_count
FROM tests t
@@ -22,18 +25,19 @@ function list(req, res) {
WHERE ${where}
GROUP BY t.id ORDER BY t.created_at DESC
`).all(...args);
if (isStudent) rows = rows.filter(r => r.question_count > 0); // пустые тесты ученику не показываем
res.json(rows);
}
/* ── POST /api/tests ─────────────────────────────────────────────────────── */
function create(req, res) {
const { title, subject_slug, description, show_answers = 1, time_limit } = req.body;
const { title, subject_slug, description, show_answers = 1, time_limit, available_to_students = 0 } = req.body;
if (!title?.trim()) return res.status(400).json({ error: 'title required' });
if (!subject_slug) return res.status(400).json({ error: 'subject_slug required' });
const tl = time_limit ? Math.max(1, Math.min(600, Number(time_limit))) : null;
const r = db.prepare(
'INSERT INTO tests (title, subject_slug, description, show_answers, time_limit, created_by) VALUES (?, ?, ?, ?, ?, ?)'
).run(title.trim(), subject_slug, description?.trim() || null, show_answers ? 1 : 0, tl, req.user.id);
'INSERT INTO tests (title, subject_slug, description, show_answers, time_limit, available_to_students, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(title.trim(), subject_slug, description?.trim() || null, show_answers ? 1 : 0, tl, available_to_students ? 1 : 0, req.user.id);
res.status(201).json({ id: r.lastInsertRowid });
}
@@ -76,13 +80,18 @@ function getOne(req, res) {
/* ── PUT /api/tests/:id ──────────────────────────────────────────────────── */
function update(req, res) {
const { title, subject_slug, description, show_answers, time_limit } = req.body;
const t = req.resource; // ownership verified by requireOwnership middleware
const tl = time_limit !== undefined ? (time_limit ? Math.max(1, Math.min(600, Number(time_limit))) : null) : undefined;
db.prepare('UPDATE tests SET title = ?, subject_slug = ?, description = ?, show_answers = ?, time_limit = ? WHERE id = ?')
.run(title?.trim(), subject_slug, description?.trim() || null, show_answers === undefined ? 1 : (show_answers ? 1 : 0),
tl !== undefined ? tl : t.time_limit,
t.id);
const b = req.body;
const t = req.resource; // ownership verified by requireOwnership middleware
// Частичный апдейт: НЕ переданные поля сохраняем из текущей строки (иначе toggleTstAvail,
// присылающий только available_to_students, обнулил бы title/subject и т.п.).
const title = b.title !== undefined ? (b.title?.trim() || t.title) : t.title;
const subject_slug = b.subject_slug !== undefined ? b.subject_slug : t.subject_slug;
const description = b.description !== undefined ? (b.description?.trim() || null) : t.description;
const show_answers = b.show_answers !== undefined ? (b.show_answers ? 1 : 0) : t.show_answers;
const time_limit = b.time_limit !== undefined ? (b.time_limit ? Math.max(1, Math.min(600, Number(b.time_limit))) : null) : t.time_limit;
const available = b.available_to_students !== undefined ? (b.available_to_students ? 1 : 0) : t.available_to_students;
db.prepare('UPDATE tests SET title = ?, subject_slug = ?, description = ?, show_answers = ?, time_limit = ?, available_to_students = ? WHERE id = ?')
.run(title, subject_slug, description, show_answers, time_limit, available, t.id);
res.json({ ok: true });
}
@@ -0,0 +1,4 @@
-- Витрина тестов для ученика: флаг «тест доступен ученикам».
-- Учитель/админ помечает свой тест доступным → он появляется в каталоге у учеников
-- (дашборд, виджет «Тесты»). По умолчанию 0 — тест виден только автору в конструкторе.
ALTER TABLE tests ADD COLUMN available_to_students INTEGER NOT NULL DEFAULT 0;