feat(groups): ZIP-архив группы с выбором категорий и режима фото
Форма архива файлов группы на /groups.html получила галочки выгрузки: - «Работы (файлы проектов)» — включена по умолчанию - «Фото записи» — выключена по умолчанию - режим фото «Последние» / «Все», по умолчанию «Последние»; радиокнопки блокируются, когда «Фото записи» выключена Дефолты периода: дата «с» пустая, дата «по» — сегодня. GET /api/groups/:id/export/files принимает include_files, include_photos и photos_mode: - latest — только entries.photo_path, то главное обработанное фото записи - all — плюс вся галерея записи из entry_photos - 400, если обе категории выключены Запросы к БД для отключённых категорий не выполняются; в data.json и в аудит добавлены options и раздельные счётчики works/photos. В аудите добавлена метка export.group_files. Проверено на стенде: группа с многофото-записями даёт 4 фото в режиме «последние» и 6 в режиме «все» (совпадает с БД: 4 главных + 2 галереи), категории комбинируются независимо. api.smoketest.js 57/57, diff.selftest.js 16/16.
This commit is contained in:
@@ -4132,6 +4132,178 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// __GROUP_FILES_EXPORT__
|
||||
app.get('/api/groups/:id/export/files', requireAuth, async (req, res) => {
|
||||
const gid = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(gid) || gid < 1) return res.status(400).json({ error: 'Некорректная группа' });
|
||||
if (!(await groupBelongsToBranches(req.user, gid))) {
|
||||
return res.status(403).json({ error: 'Нет доступа к этой группе' });
|
||||
}
|
||||
const includeFiles = req.query.include_files !== '0';
|
||||
const includePhotos = req.query.include_photos !== '0';
|
||||
const photosMode = req.query.photos_mode === 'all' ? 'all' : 'latest';
|
||||
if (!includeFiles && !includePhotos) {
|
||||
return res.status(400).json({ error: 'Выберите хотя бы одну категорию: работы или фото записи' });
|
||||
}
|
||||
let dateFrom = null;
|
||||
let dateTo = null;
|
||||
try {
|
||||
if (req.query.date_from) dateFrom = optDate(req.query.date_from);
|
||||
if (req.query.date_to) dateTo = optDate(req.query.date_to);
|
||||
} catch {
|
||||
return res.status(400).json({ error: 'Неверный период' });
|
||||
}
|
||||
if (dateFrom && dateTo && dateFrom > dateTo) {
|
||||
return res.status(400).json({ error: 'Дата «С» позже даты «По»' });
|
||||
}
|
||||
try {
|
||||
const { rows: groupRows } = await pool.query('SELECT id, name FROM groups WHERE id = $1 AND deleted_at IS NULL', [gid]);
|
||||
if (!groupRows.length) return res.status(404).json({ error: 'Группа не найдена' });
|
||||
const groupName = groupRows[0].name;
|
||||
const conds = ['e.group_id = $1', 'e.deleted_at IS NULL'];
|
||||
const params = [gid];
|
||||
if (dateFrom) {
|
||||
params.push(dateFrom);
|
||||
conds.push(`e.created_at >= $${params.length}::date`);
|
||||
}
|
||||
if (dateTo) {
|
||||
params.push(dateTo);
|
||||
conds.push(`e.created_at < ($${params.length}::date + interval '1 day')`);
|
||||
}
|
||||
const condStr = conds.join(' AND ');
|
||||
const [studentsRes, filesRes, photosRes, entryPhotosRes] = await Promise.all([
|
||||
pool.query('SELECT id, name FROM students WHERE group_id = $1 ORDER BY name', [gid]),
|
||||
includeFiles
|
||||
? pool.query(
|
||||
`SELECT pf.name, pf.path, e.student_name, e.created_at
|
||||
FROM project_files pf
|
||||
JOIN entries e ON e.id = pf.entry_id
|
||||
WHERE ${condStr} AND pf.detached_at IS NULL
|
||||
ORDER BY e.student_name, e.created_at DESC, pf.id DESC`,
|
||||
params
|
||||
)
|
||||
: null,
|
||||
includePhotos
|
||||
? pool.query(
|
||||
`SELECT e.photo_path, e.student_name, e.created_at
|
||||
FROM entries e
|
||||
WHERE ${condStr} AND e.photo_path IS NOT NULL
|
||||
ORDER BY e.student_name, e.created_at DESC`,
|
||||
params
|
||||
)
|
||||
: null,
|
||||
includePhotos && photosMode === 'all'
|
||||
? pool.query(
|
||||
`SELECT ep.photo_path, ep.caption, e.student_name, e.created_at
|
||||
FROM entry_photos ep
|
||||
JOIN entries e ON e.id = ep.entry_id
|
||||
WHERE ${condStr}
|
||||
ORDER BY e.student_name, e.created_at DESC, ep.sort_order ASC, ep.id ASC`,
|
||||
params
|
||||
)
|
||||
: null,
|
||||
]);
|
||||
const fileRows = filesRes ? filesRes.rows : [];
|
||||
const photoRows = photosRes ? photosRes.rows : [];
|
||||
const entryPhotoRows = entryPhotosRes ? entryPhotosRes.rows : [];
|
||||
const studentNames = new Set(studentsRes.rows.map(s => s.name));
|
||||
const zip = createZipWriter();
|
||||
const folderOf = name => String(name || '').replace(/[\\/:*?"<>|]/g, '_').replace(/^[\s.]+/, '').slice(0, 80) || 'Без имени';
|
||||
const seenFolders = new Set();
|
||||
const uniqueFolder = base => {
|
||||
let folder = base;
|
||||
let n = 1;
|
||||
while (seenFolders.has(folder)) {
|
||||
n++;
|
||||
folder = `${base} (${n})`;
|
||||
}
|
||||
seenFolders.add(folder);
|
||||
return folder;
|
||||
};
|
||||
const folderByStudent = new Map();
|
||||
const ensureFolder = studentName => {
|
||||
const key = String(studentName || '');
|
||||
if (folderByStudent.has(key)) return folderByStudent.get(key);
|
||||
const folder = uniqueFolder(folderOf(key));
|
||||
folderByStudent.set(key, folder);
|
||||
zip.addDir(folder);
|
||||
return folder;
|
||||
};
|
||||
const filesBuilt = [];
|
||||
const seenFiles = new Map();
|
||||
const seenPaths = new Set();
|
||||
async function addEntryFile(row, subDir) {
|
||||
if (!isSafeUploadPath(row.path)) return false;
|
||||
const stored = row.path.slice('/uploads/'.length);
|
||||
if (seenPaths.has(stored)) return false;
|
||||
const data = await storage.getBuffer(stored);
|
||||
if (!data) return false;
|
||||
seenPaths.add(stored);
|
||||
const folder = ensureFolder(row.student_name);
|
||||
const base = String(row.name || 'file').replace(/[\\/:*?"<>|]/g, '_').replace(/^[\s.]+/, '').slice(0, 120) || 'file';
|
||||
const ext = path.extname(base);
|
||||
const stem = ext ? base.slice(0, -ext.length) : base;
|
||||
const keyPath = folder + '/' + (subDir ? subDir + '/' : '');
|
||||
let saved = base;
|
||||
let n = 1;
|
||||
while (seenFiles.has(keyPath + saved)) {
|
||||
n++;
|
||||
saved = `${stem}(${n})${ext}`;
|
||||
}
|
||||
seenFiles.set(keyPath + saved, true);
|
||||
const zipPath = subDir ? `${folder}/${subDir}/${saved}` : `${folder}/${saved}`;
|
||||
if (subDir && !seenFiles.has(folder + '/' + subDir + '/')) {
|
||||
seenFiles.set(folder + '/' + subDir + '/', true);
|
||||
zip.addDir(`${folder}/${subDir}`);
|
||||
}
|
||||
zip.addFile(zipPath, data, new Date(row.created_at || Date.now()));
|
||||
filesBuilt.push({ folder: subDir ? `${folder}/${subDir}` : folder, file: saved, original: row.name, size: data.length, created_at: row.created_at || null });
|
||||
return true;
|
||||
}
|
||||
let worksAdded = 0;
|
||||
let photosAdded = 0;
|
||||
for (const f of fileRows) if (await addEntryFile(f, null)) worksAdded++;
|
||||
for (const p of photoRows) {
|
||||
if (await addEntryFile({ name: `photo_${new Date(p.created_at || Date.now()).toISOString().slice(0, 10)}.jpg`, path: p.photo_path, student_name: p.student_name, created_at: p.created_at }, 'Фото записи')) photosAdded++;
|
||||
}
|
||||
for (const p of entryPhotoRows) {
|
||||
const storedName = String(p.photo_path).split('/').pop();
|
||||
if (await addEntryFile({ name: storedName || 'photo.jpg', path: p.photo_path, student_name: p.student_name, created_at: p.created_at }, 'Фото записи')) photosAdded++;
|
||||
}
|
||||
const meta = {
|
||||
exported_at: new Date().toISOString(),
|
||||
group: { id: gid, name: groupName },
|
||||
period: { date_from: dateFrom, date_to: dateTo },
|
||||
options: { files: includeFiles, photos: includePhotos, photos_mode: photosMode },
|
||||
totals: { students: studentNames.size, files: filesBuilt.length, works: worksAdded, photos: photosAdded },
|
||||
students: [...studentNames].sort(),
|
||||
files: filesBuilt,
|
||||
};
|
||||
zip.addFile('data.json', Buffer.from(JSON.stringify(meta, null, 2), 'utf8'));
|
||||
const buf = zip.toBuffer();
|
||||
await logAudit(req, 'export.group_files', {
|
||||
group_id: gid,
|
||||
group_name: groupName,
|
||||
students: studentNames.size,
|
||||
files: filesBuilt.length,
|
||||
works: worksAdded,
|
||||
photos: photosAdded,
|
||||
photos_mode: photosMode,
|
||||
date_from: dateFrom,
|
||||
date_to: dateTo,
|
||||
});
|
||||
const safeGroup = groupName.replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 60) || 'group';
|
||||
const zipDate = new Date().toISOString().slice(0, 10);
|
||||
const zipFname = `group_${safeGroup}_${zipDate}.zip`;
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${zipFname}"; filename*=UTF-8''${encodeURIComponent(`group_${groupName}_${zipDate}.zip`)}`);
|
||||
res.send(buf);
|
||||
} catch (err) {
|
||||
console.error('export group files:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Entries ---
|
||||
app.get('/api/entries', requireAuth, async (req, res) => {
|
||||
const { group_id, module_id, date_from, date_to, student_name, search, limit, offset, deleted } = req.query;
|
||||
|
||||
Reference in New Issue
Block a user