candidates-mail-ui: двухпанельный интерфейс кандидатов (почтовый клиент)

- БД: posts.comment TEXT, posts.rewritten_text TEXT (ALTER TABLE, миграция применена)
- GET /candidates: двухпанельный рендер — слева список с группировкой
  (source/date/status, свертываемые <details>) + чекбоксы bulk,
  справа детали поста (действия, комментарий, пересказ, оригинал с медиа)
- Роуты: POST /candidates/select, /candidates/bulk (approve/reject/reject-old),
  /posts/{id}/comment, /posts/{id}/reclassify (qwen), /posts/{id}/rewrite (qwen)
- approve вынесен в impl_approve (переиспользуется bulk-approve: publisher + бандл)
- без JS (только формы); HTML: Jinja2 + Bootstrap 5.3
- Протестировано TestClient: группировка, bulk-reject, comment, reclassify
  (реальный qwen), rewrite (реальный qwen), поиск, media, published — зелёные
- openspec: change candidates-mail-ui архивирован (2026-09-13-candidates-mail-ui),
  spec влита в openspec/specs/candidates-mail-ui; validate — чисто
- docs: STATUS.md, WALKTHROUGH.md обновлены
This commit is contained in:
kpa39l
2026-09-13 20:53:53 +00:00
parent 970bf14a9d
commit fc6d725bd1
11 changed files with 601 additions and 159 deletions
+303 -68
View File
@@ -102,36 +102,281 @@ def login(request: Request, password: str = Form(...)):
return RedirectResponse(url="/login?error=1", status_code=302)
@app.get("/candidates", response_class=HTMLResponse)
def candidates(request: Request, direction: str = "", status: str = "new", own: str = "", error: str = ""):
_require_auth(request)
conn = _db()
q = """SELECT p.*, s.name source_name FROM posts p
LEFT JOIN sources s ON s.id=p.source_id
WHERE 1=1"""
DIRECTIONS = ["linux", "tech", "politics", "games", "electronics", "llm"]
class _Row(dict):
"""sqlite3.Row → dict с доступом через точку (для шаблонов)."""
def __getattr__(self, k):
try:
return self[k]
except KeyError:
raise AttributeError(k)
def _row(r):
return _Row(dict(r)) if r is not None else None
STATUS_LABELS = {
"new": "💎 Новые",
"rejected": "🗑 Отклонённые",
"published": "✅ Опубликованные",
"": "Все",
}
STATUS_COLORS = {"new": "success", "rejected": "danger", "published": "primary"}
def _group_label(group_by: str, key: str) -> str:
"""Человекочитаемая подпись группы для списка кандидатов."""
if group_by == "source":
return key or "без источника"
if group_by == "date":
if key == "today":
return "Сегодня"
if key == "yesterday":
return "Вчера"
if key == "week":
return "Ранее на этой неделе"
return f"Ранее · {key}"
# status
return STATUS_LABELS.get(key, key or "?")
def _fetch_candidates(conn, direction: str, status: str, own: str, q: str, group_by: str, limit: int = 200):
"""Выборка кандидатов для левой панели. Возвращает (grouped, counts, flat).
flat — сгруппированный список; counts — {статус: число} для фильтра-табов."""
where = []
params = []
if direction:
q += " AND p.direction=?"
where.append("p.direction=?")
params.append(direction)
if status:
q += " AND p.status=?"
where.append("p.status=?")
params.append(status)
if own in ("1", "0"):
q += " AND p.is_own=?"
where.append("p.is_own=?")
params.append(int(own))
q += " ORDER BY p.published_at DESC LIMIT 100"
rows = conn.execute(q, params).fetchall()
if q:
where.append("(p.text LIKE ? OR p.summary LIKE ?)")
params += [f"%{q}%", f"%{q}%"]
w = ("WHERE " + " AND ".join(where)) if where else ""
sql = f"""SELECT p.*, s.name source_name, s.slug source_slug, s.lang source_lang,
s.direction AS source_direction
FROM posts p LEFT JOIN sources s ON s.id=p.source_id
{w}"""
rows = conn.execute(sql + " ORDER BY COALESCE(p.published_at,p.fetched_at) DESC LIMIT ?", params + [limit]).fetchall()
counts = {r["status"]: r["c"] for r in conn.execute(
"SELECT p.status, COUNT(*) c FROM posts p" + (" WHERE " + " AND ".join(where) if where else "") + " GROUP BY p.status",
params,
)}
counts[""] = sum(counts.values())
# статус по умолчанию для табов: если в выборке нет ничего "нового" — смотрим rejected
if status == "new" and not rows:
for s in ("rejected", "published"):
if counts.get(s):
status = s
break
flat = []
for r in rows:
d = dict(r)
d["source_name"] = d.get("source_name") or d.get("tg_channel") or "?"
flat.append(_Row(d))
# группировка
import itertools
def _key_source(p):
return p["source_name"] or "?"
def _key_date(p):
from datetime import date, timedelta
today = date.today()
ts = p.get("published_at") or p.get("fetched_at") or ""
d = None
try:
d = datetime.fromisoformat(str(ts)[:19]).date()
except ValueError:
pass
if d is None:
return "other"
if d == today:
return "today"
if d == today - timedelta(days=1):
return "yesterday"
if (today - d).days <= 7:
return "week"
return f"{d:%m.%Y}"
def _key_status(p):
return p["status"]
keyf = {"source": _key_source, "date": _key_date, "status": _key_status}[group_by]
grouped = []
for k, it in itertools.groupby(sorted(flat, key=keyf), key=keyf):
items = list(it)
grouped.append({
"key": k,
"label": _group_label(group_by, k),
"status": items[0]["status"],
"posts": items,
})
# порядок групп: new → rejected → published (для status), иначе по ключу
if group_by == "status":
grouped.sort(key=lambda g: {"new": 0, "rejected": 1, "published": 2, "": 3}.get(g["key"], 9))
return grouped, counts, status
@app.get("/candidates", response_class=HTMLResponse)
def candidates(request: Request, direction: str = "", status: str = "", own: str = "",
q: str = "", group_by: str = "source", selected: int = 0, error: str = ""):
_require_auth(request)
conn = _db()
grouped, counts, status = _fetch_candidates(conn, direction, status, own, q, group_by)
# выбор по умолчанию: первый кандидат списка (или указанный selected)
sel = None
if grouped:
sel = grouped[0]["posts"][0]
if selected:
for g in grouped:
for it in g["posts"]:
if it["id"] == selected:
sel = it
break
else:
continue
break
conn.close()
html = tpl.get_template("candidates.html").render(
posts=rows,
direction=direction,
status=status,
own=own,
directions=["linux", "tech", "politics", "games", "electronics", "llm"],
posts=grouped, counts=counts, status=status, direction=direction, own=own,
q=q, group_by=group_by, selected=sel, error=error,
directions=DIRECTIONS, STATUS_LABELS=STATUS_LABELS, STATUS_COLORS=STATUS_COLORS,
)
return HTMLResponse(html)
@app.post("/candidates/select")
def candidates_select(request: Request, id: int = Form(...), direction: str = Form(""),
status: str = Form(""), own: str = Form(""), q: str = Form(""), group_by: str = Form("source")):
"""Выбор кандидата левой панели (обычная форма без JS)."""
_require_auth(request)
url = f"/candidates?selected={id}&group_by={group_by}"
for k, v in (("direction", direction), ("status", status), ("own", own), ("q", q)):
if v:
url += f"&{k}={v}"
return RedirectResponse(url=url, status_code=302)
@app.post("/candidates/bulk")
def candidates_bulk(request: Request, action: str = Form(...), ids: list[int] = Form(...),
direction: str = Form(""), status: str = Form(""), own: str = Form(""),
q: str = Form(""), group_by: str = Form("source")):
"""Bulk-операции: approve/reject/reject-old по отмеченным ids."""
_require_auth(request)
if not ids:
return RedirectResponse(url="/candidates?error=no_selection", status_code=302)
conn = _db()
ph = ",".join("?" * len(ids))
rows = conn.execute(f"SELECT * FROM posts WHERE id IN ({ph})", ids).fetchall()
if action == "reject":
conn.execute(f"UPDATE posts SET status='rejected' WHERE id IN ({ph})", ids)
conn.commit()
conn.close()
return RedirectResponse(url=f"/candidates?status=rejected&group_by={group_by}", status_code=302)
if action == "reject-old":
# все выбранные, кроме самых свежих (по 1 от источника) — «почистить старьё»
keep = {}
for r in rows:
key = r["source_id"]
keep.setdefault(key, r["id"])
kill = [r["id"] for r in rows if r["id"] not in keep.values()]
if kill:
ph2 = ",".join("?" * len(kill))
conn.execute(f"UPDATE posts SET status='rejected' WHERE id IN ({ph2})", kill)
conn.commit()
conn.close()
return RedirectResponse(url=f"/candidates?status=rejected&group_by={group_by}", status_code=302)
# approve — каждый через существующую логику (публикация + бандл)
conn.close()
errs = []
for r in rows:
if r["status"] == "published":
continue
err = impl_approve(int(r["id"]))
if err:
errs.append(f"{r['id']}({err})")
if errs:
return RedirectResponse(url=f"/candidates?error=bulk_partial:{','.join(map(str,errs))[:120]}&group_by={group_by}", status_code=302)
return RedirectResponse(url="/published", status_code=302)
@app.post("/posts/{post_id}/comment")
def post_comment(post_id: int, request: Request, comment: str = Form("")):
_require_auth(request)
conn = _db()
conn.execute("UPDATE posts SET comment=? WHERE id=?", (comment.strip(), post_id))
conn.commit()
conn.close()
return RedirectResponse(url=f"/candidates?selected={post_id}", status_code=302)
@app.post("/posts/{post_id}/reclassify")
def post_reclassify(post_id: int, request: Request):
_require_auth(request)
from classifier.classify import classify_text
conn = _db()
p = conn.execute("SELECT * FROM posts WHERE id=?", (post_id,)).fetchone()
if not p:
conn.close()
return RedirectResponse(url="/candidates?error=notfound", status_code=302)
try:
res = classify_text(dict(p))
except Exception as e:
conn.close()
return RedirectResponse(url=f"/candidates?selected={post_id}&error=reclassify:{e}", status_code=302)
conn.execute(
"UPDATE posts SET direction=?, relevance=?, interest=?, summary=?, classified=? WHERE id=?",
(res["direction"], res["relevance"], res["interest"], res["summary"], 1 if res["classified"] else 0, post_id),
)
conn.commit()
conn.close()
return RedirectResponse(url=f"/candidates?selected={post_id}", status_code=302)
@app.post("/posts/{post_id}/rewrite")
def post_rewrite(post_id: int, request: Request):
_require_auth(request)
from classifier.classify import call_ollama
conn = _db()
p = conn.execute("SELECT * FROM posts WHERE id=?", (post_id,)).fetchone()
if not p:
conn.close()
return RedirectResponse(url="/candidates?error=notfound", status_code=302)
d = dict(p)
text_ = (d.get("text") or "")[:2000]
prompt = (
"Ты — редактор новостей. Перепиши новость СВОИМИ словами от первого лица\n"
"(как автор канала «Дед в АйТи»), без копипасты и без канцелярита. Сохрани смысл,\n"
"факты и ссылку на оригинал. Верни ТОЛЬКО текст пересказа, без пояснений.\n\n"
f"Оригинал:\n{text_}"
)
try:
r = call_ollama(prompt, 0, 0, str(d.get("direction") or ""))
draft = (r.get("text") or r.get("content") or "").strip()
if not draft:
draft = r.get("summary") or ""
except Exception as e:
conn.close()
return RedirectResponse(url=f"/candidates?selected={post_id}&error=rewrite:{e}", status_code=302)
conn.execute("UPDATE posts SET rewritten_text=? WHERE id=?", (draft, post_id))
conn.commit()
conn.close()
return RedirectResponse(url=f"/candidates?selected={post_id}", status_code=302)
@app.get("/published", response_class=HTMLResponse)
def published(request: Request):
_require_auth(request)
@@ -149,11 +394,10 @@ def published(request: Request):
return HTMLResponse(html)
@app.post("/posts/{post_id}/approve")
async def approve(post_id: int, request: Request):
"""Подтверждение черновика → публикация в TG (fan-out по направлениям) + создание бандла + статус published."""
_require_auth(request)
from web.publisher_client import publish as http_publish, get_views as http_views
def impl_approve(post_id: int, comment: str = "", dirs_selected: list[str] | None = None) -> str | None:
"""Публикация поста: publisher + бандл + статус published. Возвращает None при успехе,
строку ошибки при неудаче (для bulk — собираем ошибки, для POST — редирект с error)."""
from web.publisher_client import publish as http_publish
from publisher.card import make_card
from web.store import create_bundle
@@ -161,62 +405,35 @@ async def approve(post_id: int, request: Request):
post = conn.execute("SELECT p.*, s.name source_name, s.lang FROM posts p LEFT JOIN sources s ON s.id=p.source_id WHERE p.id=?", (post_id,)).fetchone()
if not post:
conn.close()
return RedirectResponse(url="/candidates?error=notfound", status_code=302)
return "notfound"
post = dict(post)
is_own = int(post.get("is_own") or 0) == 1
dirn = post.get("direction") or "linux" # направление поста (используется ниже)
dirn = post.get("direction") or "linux"
lang = post.get("lang") or "ru"
# направления fan-out: из формы (чекбоксы) — по умолчанию направления классификации или направление поста
form = None
dirs_selected = []
comment = ""
if request.method == "POST":
try:
form = await request.form() # Starlette: form awaitable
dirs_selected = form.getlist("dirs")
comment = (form.get("comment") or "").strip()
except Exception:
dirs_selected = []
if not dirs_selected:
if dirs_selected is None:
# по умолчанию: направления классификации (classifications) или направление поста
cls = conn.execute(
"SELECT direction FROM classifications WHERE post_id=? ORDER BY id", (post_id,)
).fetchall()
cls = conn.execute("SELECT direction FROM classifications WHERE post_id=? ORDER BY id", (post_id,)).fetchall()
dirs_selected = [r["direction"] for r in cls] if cls else [dirn]
# дедуп, без пустых
dirs_selected = list(dict.fromkeys([d for d in dirs_selected if d]))
card = make_card(post, comment)
card["direction"] = dirn
card["lang"] = lang
try:
res = http_publish(card)
except RuntimeError as e:
conn.close()
return f"publisher:{e}"
_res = res.get("results", {})
# свой контент: ключи — направления; внешний: ключи — каналы
if is_own:
# СВОЙ контент → fan-out: сервис сам берёт каналы из конфига (VESTI_BOT_CHANNELS)
# направления передаём в card (для карточки), не как адресаты.
card["direction"] = dirn
card["lang"] = lang
try:
res = http_publish(card)
except RuntimeError as e:
conn.close()
return RedirectResponse(url=f"/candidates?error=publisher:{e}", status_code=302)
tg_message_id = next(iter((r or {}).get("message_id") or 0 for r in res.get("results", {}).values()), 0) if res else 0
views_by_dir = {
d: (res.get("results", {}).get(d, {}).get("views") or 0)
for d in res.get("results", {})
}
tg_message_id = next(iter((r or {}).get("message_id") or 0 for r in _res.values()), 0) if res else 0
views_by_dir = {d: (r.get("views") or 0) for d, r in _res.items()}
else:
# Внешний → без fan-out: сервис публикует в каналы по умолчанию
card["direction"] = dirn
card["lang"] = lang
try:
res = http_publish(card)
except RuntimeError as e:
conn.close()
return RedirectResponse(url=f"/candidates?publisher_error={e}", status_code=302)
# результаты ключаются по КАНАЛУ (@dedinit_vesti), а не по направлению — берём первый успешный
_res = res.get("results", {})
tg_message_id = next((r.get("message_id") or 0 for r in _res.values() if r.get("message_id")), 0)
tg_message_id = next((r.get("message_id") or 0 for r in _res.values() if r.get("message_id")), 0) if _res else 0
views_by_dir = {d: (r.get("views") or 0) for d, r in _res.items()}
# банк статей (markdown-бандл + медиа) — по каждому направлению fan-out
@@ -225,9 +442,8 @@ async def approve(post_id: int, request: Request):
except Exception as e:
bundle = {"error": str(e)}
# 3) статус + запись published (для своего — distributed_dirs + метрики)
import json as _json
conn.execute("UPDATE posts SET status='published' WHERE id=?", (post_id,))
conn.execute("UPDATE posts SET status='published', comment=? WHERE id=?", (comment.strip(), post_id))
distributed = _json.dumps(dirs_selected, ensure_ascii=False) if is_own else _json.dumps([dirn])
conn.execute(
"INSERT INTO published (post_id, bundle_path, tg_message_id, distributed_dirs, views) VALUES (?,?,?,?,?)",
@@ -235,6 +451,25 @@ async def approve(post_id: int, request: Request):
)
conn.commit()
conn.close()
return None
@app.post("/posts/{post_id}/approve")
async def approve(post_id: int, request: Request):
"""Подтверждение черновика → публикация в TG (fan-out по направлениям) + создание бандла + статус published."""
_require_auth(request)
# направления fan-out: из формы (чекбоксы) — по умолчанию направления классификации или направление поста
dirs_selected: list[str] = []
comment = ""
try:
form = await request.form()
dirs_selected = [str(d) for d in form.getlist("dirs")]
comment = str(form.get("comment") or "").strip()
except Exception:
pass
err = impl_approve(post_id, comment, dirs_selected)
if err:
return RedirectResponse(url=f"/candidates?selected={post_id}&error={err}", status_code=302)
return RedirectResponse(url="/published", status_code=302)