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:
kpa39l
2026-09-15 16:30:19 +00:00
parent ee2f8ac61a
commit 562d787f1b
11 changed files with 551 additions and 29 deletions
+224 -3
View File
@@ -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)