${esc(e.student_name)}
${esc(e.description)}${aiBadge(e)}
@@ -216,6 +219,31 @@ async function loadGroupOptions() {
groupsLoaded = true;
}
+async function loadModuleOptions() {
+ const res = await fetch(`${API}/api/modules`);
+ if (!res.ok) return;
+ const data = await res.json();
+ editModules = data.modules || [];
+ modulesLoaded = true;
+ const filt = document.getElementById('moduleFilter');
+ if (filt) {
+ while (filt.options.length > 1) filt.remove(1);
+ editModules.forEach(m => {
+ const o = document.createElement('option');
+ o.value = m.id; o.textContent = m.name;
+ filt.appendChild(o);
+ });
+ }
+}
+
+function fillEditModuleSelect(selected) {
+ const sel = document.getElementById('editModule');
+ if (!sel) return;
+ sel.innerHTML = '
— не выбрано — ' +
+ editModules.map(m => `
${esc(m.name)} `).join('');
+ sel.value = selected ? String(selected) : '';
+}
+
async function loadStudentOptions() {
if (studentsLoaded) return;
const dl = document.getElementById('studentFilterList');
@@ -237,6 +265,8 @@ async function loadEntries() {
if (gid) p.set('group_id', gid);
if (df) p.set('date_from', df);
if (dt) p.set('date_to', dt);
+ const mid = document.getElementById('moduleFilter') ? document.getElementById('moduleFilter').value : '';
+ if (mid) p.set('module_id', mid);
p.set('limit', PAGE_SIZE);
p.set('offset', (page - 1) * PAGE_SIZE);
const res = await fetch(`${API}/api/entries?${p}`, { headers: hdr() });
@@ -337,6 +367,8 @@ async function openEdit(id) {
sel.innerHTML = editGroups.map(g => `
${esc(g.name)} `).join('');
}
sel.value = e.group_id;
+ if (!modulesLoaded) await loadModuleOptions();
+ fillEditModuleSelect(e.module_id);
renderEditPhotos(e.photos || [], e.photo_path);
document.getElementById('editModal').classList.add('open');
}
@@ -1130,6 +1162,7 @@ async function saveEdit() {
formData.append('student_name', name);
formData.append('group_id', group_id);
formData.append('description', description);
+ formData.append('module_id', document.getElementById('editModule').value);
const photoFiles = document.getElementById('editPhoto').files;
for (const f of photoFiles) formData.append('photo', f);
@@ -1173,6 +1206,7 @@ async function delEntry(id) {
document.getElementById('studentFilter').addEventListener('change', () => { page = 1; loadEntries(); });
document.getElementById('groupFilter').addEventListener('change', () => { page = 1; loadEntries(); });
+document.getElementById('moduleFilter').addEventListener('change', () => { page = 1; loadEntries(); });
document.getElementById('dateFrom').addEventListener('change', () => { page = 1; loadEntries(); });
document.getElementById('dateTo').addEventListener('change', () => { page = 1; loadEntries(); });
document.getElementById('searchInput').addEventListener('input', () => {
@@ -1227,14 +1261,17 @@ async function exportCSV() {
if (gid) p.set('group_id', gid);
if (df) p.set('date_from', df);
if (dt) p.set('date_to', dt);
+ const mid = document.getElementById('moduleFilter') ? document.getElementById('moduleFilter').value : '';
+ if (mid) p.set('module_id', mid);
const res = await fetch(`${API}/api/entries?${p}`, { headers: hdr() });
const entries = (await res.json()).entries;
if (!entries.length) { alert('Нет данных для экспорта'); return; }
- const rows = [['Фамилия и имя', 'Группа', 'Описание', 'Дата', 'Фото']];
+ const rows = [['Фамилия и имя', 'Группа', 'Тема модуля', 'Описание', 'Дата', 'Фото']];
entries.forEach(e => {
rows.push([
e.student_name,
e.group_name,
+ e.module_name || '',
e.description.replace(/\n/g, ' '),
new Date(e.created_at).toLocaleString('ru'),
e.photo_path ? `${API}${e.photo_path}` : ''
@@ -1440,6 +1477,7 @@ document.getElementById('linkCopyBtn').addEventListener('click', copyLink);
});
setDefaultDates();
loadGroupOptions();
+ loadModuleOptions();
loadStudentOptions();
loadEntries();
connectLiveUpdates();
diff --git a/public/js/modules.js b/public/js/modules.js
new file mode 100644
index 0000000..3132e59
--- /dev/null
+++ b/public/js/modules.js
@@ -0,0 +1,137 @@
+let modulesList = [];
+let mPage = 1;
+const M_PAGE_SIZE = 25;
+let mSearchTimer = null;
+
+function mPlural(n, one, few, many) {
+ n = Math.abs(n) % 100; const n1 = n % 10;
+ if (n > 10 && n < 20) return many;
+ if (n1 > 1 && n1 < 5) return few;
+ if (n1 === 1) return one;
+ return many;
+}
+
+async function loadModules() {
+ const p = new URLSearchParams();
+ const q = document.getElementById('moduleSearch').value.trim();
+ if (q) p.set('search', q);
+ p.set('limit', M_PAGE_SIZE);
+ p.set('offset', (mPage - 1) * M_PAGE_SIZE);
+ const res = await fetch(`${API}/api/modules?${p}`, { headers: hdr() });
+ if (!res.ok) return;
+ const data = await res.json();
+ modulesList = data.modules || [];
+ const totalPages = Math.max(1, Math.ceil(data.total / M_PAGE_SIZE));
+ if (mPage > totalPages) { mPage = totalPages; return loadModules(); }
+ renderModules(data.total);
+}
+
+function renderModules(total) {
+ const list = document.getElementById('moduleList');
+ if (!modulesList.length) {
+ list.innerHTML = '
Модулей пока нет. Нажмите «Добавить модуль».
';
+ } else {
+ list.innerHTML = modulesList.map(m => `
+
+
+
${esc(m.name)}
+
${m.lessons_count} ${mPlural(m.lessons_count, 'занятие', 'занятия', 'занятий')} · ${m.entries_count} ${mPlural(m.entries_count, 'запись', 'записи', 'записей')}
+
+
+ Изменить
+ ×
+
+
+ `).join('');
+ }
+ renderPager(document.getElementById('pager'), mPage, Math.max(1, Math.ceil(total / M_PAGE_SIZE)), p => { mPage = p; loadModules(); });
+}
+
+function openModuleModal(id = null) {
+ const modal = document.getElementById('moduleModal');
+ const form = document.getElementById('moduleForm');
+ const title = document.getElementById('moduleModalTitle');
+ const submitBtn = document.getElementById('moduleSubmitBtn');
+ form.reset();
+ document.getElementById('moduleId').value = '';
+ document.getElementById('moduleLessons').value = '0';
+ if (id) {
+ const m = modulesList.find(x => x.id === id);
+ if (!m) return;
+ title.textContent = 'Редактировать модуль';
+ submitBtn.textContent = 'Сохранить';
+ form.name.value = m.name;
+ document.getElementById('moduleLessons').value = String(m.lessons_count);
+ document.getElementById('moduleId').value = m.id;
+ } else {
+ title.textContent = 'Создать модуль';
+ submitBtn.textContent = 'Создать';
+ }
+ modal.classList.add('open');
+ setTimeout(() => form.name.focus(), 100);
+}
+
+function closeModuleModal() {
+ document.getElementById('moduleModal').classList.remove('open');
+}
+
+async function deleteModule(id) {
+ const m = modulesList.find(x => x.id === id);
+ const msg = m && m.entries_count > 0
+ ? `Удалить модуль «${m.name}»? У ${m.entries_count} связанных записей тема будет очищена.`
+ : 'Удалить этот модуль?';
+ if (!confirm(msg)) return;
+ const res = await fetch(`${API}/api/modules/${id}`, { method: 'DELETE', headers: hdr() });
+ if (res.ok) {
+ showToast('Модуль удалён');
+ loadModules();
+ } else {
+ const err = await res.json().catch(() => ({}));
+ alert(err.error || 'Ошибка');
+ }
+}
+
+document.getElementById('moduleForm').addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const form = e.target;
+ const id = document.getElementById('moduleId').value;
+ const name = form.name.value.trim();
+ const lessons_count = parseInt(form.lessons_count.value, 10);
+ if (!name) { alert('Название обязательно'); return; }
+ if (!Number.isInteger(lessons_count) || lessons_count < 0) { alert('Количество занятий — целое неотрицательное число'); return; }
+ const res = await fetch(id ? `${API}/api/modules/${id}` : `${API}/api/modules`, {
+ method: id ? 'PUT' : 'POST',
+ headers: hdrJson(),
+ body: JSON.stringify({ name, lessons_count })
+ });
+ if (res.ok) {
+ showToast(id ? 'Модуль обновлён' : 'Модуль создан');
+ closeModuleModal();
+ loadModules();
+ } else {
+ const err = await res.json().catch(() => ({}));
+ alert(err.error || 'Ошибка');
+ }
+});
+
+document.getElementById('addModuleBtn').addEventListener('click', () => openModuleModal());
+document.getElementById('moduleCancelBtn').addEventListener('click', closeModuleModal);
+document.getElementById('moduleModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeModuleModal(); });
+document.getElementById('moduleSearch').addEventListener('input', () => {
+ clearTimeout(mSearchTimer);
+ mSearchTimer = setTimeout(() => { mPage = 1; loadModules(); }, 300);
+});
+
+document.getElementById('moduleList').addEventListener('click', e => {
+ const ed = e.target.closest('[data-edit-module]');
+ if (ed) { openModuleModal(parseInt(ed.dataset.editModule, 10)); return; }
+ const dl = e.target.closest('[data-del-module]');
+ if (dl) deleteModule(parseInt(dl.dataset.delModule, 10));
+});
+
+(async () => {
+ if (await requireAdminPage()) {
+ buildSidebar(document.body.dataset.page);
+ loadModules();
+ }
+})();
\ No newline at end of file
diff --git a/public/modules.html b/public/modules.html
new file mode 100644
index 0000000..4c67480
--- /dev/null
+++ b/public/modules.html
@@ -0,0 +1,64 @@
+
+
+
+
+
+
Темы модулей — Админ-панель
+
+
+
+
+
+
+
+
Темы модулей
+
Ученик выбирает тему модуля при заполнении формы
+
+
+
+
+ + Добавить модуль
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/server.js b/server.js
index d91ce40..8af621f 100644
--- a/server.js
+++ b/server.js
@@ -665,6 +665,17 @@ async function ensureBannedIpsTable() {
)`);
}
+async function ensureModulesTable() {
+ await pool.query(`CREATE TABLE IF NOT EXISTS modules (
+ id SERIAL PRIMARY KEY,
+ name VARCHAR(200) NOT NULL UNIQUE,
+ lessons_count INT NOT NULL DEFAULT 0,
+ created_at TIMESTAMPTZ DEFAULT now()
+ )`);
+ await pool.query('ALTER TABLE entries ADD COLUMN IF NOT EXISTS module_id INT REFERENCES modules(id) ON DELETE SET NULL');
+ await pool.query('CREATE INDEX IF NOT EXISTS idx_entries_module_id ON entries(module_id)');
+}
+
async function ensureEntryPhotosTable() {
await pool.query(`CREATE TABLE IF NOT EXISTS entry_photos (
id SERIAL PRIMARY KEY,
@@ -1292,6 +1303,7 @@ function normalizeRestoreData(data) {
id: reqInt(x.id),
student_name: reqStr(x.student_name, 150),
group_id: reqInt(x.group_id),
+ module_id: optInt(x.module_id, 0, 2147483647),
description: reqStr(x.description, 100000),
description_original: optAiText(x.description_original, 100000) ?? reqStr(x.description, 100000),
description_ai: optAiText(x.description_ai, 100000),
@@ -1330,6 +1342,12 @@ function normalizeRestoreData(data) {
user_id: reqInt(x.user_id),
branch_id: reqInt(x.branch_id),
}));
+ const modules = (data.modules || []).map(x => ({
+ id: reqInt(x.id),
+ name: reqStr(x.name, 200),
+ lessons_count: optInt(x.lessons_count, 0, 10000) ?? 0,
+ created_at: optTs(x.created_at),
+ }));
const entry_photos = (data.entry_photos || []).map(x => ({
id: reqInt(x.id),
entry_id: reqInt(x.entry_id),
@@ -1369,13 +1387,13 @@ function normalizeRestoreData(data) {
for (const [k, v] of Object.entries(data.settings || {})) {
settings[reqStr(k, 100)] = reqStr(String(v), 10000);
}
- return { groups, students, entries, project_files, settings, branches, users, user_branches, entry_photos, group_photos, share_links };
+ return { groups, students, entries, project_files, settings, branches, users, user_branches, entry_photos, group_photos, share_links, modules };
}
app.get('/api/backup', requireAdmin, async (req, res) => {
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-bk-'));
try {
- const [g, s, e, st, pf, br, us, ub, gp, ep] = await Promise.all([
+ const [g, s, e, st, pf, br, us, ub, gp, ep, md] = await Promise.all([
pool.query('SELECT * FROM groups ORDER BY id'),
pool.query('SELECT * FROM students ORDER BY id'),
pool.query('SELECT * FROM entries ORDER BY id'),
@@ -1386,10 +1404,11 @@ app.get('/api/backup', requireAdmin, async (req, res) => {
pool.query('SELECT * FROM user_branches ORDER BY user_id, branch_id'),
pool.query('SELECT * FROM group_photos ORDER BY id'),
pool.query('SELECT * FROM entry_photos ORDER BY id'),
+ pool.query('SELECT * FROM modules ORDER BY id'),
]);
const settings = {};
st.rows.forEach(r => { settings[r.key] = r.value; });
- const payload = { version: 1, created_at: new Date().toISOString(), groups: g.rows, students: s.rows, entries: e.rows, settings, project_files: pf.rows, branches: br.rows, users: us.rows, user_branches: ub.rows, group_photos: gp.rows, entry_photos: ep.rows };
+ const payload = { version: 1, created_at: new Date().toISOString(), groups: g.rows, students: s.rows, entries: e.rows, settings, project_files: pf.rows, branches: br.rows, users: us.rows, user_branches: ub.rows, group_photos: gp.rows, entry_photos: ep.rows, modules: md.rows };
fs.writeFileSync(path.join(staging, 'data.json'), JSON.stringify(payload));
fs.mkdirSync(path.join(staging, 'uploads'), { recursive: true });
const dir = path.join(__dirname, 'uploads');
@@ -1483,6 +1502,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
await client.query('BEGIN');
await client.query('DELETE FROM project_files');
await client.query('DELETE FROM entries');
+ await client.query('DELETE FROM modules');
await client.query('DELETE FROM students');
await client.query('DELETE FROM groups');
await client.query('DELETE FROM user_branches');
@@ -1507,10 +1527,16 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
[x.id, x.name, x.created_at, x.group_id]
);
}
+ for (const x of ndata.modules) {
+ await client.query(
+ 'INSERT INTO modules (id, name, lessons_count, created_at) VALUES ($1,$2,$3,$4)',
+ [x.id, x.name, x.lessons_count, x.created_at]
+ );
+ }
for (const x of ndata.entries) {
await client.query(
- 'INSERT INTO entries (id, student_name, group_id, description, description_original, description_ai, ai_status, ai_checked_at, ai_error, photo_path, deleted_at, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)',
- [x.id, x.student_name, x.group_id, x.description, x.description_original, x.description_ai, x.ai_status, x.ai_checked_at, x.ai_error, x.photo_path, x.deleted_at, x.created_at]
+ 'INSERT INTO entries (id, student_name, group_id, module_id, description, description_original, description_ai, ai_status, ai_checked_at, ai_error, photo_path, deleted_at, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)',
+ [x.id, x.student_name, x.group_id, x.module_id, x.description, x.description_original, x.description_ai, x.ai_status, x.ai_checked_at, x.ai_error, x.photo_path, x.deleted_at, x.created_at]
);
}
for (const x of ndata.project_files) {
@@ -1549,7 +1575,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
[k, String(v ?? '')]
);
}
- for (const tbl of ['groups', 'students', 'entries', 'project_files', 'branches', 'users', 'group_photos', 'entry_photos']) {
+ for (const tbl of ['groups', 'students', 'entries', 'project_files', 'branches', 'users', 'group_photos', 'entry_photos', 'modules']) {
const r = await client.query('SELECT COALESCE(MAX(id), 1) AS m FROM ' + tbl);
await client.query('SELECT setval(pg_get_serial_sequence($1, $2), $3)', [tbl, 'id', r.rows[0].m]);
}
@@ -2263,6 +2289,81 @@ app.put('/api/groups/:id/photos/:photoId/cover', requireAuth, async (req, res) =
res.json(gRows[0]);
});
+// --- Modules (темы модулей) ---
+app.get('/api/modules', apiLimiter, async (req, res) => {
+ const { limit, offset, search } = req.query;
+ const conditions = [];
+ const params = [];
+ if (search?.trim()) { params.push(`%${search.trim()}%`); conditions.push(`m.name ILIKE $${params.length}`); }
+ const where = conditions.length ? ' WHERE ' + conditions.join(' AND ') : '';
+ const { rows: crows } = await pool.query(`SELECT count(*)::int AS n FROM modules m${where}`, params);
+ const total = crows[0].n;
+ let q = `SELECT m.*, count(e.id)::int AS entries_count
+ FROM modules m
+ LEFT JOIN entries e ON e.module_id = m.id${where}
+ GROUP BY m.id ORDER BY m.id`;
+ const qparams = params.slice();
+ const lim = parseInt(limit, 10);
+ if (lim > 0) { qparams.push(lim); q += ` LIMIT $${qparams.length}`; }
+ const off = parseInt(offset, 10);
+ if (off > 0) { qparams.push(off); q += ` OFFSET $${qparams.length}`; }
+ const { rows } = await pool.query(q, qparams);
+ res.json({ modules: rows, total });
+});
+
+function parseLessonsCount(v) {
+ if (v === undefined || v === null || v === '') return 0;
+ const n = Number(v);
+ if (!Number.isInteger(n) || n < 0 || n > 10000) throw new Error('Количество занятий — целое число от 0 до 10000');
+ return n;
+}
+
+app.post('/api/modules', requireAdmin, async (req, res) => {
+ const { name, lessons_count } = req.body;
+ if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' });
+ let lessons;
+ try { lessons = parseLessonsCount(lessons_count); }
+ catch (e) { return res.status(400).json({ error: e.message }); }
+ try {
+ const { rows } = await pool.query(
+ 'INSERT INTO modules (name, lessons_count) VALUES ($1, $2) RETURNING *',
+ [name.trim(), lessons]
+ );
+ await logAudit(req, 'module.create', { id: rows[0].id, name: name.trim(), lessons_count: lessons });
+ res.status(201).json(rows[0]);
+ } catch (e) {
+ if (e.code === '23505') return res.status(409).json({ error: 'Модуль с таким названием уже существует' });
+ throw e;
+ }
+});
+
+app.put('/api/modules/:id', requireAdmin, async (req, res) => {
+ const { name, lessons_count } = req.body;
+ if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' });
+ let lessons;
+ try { lessons = parseLessonsCount(lessons_count); }
+ catch (e) { return res.status(400).json({ error: e.message }); }
+ try {
+ const { rows } = await pool.query(
+ 'UPDATE modules SET name = $1, lessons_count = $2 WHERE id = $3 RETURNING *',
+ [name.trim(), lessons, req.params.id]
+ );
+ if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
+ await logAudit(req, 'module.update', { id: req.params.id, name: name.trim(), lessons_count: lessons });
+ res.json(rows[0]);
+ } catch (e) {
+ if (e.code === '23505') return res.status(409).json({ error: 'Модуль с таким названием уже существует' });
+ throw e;
+ }
+});
+
+app.delete('/api/modules/:id', requireAdmin, async (req, res) => {
+ const { rows } = await pool.query('DELETE FROM modules WHERE id = $1 RETURNING id', [req.params.id]);
+ if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
+ await logAudit(req, 'module.delete', { id: req.params.id });
+ res.json({ ok: true });
+});
+
// --- Students CRUD ---
app.get('/api/students', apiLimiter, optionalAuth, async (req, res) => {
const rows = await cacheWrap('students:list:' + scopeKey(req.user), PUBLIC_TTL_MS, async () => {
@@ -3009,12 +3110,13 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
// --- Entries ---
app.get('/api/entries', requireAuth, async (req, res) => {
- const { group_id, date_from, date_to, student_name, search, limit, offset, deleted } = req.query;
+ const { group_id, module_id, date_from, date_to, student_name, search, limit, offset, deleted } = req.query;
const conditions = [];
const params = [];
if (deleted === '1') conditions.push('e.deleted_at IS NOT NULL');
else conditions.push('e.deleted_at IS NULL');
if (group_id) { params.push(group_id); conditions.push(`e.group_id = $${params.length}`); }
+ if (module_id) { params.push(module_id); conditions.push(`e.module_id = $${params.length}`); }
if (date_from) { params.push(date_from); conditions.push(`e.created_at >= $${params.length}::date`); }
if (date_to) { params.push(date_to); conditions.push(`e.created_at < ($${params.length}::date + interval '1 day')`); }
if (student_name) { params.push(student_name); conditions.push(`e.student_name = $${params.length}`); }
@@ -3037,8 +3139,9 @@ app.get('/api/entries', requireAuth, async (req, res) => {
params
);
const total = crows[0].n;
- let q = `SELECT e.*, g.name AS group_name FROM entries e
- JOIN groups g ON g.id = e.group_id${where} ORDER BY e.created_at DESC`;
+ let q = `SELECT e.*, g.name AS group_name, m.name AS module_name FROM entries e
+ JOIN groups g ON g.id = e.group_id
+ LEFT JOIN modules m ON m.id = e.module_id${where} ORDER BY e.created_at DESC`;
const qparams = params.slice();
const lim = parseInt(limit, 10);
if (lim > 0) { qparams.push(lim); q += ` LIMIT $${qparams.length}`; }
@@ -3082,7 +3185,10 @@ app.get('/api/entries/:id', requireAuth, async (req, res) => {
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
}
const { rows } = await pool.query(
- 'SELECT e.*, g.name AS group_name FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.id = $1',
+ `SELECT e.*, g.name AS group_name, m.name AS module_name FROM entries e
+ JOIN groups g ON g.id = e.group_id
+ LEFT JOIN modules m ON m.id = e.module_id
+ WHERE e.id = $1`,
[req.params.id]
);
if (!rows.length) return res.status(404).json({ error: 'Not found' });
@@ -3543,7 +3649,7 @@ app.post('/api/entries', entryLimiter, (req, res, next) => {
return res.status(400).json({ error: 'Недопустимый файл' });
});
}, async (req, res) => {
- const { student_name, group_id, description, website } = req.body;
+ const { student_name, group_id, description, module_id, website } = req.body;
const photos = req.files?.photo || [];
const projectFiles = req.files?.files || [];
if (website) {
@@ -3579,6 +3685,20 @@ app.post('/api/entries', entryLimiter, (req, res, next) => {
projectFiles.forEach(removeUpload);
return res.status(400).json({ error: 'Группа не найдена' });
}
+ const mid = module_id === undefined || module_id === null || module_id === '' ? null : Number.parseInt(module_id, 10);
+ if (mid !== null && !Number.isInteger(mid)) {
+ photos.forEach(removeUpload);
+ projectFiles.forEach(removeUpload);
+ return res.status(400).json({ error: 'Модуль не найден' });
+ }
+ if (mid !== null) {
+ const modCheck = await pool.query('SELECT id FROM modules WHERE id = $1', [mid]);
+ if (!modCheck.rows.length) {
+ photos.forEach(removeUpload);
+ projectFiles.forEach(removeUpload);
+ return res.status(400).json({ error: 'Модуль не найден' });
+ }
+ }
const intervalMin = parseInt(await getSetting('spam_interval_min', '30'), 10) || 0;
if (intervalMin > 0) {
const dup = await pool.query(
@@ -3603,9 +3723,9 @@ app.post('/api/entries', entryLimiter, (req, res, next) => {
);
const mainPhotoPath = photos.length ? `/uploads/${photos[0].filename}` : null;
const { rows } = await client.query(
- `INSERT INTO entries (student_name, group_id, description, description_original, photo_path)
- VALUES ($1, $2, $3, $3, $4) RETURNING *`,
- [student_name.trim(), gid, description.trim(), mainPhotoPath]
+ `INSERT INTO entries (student_name, group_id, module_id, description, description_original, photo_path)
+ VALUES ($1, $2, $3, $4, $4, $5) RETURNING *`,
+ [student_name.trim(), gid, mid, description.trim(), mainPhotoPath]
);
for (let i = 0; i < photos.length; i++) {
const p = photos[i];
@@ -3644,12 +3764,27 @@ app.put('/api/entries/:id', requireAuth, upload.array('photo', 10), async (req,
if (!acc.found) return res.status(404).json({ error: 'Not found' });
if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
}
- const { student_name, group_id, description } = req.body;
+ const { student_name, group_id, description, module_id } = req.body;
const newPhotos = req.files || [];
for (const p of newPhotos) {
await convertPhoto(p);
}
+ const hasModule = Object.prototype.hasOwnProperty.call(req.body, 'module_id');
+ let mid = null;
+ if (hasModule && module_id !== undefined && module_id !== null && module_id !== '') {
+ mid = Number.parseInt(module_id, 10);
+ if (!Number.isInteger(mid)) {
+ newPhotos.forEach(p => safeUnlink(p.path));
+ return res.status(400).json({ error: 'Модуль не найден' });
+ }
+ const modCheck = await pool.query('SELECT id FROM modules WHERE id = $1', [mid]);
+ if (!modCheck.rows.length) {
+ newPhotos.forEach(p => safeUnlink(p.path));
+ return res.status(400).json({ error: 'Модуль не найден' });
+ }
+ }
+
const { rows } = await pool.query(
`UPDATE entries SET
student_name = COALESCE($1, student_name),
@@ -3659,13 +3794,16 @@ app.put('/api/entries/:id', requireAuth, upload.array('photo', 10), async (req,
description_ai = CASE WHEN $3 IS NULL THEN description_ai ELSE NULL END,
ai_status = CASE WHEN $3 IS NULL THEN ai_status ELSE 'pending' END,
ai_error = CASE WHEN $3 IS NULL THEN ai_error ELSE NULL END,
- ai_checked_at = CASE WHEN $3 IS NULL THEN ai_checked_at ELSE NULL END
+ ai_checked_at = CASE WHEN $3 IS NULL THEN ai_checked_at ELSE NULL END,
+ module_id = CASE WHEN $5 THEN $6 ELSE module_id END
WHERE id = $4 RETURNING *`,
[
student_name ? student_name.trim() : null,
group_id || null,
description ? description.trim() : null,
req.params.id,
+ hasModule,
+ mid,
]
);
if (!rows.length) {
@@ -4674,6 +4812,7 @@ if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
try { await ensureBannedIpsTable(); } catch (err) { console.error('Banned IPs table:', err); }
try { await loadBans(); } catch (err) { console.error('Load bans:', err); }
setInterval(() => { loadBans().catch(err => console.error('Load bans:', err)); }, 60 * 1000).unref();
+ try { await ensureModulesTable(); } catch (err) { console.error('Modules table:', err); }
try { await ensureEntryPhotosTable(); } catch (err) { console.error('Entry photos table:', err); }
try { await ensurePhotoOriginalColumn(); } catch (err) { console.error('Entry original photo column:', err); }
try { await ensureEntryAiColumns(); } catch (err) { console.error('Entry AI columns:', err); }