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:
+224
-3
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user