feat(prep): мастер-флаг подготовки к направлению (ЦТ) + коллекции колод — бэкенд
Система «готовится к ЦТ»: флаг student_prep(user_id,track) открывает ученику ВЕСЬ контент трека (карточки + курс + пробники) динамически, без материализации. - мигр.078: таблица student_prep + flashcard_decks.collection + разметка ЦТ-колод 'ct-math' - services/prepTracks.js: реестр треков (трек→коллекция/курсы/экзамены), устойчив до миграции - contentAccess.resolve/allowedRefs: учитывают мастер-флаг (явный запрет ученика побеждает) - flashcardController.deckAccess/listDecks: колоды коллекции открыты по флагу - prepController + /api/prep: учитель (своим) и админ ставят/снимают флаг (ученику/классу) - js/api.js: LS.prep* обёртки Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
const db = require('../db/db');
|
const db = require('../db/db');
|
||||||
const { stripTags } = require('../utils/sanitize');
|
const { stripTags } = require('../utils/sanitize');
|
||||||
|
const prepTracks = require('../services/prepTracks');
|
||||||
|
|
||||||
/* ── валидация URL картинки ────────────────────────────────────────────────
|
/* ── валидация URL картинки ────────────────────────────────────────────────
|
||||||
Принимаем ТОЛЬКО свои загруженные файлы (/uploads/flashcards/<file>) —
|
Принимаем ТОЛЬКО свои загруженные файлы (/uploads/flashcards/<file>) —
|
||||||
@@ -110,7 +111,10 @@ function deckAccess(deckId, user) {
|
|||||||
(a.type = 'class' AND a.target_id IN (SELECT class_id FROM class_members WHERE user_id = ?))
|
(a.type = 'class' AND a.target_id IN (SELECT class_id FROM class_members WHERE user_id = ?))
|
||||||
) LIMIT 1
|
) LIMIT 1
|
||||||
`).get(deckId, user.id, user.id);
|
`).get(deckId, user.id, user.id);
|
||||||
return { deck, owner: false, canRead: !!shared, canEdit: false };
|
// Колода открыта мастер-флагом направления: collection ∈ коллекций треков ученика.
|
||||||
|
const byTrack = !shared && deck.collection &&
|
||||||
|
prepTracks.studentCollections(user.id).has(deck.collection);
|
||||||
|
return { deck, owner: false, canRead: !!shared || !!byTrack, canEdit: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
/* due_count карты колоды для пользователя: learning/review к повтору (due_at<=now)
|
/* due_count карты колоды для пользователя: learning/review к повтору (due_at<=now)
|
||||||
@@ -140,6 +144,10 @@ function deckDueCount(deckId, uid) {
|
|||||||
для UI: общие открываются только на чтение и изучение. */
|
для UI: общие открываются только на чтение и изучение. */
|
||||||
function listDecks(req, res) {
|
function listDecks(req, res) {
|
||||||
const uid = req.user.id;
|
const uid = req.user.id;
|
||||||
|
// Коллекции, открытые мастер-флагом направления (динамически, без правил доступа).
|
||||||
|
const cols = [...prepTracks.studentCollections(uid)];
|
||||||
|
const colClause = cols.length
|
||||||
|
? `OR d.collection IN (${cols.map(() => '?').join(',')})` : '';
|
||||||
const decks = db.prepare(`
|
const decks = db.prepare(`
|
||||||
SELECT d.*, u.name AS owner_name,
|
SELECT d.*, u.name AS owner_name,
|
||||||
CASE WHEN d.user_id = ? THEN 1 ELSE 0 END AS can_edit,
|
CASE WHEN d.user_id = ? THEN 1 ELSE 0 END AS can_edit,
|
||||||
@@ -152,8 +160,9 @@ function listDecks(req, res) {
|
|||||||
OR EXISTS (SELECT 1 FROM flashcard_deck_access a
|
OR EXISTS (SELECT 1 FROM flashcard_deck_access a
|
||||||
JOIN class_members cm ON cm.class_id = a.target_id AND cm.user_id = ?
|
JOIN class_members cm ON cm.class_id = a.target_id AND cm.user_id = ?
|
||||||
WHERE a.deck_id = d.id AND a.type = 'class')
|
WHERE a.deck_id = d.id AND a.type = 'class')
|
||||||
|
${colClause}
|
||||||
ORDER BY shared ASC, d.created_at DESC
|
ORDER BY shared ASC, d.created_at DESC
|
||||||
`).all(uid, uid, uid, uid, uid);
|
`).all(uid, uid, uid, uid, uid, ...cols);
|
||||||
|
|
||||||
const cardStmt = db.prepare(`SELECT COUNT(*) AS n FROM flashcard_cards WHERE deck_id = ?`);
|
const cardStmt = db.prepare(`SELECT COUNT(*) AS n FROM flashcard_cards WHERE deck_id = ?`);
|
||||||
for (const d of decks) {
|
for (const d of decks) {
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
'use strict';
|
||||||
|
/* prepController — управление флагом «подготовки к направлению» (мастер-флаг).
|
||||||
|
*
|
||||||
|
* Флаг student_prep(user_id, track) открывает ученику весь контент трека
|
||||||
|
* (карточки + курс + пробники) — резолв в services/prepTracks.js + contentAccess.js.
|
||||||
|
*
|
||||||
|
* Управление: учитель — СВОИМ ученикам (из своих классов или персональным),
|
||||||
|
* админ — любым. Read own-статус доступен самому ученику (GET /me).
|
||||||
|
*/
|
||||||
|
const db = require('../db/db');
|
||||||
|
const prepTracks = require('../services/prepTracks');
|
||||||
|
|
||||||
|
/* Ученик «свой» для учителя: член одного из его классов ИЛИ персональный ученик. */
|
||||||
|
function canManageStudent(user, studentId) {
|
||||||
|
if (user.role === 'admin') return true;
|
||||||
|
if (user.role !== 'teacher') return false;
|
||||||
|
return !!db.prepare(`
|
||||||
|
SELECT 1 FROM class_members cm JOIN classes c ON c.id = cm.class_id
|
||||||
|
WHERE cm.user_id = ? AND c.teacher_id = ?
|
||||||
|
UNION
|
||||||
|
SELECT 1 FROM teacher_students WHERE student_id = ? AND teacher_id = ?
|
||||||
|
LIMIT 1
|
||||||
|
`).get(studentId, user.id, studentId, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Класс «свой» для учителя (admin — любой). */
|
||||||
|
function canManageClass(user, classId) {
|
||||||
|
if (user.role === 'admin') return true;
|
||||||
|
if (user.role !== 'teacher') return false;
|
||||||
|
return !!db.prepare(`SELECT 1 FROM classes WHERE id = ? AND teacher_id = ?`).get(classId, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── GET /api/prep/tracks — список доступных направлений (для UI-меток) ── */
|
||||||
|
function listTracks(req, res) {
|
||||||
|
res.json({ tracks: prepTracks.listTracks() });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── GET /api/prep/me — мои треки (ученик видит свой статус) ── */
|
||||||
|
function myTracks(req, res) {
|
||||||
|
const rows = db.prepare(`SELECT track FROM student_prep WHERE user_id = ?`).all(req.user.id);
|
||||||
|
res.json({ tracks: rows.map(r => r.track).filter(prepTracks.isTrack) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── GET /api/prep/student/:id — треки ученика (учитель своего / админ) ── */
|
||||||
|
function studentTracks(req, res) {
|
||||||
|
const studentId = Number(req.params.id) || 0;
|
||||||
|
if (!canManageStudent(req.user, studentId)) return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
const rows = db.prepare(`SELECT track FROM student_prep WHERE user_id = ?`).all(studentId);
|
||||||
|
res.json({ tracks: rows.map(r => r.track).filter(prepTracks.isTrack) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── POST /api/prep/student/:id { track } — включить флаг ── */
|
||||||
|
function setStudent(req, res) {
|
||||||
|
const studentId = Number(req.params.id) || 0;
|
||||||
|
const track = String(req.body.track || '');
|
||||||
|
if (!prepTracks.isTrack(track)) return res.status(400).json({ error: 'Неизвестный трек' });
|
||||||
|
if (!canManageStudent(req.user, studentId)) return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
// Ставим флаг только реально существующему ученику.
|
||||||
|
const stu = db.prepare(`SELECT id, role FROM users WHERE id = ?`).get(studentId);
|
||||||
|
if (!stu) return res.status(404).json({ error: 'Ученик не найден' });
|
||||||
|
db.prepare(`INSERT OR IGNORE INTO student_prep (user_id, track, created_by) VALUES (?,?,?)`)
|
||||||
|
.run(studentId, track, req.user.id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── DELETE /api/prep/student/:id?track= — снять флаг ── */
|
||||||
|
function unsetStudent(req, res) {
|
||||||
|
const studentId = Number(req.params.id) || 0;
|
||||||
|
const track = String(req.query.track || '');
|
||||||
|
if (!prepTracks.isTrack(track)) return res.status(400).json({ error: 'Неизвестный трек' });
|
||||||
|
if (!canManageStudent(req.user, studentId)) return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
db.prepare(`DELETE FROM student_prep WHERE user_id = ? AND track = ?`).run(studentId, track);
|
||||||
|
res.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── GET /api/prep/class/:id?track= — статус флага у членов класса ── */
|
||||||
|
function classStatus(req, res) {
|
||||||
|
const classId = Number(req.params.id) || 0;
|
||||||
|
const track = String(req.query.track || '');
|
||||||
|
if (!prepTracks.isTrack(track)) return res.status(400).json({ error: 'Неизвестный трек' });
|
||||||
|
if (!canManageClass(req.user, classId)) return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT u.id, u.name,
|
||||||
|
CASE WHEN sp.id IS NULL THEN 0 ELSE 1 END AS prep
|
||||||
|
FROM class_members cm
|
||||||
|
JOIN users u ON u.id = cm.user_id
|
||||||
|
LEFT JOIN student_prep sp ON sp.user_id = u.id AND sp.track = ?
|
||||||
|
WHERE cm.class_id = ?
|
||||||
|
ORDER BY u.name
|
||||||
|
`).all(track, classId);
|
||||||
|
res.json({ students: rows });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── POST /api/prep/class/:id { track, on } — массово по классу ── */
|
||||||
|
function setClass(req, res) {
|
||||||
|
const classId = Number(req.params.id) || 0;
|
||||||
|
const track = String(req.body.track || '');
|
||||||
|
const on = !!req.body.on;
|
||||||
|
if (!prepTracks.isTrack(track)) return res.status(400).json({ error: 'Неизвестный трек' });
|
||||||
|
if (!canManageClass(req.user, classId)) return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
const members = db.prepare(`SELECT user_id FROM class_members WHERE class_id = ?`).all(classId);
|
||||||
|
const ins = db.prepare(`INSERT OR IGNORE INTO student_prep (user_id, track, created_by) VALUES (?,?,?)`);
|
||||||
|
const del = db.prepare(`DELETE FROM student_prep WHERE user_id = ? AND track = ?`);
|
||||||
|
const run = db.transaction(() => {
|
||||||
|
for (const m of members) on ? ins.run(m.user_id, track, req.user.id) : del.run(m.user_id, track);
|
||||||
|
});
|
||||||
|
run();
|
||||||
|
res.json({ ok: true, count: members.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
listTracks, myTracks, studentTracks, setStudent, unsetStudent, classStatus, setClass,
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- 078_prep_tracks.sql
|
||||||
|
-- Система «подготовка к направлению» (ЦТ и др.): флаг ученика + коллекция колод.
|
||||||
|
--
|
||||||
|
-- МАСТЕР-ФЛАГ: запись student_prep(user_id, track) открывает ученику ВЕСЬ контент
|
||||||
|
-- трека сразу — коллекцию флешкарт (flashcard_decks.collection), курс и пробники
|
||||||
|
-- (content_type='course'/'exam'). Маппинг трек→контент — services/prepTracks.js;
|
||||||
|
-- динамический резолв доступа — services/contentAccess.js (без материализации
|
||||||
|
-- правил content_access) и flashcardController.deckAccess/listDecks.
|
||||||
|
--
|
||||||
|
-- Управление флагом — учитель (своим ученикам) и админ (см. prepController.js).
|
||||||
|
|
||||||
|
-- 1) Флаг подготовки ученика по треку. track — ключ направления ('ct-math', …).
|
||||||
|
CREATE TABLE IF NOT EXISTS student_prep (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
track TEXT NOT NULL,
|
||||||
|
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
UNIQUE (user_id, track)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_student_prep_user ON student_prep(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_student_prep_track ON student_prep(track);
|
||||||
|
|
||||||
|
-- 2) Коллекция (папка/направление) колоды. NULL = вне коллекции (поведение как раньше).
|
||||||
|
ALTER TABLE flashcard_decks ADD COLUMN collection TEXT;
|
||||||
|
|
||||||
|
-- 3) Разметить существующие ЦТ-колоды коллекцией 'ct-math' (заголовки «ЦТ · …»).
|
||||||
|
UPDATE flashcard_decks SET collection = 'ct-math' WHERE title LIKE 'ЦТ ·%';
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
'use strict';
|
||||||
|
/* /api/prep — управление мастер-флагом «подготовка к направлению».
|
||||||
|
* Все роуты под authMiddleware. Мутации/чтение чужого статуса — учитель (своих)
|
||||||
|
* или админ (проверка владения в контроллере: canManageStudent/canManageClass). */
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const prep = require('../controllers/prepController');
|
||||||
|
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||||
|
|
||||||
|
router.use(authMiddleware);
|
||||||
|
|
||||||
|
router.get ('/tracks', prep.listTracks); // справочник направлений (любой авторизованный)
|
||||||
|
router.get ('/me', prep.myTracks); // свой статус (ученик)
|
||||||
|
|
||||||
|
// Управление флагами учеников/классов — только учитель/админ (владение — в контроллере).
|
||||||
|
router.get ('/student/:id', requireRole('teacher', 'admin'), prep.studentTracks);
|
||||||
|
router.post ('/student/:id', requireRole('teacher', 'admin'), prep.setStudent);
|
||||||
|
router.delete('/student/:id', requireRole('teacher', 'admin'), prep.unsetStudent);
|
||||||
|
router.get ('/class/:id', requireRole('teacher', 'admin'), prep.classStatus);
|
||||||
|
router.post ('/class/:id', requireRole('teacher', 'admin'), prep.setClass);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -198,6 +198,7 @@ app.use('/api/lab', labRoutes);
|
|||||||
app.use('/api/materials', require('./routes/materials'));
|
app.use('/api/materials', require('./routes/materials'));
|
||||||
app.use('/api/custom-sims', require('./routes/customSims'));
|
app.use('/api/custom-sims', require('./routes/customSims'));
|
||||||
app.use('/api/game', require('./routes/game'));
|
app.use('/api/game', require('./routes/game'));
|
||||||
|
app.use('/api/prep', require('./routes/prep'));
|
||||||
app.use('/api/dashboard', require('./routes/dashboard'));
|
app.use('/api/dashboard', require('./routes/dashboard'));
|
||||||
|
|
||||||
/* ── Public features endpoint (merges global + per-class for authenticated students) ── */
|
/* ── Public features endpoint (merges global + per-class for authenticated students) ── */
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
* allowedRefs(userId, type) → Set<content_ref> доступных пользователю
|
* allowedRefs(userId, type) → Set<content_ref> доступных пользователю
|
||||||
*/
|
*/
|
||||||
const db = require('../db/db');
|
const db = require('../db/db');
|
||||||
|
const prepTracks = require('./prepTracks');
|
||||||
|
|
||||||
const PRIVILEGED = new Set(['admin', 'teacher']);
|
const PRIVILEGED = new Set(['admin', 'teacher']);
|
||||||
|
|
||||||
@@ -33,9 +34,10 @@ const _classRule = db.prepare(`
|
|||||||
|
|
||||||
function resolve(userId, type, ref) {
|
function resolve(userId, type, ref) {
|
||||||
const s = _studentRule.get(type, ref, userId);
|
const s = _studentRule.get(type, ref, userId);
|
||||||
if (s) return s.allow === 1; // правило ученика побеждает
|
if (s) return s.allow === 1; // явное правило ученика побеждает (вкл. запрет)
|
||||||
const c = _classRule.get(type, ref, userId);
|
const c = _classRule.get(type, ref, userId);
|
||||||
if (c && c.n > 0) return c.any_allow === 1; // открыт хотя бы одним классом
|
if (c && c.n > 0) return c.any_allow === 1; // открыт хотя бы одним классом
|
||||||
|
if (prepTracks.trackGrants(userId, type, ref)) return true; // мастер-флаг направления
|
||||||
return false; // allowlist — по умолчанию закрыто
|
return false; // allowlist — по умолчанию закрыто
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,9 +71,10 @@ function allowedRefs(userId, type) {
|
|||||||
for (const r of _allClassRules.all(type, userId)) {
|
for (const r of _allClassRules.all(type, userId)) {
|
||||||
if (r.any_allow === 1) out.add(r.content_ref);
|
if (r.any_allow === 1) out.add(r.content_ref);
|
||||||
}
|
}
|
||||||
|
for (const r of prepTracks.trackRefs(userId, type)) out.add(r); // мастер-флаг направления открывает
|
||||||
for (const r of _allStudentRules.all(type, userId)) {
|
for (const r of _allStudentRules.all(type, userId)) {
|
||||||
if (r.allow === 1) out.add(r.content_ref); // ученик-разрешение добавляет
|
if (r.allow === 1) out.add(r.content_ref); // ученик-разрешение добавляет
|
||||||
else out.delete(r.content_ref); // ученик-запрет снимает
|
else out.delete(r.content_ref); // ученик-запрет снимает (в т.ч. поверх трека)
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
'use strict';
|
||||||
|
/* prepTracks — направления подготовки (ЦТ и др.) и их контент («мастер-флаг»).
|
||||||
|
*
|
||||||
|
* Флаг student_prep(user_id, track) открывает ученику ВЕСЬ контент трека:
|
||||||
|
* - коллекцию флешкарт (flashcard_decks.collection)
|
||||||
|
* - курсы (content_type='course', content_ref=id курса)
|
||||||
|
* - экзамен-модули (content_type='exam', content_ref=exam_key)
|
||||||
|
* Доступ резолвится ДИНАМИЧЕСКИ (без материализации правил content_access):
|
||||||
|
* - contentAccess.resolve/allowedRefs читают trackGrants/trackRefs;
|
||||||
|
* - flashcardController.deckAccess/listDecks читают studentCollections.
|
||||||
|
*
|
||||||
|
* Добавить направление = добавить запись в TRACKS (+ при необходимости миграцию,
|
||||||
|
* проставляющую collection нужным колодам). Никаких изменений в резолверах. */
|
||||||
|
const db = require('../db/db');
|
||||||
|
|
||||||
|
/* Реестр треков. collection — ключ коллекции колод (flashcard_decks.collection);
|
||||||
|
content — что открывает трек, по типам content_access. */
|
||||||
|
const TRACKS = {
|
||||||
|
'ct-math': {
|
||||||
|
title: 'ЦТ/ЦЭ — математика',
|
||||||
|
label: 'Подготовка к ЦТ',
|
||||||
|
collection: 'ct-math',
|
||||||
|
content: { course: ['13'], exam: ['ctmath'] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function isTrack(track) { return Object.prototype.hasOwnProperty.call(TRACKS, track); }
|
||||||
|
|
||||||
|
/* Множество известных треков, включённых у пользователя.
|
||||||
|
Inline-prepare + try/catch: до миграции 078 (нет таблицы student_prep) фича
|
||||||
|
просто не активна (пустой набор), а не падает на загрузке модуля/в тестах. */
|
||||||
|
function studentTracks(userId) {
|
||||||
|
try {
|
||||||
|
const rows = db.prepare('SELECT track FROM student_prep WHERE user_id = ?').all(userId);
|
||||||
|
return new Set(rows.map(r => r.track).filter(isTrack));
|
||||||
|
} catch (_) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Множество collection-ключей, открытых пользователю его треками. */
|
||||||
|
function studentCollections(userId) {
|
||||||
|
const out = new Set();
|
||||||
|
for (const t of studentTracks(userId)) {
|
||||||
|
if (TRACKS[t].collection) out.add(TRACKS[t].collection);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Открывает ли какой-либо трек пользователя данный (type, ref)? */
|
||||||
|
function trackGrants(userId, type, ref) {
|
||||||
|
const r = String(ref);
|
||||||
|
for (const t of studentTracks(userId)) {
|
||||||
|
const refs = TRACKS[t].content && TRACKS[t].content[type];
|
||||||
|
if (refs && refs.includes(r)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* content_ref'ы данного типа, открытые треками пользователя (для allowedRefs). */
|
||||||
|
function trackRefs(userId, type) {
|
||||||
|
const out = new Set();
|
||||||
|
for (const t of studentTracks(userId)) {
|
||||||
|
const refs = TRACKS[t].content && TRACKS[t].content[type];
|
||||||
|
if (refs) refs.forEach(x => out.add(String(x)));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Список треков для UI: [{ key, title, label }]. */
|
||||||
|
function listTracks() {
|
||||||
|
return Object.entries(TRACKS).map(([key, t]) => ({ key, title: t.title, label: t.label }));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
TRACKS, isTrack, studentTracks, studentCollections,
|
||||||
|
trackGrants, trackRefs, listTracks,
|
||||||
|
};
|
||||||
@@ -1065,6 +1065,7 @@ window.LS = {
|
|||||||
adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks,
|
adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks,
|
||||||
adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, adminAssistantModels,
|
adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, adminAssistantModels,
|
||||||
fcListDecks, fcCreateDeck, fcAddCard, fcStudySession, fcReview,
|
fcListDecks, fcCreateDeck, fcAddCard, fcStudySession, fcReview,
|
||||||
|
prepListTracks, prepMyTracks, prepStudentTracks, prepSetStudent, prepUnsetStudent, prepClassStatus, prepSetClass,
|
||||||
escapeHtml, esc,
|
escapeHtml, esc,
|
||||||
parseDate, fmtRelTime, safeHref,
|
parseDate, fmtRelTime, safeHref,
|
||||||
initPage,
|
initPage,
|
||||||
@@ -1317,6 +1318,14 @@ async function fcCreateDeck(d) { return req('POST', '/flashcards/decks', d
|
|||||||
async function fcAddCard(deckId, d) { return req('POST', `/flashcards/decks/${deckId}/cards`, d); }
|
async function fcAddCard(deckId, d) { return req('POST', `/flashcards/decks/${deckId}/cards`, d); }
|
||||||
async function fcStudySession(deckId){ return req('GET', `/flashcards/decks/${deckId}/study`); }
|
async function fcStudySession(deckId){ return req('GET', `/flashcards/decks/${deckId}/study`); }
|
||||||
async function fcReview(cardId, quality) { return req('POST', `/flashcards/cards/${cardId}/review`, { quality }); }
|
async function fcReview(cardId, quality) { return req('POST', `/flashcards/cards/${cardId}/review`, { quality }); }
|
||||||
|
/* ── prep tracks (мастер-флаг «подготовка к ЦТ» и т.п.) ──────────────────── */
|
||||||
|
async function prepListTracks() { return req('GET', '/prep/tracks'); }
|
||||||
|
async function prepMyTracks() { return req('GET', '/prep/me'); }
|
||||||
|
async function prepStudentTracks(uid) { return req('GET', `/prep/student/${uid}`); }
|
||||||
|
async function prepSetStudent(uid, track) { return req('POST', `/prep/student/${uid}`, { track }); }
|
||||||
|
async function prepUnsetStudent(uid, track){ return req('DELETE', `/prep/student/${uid}?track=${encodeURIComponent(track)}`); }
|
||||||
|
async function prepClassStatus(classId, track) { return req('GET', `/prep/class/${classId}?track=${encodeURIComponent(track)}`); }
|
||||||
|
async function prepSetClass(classId, track, on) { return req('POST', `/prep/class/${classId}`, { track, on: !!on }); }
|
||||||
async function deleteFile(id) { return req('DELETE', `/files/${id}`); }
|
async function deleteFile(id) { return req('DELETE', `/files/${id}`); }
|
||||||
async function getFileAccess(id) { return req('GET', `/files/${id}/access`); }
|
async function getFileAccess(id) { return req('GET', `/files/${id}/access`); }
|
||||||
async function assignFile(id, data) { return req('POST', `/files/${id}/assign`, data); }
|
async function assignFile(id, data) { return req('POST', `/files/${id}/assign`, data); }
|
||||||
|
|||||||
Reference in New Issue
Block a user