diff --git a/backend/src/controllers/testController.js b/backend/src/controllers/testController.js index 284165f..f969079 100644 --- a/backend/src/controllers/testController.js +++ b/backend/src/controllers/testController.js @@ -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 }); } diff --git a/backend/src/db/migrations/079_tests_available.sql b/backend/src/db/migrations/079_tests_available.sql new file mode 100644 index 0000000..62703f0 --- /dev/null +++ b/backend/src/db/migrations/079_tests_available.sql @@ -0,0 +1,4 @@ +-- Витрина тестов для ученика: флаг «тест доступен ученикам». +-- Учитель/админ помечает свой тест доступным → он появляется в каталоге у учеников +-- (дашборд, виджет «Тесты»). По умолчанию 0 — тест виден только автору в конструкторе. +ALTER TABLE tests ADD COLUMN available_to_students INTEGER NOT NULL DEFAULT 0; diff --git a/frontend/dashboard.html b/frontend/dashboard.html index 3da0e8e..5797f9a 100644 --- a/frontend/dashboard.html +++ b/frontend/dashboard.html @@ -1750,6 +1750,11 @@
Тесты
+ +
@@ -2257,6 +2262,32 @@ window.location.href = url; } + /* Витрина доступных тестов (бэкенд ученику отдаёт только помеченные доступными). */ + async function loadAvailableTests() { + const wrap = document.getElementById('avail-tests-wrap'); + const list = document.getElementById('available-tests-list'); + if (!wrap || !list) return; + try { + const tests = await LS.getTests(); + if (!tests || !tests.length) { wrap.style.display = 'none'; return; } + const SUBJ_N = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика' }; + list.innerHTML = tests.map((t, i) => { + const color = SUBJ_COLORS[t.subject_slug] || '#9B5DE5'; + const iconName = ICONS[t.subject_slug] || 'book-open'; + return `
+
${lci(iconName)}
+
+
${esc(t.title)}
+
${SUBJ_N[t.subject_slug] || t.subject_slug} · ${t.question_count} вопр.
+
+ +
`; + }).join(''); + wrap.style.display = ''; + reIcons(); + } catch { wrap.style.display = 'none'; } + } + /* ══ ЗАДАНИЯ ══════════════════════════════════════════════════════════ */ async function loadAssignments() { try { @@ -4505,6 +4536,7 @@ } else { // Student: full layout loadSubjects(); + loadAvailableTests(); loadAssignments(); loadStats(); loadGamification(); diff --git a/frontend/js/admin/sections/tests.js b/frontend/js/admin/sections/tests.js index f87feff..651162a 100644 --- a/frontend/js/admin/sections/tests.js +++ b/frontend/js/admin/sections/tests.js @@ -40,10 +40,12 @@ ${SUBJ_N[t.subject_slug]||t.subject_slug} ${t.question_count} вопросов ${fmtDate(t.created_at)} + ${t.available_to_students ? `Доступен ученикам` : ''} ${t.description ? `${esc(t.description)}` : ''}
+
@@ -261,6 +263,20 @@ } catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); } } + // Открыть/скрыть тест для учеников (попадает в каталог на дашборде) + async function toggleTstAvail(id) { + const t = allTests.find(x => x.id === id); + if (!t) return; + if (!t.question_count) { LS.toast('Сначала добавьте вопросы в тест', 'error'); return; } + const next = t.available_to_students ? 0 : 1; + try { + await LS.updateTest(id, { available_to_students: next }); + t.available_to_students = next; + renderTests(); + LS.toast(next ? 'Тест открыт ученикам' : 'Тест скрыт от учеников', 'success'); + } catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); } + } + // Expose handlers window.loadTests = load; window.renderTests = renderTests; @@ -274,6 +290,7 @@ window.closeTstModal = closeTstModal; window.saveTst = saveTst; window.deleteTst = deleteTst; + window.toggleTstAvail = toggleTstAvail; window.AdminSections = window.AdminSections || {}; window.AdminSections.tests = {