diff --git a/backend/src/controllers/practiceController.js b/backend/src/controllers/practiceController.js index 9a6e4da..8c81feb 100644 --- a/backend/src/controllers/practiceController.js +++ b/backend/src/controllers/practiceController.js @@ -84,6 +84,7 @@ function submitAttempt(req, res) { /* ── Пул текстовых задач (Уровень 1, LLM + проверка) ── */ const genService = require('../services/practiceGenService'); +const { pushNotif } = require('../utils/notifications'); const POOL_TOPICS = { 'word-linear': 1, 'word-proportion': 1, 'word-percent': 1 }; function toClientProblem(r) { @@ -131,6 +132,47 @@ async function generateProblem(req, res) { res.json({ ok: true, problem: toClientProblem(row), attempts: result.attempts }); } +/* POST /api/practice/author — учитель пишет задачу ВРУЧНУЮ (без LLM). + * Та же проверка подстановкой (validateAndVerify): не сходится → 422, в пул не пишем. */ +function authorProblem(req, res) { + const b = req.body || {}; + const topic = (typeof b.topic === 'string') ? b.topic.trim() : 'word-linear'; + if (!POOL_TOPICS[topic]) return res.status(400).json({ error: 'unknown topic' }); + + const v = genService.validateAndVerify({ + story: b.story, lhs: b.lhs, rhs: b.rhs, answer: b.answer, answerVar: b.answerVar, solution: b.solution + }); + if (!v.ok) return res.status(422).json({ error: 'verify', reason: v.reason }); + + const p = v.problem; + const info = db.prepare(` + INSERT INTO practice_problems (topic, skill, difficulty, story, lhs, rhs, answer_var, answer, solution_json, status, created_by) + VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, 'approved', ?) + `).run(topic, topic, p.story, p.lhs, p.rhs, p.answerVar, p.answer, JSON.stringify(p.solution || []), req.user.id); + const row = db.prepare('SELECT * FROM practice_problems WHERE id = ?').get(info.lastInsertRowid); + res.json({ ok: true, problem: toClientProblem(row) }); +} + +/* POST /api/practice/assign { class_id, topic, title } — выдать тему классу. + * Адресное durable-уведомление каждому ученику (pushNotif → таблица + SSE), ссылка /trainer. + * Доступ: владелец класса или админ. */ +function assignToClass(req, res) { + const uid = req.user.id, role = req.user.role; + const b = req.body || {}; + const classId = parseInt(b.class_id, 10); + if (!classId) return res.status(400).json({ error: 'class_id обязателен' }); + const title = (typeof b.title === 'string' ? b.title.trim() : '').slice(0, 200); + + if (role !== 'admin') { + const own = db.prepare('SELECT 1 FROM classes WHERE id = ? AND teacher_id = ?').get(classId, uid); + if (!own) return res.status(403).json({ error: 'не ваш класс' }); + } + const members = db.prepare('SELECT user_id FROM class_members WHERE class_id = ?').all(classId); + const msg = 'Тренажёр: ' + (title || 'новое задание для практики'); + members.forEach(m => pushNotif(m.user_id, 'practice', msg, '/trainer')); + res.json({ ok: true, notified: members.length }); +} + /* GET /api/practice/class-stats?class_id= — аналитика класса для учителя. * Возвращает агрегаты по навыкам (кто застрял) + матрицу ученик×навык для * тепловой карты. Доступ: владелец класса (teacher_id) или админ. */ @@ -173,4 +215,4 @@ function classStats(req, res) { res.json({ students: studentRows, skills, perSkill }); } -module.exports = { listProgress, submitAttempt, listPool, generateProblem, classStats }; +module.exports = { listProgress, submitAttempt, listPool, generateProblem, authorProblem, assignToClass, classStats }; diff --git a/backend/src/routes/practice.js b/backend/src/routes/practice.js index 53d1896..db0a4ca 100644 --- a/backend/src/routes/practice.js +++ b/backend/src/routes/practice.js @@ -13,9 +13,11 @@ router.use(authMiddleware); router.get('/progress', c.listProgress); router.post('/attempt', c.submitAttempt); -// Текстовые задачи (Уровень 1): пул читают все; генерирует учитель/админ. +// Текстовые задачи (Уровень 1): пул читают все; генерирует/авторит учитель/админ. router.get('/pool', c.listPool); router.post('/generate', requireRole('teacher', 'admin'), c.generateProblem); +router.post('/author', requireRole('teacher', 'admin'), c.authorProblem); +router.post('/assign', requireRole('teacher', 'admin'), c.assignToClass); // Аналитика класса — только учитель/админ (владение проверяется в хендлере). router.get('/class-stats', requireRole('teacher', 'admin'), c.classStats); diff --git a/backend/tests/practice-gen.test.js b/backend/tests/practice-gen.test.js index 0da24f7..0d72419 100644 --- a/backend/tests/practice-gen.test.js +++ b/backend/tests/practice-gen.test.js @@ -106,6 +106,27 @@ describe('/api/practice pool endpoints', () => { assert.equal(res.status, 400, `got ${res.status}`); }); + it('POST /author учителем (валидная) → в пул', async () => { + const res = await inject('POST', '/api/practice/author', + { topic: 'word-linear', story: 'Задача от учителя', lhs: '2*x + 1', rhs: '7', answer: 3 }, teacher); + assert.equal(res.status, 200, `got ${res.status}`); + assert.equal(res.body.ok, true); + assert.equal(res.body.problem.answer, 3); + assert.equal(res.body.problem.kind, 'word'); + }); + + it('POST /author с неверным корнем → 422 (в пул не попадёт)', async () => { + const res = await inject('POST', '/api/practice/author', + { topic: 'word-linear', story: 'X', lhs: '2*x + 1', rhs: '7', answer: 5 }, teacher); + assert.equal(res.status, 422, `got ${res.status}`); + }); + + it('POST /author ученику запрещён (403)', async () => { + const res = await inject('POST', '/api/practice/author', + { topic: 'word-linear', story: 'X', lhs: '2*x + 1', rhs: '7', answer: 3 }, student); + assert.equal(res.status, 403, `got ${res.status}`); + }); + it('GET /pool отдаёт одобренные задачи', async () => { db.prepare(`INSERT INTO practice_problems (topic, skill, difficulty, story, lhs, rhs, answer_var, answer, solution_json, status) VALUES ('word-linear','word-linear',1,'Условие','3*x + 4','19','x',5,'[]','approved')`).run(); diff --git a/backend/tests/practice.test.js b/backend/tests/practice.test.js index c20ef24..1359c42 100644 --- a/backend/tests/practice.test.js +++ b/backend/tests/practice.test.js @@ -159,4 +159,16 @@ describe('/api/practice/class-stats (аналитика класса)', () => { const res = await inject('GET', '/api/practice/class-stats', null, teacher.token); assert.equal(res.status, 400, `got ${res.status}`); }); + + it('POST /assign владельцем → уведомляет всех учеников', async () => { + const res = await inject('POST', '/api/practice/assign', { class_id: classId, topic: 'word-linear', title: 'Линейные уравнения' }, teacher.token); + assert.equal(res.status, 200, `got ${res.status}`); + assert.equal(res.body.ok, true); + assert.equal(res.body.notified, 2, 'двое учеников уведомлены'); + }); + + it('POST /assign чужой класс → 403', async () => { + const res = await inject('POST', '/api/practice/assign', { class_id: classId, topic: 'word-linear' }, other.token); + assert.equal(res.status, 403, `got ${res.status}`); + }); }); diff --git a/frontend/trainer.html b/frontend/trainer.html index 2387832..707e0eb 100644 --- a/frontend/trainer.html +++ b/frontend/trainer.html @@ -128,6 +128,15 @@ .tr-hm-cell { font-weight: 700; color: #334155; } .tr-hm-cell .ic { width: 14px; height: 14px; color: #fff; } .tr-hm-sum { font-weight: 800; color: #4f46e5; background: #eef2ff; } + /* форма авторинга задачи */ + .tr-form { display: flex; flex-direction: column; gap: 12px; } + .tr-form label { display: flex; flex-direction: column; gap: 4px; font-size: .85rem; font-weight: 600; color: #475569; } + .tr-form input, .tr-form textarea { font: inherit; padding: 9px 11px; border: 1px solid rgba(148,163,184,0.4); border-radius: 10px; outline: none; resize: vertical; } + .tr-form input:focus, .tr-form textarea:focus { border-color: #818cf8; box-shadow: 0 0 0 3px rgba(129,140,248,0.15); } + .tr-form-row { display: flex; gap: 10px; flex-wrap: wrap; } + .tr-form-row label { flex: 1; min-width: 110px; } + .tr-form-hint { font-size: .8rem; color: #64748b; } + .tr-form-err { color: #dc2626; font-size: .85rem; font-weight: 600; min-height: 18px; } /* ── режим (умная тренировка) ── */ .tr-mode { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; } @@ -197,6 +206,18 @@ +
+ @@ -392,9 +413,15 @@ } function renderSkills() { if (isWord()) { - var btn = isTeacher ? '' : ''; - $('tr-skills').innerHTML = '' + (wordLoading ? 'Загрузка…' : ('Задач в банке: ' + wordPool.length)) + '' + btn; + var tb = isTeacher + ? '' + + '' + + '' + : ''; + $('tr-skills').innerHTML = '' + (wordLoading ? 'Загрузка…' : ('Задач в банке: ' + wordPool.length)) + '' + tb; var gb = $('tr-gen-btn'); if (gb) gb.addEventListener('click', genWordProblem); + var ab = $('tr-author-btn'); if (ab) ab.addEventListener('click', openAuthor); + var asg = $('tr-assign-btn'); if (asg) asg.addEventListener('click', openAssign); return; } var ss = skillsOf(curTopic); @@ -620,7 +647,67 @@ }).catch(function () { $('tr-an-body').innerHTML = '