diff --git a/api.smoketest.js b/api.smoketest.js
index 2075878..74a8ce3 100644
--- a/api.smoketest.js
+++ b/api.smoketest.js
@@ -250,6 +250,37 @@ async function main() {
plain.status === 201 && plain.data.ai_status === 'none' && plain.data.text_original === null,
{ status: plain.status, ai_status: plain.data.ai_status });
+ const topicName = 'Циклы for и while';
+ const withTopic = await api('/api/lesson-reports', {
+ token, method: 'POST',
+ body: { group_id: gid, lesson_date: '2019-05-18', lesson_time: '10:00', topic: topicName, text: 'Тема занятия для смоук-теста.', ai_check: false },
+ });
+ ok('lesson-report: тема занятия сохраняется при создании',
+ withTopic.status === 201 && withTopic.data.topic === topicName, { status: withTopic.status, topic: withTopic.data.topic });
+
+ const listed = await api(`/api/lesson-reports?group_id=${gid}&date_from=2019-05-18&date_to=2019-05-18`, { token });
+ ok('lesson-report: тема занятия в списке',
+ listed.status === 200 && (listed.data.items || []).some(i => i.topic === topicName),
+ { status: listed.status, item: (listed.data.items || [])[0] && (listed.data.items || [])[0].topic });
+
+ const edited = await api(`/api/lesson-reports/${withTopic.data.id}`, {
+ token, method: 'PUT', body: { topic: 'Обновлённая тема' },
+ });
+ ok('lesson-report: тема занятия обновляется при редактировании',
+ edited.status === 200 && edited.data.topic === 'Обновлённая тема', { status: edited.status, topic: edited.data.topic });
+
+ const cleared = await api(`/api/lesson-reports/${withTopic.data.id}`, {
+ token, method: 'PUT', body: { topic: '' },
+ });
+ ok('lesson-report: тему занятия можно очистить',
+ cleared.status === 200 && !cleared.data.topic, { status: cleared.status, topic: cleared.data.topic });
+
+ const tooLong = await api(`/api/lesson-reports/${withTopic.data.id}`, {
+ token, method: 'PUT', body: { topic: 'я'.repeat(301) },
+ });
+ ok('lesson-report: слишком длинная тема -> 400', tooLong.status === 400, { status: tooLong.status, error: tooLong.data && tooLong.data.error });
+ await api(`/api/lesson-reports/${withTopic.data.id}`, { token, method: 'DELETE' });
+
const versions = await api(`/api/lesson-reports/${plain.data.id}/versions`, { token });
ok('lesson-report: история версий содержит исходный текст',
versions.status === 200 && Array.isArray(versions.data.items) && versions.data.items.length >= 1
diff --git a/backup-restore.js b/backup-restore.js
index 05038c3..309b4a5 100644
--- a/backup-restore.js
+++ b/backup-restore.js
@@ -1,5 +1,6 @@
const PHOTO_JOB_ACTIONS = new Set(['ai', 'ai_face', 'ai_upscale', 'enhance', 'restore', 'rollback']);
const LESSON_REPORT_TEXT_MAX = 5000;
+const LESSON_REPORT_TOPIC_MAX = 300;
const BACKUP_FORMAT_VERSION = 2;
const BACKUP_MIN_FORMAT_VERSION = 1;
const BACKUP_TABLES = [
@@ -390,6 +391,7 @@ function normalizeRestoreData(data) {
group_id: reqInt(x.group_id),
lesson_date: reqDate(x.lesson_date),
lesson_time: optTime(x.lesson_time),
+ topic: optStr(x.topic, LESSON_REPORT_TOPIC_MAX),
text: reqStr(x.text, LESSON_REPORT_TEXT_MAX),
text_original: optStr(x.text_original, LESSON_REPORT_TEXT_MAX),
text_ai: optStr(x.text_ai, LESSON_REPORT_TEXT_MAX),
@@ -466,6 +468,7 @@ function normalizeRestoreData(data) {
module.exports = {
PHOTO_JOB_ACTIONS,
LESSON_REPORT_TEXT_MAX,
+ LESSON_REPORT_TOPIC_MAX,
SAFE_NAME,
isSafeUploadPath,
photoRefKey,
diff --git a/db/init.sql b/db/init.sql
index 7f2ef8a..4bdf149 100644
--- a/db/init.sql
+++ b/db/init.sql
@@ -230,6 +230,7 @@ CREATE TABLE IF NOT EXISTS lesson_reports (
group_id INT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
lesson_date DATE NOT NULL,
lesson_time TIME,
+ topic TEXT,
text TEXT NOT NULL,
text_original TEXT,
text_ai TEXT,
diff --git a/db/migration.sql b/db/migration.sql
index 644b313..a8d1ea9 100644
--- a/db/migration.sql
+++ b/db/migration.sql
@@ -326,6 +326,7 @@ ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS text_ai TEXT;
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'none';
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ;
ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_error TEXT;
+ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS topic TEXT;
CREATE TABLE IF NOT EXISTS lesson_report_versions (
id SERIAL PRIMARY KEY,
diff --git a/public/admin.css b/public/admin.css
index ba014b2..8375484 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -689,6 +689,7 @@ body[data-page=settings] .page-head{align-items:flex-end}
.lesson-when i{width:13px;height:13px}
.lesson-badge{font-size:.7rem;font-weight:700;padding:2px 9px;border-radius:999px;background:rgba(37,99,235,.1);color:var(--accent)}
.lesson-badge.today{background:rgba(16,185,129,.14);color:#059669}
+.lesson-topic{font-size:.85rem;font-weight:600;color:var(--accent)}
.lesson-text{font-size:.88rem;color:var(--text);white-space:pre-wrap;word-break:break-word;line-height:1.55}
.lesson-author{font-size:.75rem;color:var(--muted)}
.lesson-actions{display:flex;gap:8px;flex-wrap:wrap;margin-left:auto}
diff --git a/public/admin.js b/public/admin.js
index 2fdca0f..8fd0127 100644
--- a/public/admin.js
+++ b/public/admin.js
@@ -534,6 +534,11 @@ function ensureLessonModal() {
+
+
+
+
+ ${r.topic ? `${esc(r.topic)}
` : ''}
${esc(r.text || '')}
${author ? `${esc(author)}
` : ''}
`;
diff --git a/server.js b/server.js
index c5a507f..03632e2 100644
--- a/server.js
+++ b/server.js
@@ -14,6 +14,7 @@ const { buildEntryDiff, textDiff, normalizeEditSource, stripDiffs } = require('.
const {
PHOTO_JOB_ACTIONS,
LESSON_REPORT_TEXT_MAX,
+ LESSON_REPORT_TOPIC_MAX,
SAFE_NAME,
isSafeUploadPath,
photoRefKey,
@@ -1307,6 +1308,7 @@ async function ensureLessonReportsTable() {
group_id INT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
lesson_date DATE NOT NULL,
lesson_time TIME,
+ topic TEXT,
text TEXT NOT NULL,
text_original TEXT,
text_ai TEXT,
@@ -1327,6 +1329,7 @@ async function ensureLessonReportsTable() {
await pool.query("ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_status VARCHAR(20) NOT NULL DEFAULT 'none'");
await pool.query('ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ');
await pool.query('ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS ai_error TEXT');
+ await pool.query('ALTER TABLE lesson_reports ADD COLUMN IF NOT EXISTS topic TEXT');
await pool.query('CREATE UNIQUE INDEX IF NOT EXISTS idx_lesson_reports_group_date ON lesson_reports(group_id, lesson_date)');
await pool.query('CREATE INDEX IF NOT EXISTS idx_lesson_reports_date ON lesson_reports(lesson_date DESC)');
await pool.query(`CREATE TABLE IF NOT EXISTS lesson_report_versions (
@@ -2474,8 +2477,8 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
const ex = await client.query('SELECT 1 FROM groups WHERE id = $1', [x.group_id]);
if (!ex.rowCount) continue;
await client.query(
- 'INSERT INTO lesson_reports (id, group_id, lesson_date, lesson_time, text, text_original, text_ai, ai_status, ai_checked_at, ai_error, author_id, branch_id, created_at, updated_at) VALUES ($1,$2,$3::date,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)',
- [x.id, x.group_id, x.lesson_date, x.lesson_time, x.text, x.text_original, x.text_ai, x.ai_status || 'none', x.ai_checked_at, x.ai_error, x.author_id, x.branch_id, x.created_at, x.updated_at]
+ 'INSERT INTO lesson_reports (id, group_id, lesson_date, lesson_time, topic, text, text_original, text_ai, ai_status, ai_checked_at, ai_error, author_id, branch_id, created_at, updated_at) VALUES ($1,$2,$3::date,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)',
+ [x.id, x.group_id, x.lesson_date, x.lesson_time, x.topic, x.text, x.text_original, x.text_ai, x.ai_status || 'none', x.ai_checked_at, x.ai_error, x.author_id, x.branch_id, x.created_at, x.updated_at]
);
}
for (const x of ndata.lesson_report_versions) {
@@ -3624,7 +3627,7 @@ app.get('/api/lesson-reports', requireAuth, async (req, res) => {
const key = ['lessons:list:', scopeKey(req.user), date_from || '', date_to || '', group_id || '', search || '', limit || '', offset || ''].join(':');
const rows = await cacheWrap(key, LESSON_REPORT_LIST_TTL_MS, async () => {
const qparams = params.slice();
- let q = `SELECT lr.id, lr.group_id, lr.lesson_date, lr.lesson_time, lr.text, lr.author_id,
+ let q = `SELECT lr.id, lr.group_id, lr.lesson_date, lr.lesson_time, lr.topic, lr.text, lr.author_id,
lr.text_original, lr.text_ai, lr.ai_status, lr.ai_checked_at, lr.ai_error,
lr.created_at, lr.updated_at, g.name AS group_name,
u.name AS author_name, u.username AS author_username
@@ -3647,13 +3650,15 @@ app.get('/api/lesson-reports/:id', requireAuth, async (req, res) => {
});
app.post('/api/lesson-reports', requireAuth, async (req, res) => {
- const { group_id, lesson_date, lesson_time, text, ai_check } = req.body || {};
+ const { group_id, lesson_date, lesson_time, topic, text, ai_check } = req.body || {};
const grp = await lessonReportGroup(req.user, group_id);
if (grp.error) return res.status(grp.code || 400).json({ error: grp.error });
const date = parseLessonReportDate(lesson_date);
if (!date) return res.status(400).json({ error: 'Некорректная дата занятия' });
const time = parseLessonReportTime(lesson_time);
if (time === undefined) return res.status(400).json({ error: 'Некорректное время занятия' });
+ const topicText = typeof topic === 'string' ? topic.trim() : '';
+ if (topicText.length > LESSON_REPORT_TOPIC_MAX) return res.status(400).json({ error: `Тема занятия длиннее ${LESSON_REPORT_TOPIC_MAX} символов` });
const body = typeof text === 'string' ? text.trim() : '';
if (!body) return res.status(400).json({ error: 'Введите текст отчёта' });
if (body.length > LESSON_REPORT_TEXT_MAX) return res.status(400).json({ error: `Текст отчёта длиннее ${LESSON_REPORT_TEXT_MAX} символов` });
@@ -3666,10 +3671,10 @@ app.post('/api/lesson-reports', requireAuth, async (req, res) => {
}
const aiWanted = ai_check === true && (await getSetting('lesson_ai_enabled', 'true')) !== 'false';
const { rows } = await pool.query(
- `INSERT INTO lesson_reports (group_id, lesson_date, lesson_time, text, text_original, text_ai, ai_status, ai_checked_at, ai_error, author_id, branch_id)
- VALUES ($1, $2::date, $3, $4, $5, NULL, $6::text, CASE WHEN $6::text = 'none' THEN NULL ELSE now() END, NULL, $7, $8) RETURNING *`,
+ `INSERT INTO lesson_reports (group_id, lesson_date, lesson_time, topic, text, text_original, text_ai, ai_status, ai_checked_at, ai_error, author_id, branch_id)
+ VALUES ($1, $2::date, $3, $4, $5, $6, NULL, $7::text, CASE WHEN $7::text = 'none' THEN NULL ELSE now() END, NULL, $8, $9) RETURNING *`,
[
- grp.group.id, date, time, body,
+ grp.group.id, date, time, topicText || null, body,
aiWanted ? body : null,
aiWanted ? 'pending' : 'none',
req.user.id || null,
@@ -3695,7 +3700,7 @@ app.post('/api/lesson-reports', requireAuth, async (req, res) => {
app.put('/api/lesson-reports/:id', requireAuth, async (req, res) => {
const { report, error, code } = await lessonReportById(req.user, req.params.id);
if (!report) return res.status(code || 404).json({ error });
- const { lesson_date, lesson_time, text, ai_check } = req.body || {};
+ const { lesson_date, lesson_time, topic, text, ai_check } = req.body || {};
const curDate = String(report.lesson_date).slice(0, 10);
let date = curDate;
if (lesson_date !== undefined && lesson_date !== null && lesson_date !== '') {
@@ -3710,6 +3715,11 @@ app.put('/api/lesson-reports/:id', requireAuth, async (req, res) => {
const body = text === undefined ? null : (typeof text === 'string' ? text.trim() : '');
if (text !== undefined && !body) return res.status(400).json({ error: 'Введите текст отчёта' });
if (body && body.length > LESSON_REPORT_TEXT_MAX) return res.status(400).json({ error: `Текст отчёта длиннее ${LESSON_REPORT_TEXT_MAX} символов` });
+ let topicText;
+ if (topic !== undefined) {
+ topicText = typeof topic === 'string' ? topic.trim() : '';
+ if (topicText.length > LESSON_REPORT_TOPIC_MAX) return res.status(400).json({ error: `Тема занятия длиннее ${LESSON_REPORT_TOPIC_MAX} символов` });
+ }
if (date !== curDate) {
const { rows: clash } = await pool.query(
'SELECT id FROM lesson_reports WHERE group_id = $1 AND lesson_date = $2 AND id <> $3',
@@ -3723,15 +3733,16 @@ app.put('/api/lesson-reports/:id', requireAuth, async (req, res) => {
`UPDATE lesson_reports SET
lesson_date = $1::date,
lesson_time = $2,
- text = COALESCE($3, text),
- text_original = CASE WHEN $4::boolean THEN $3::text ELSE text_original END,
- text_ai = CASE WHEN $4::boolean THEN NULL ELSE text_ai END,
- ai_status = CASE WHEN $4::boolean THEN 'pending'::varchar ELSE ai_status END,
- ai_checked_at = CASE WHEN $4::boolean THEN now() ELSE ai_checked_at END,
- ai_error = CASE WHEN $4::boolean THEN NULL ELSE ai_error END,
+ topic = CASE WHEN $6::boolean THEN $3::text ELSE topic END,
+ text = COALESCE($4, text),
+ text_original = CASE WHEN $5::boolean THEN $4::text ELSE text_original END,
+ text_ai = CASE WHEN $5::boolean THEN NULL ELSE text_ai END,
+ ai_status = CASE WHEN $5::boolean THEN 'pending'::varchar ELSE ai_status END,
+ ai_checked_at = CASE WHEN $5::boolean THEN now() ELSE ai_checked_at END,
+ ai_error = CASE WHEN $5::boolean THEN NULL ELSE ai_error END,
updated_at = now()
- WHERE id = $5 RETURNING *`,
- [date, nextTime, body, aiWanted, report.id]
+ WHERE id = $7 RETURNING *`,
+ [date, nextTime, topicText === undefined ? null : (topicText || null), body, aiWanted, topic !== undefined, report.id]
);
const updated = rows[0];
if (body) await saveLessonReportVersion(report.id, body, 'manual', req.user.id);
diff --git a/worker.js b/worker.js
index 755253d..253a3eb 100644
--- a/worker.js
+++ b/worker.js
@@ -809,7 +809,7 @@ function createLessonReportChecker({ pool, getSetting, logAudit, aiUrl, model, d
try {
await client.query('BEGIN');
const { rows } = await client.query(
- `SELECT lr.id, lr.text, lr.text_original, lr.lesson_date, lr.lesson_time, lr.author_id,
+ `SELECT lr.id, lr.text, lr.text_original, lr.lesson_date, lr.lesson_time, lr.topic, lr.author_id,
g.name AS group_name, g.branch_id
FROM lesson_reports lr
JOIN groups g ON g.id = lr.group_id
@@ -928,10 +928,12 @@ async function finish(row, status, text, error, aiText) {
const date = String(row.lesson_date).slice(0, 10);
const time = row.lesson_time ? String(row.lesson_time).slice(0, 5) : '';
const input = original.length > MAX_INPUT_CHARS ? original.slice(0, MAX_INPUT_CHARS) : original;
+ const topic = String(row.topic || '').trim();
const context = [
`Группа: ${row.group_name || '—'}`,
`Дата занятия: ${date}`,
time ? `Время занятия: ${time}` : '',
+ topic ? `Тема занятия: ${topic}` : '',
'',
'Текст отчёта:',
input,