diff --git a/STATUS.md b/STATUS.md index 5ee4593..6212d5b 100644 --- a/STATUS.md +++ b/STATUS.md @@ -130,6 +130,7 @@ CLASSIFY_TIMEOUT=20 .venv/bin/python -m classifier.classify --db db/vesti.db --l - [x] **Тест публикации с медиа сквозь веб**: сообщения ушли в @dedinit_vesti (11/12) и удалены. Обнаружен и исправлен фейковый `dry_run` в publisher (поле уехало в PublishResponse вместо PublishRequest; собыл 500). Change `publisher-dry-run-fix` архивирован: `dry_run=true` → эмуляция без TG. - [x] **OpenSpec changes архивированы** (8): fix-approve-dirn, web-media-preview, web-format-datetime, web-list-filter-no-js, deexternalize-web-assets, web-render-markdown, publisher-dry-run-fix, unify-directions. - [x] **Реализован change `candidates-mail-ui`** (двухпанельный интерфейс кандидатов как почтовый клиент): левая панель — список с группировкой (source/date/status, свертываемые
) + чекбоксы bulk (approve/reject/reject-old), правая — детали поста (меню действий, комментарий, черновик пересказа, оригинал с медиа). Роуты: POST /candidates/select, /candidates/bulk, /posts/{id}/comment, /posts/{id}/reclassify (qwen), /posts/{id}/rewrite (qwen). БД: добавлены `comment TEXT`, `rewritten_text TEXT`. Без JS (только данные формы). Протестировано TestClient (группировка, bulk-reject, comment, reclassify, rewrite, поиск) — зелёные. Change архивирован (2026-09-13). +- [x] **Change `rewrite-as-author`** (2026-09-15): блок «✍️ Переписать» в карточке кандидата — убран `readonly` (textarea теперь редактируемая + кнопка «💾 Сохранить пересказ» → `POST /posts/{id}/rewrite-save`), при публикации пересказ (`rewritten_text`) приоритетнее исходного текста; карточка с пересказом подписывается «✍️ Дед в АйТи (@dedinit) · Источник: …» (от имени автора канала, без «🔗 Оригинал»). Посты без пересказа — как раньше. ## Текущие задачи (из TODO, открытые) - Интерфейс кандидатов как почтовый клиент (2 панели, группировка, bulk) — ✅ реализован в change candidates-mail-ui (см. выше). @@ -156,9 +157,17 @@ CLASSIFY_TIMEOUT=20 .venv/bin/python -m classifier.classify --db db/vesti.db --l - Список: только внешние new-посты (сейчас 163), без published/rejected и без своих. - Группировка (date/source/status) и фильтры сохраняются после любых действий из правой панели. - Тихие OpenSpec-change: `candidates-only-external-new`, `keep-candidates-context` — задачи [x], валидны; архивировать после подтверждения. +- Правый блок действий: кнопки «🤖 Обработать моделью / ✍️ Переписать / 🚫 Отклонить» вынесены в ОДИН ряд НАД полем комментария, «✅ Опубликовать» — в начале approve-формы, поле комментария + «💾 Сохранить комментарий» — ниже (change `candidates-buttons-order`). +- После «🚫 Отклонить» (и bulk reject/reject-old) — остаёмся на странице кандидатов в текущей группировке, НЕ уходим в «🗑 Отклонённые» (change `reject-stay-candidates`); отклонённые — через фильтр статуса. ## Ключевые артефакты - /opt/vesti/AGENT.MD — правила проекта (обязательно: все изменения через OpenSpec) +- /opt/vesti/crawler/rss_crawler.py — RSS/Atom-краулер (feedparser+httpx, etag/modified, trafilatura, rss_state; lwn первый источник) +- /opt/vesti/crawler/worker.py — фаза 3: параллельная обработка очереди кандидатов (ThreadPoolExecutor, атомарный claim по posts, лимит MAX_CONCURRENT_LLM к Ollama) +- /opt/vesti/crawler/crawl_sources.py — фаза 1: сбор кандидатов с источников (telegram+rss) в очередь +- /opt/vesti/web/templates/crawlers.html + /crawlers — статус краулеров (alive/dead, last_fetch, ошибки) и размер очереди (pending/processing/done), кнопки «Запустить воркер» и «Сбросить dead» +- /opt/vesti/web/templates/sources.html + /sources — страница управления источниками (add/edit/toggle-пауза/delete с confirm; источник истины — sources.yaml → полный ре-синк в БД; каскад при удалении: посты остаются с source_id=NULL) +- /opt/vesti/openspec/changes/rss-crawler/ — OpenSpec change RSS-краулера (proposal/design/tasks/spec) - /opt/vesti/openspec/changes/{deexternalize-web-assets, web-render-markdown, web-format-datetime, web-list-filter-no-js}/ — 4 change веб-интерфейса (валидны, задачи [x]) - /opt/vesti/openspec/changes/rich-repost-card/ — change формата публикации (proposal/design/tasks/specs/tg-publisher, валиден) - /opt/vesti/services/publisher/ — микросервис (app/, Dockerfile, compose, README) diff --git a/openspec/changes/candidates-buttons-order/.openspec.yaml b/openspec/changes/candidates-buttons-order/.openspec.yaml new file mode 100644 index 0000000..4258753 --- /dev/null +++ b/openspec/changes/candidates-buttons-order/.openspec.yaml @@ -0,0 +1,3 @@ +schema: spec-driven +skip_specs: true +created: 2026-09-15 diff --git a/openspec/changes/candidates-buttons-order/design.md b/openspec/changes/candidates-buttons-order/design.md new file mode 100644 index 0000000..bbed6b1 --- /dev/null +++ b/openspec/changes/candidates-buttons-order/design.md @@ -0,0 +1,106 @@ +## Design + +Файл: `/opt/vesti/web/templates/candidates.html`, правая панель +(`{% if selected %}`), блок действий (строки ~150–184). + +### Текущая разметка + +```html +
+ {% set hid %}...{% endset %} + {% if selected.status != 'published' %} +
+ {{ hid }} + + +
+ + +
+
+ {% endif %} + {% if selected.status != 'published' %} +
+ {{ hid }} + +
+ {% endif %} + {% if selected.status != 'published' %} +
+ {{ hid }} + +
+ {% endif %} + {% if selected.status != 'rejected' and selected.status != 'published' %} +
+ {{ hid }} + +
+ {% endif %} +
+``` + +### Новая разметка + +Кнопки действий выносятся в ОДИН flex-ряд НАД полем комментария; approve-форма +остаётся обёрткой только для «Опубликовать» + «Сохранить комментарий» + textarea +(поля `hid` нужны каждой форме; внутри approve-формы они уже есть). + +```html +
+ {% set hid %}...{% endset %} + {% if selected.status != 'published' %} +
+ {{ hid }} + +
+ {% endif %} + {% if selected.status != 'published' %} +
+ {{ hid }} + +
+ {% endif %} + {% if selected.status != 'rejected' and selected.status != 'published' %} +
+ {{ hid }} + +
+ {% endif %} + {% if selected.status != 'published' %} +
+ {{ hid }} +
+ +
+ + +
+ +
+
+ {% endif %} +
+``` + +Порядок в ряду: «🤖 Обработать моделью» → «✍️ Переписать» → «🚫 Отклонить» → +«✅ Опубликовать» (кнопка публикации — правая, главная). Затем — поле +комментария и «💾 Сохранить комментарий». + +Все кнопки остаются `type="submit"` в своих формах; `formaction` у +«Сохранить комментарий» сохраняется; логика видимости не меняется. + +## Верификация + +- `openspec validate candidates-buttons-order` — чисто. +- Рендер шаблона проверяется только ч/з сам сервис (TestClient/живой GET), + т.к. прямой рендер `tpl.get_template(...)` блокируется политикой. +- Перезапуск: `sudo systemctl restart vesti-web` (Jinja2-кеш!). +- Ручная проверка: `GET /candidates` → выбран неопубликованный пост → в + правой панели НАД полем комментария в одном ряду кнопки + Обработать/Переписать/Отклонить/Опубликовать, под textarea — «Сохранить + комментарий». \ No newline at end of file diff --git a/openspec/changes/candidates-buttons-order/proposal.md b/openspec/changes/candidates-buttons-order/proposal.md new file mode 100644 index 0000000..ef53a27 --- /dev/null +++ b/openspec/changes/candidates-buttons-order/proposal.md @@ -0,0 +1,46 @@ +## Why + +В прошлой сессии кнопку «Опубликовать» перенесли в approve-форму (вместе с +полем комментария). В результате в правой панели деталей кандидата кнопки +«Обработать моделью», «Переписать», «Отклонить» оказались ПОСЛЕ блока +комментария отдельным flex-рядом — визуально «съехали» и разорвали основной +ряд действий. + +Пользователь: действия модератора должны быть над полем комментария, в одном +ряду с «Опубликовать». + +## What Changes + +- В `web/templates/candidates.html`, правая панель выбранного поста: + - Перенести кнопки «🤖 Обработать моделью», «✍️ Переписать», «🚫 Отклонить» + из отдельного flex-ряда ПОСЛЕ approve-формы в ОДИН ряд с кнопкой + «✅ Опубликовать» — НАД полем комментария. +- Итоговая структура блока действий (когда пост не опубликован/не отклонён): + +``` +[✅ Опубликовать] [🤖 Обработать моделью] [✍️ Переписать] [🚫 Отклонить] +💬 Мой комментарий (будет первым в канале): +[textarea комментария] +[💾 Сохранить комментарий] +``` + +- Логика видимости кнопок не меняется: + - «Опубликовать» — статус != published; + - «Обработать моделью», «Переписать» — статус != published; + - «Отклонить» — статус != rejected и != published. + +## Why Not + +- Не объединять все кнопки в одну approve-форму: «Сохранить комментарий» + использует отдельный эндпоинт (`/posts/{id}/comment`), а «Обработать + моделью»/«Переписать»/«Отклонить» — свои POST-эндпоинты; в одну форму их + не собрать без JS (а htmx/JS-сложная логика в проекте запрещены). +- Оставлять текущий порядок нельзя — кнопки визуально разорваны. + +## Impact + +- Файл: `web/templates/candidates.html` (правка только разметки, без JS и + эндпоинтов). +- Данные: без изменений. +- Сервис: `vesti-web` (:8400) — нужен перезапуск (Jinja2-кеш без --reload). +- Rollback: вернуть разметку и перезапустить сервис. \ No newline at end of file diff --git a/openspec/changes/candidates-buttons-order/tasks.md b/openspec/changes/candidates-buttons-order/tasks.md new file mode 100644 index 0000000..4f9ee0e --- /dev/null +++ b/openspec/changes/candidates-buttons-order/tasks.md @@ -0,0 +1,10 @@ +# candidates-buttons-order + +- [x] Создан OpenSpec change (proposal/design) — `skip_specs: true`, без delta-спеки (правка только разметки, поведение кнопок не меняется) +- [x] `openspec validate candidates-buttons-order` — чисто +- [x] candidates.html: кнопки Обработать/Переписать/Отклонить перенесены над полем комментария, в одном ряду с Опубликовать +- [x] Проверка: рендер правой панели (живой GET :8400 после рестарта) — ряд кнопок над textarea комментария +- [x] Перезапуск `sudo systemctl restart vesti-web` +- [x] Ручная проверка в браузере: порядок кнопок над комментарием +- [x] Обновить STATUS.md / TODO.md +- [x] Бэкап (крон 2:45 делает сам, вручную НЕ запускать) \ No newline at end of file diff --git a/openspec/changes/reject-stay-candidates/.openspec.yaml b/openspec/changes/reject-stay-candidates/.openspec.yaml new file mode 100644 index 0000000..4258753 --- /dev/null +++ b/openspec/changes/reject-stay-candidates/.openspec.yaml @@ -0,0 +1,3 @@ +schema: spec-driven +skip_specs: true +created: 2026-09-15 diff --git a/openspec/changes/reject-stay-candidates/design.md b/openspec/changes/reject-stay-candidates/design.md new file mode 100644 index 0000000..08c3f15 --- /dev/null +++ b/openspec/changes/reject-stay-candidates/design.md @@ -0,0 +1,57 @@ +## Design + +Файл: `/opt/vesti/web/app.py`. + +### 1. `reject()` — строка 582 + +Сейчас: + +```python +return RedirectResponse(url=_cand_back(group_by, direction, own, q, status="rejected"), status_code=302) +``` + +Стало: + +```python +return RedirectResponse(url=_cand_back(group_by, direction, own, q), status_code=302) +``` + +`_cand_back` без `status` строит `/candidates` с сохранением +`group_by/direction/own/q` — очереди кандидатов (`status=new` по умолчанию). + +### 2. `candidates_bulk()` — строки 345, 358 + +Сейчас: + +```python +if action == "reject": + ... + return RedirectResponse(url=back + "&status=rejected", status_code=302) +... +if action == "reject-old": + ... + return RedirectResponse(url=back + "&status=rejected", status_code=302) +``` + +Стало: редирект на `back` (уже содержит `group_by` + фильтры), без +`&status=rejected`. + +```python +if action == "reject": + ... + return RedirectResponse(url=back, status_code=302) +... +if action == "reject-old": + ... + return RedirectResponse(url=back, status_code=302) +``` + +## Верификация + +- `openspec validate reject-stay-candidates` — чисто. +- Юнит (TestClient): login → POST `/posts/{id}/reject` с `group_by=date` + → ожидаем `Location: /candidates?group_by=date` (БЕЗ `status=rejected`). +- Перезапуск: `sudo systemctl restart vesti-web`. +- Ручная проверка: отклонить пост из правой панели → остаёмся на + «Кандидатах» в той же группировке, пост пропал из списка; список + отклонённых доступен через фильтр «🗑 Отклонённые». \ No newline at end of file diff --git a/openspec/changes/reject-stay-candidates/proposal.md b/openspec/changes/reject-stay-candidates/proposal.md new file mode 100644 index 0000000..a0e145f --- /dev/null +++ b/openspec/changes/reject-stay-candidates/proposal.md @@ -0,0 +1,37 @@ +## Why + +После нажатия «🚫 Отклонить» в правой панели кандидата пользователь +попадает на страницу `/candidates?status=rejected` (отдельный список +отклонённых), а не остаётся в текущем списке кандидатов. Это разрывает +поток отбора: после отклонения одного поста нужно вернуться в очередь +кандидатов и продолжить. + +Same для массового отклонения через bulk-форму (левая панель, +«🚫 Отклонить» / «🗑 Старьё»): редирект уводит на `status=rejected`. + +## What Changes + +- `web/app.py`, `reject()` (`POST /posts/{id}/reject`): редирект после + отклонения — на `/candidates` с сохранением текущей группировки и + фильтров, БЕЗ `status=rejected` (чтобы остаться в очереди кандидатов). + Т.е. `_cand_back(group_by, direction, own, q)` без `status`. +- `web/app.py`, `candidates_bulk()`: для `action == "reject"` и + `action == "reject-old"` — редирект на `back` (текущая группировка + + фильтры) БЕЗ `&status=rejected`. + +Текущее поведение (reject → список отклонённых) доступно вручную через +фильтр статуса «🗑 Отклонённые» или `?status=rejected`. + +## Why Not + +- Не менять approve: после публикации переход на опубликованные — + осознанное поведение (посмотреть результат), менять не просили. +- Не добавлять параметр «куда вернуться»: редирект на текущую группировку + достаточен и предсказуем. + +## Impact + +- Файл: `web/app.py` (две точки редиректа). +- Данные: без изменений. +- Сервис: `vesti-web` (:8400) — перезапуск. +- Rollback: вернуть `status="rejected"` в редиректах и перезапустить. \ No newline at end of file diff --git a/openspec/changes/reject-stay-candidates/tasks.md b/openspec/changes/reject-stay-candidates/tasks.md new file mode 100644 index 0000000..f111d64 --- /dev/null +++ b/openspec/changes/reject-stay-candidates/tasks.md @@ -0,0 +1,11 @@ +# reject-stay-candidates + +- [x] Создан OpenSpec change (proposal/design) — `skip_specs: true` +- [x] `openspec validate reject-stay-candidates` — чисто +- [x] web/app.py `reject()`: редирект на `/candidates` без `status=rejected` +- [x] web/app.py `candidates_bulk()`: reject/reject-old → `back` без `status=rejected` +- [x] Тест (TestClient): Location после reject содержит group_by, не содержит `status=rejected` +- [x] Перезапуск `sudo systemctl restart vesti-web` +- [x] Ручная проверка: после отклонения остаёмся на кандидатах (живой URL `/candidates?group_by=date`) +- [x] Обновить STATUS.md / TODO.md +- [x] Бэкап (крон 2:45 делает сам, вручную НЕ запускать) \ No newline at end of file diff --git a/web/app.py b/web/app.py index e3ef506..4f6d6f7 100644 --- a/web/app.py +++ b/web/app.py @@ -17,6 +17,12 @@ from fastapi.staticfiles import StaticFiles from jinja2 import Environment, FileSystemLoader from markupsafe import Markup, escape +# CRUD источников: работа с sources.yaml (источник истины) + БД синк +from sources.sources import ( + db_source_to_yaml, + add_or_update_source, delete_source, set_source_enabled, +) + BASE_DIR = Path(__file__).resolve().parent.parent DB_PATH = BASE_DIR / "db" / "vesti.db" TPL_DIR = Path(__file__).resolve().parent / "templates" @@ -336,7 +342,8 @@ def candidates_bulk(request: Request, action: str = Form(...), ids: list[int] = conn.execute(f"UPDATE posts SET status='rejected' WHERE id IN ({ph})", ids) conn.commit() conn.close() - return RedirectResponse(url=back + "&status=rejected", status_code=302) + # остаёмся на кандидатах (текущая группировка), не уходим в отклонённые + return RedirectResponse(url=back, status_code=302) if action == "reject-old": # все выбранные, кроме самых свежих (по 1 от источника) — «почистить старьё» keep = {} @@ -349,7 +356,8 @@ def candidates_bulk(request: Request, action: str = Form(...), ids: list[int] = conn.execute(f"UPDATE posts SET status='rejected' WHERE id IN ({ph2})", kill) conn.commit() conn.close() - return RedirectResponse(url=back + "&status=rejected", status_code=302) + # остаёмся на кандидатах (текущая группировка), не уходим в отклонённые + return RedirectResponse(url=back, status_code=302) conn.close() if action == "process": # обработка локальной моделью (переклассификация) по выбранным @@ -377,6 +385,24 @@ def candidates_bulk(request: Request, action: str = Form(...), ids: list[int] = return RedirectResponse(url="/published", status_code=302) +@app.post("/posts/{post_id}/rewrite-save") +async def post_rewrite_save(post_id: int, request: Request, + group_by: str = Form(""), direction: str = Form(""), own: str = Form(""), q: str = Form("")): + """Сохраняет отредактированный пользователем пересказ (rewritten_text).""" + _require_auth(request) + conn = _db() + p = conn.execute("SELECT id FROM posts WHERE id=?", (post_id,)).fetchone() + if not p: + conn.close() + return RedirectResponse(url=_cand_back(group_by, direction, own, q, error="notfound"), status_code=302) + form = await request.form() + draft = str(form.get("rewritten_text") or "").strip() + conn.execute("UPDATE posts SET rewritten_text=? WHERE id=?", (draft or None, post_id)) + conn.commit() + conn.close() + return RedirectResponse(url=_cand_back(group_by, direction, own, q, selected=post_id), status_code=302) + + @app.post("/posts/{post_id}/comment") def post_comment(post_id: int, request: Request, comment: str = Form(""), group_by: str = Form(""), direction: str = Form(""), own: str = Form(""), q: str = Form("")): @@ -555,7 +581,9 @@ def reject(post_id: int, request: Request, group_by: str = Form(""), direction: conn.execute("UPDATE posts SET status='rejected' WHERE id=?", (post_id,)) conn.commit() conn.close() - return RedirectResponse(url=_cand_back(group_by, direction, own, q, status="rejected"), status_code=302) + # после отклонения остаёмся на странице кандидатов (та же группировка/фильтры), + # чтобы продолжить отбор; список отклонённых — через фильтр «🗑 Отклонённые» + return RedirectResponse(url=_cand_back(group_by, direction, own, q), status_code=302) @app.get("/metrics", response_class=HTMLResponse) @@ -572,6 +600,199 @@ def metrics(request: Request): return HTMLResponse(html) +@app.get("/crawlers", response_class=HTMLResponse) +def crawlers(request: Request): + """Страница статуса краулеров: источники (alive/dead, last_fetch) + размер очереди.""" + _require_auth(request) + conn = _db() + # источники + rss_state (для RSS — etag/modified/last_build_date/ошибки) + sources = conn.execute( + """SELECT s.id, s.slug, s.name, s.crawler, s.status, s.last_fetch, s.last_error, + s.priority, s.enabled, + COALESCE(rs.etag, '') AS etag, COALESCE(rs.modified, '') AS modified, + COALESCE(rs.last_build_date, '') AS last_build_date, + COALESCE(rs.error_count, 0) AS error_count, + COALESCE(s.last_error, rs.last_error) AS last_error + FROM sources s LEFT JOIN rss_state rs ON rs.slug=s.slug + ORDER BY s.enabled DESC, s.priority, s.slug""" + ).fetchall() + # очередь (постов), карточки + pending = conn.execute( + "SELECT COUNT(*) FROM posts WHERE classified IS NULL OR classified=0" + ).fetchone()[0] + processing = conn.execute( + "SELECT COUNT(*) FROM posts WHERE classified=-1" + ).fetchone()[0] + done_today = conn.execute( + "SELECT COUNT(*) FROM posts WHERE classified=1 AND fetched_at >= date('now')" + ).fetchone()[0] + # статусы источников для бейджей + alive = conn.execute("SELECT COUNT(*) FROM sources WHERE enabled=1 AND status='alive'").fetchone()[0] + dead = conn.execute("SELECT COUNT(*) FROM sources WHERE enabled=1 AND status='dead'").fetchone()[0] + total_sources = conn.execute("SELECT COUNT(*) FROM sources WHERE enabled=1").fetchone()[0] + # последние запуски + runs = conn.execute( + """SELECT r.*, s.slug, s.name AS source_name FROM runs r + LEFT JOIN sources s ON s.id=r.source_id ORDER BY r.id DESC LIMIT 12""" + ).fetchall() + conn.close() + html = tpl.get_template("crawlers.html").render( + request=request, sources=sources, pending=pending, processing=processing, done_today=done_today, + alive=alive, dead=dead, total_sources=total_sources, runs=runs, + ) + return HTMLResponse(html) + + +@app.post("/crawlers/reset") +def crawlers_reset(request: Request): + """Сброс dead-источников в alive (после починки).""" + _require_auth(request) + conn = _db() + cur = conn.execute( + "UPDATE sources SET status='alive', error_count=0, last_error=NULL WHERE status='dead'" + ) + conn.commit() + conn.close() + return RedirectResponse(url="/crawlers?reset=" + str(cur.rowcount), status_code=302) + + +@app.post("/crawlers/run") +async def crawlers_run(request: Request): + """Запуск воркера (фаза 3): параллельная обработка очереди кандидатов. + + Запускается в фоне (subprocess.Popen, не блокирует веб); страница обновляется, + очередь processing покажет прогресс. Результат — в runs/posts. + """ + _require_auth(request) + workers, limit = 8, 200 + try: + form = await request.form() + workers = int(str(form.get("workers") or 8)) + limit = int(str(form.get("limit") or 200)) + except Exception: + pass + try: + import subprocess + py = str(Path(__file__).resolve().parent.parent / ".venv" / "bin" / "python") + log_path = Path(__file__).resolve().parent.parent / "logs" / "worker.log" + log_path.parent.mkdir(exist_ok=True) + with open(log_path, "a") as f: + subprocess.Popen( + [py, "-m", "crawler.worker", "--workers", str(workers), "--limit", str(limit)], + cwd=str(Path(__file__).resolve().parent.parent), + stdout=f, stderr=subprocess.STDOUT, + ) + except Exception as e: + return RedirectResponse(url=f"/crawlers?run=error&msg={e}", status_code=302) + return RedirectResponse(url=f"/crawlers?run=started&workers={workers}&limit={limit}", status_code=302) + + +@app.get("/sources", response_class=HTMLResponse) +def sources_page(request: Request): + """Страница управления реестром источников: таблица + добавление/редактирование.""" + _require_auth(request) + conn = _db() + sources = conn.execute( + """SELECT s.*, COALESCE(rs.error_count, 0) AS error_count, + COALESCE(s.last_error, rs.last_error) AS last_error, + COALESCE(rs.etag, '') AS etag, COALESCE(rs.modified, '') AS modified + FROM sources s LEFT JOIN rss_state rs ON rs.slug=s.slug + ORDER BY s.enabled DESC, s.priority, s.slug""" + ).fetchall() + conn.close() + html = tpl.get_template("sources.html").render( + request=request, sources=sources, + did=request.query_params.get("did", ""), + ) + return HTMLResponse(html) + + +def _form_source(form) -> dict: + """Собирает источник из формы (str поля, None для пустых).""" + def s(k, default=None): + v = form.get(k) + return (v or "").strip() if v else default + return { + "slug": s("slug"), + "name": s("name"), + "url": s("url"), + "channel": s("channel"), + "crawler": s("crawler", "telegram"), + "feed_url": s("feed_url"), + "direction": s("direction"), + "lang": s("lang", "ru"), + "priority": s("priority", "P1"), + "enabled": bool(form.get("enabled")), + "own": bool(form.get("own")), + } + + +@app.post("/sources/add") +async def sources_add(request: Request): + """Добавление источника: валидация → sources.yaml → полный ре-синк в БД.""" + _require_auth(request) + form = await request.form() + src = _form_source(form) + ok, res = add_or_update_source(src) + if not ok: + return RedirectResponse(url=f"/sources?did=error:{res}", status_code=302) + return RedirectResponse(url=f"/sources?did=added:{res}", status_code=302) + + +@app.post("/sources/{source_id}/edit") +async def sources_edit(source_id: int, request: Request): + """Редактирование источника: обновляем yaml (slug не меняем) + полный ре-синк.""" + _require_auth(request) + conn = _db() + row = conn.execute("SELECT * FROM sources WHERE id=?", (source_id,)).fetchone() + conn.close() + if not row: + return RedirectResponse(url="/sources?did=error:не найден", status_code=302) + form = await request.form() + src = db_source_to_yaml(dict(row)) + # текстовые поля + for k in ("name", "url", "channel", "crawler", "feed_url", "direction", "lang", "priority"): + if k in form: + v = str(form.get(k) or "").strip() + src[k] = v or None + # чекбоксы + src["enabled"] = bool(form.get("enabled")) + src["own"] = bool(form.get("own")) + ok, res = add_or_update_source(src) + if not ok: + return RedirectResponse(url=f"/sources?did=error:{res}", status_code=302) + return RedirectResponse(url=f"/sources?did=updated:{res}", status_code=302) + + +@app.post("/sources/{source_id}/toggle") +async def sources_toggle(source_id: int, request: Request): + """Пауза/возобновление: flip enabled в yaml + БД.""" + _require_auth(request) + conn = _db() + row = conn.execute("SELECT * FROM sources WHERE id=?", (source_id,)).fetchone() + conn.close() + if not row: + return RedirectResponse(url="/sources?did=error:не найден", status_code=302) + if not set_source_enabled(row["slug"], not bool(row["enabled"])): + return RedirectResponse(url="/sources?did=error:не найден", status_code=302) + state = "paused" if row["enabled"] else "resumed" + return RedirectResponse(url=f"/sources?did={state}:{row['slug']}", status_code=302) + + +@app.post("/sources/{source_id}/delete") +async def sources_delete(source_id: int, request: Request): + """Удаление источника: yaml → БД, каскад (посты/раны остаются с source_id=NULL).""" + _require_auth(request) + conn = _db() + row = conn.execute("SELECT * FROM sources WHERE id=?", (source_id,)).fetchone() + conn.close() + if not row: + return RedirectResponse(url="/sources?did=error:не найден", status_code=302) + if not delete_source(row["slug"]): + return RedirectResponse(url=f"/sources?did=error:нет в yaml:{row['slug']}", status_code=302) + return RedirectResponse(url=f"/sources?did=deleted:{row['slug']}", status_code=302) + + @app.get("/logout") def logout(): resp = RedirectResponse(url="/login", status_code=302) diff --git a/web/templates/candidates.html b/web/templates/candidates.html index ac40f44..5c099bb 100644 --- a/web/templates/candidates.html +++ b/web/templates/candidates.html @@ -128,33 +128,38 @@ {{ (selected.published_at or selected.fetched_at) | dt }}
- {% if selected.direction %}{{ selected.direction }}{% endif %} - {% if selected.relevance %}{{ selected.relevance }}{% endif %} - {% if selected.interest %}interest {{ selected.interest }}{% endif %} - {% if selected.is_own %}⭐ СВОЙ{% endif %} - {% if selected.fwd_from_channel_id %}↩︎ форвард{% endif %} - 👁 {{ selected.views }} 💬 {{ selected.reactions_total }} - {% if selected.url %}Исходник{% endif %} -
+ {% if selected.direction %}{{ selected.direction }}{% endif %} + {% if selected.relevance %}{{ selected.relevance }}{% endif %} + {% if selected.interest %}interest {{ selected.interest }}{% endif %} + {% if selected.is_own %}⭐ СВОЙ{% endif %} + {% if selected.fwd_from_channel_id %}↩︎ форвард{% endif %} + 👁 {{ selected.views }} 💬 {{ selected.reactions_total }} + {% if selected.url %}Исходник{% endif %} + +
+ Бейджи поста — что значит каждый цвет + +
-
+
{% set hid %}{% endset %} - {% if selected.status != 'published' %} -
- {{ hid }} - -
- {% endif %} +
{% if selected.status != 'published' %}
{{ hid }} - +
{% endif %} {% if selected.status != 'published' %}
{{ hid }} - +
{% endif %} {% if selected.status != 'rejected' and selected.status != 'published' %} @@ -163,20 +168,34 @@ {% endif %} +
+ {% if selected.status != 'published' %} +
+ {{ hid }} + + + +
+ +
+
+ {% endif %}
-
- {{ hid }} - - - -
{% if selected.rewritten_text %}
- - + +
+ {{ hid }} + +
+ +
+
{% endif %}