feat(trainer): P6 — учительская аналитика класса + общий прогресс
- GET /api/practice/class-stats (classStats): агрегаты по навыкам + матрица ученик×навык; доступ владелец класса/админ - клиент: кнопка «Аналитика класса» (учителю) → модалка с тепловой картой (точность/освоено) + пикер классов; LS.practiceClassStats - лёгкая геймификация: строка «Освоено навыков M из N · решено всего K» из агрегатов practice_progress - тесты practice.test.js +4 (владелец видит; чужой/ученик → 403; без class_id → 400); смоук страницы 27/27; план P6 → DONE Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -131,4 +131,46 @@ async function generateProblem(req, res) {
|
||||
res.json({ ok: true, problem: toClientProblem(row), attempts: result.attempts });
|
||||
}
|
||||
|
||||
module.exports = { listProgress, submitAttempt, listPool, generateProblem };
|
||||
/* GET /api/practice/class-stats?class_id= — аналитика класса для учителя.
|
||||
* Возвращает агрегаты по навыкам (кто застрял) + матрицу ученик×навык для
|
||||
* тепловой карты. Доступ: владелец класса (teacher_id) или админ. */
|
||||
function classStats(req, res) {
|
||||
const uid = req.user.id, role = req.user.role;
|
||||
const classId = parseInt((req.query && req.query.class_id), 10);
|
||||
if (!classId) return res.status(400).json({ error: 'class_id обязателен' });
|
||||
|
||||
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 students = db.prepare(
|
||||
'SELECT u.id, u.name FROM class_members cm JOIN users u ON u.id = cm.user_id WHERE cm.class_id = ? ORDER BY u.name'
|
||||
).all(classId);
|
||||
if (!students.length) return res.json({ students: [], skills: [], perSkill: [] });
|
||||
|
||||
const ids = students.map(s => s.id);
|
||||
const ph = ids.map(() => '?').join(',');
|
||||
const rows = db.prepare(
|
||||
`SELECT user_id, skill, solved, attempts, mastered FROM practice_progress WHERE user_id IN (${ph})`
|
||||
).all(...ids);
|
||||
|
||||
const bySkill = {}, byStudent = {};
|
||||
for (const r of rows) {
|
||||
const s = bySkill[r.skill] || (bySkill[r.skill] = { skill: r.skill, attempted: 0, solved: 0, attempts: 0, mastered: 0 });
|
||||
s.attempted++; s.solved += r.solved; s.attempts += r.attempts; if (r.mastered) s.mastered++;
|
||||
const st = byStudent[r.user_id] || (byStudent[r.user_id] = {});
|
||||
st[r.skill] = { solved: r.solved, attempts: r.attempts, mastered: r.mastered ? 1 : 0,
|
||||
accuracy: r.attempts ? Math.round(100 * r.solved / r.attempts) : 0 };
|
||||
}
|
||||
const skills = Object.keys(bySkill).sort();
|
||||
const perSkill = skills.map(k => {
|
||||
const s = bySkill[k];
|
||||
return { skill: k, attempted: s.attempted, mastered: s.mastered,
|
||||
accuracy: s.attempts ? Math.round(100 * s.solved / s.attempts) : 0 };
|
||||
});
|
||||
const studentRows = students.map(s => ({ id: s.id, name: s.name, perSkill: byStudent[s.id] || {} }));
|
||||
res.json({ students: studentRows, skills, perSkill });
|
||||
}
|
||||
|
||||
module.exports = { listProgress, submitAttempt, listPool, generateProblem, classStats };
|
||||
|
||||
@@ -17,4 +17,7 @@ router.post('/attempt', c.submitAttempt);
|
||||
router.get('/pool', c.listPool);
|
||||
router.post('/generate', requireRole('teacher', 'admin'), c.generateProblem);
|
||||
|
||||
// Аналитика класса — только учитель/админ (владение проверяется в хендлере).
|
||||
router.get('/class-stats', requireRole('teacher', 'admin'), c.classStats);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
const { describe, it, before } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { app, inject, getToken, cleanup } = require('./setup');
|
||||
const { app, db, inject, getToken, cleanup } = require('./setup');
|
||||
|
||||
// Mount /api/practice on the shared test app (setup.js не монтирует новые роуты).
|
||||
app.use('/api/practice', require('../src/routes/practice'));
|
||||
@@ -114,3 +114,49 @@ describe('/api/practice progress', () => {
|
||||
assert.equal(res.status, 400, `got ${res.status}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/api/practice/class-stats (аналитика класса)', () => {
|
||||
let teacher, other, s1, s2, classId;
|
||||
|
||||
before(async () => {
|
||||
teacher = await getToken('teacher');
|
||||
other = await getToken('teacher');
|
||||
s1 = await getToken('student');
|
||||
s2 = await getToken('student');
|
||||
// класс учителя + два ученика в нём
|
||||
const info = db.prepare("INSERT INTO classes (name, teacher_id, invite_code) VALUES ('P6 класс', ?, ?)").run(teacher.userId, 'P6CODE');
|
||||
classId = info.lastInsertRowid;
|
||||
db.prepare('INSERT INTO class_members (class_id, user_id) VALUES (?, ?)').run(classId, s1.userId);
|
||||
db.prepare('INSERT INTO class_members (class_id, user_id) VALUES (?, ?)').run(classId, s2.userId);
|
||||
// прогресс: s1 решил lin-basic верно, s2 ошибся на lin-basic
|
||||
await inject('POST', '/api/practice/attempt', { skill: 'lin-basic', correct: true }, s1.token);
|
||||
await inject('POST', '/api/practice/attempt', { skill: 'lin-basic', correct: false }, s2.token);
|
||||
await inject('POST', '/api/practice/attempt', { skill: 'lin-paren', correct: true }, s1.token);
|
||||
});
|
||||
|
||||
it('владелец класса видит агрегаты и матрицу', async () => {
|
||||
const res = await inject('GET', `/api/practice/class-stats?class_id=${classId}`, null, teacher.token);
|
||||
assert.equal(res.status, 200, `got ${res.status}`);
|
||||
assert.equal(res.body.students.length, 2, 'два ученика');
|
||||
assert.ok(res.body.skills.includes('lin-basic'), 'навык в списке');
|
||||
const lb = res.body.perSkill.find(s => s.skill === 'lin-basic');
|
||||
assert.ok(lb, 'агрегат по lin-basic есть');
|
||||
assert.equal(lb.attempted, 2, 'оба пробовали lin-basic');
|
||||
assert.equal(lb.accuracy, 50, '1 верный из 2 попыток → 50%');
|
||||
});
|
||||
|
||||
it('чужой класс → 403', async () => {
|
||||
const res = await inject('GET', `/api/practice/class-stats?class_id=${classId}`, null, other.token);
|
||||
assert.equal(res.status, 403, `got ${res.status}`);
|
||||
});
|
||||
|
||||
it('ученику запрещено (требуется роль) → 403', async () => {
|
||||
const res = await inject('GET', `/api/practice/class-stats?class_id=${classId}`, null, s1.token);
|
||||
assert.equal(res.status, 403, `got ${res.status}`);
|
||||
});
|
||||
|
||||
it('без class_id → 400', async () => {
|
||||
const res = await inject('GET', '/api/practice/class-stats', null, teacher.token);
|
||||
assert.equal(res.status, 400, `got ${res.status}`);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user