mirror of
https://gitverse.ru/kpa39l/vesti.git
synced 2026-09-29 09:55:03 +00:00
fix(candidates): после Отклонить остаёмся на кандидатах, кнопки действий над комментарием
- reject/reject-old (single + bulk): редирект на /candidates с текущей группировкой вместо /candidates?status=rejected — не прерываем отбор. Отклонённые доступны через фильтр статуса. - candidates.html: Обработать/Переписать/Отклонить — один ряд над полем комментария (после переноса Опубликовать в approve-форму кнопки съехали). changes: candidates-buttons-order, reject-stay-candidates (skip_specs, валидны).
This commit is contained in:
@@ -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] **Тест публикации с медиа сквозь веб**: сообщения ушли в @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] **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, свертываемые <details>) + чекбоксы 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 `candidates-mail-ui`** (двухпанельный интерфейс кандидатов как почтовый клиент): левая панель — список с группировкой (source/date/status, свертываемые <details>) + чекбоксы 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, открытые)
|
## Текущие задачи (из TODO, открытые)
|
||||||
- Интерфейс кандидатов как почтовый клиент (2 панели, группировка, bulk) — ✅ реализован в change candidates-mail-ui (см. выше).
|
- Интерфейс кандидатов как почтовый клиент (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 и без своих.
|
- Список: только внешние new-посты (сейчас 163), без published/rejected и без своих.
|
||||||
- Группировка (date/source/status) и фильтры сохраняются после любых действий из правой панели.
|
- Группировка (date/source/status) и фильтры сохраняются после любых действий из правой панели.
|
||||||
- Тихие OpenSpec-change: `candidates-only-external-new`, `keep-candidates-context` — задачи [x], валидны; архивировать после подтверждения.
|
- Тихие 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/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/{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/openspec/changes/rich-repost-card/ — change формата публикации (proposal/design/tasks/specs/tg-publisher, валиден)
|
||||||
- /opt/vesti/services/publisher/ — микросервис (app/, Dockerfile, compose, README)
|
- /opt/vesti/services/publisher/ — микросервис (app/, Dockerfile, compose, README)
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
skip_specs: true
|
||||||
|
created: 2026-09-15
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
## Design
|
||||||
|
|
||||||
|
Файл: `/opt/vesti/web/templates/candidates.html`, правая панель
|
||||||
|
(`{% if selected %}`), блок действий (строки ~150–184).
|
||||||
|
|
||||||
|
### Текущая разметка
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="mb-3 d-flex flex-wrap gap-2">
|
||||||
|
{% set hid %}...{% endset %}
|
||||||
|
{% if selected.status != 'published' %}
|
||||||
|
<form method="post" action="/posts/{{ selected.id }}/approve" id="approve-form">
|
||||||
|
{{ hid }}
|
||||||
|
<label class="form-label small text-muted mb-0">💬 Мой комментарий...</label>
|
||||||
|
<textarea class="form-control form-control-sm mt-1" name="comment" rows="2"
|
||||||
|
placeholder="Комментарий/анонс…">{{ selected.comment or '' }}</textarea>
|
||||||
|
<div class="mt-2 d-flex gap-2">
|
||||||
|
<button class="btn btn-success" type="submit" title="...">✅ Опубликовать</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="submit"
|
||||||
|
formaction="/posts/{{ selected.id }}/comment" title="...">💾 Сохранить комментарий</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if selected.status != 'published' %}
|
||||||
|
<form method="post" action="/posts/{{ selected.id }}/reclassify">
|
||||||
|
{{ hid }}
|
||||||
|
<button class="btn btn-info" type="submit" title="...">🤖 Обработать моделью</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if selected.status != 'published' %}
|
||||||
|
<form method="post" action="/posts/{{ selected.id }}/rewrite">
|
||||||
|
{{ hid }}
|
||||||
|
<button class="btn btn-outline-primary" type="submit" title="...">✍️ Переписать</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if selected.status != 'rejected' and selected.status != 'published' %}
|
||||||
|
<form method="post" action="/posts/{{ selected.id }}/reject">
|
||||||
|
{{ hid }}
|
||||||
|
<button class="btn btn-outline-danger" type="submit">🚫 Отклонить</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Новая разметка
|
||||||
|
|
||||||
|
Кнопки действий выносятся в ОДИН flex-ряд НАД полем комментария; approve-форма
|
||||||
|
остаётся обёрткой только для «Опубликовать» + «Сохранить комментарий» + textarea
|
||||||
|
(поля `hid` нужны каждой форме; внутри approve-формы они уже есть).
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="mb-3 d-flex flex-wrap gap-2">
|
||||||
|
{% set hid %}...{% endset %}
|
||||||
|
{% if selected.status != 'published' %}
|
||||||
|
<form method="post" action="/posts/{{ selected.id }}/reclassify">
|
||||||
|
{{ hid }}
|
||||||
|
<button class="btn btn-info" type="submit" title="...">🤖 Обработать моделью</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if selected.status != 'published' %}
|
||||||
|
<form method="post" action="/posts/{{ selected.id }}/rewrite">
|
||||||
|
{{ hid }}
|
||||||
|
<button class="btn btn-outline-primary" type="submit" title="...">✍️ Переписать</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if selected.status != 'rejected' and selected.status != 'published' %}
|
||||||
|
<form method="post" action="/posts/{{ selected.id }}/reject">
|
||||||
|
{{ hid }}
|
||||||
|
<button class="btn btn-outline-danger" type="submit">🚫 Отклонить</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if selected.status != 'published' %}
|
||||||
|
<form method="post" action="/posts/{{ selected.id }}/approve" id="approve-form">
|
||||||
|
{{ hid }}
|
||||||
|
<div class="mt-0 d-flex gap-2">
|
||||||
|
<button class="btn btn-success" type="submit" title="...">✅ Опубликовать</button>
|
||||||
|
</div>
|
||||||
|
<label class="form-label small text-muted mb-0 mt-2">💬 Мой комментарий...</label>
|
||||||
|
<textarea class="form-control form-control-sm mt-1" name="comment" rows="2"
|
||||||
|
placeholder="Комментарий/анонс…">{{ selected.comment or '' }}</textarea>
|
||||||
|
<div class="mt-2">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="submit"
|
||||||
|
formaction="/posts/{{ selected.id }}/comment" title="...">💾 Сохранить комментарий</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Порядок в ряду: «🤖 Обработать моделью» → «✍️ Переписать» → «🚫 Отклонить» →
|
||||||
|
«✅ Опубликовать» (кнопка публикации — правая, главная). Затем — поле
|
||||||
|
комментария и «💾 Сохранить комментарий».
|
||||||
|
|
||||||
|
Все кнопки остаются `type="submit"` в своих формах; `formaction` у
|
||||||
|
«Сохранить комментарий» сохраняется; логика видимости не меняется.
|
||||||
|
|
||||||
|
## Верификация
|
||||||
|
|
||||||
|
- `openspec validate candidates-buttons-order` — чисто.
|
||||||
|
- Рендер шаблона проверяется только ч/з сам сервис (TestClient/живой GET),
|
||||||
|
т.к. прямой рендер `tpl.get_template(...)` блокируется политикой.
|
||||||
|
- Перезапуск: `sudo systemctl restart vesti-web` (Jinja2-кеш!).
|
||||||
|
- Ручная проверка: `GET /candidates` → выбран неопубликованный пост → в
|
||||||
|
правой панели НАД полем комментария в одном ряду кнопки
|
||||||
|
Обработать/Переписать/Отклонить/Опубликовать, под textarea — «Сохранить
|
||||||
|
комментарий».
|
||||||
@@ -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: вернуть разметку и перезапустить сервис.
|
||||||
@@ -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 делает сам, вручную НЕ запускать)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
skip_specs: true
|
||||||
|
created: 2026-09-15
|
||||||
@@ -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`.
|
||||||
|
- Ручная проверка: отклонить пост из правой панели → остаёмся на
|
||||||
|
«Кандидатах» в той же группировке, пост пропал из списка; список
|
||||||
|
отклонённых доступен через фильтр «🗑 Отклонённые».
|
||||||
@@ -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"` в редиректах и перезапустить.
|
||||||
@@ -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 делает сам, вручную НЕ запускать)
|
||||||
+224
-3
@@ -17,6 +17,12 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
from markupsafe import Markup, escape
|
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
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
DB_PATH = BASE_DIR / "db" / "vesti.db"
|
DB_PATH = BASE_DIR / "db" / "vesti.db"
|
||||||
TPL_DIR = Path(__file__).resolve().parent / "templates"
|
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.execute(f"UPDATE posts SET status='rejected' WHERE id IN ({ph})", ids)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
return RedirectResponse(url=back + "&status=rejected", status_code=302)
|
# остаёмся на кандидатах (текущая группировка), не уходим в отклонённые
|
||||||
|
return RedirectResponse(url=back, status_code=302)
|
||||||
if action == "reject-old":
|
if action == "reject-old":
|
||||||
# все выбранные, кроме самых свежих (по 1 от источника) — «почистить старьё»
|
# все выбранные, кроме самых свежих (по 1 от источника) — «почистить старьё»
|
||||||
keep = {}
|
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.execute(f"UPDATE posts SET status='rejected' WHERE id IN ({ph2})", kill)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
return RedirectResponse(url=back + "&status=rejected", status_code=302)
|
# остаёмся на кандидатах (текущая группировка), не уходим в отклонённые
|
||||||
|
return RedirectResponse(url=back, status_code=302)
|
||||||
conn.close()
|
conn.close()
|
||||||
if action == "process":
|
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)
|
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")
|
@app.post("/posts/{post_id}/comment")
|
||||||
def post_comment(post_id: int, request: Request, comment: str = Form(""),
|
def post_comment(post_id: int, request: Request, comment: str = Form(""),
|
||||||
group_by: str = Form(""), direction: str = Form(""), own: str = Form(""), q: 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.execute("UPDATE posts SET status='rejected' WHERE id=?", (post_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
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)
|
@app.get("/metrics", response_class=HTMLResponse)
|
||||||
@@ -572,6 +600,199 @@ def metrics(request: Request):
|
|||||||
return HTMLResponse(html)
|
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")
|
@app.get("/logout")
|
||||||
def logout():
|
def logout():
|
||||||
resp = RedirectResponse(url="/login", status_code=302)
|
resp = RedirectResponse(url="/login", status_code=302)
|
||||||
|
|||||||
@@ -136,25 +136,30 @@
|
|||||||
<small class="text-muted ms-2">👁 {{ selected.views }} 💬 {{ selected.reactions_total }}</small>
|
<small class="text-muted ms-2">👁 {{ selected.views }} 💬 {{ selected.reactions_total }}</small>
|
||||||
{% if selected.url %}<a class="btn btn-sm btn-outline-secondary ms-2" href="{{ selected.url }}" target="_blank">Исходник</a>{% endif %}
|
{% if selected.url %}<a class="btn btn-sm btn-outline-secondary ms-2" href="{{ selected.url }}" target="_blank">Исходник</a>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
<details class="small text-muted mb-2">
|
||||||
|
<summary class="text-muted">Бейджи поста — что значит каждый цвет</summary>
|
||||||
|
<ul class="mb-0 mt-1" style="padding-left:1.2rem">
|
||||||
|
<li><span class="badge bg-secondary">направление</span> — тема (linux, tech, games…) — из классификатора</li>
|
||||||
|
<li><span class="badge bg-success">critical</span> / <span class="badge bg-info">high</span> — важность по мнению модели (зелёный = критичная, но не критичная; синий = важная)</li>
|
||||||
|
<li><span class="badge bg-warning text-dark">interest N</span> — интересность для твоего канала 1–5 (жёлтый, N — оценка; 5 = лучшее)</li>
|
||||||
|
<li><span class="badge bg-warning text-dark">⭐ СВОЙ</span> — пост твоего канала (dedinit)</li>
|
||||||
|
<li><span class="badge bg-light text-dark">↩︎ форвард</span> — пересланное сообщение</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
|
||||||
<div class="mb-3 d-flex flex-wrap gap-2">
|
<div class="mb-3">
|
||||||
{% set hid %}<input type="hidden" name="group_by" value="{{ group_by }}"><input type="hidden" name="direction" value="{{ direction }}"><input type="hidden" name="own" value="{{ own }}"><input type="hidden" name="q" value="{{ q }}">{% endset %}
|
{% set hid %}<input type="hidden" name="group_by" value="{{ group_by }}"><input type="hidden" name="direction" value="{{ direction }}"><input type="hidden" name="own" value="{{ own }}"><input type="hidden" name="q" value="{{ q }}">{% endset %}
|
||||||
{% if selected.status != 'published' %}
|
<div class="d-flex flex-wrap gap-2 mb-2">
|
||||||
<form method="post" action="/posts/{{ selected.id }}/approve">
|
|
||||||
{{ hid }}
|
|
||||||
<button class="btn btn-success" type="submit">✅ Опубликовать</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
{% if selected.status != 'published' %}
|
{% if selected.status != 'published' %}
|
||||||
<form method="post" action="/posts/{{ selected.id }}/reclassify">
|
<form method="post" action="/posts/{{ selected.id }}/reclassify">
|
||||||
{{ hid }}
|
{{ hid }}
|
||||||
<button class="btn btn-info" type="submit">🤖 Обработать моделью</button>
|
<button class="btn btn-info" type="submit" title="Заново прогнать через модель (qwen3:8b): направление, релевантность, интерес — обновит бейджи">🤖 Обработать моделью</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if selected.status != 'published' %}
|
{% if selected.status != 'published' %}
|
||||||
<form method="post" action="/posts/{{ selected.id }}/rewrite">
|
<form method="post" action="/posts/{{ selected.id }}/rewrite">
|
||||||
{{ hid }}
|
{{ hid }}
|
||||||
<button class="btn btn-outline-primary" type="submit">✍️ Переписать</button>
|
<button class="btn btn-outline-primary" type="submit" title="Сгенерировать черновик пересказа через модель — затем можно отредактировать и сохранить (пойдёт в канал вместо оригинала, от вашего имени)">✍️ Переписать</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if selected.status != 'rejected' and selected.status != 'published' %}
|
{% if selected.status != 'rejected' and selected.status != 'published' %}
|
||||||
@@ -164,19 +169,33 @@
|
|||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% if selected.status != 'published' %}
|
||||||
|
<form method="post" action="/posts/{{ selected.id }}/approve" id="approve-form">
|
||||||
|
{{ hid }}
|
||||||
|
<button class="btn btn-success" type="submit" title="Опубликовать в канал: комментарий (если есть) первым, затем текст поста/пересказ, картинка, ссылка">✅ Опубликовать</button>
|
||||||
|
<label class="form-label small text-muted mb-0 mt-2 d-block">💬 Мой комментарий (будет первым в канале):</label>
|
||||||
|
<textarea class="form-control form-control-sm mt-1" name="comment" rows="2"
|
||||||
|
placeholder="Комментарий/анонс…">{{ selected.comment or '' }}</textarea>
|
||||||
|
<div class="mt-2">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="submit"
|
||||||
|
formaction="/posts/{{ selected.id }}/comment"
|
||||||
|
title="Сохранить комментарий в черновик (не публикует). Он подтянется при следующей публикации">💾 Сохранить комментарий</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<form method="post" action="/posts/{{ selected.id }}/comment">
|
|
||||||
{{ hid }}
|
|
||||||
<label class="form-label small text-muted">💬 Мой комментарий (будет первым в канале):</label>
|
|
||||||
<textarea class="form-control form-control-sm" name="comment" rows="2"
|
|
||||||
placeholder="Комментарий/анонс…">{{ selected.comment or '' }}</textarea>
|
|
||||||
<button class="btn btn-sm btn-outline-secondary mt-1" type="submit">Сохранить комментарий</button>
|
|
||||||
</form>
|
|
||||||
{% if selected.rewritten_text %}
|
{% if selected.rewritten_text %}
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<label class="form-label small text-muted">✍️ Черновик пересказа (редактируйте и копируйте):</label>
|
<label class="form-label small text-muted">✍️ Пересказ (редактируйте и сохраните — он пойдёт в канал от вашего имени):</label>
|
||||||
<textarea class="form-control form-control-sm" rows="4" readonly>{{ selected.rewritten_text }}</textarea>
|
<form method="post" action="/posts/{{ selected.id }}/rewrite-save" class="row g-1">
|
||||||
|
{{ hid }}
|
||||||
|
<textarea class="form-control form-control-sm" name="rewritten_text" rows="5">{{ selected.rewritten_text }}</textarea>
|
||||||
|
<div class="col-auto mt-1">
|
||||||
|
<button class="btn btn-sm btn-outline-primary" type="submit">💾 Сохранить пересказ</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user