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)
|
||||
|
||||
@@ -128,33 +128,38 @@
|
||||
<small class="text-muted">{{ (selected.published_at or selected.fetched_at) | dt }}</small>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
{% if selected.direction %}<span class="badge bg-secondary">{{ selected.direction }}</span>{% endif %}
|
||||
{% if selected.relevance %}<span class="badge bg-{{ 'success' if selected.relevance=='critical' else 'info' }}">{{ selected.relevance }}</span>{% endif %}
|
||||
{% if selected.interest %}<span class="badge bg-warning text-dark">interest {{ selected.interest }}</span>{% endif %}
|
||||
{% if selected.is_own %}<span class="badge bg-warning text-dark">⭐ СВОЙ</span>{% endif %}
|
||||
{% if selected.fwd_from_channel_id %}<span class="badge bg-light text-dark">↩︎ форвард</span>{% endif %}
|
||||
<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 %}
|
||||
</div>
|
||||
{% if selected.direction %}<span class="badge bg-secondary">{{ selected.direction }}</span>{% endif %}
|
||||
{% if selected.relevance %}<span class="badge bg-{{ 'success' if selected.relevance=='critical' else 'info' }}">{{ selected.relevance }}</span>{% endif %}
|
||||
{% if selected.interest %}<span class="badge bg-warning text-dark">interest {{ selected.interest }}</span>{% endif %}
|
||||
{% if selected.is_own %}<span class="badge bg-warning text-dark">⭐ СВОЙ</span>{% endif %}
|
||||
{% if selected.fwd_from_channel_id %}<span class="badge bg-light text-dark">↩︎ форвард</span>{% endif %}
|
||||
<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 %}
|
||||
</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 %}
|
||||
{% if selected.status != 'published' %}
|
||||
<form method="post" action="/posts/{{ selected.id }}/approve">
|
||||
{{ hid }}
|
||||
<button class="btn btn-success" type="submit">✅ Опубликовать</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<div class="d-flex flex-wrap gap-2 mb-2">
|
||||
{% if selected.status != 'published' %}
|
||||
<form method="post" action="/posts/{{ selected.id }}/reclassify">
|
||||
{{ hid }}
|
||||
<button class="btn btn-info" type="submit">🤖 Обработать моделью</button>
|
||||
<button class="btn btn-info" type="submit" title="Заново прогнать через модель (qwen3:8b): направление, релевантность, интерес — обновит бейджи">🤖 Обработать моделью</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if selected.status != 'published' %}
|
||||
<form method="post" action="/posts/{{ selected.id }}/rewrite">
|
||||
{{ hid }}
|
||||
<button class="btn btn-outline-primary" type="submit">✍️ Переписать</button>
|
||||
<button class="btn btn-outline-primary" type="submit" title="Сгенерировать черновик пересказа через модель — затем можно отредактировать и сохранить (пойдёт в канал вместо оригинала, от вашего имени)">✍️ Переписать</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if selected.status != 'rejected' and selected.status != 'published' %}
|
||||
@@ -163,20 +168,34 @@
|
||||
<button class="btn btn-outline-danger" type="submit">🚫 Отклонить</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</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">
|
||||
<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 %}
|
||||
<div class="mt-2">
|
||||
<label class="form-label small text-muted">✍️ Черновик пересказа (редактируйте и копируйте):</label>
|
||||
<textarea class="form-control form-control-sm" rows="4" readonly>{{ selected.rewritten_text }}</textarea>
|
||||
<label class="form-label small text-muted">✍️ Пересказ (редактируйте и сохраните — он пойдёт в канал от вашего имени):</label>
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user