candidates UI: full width, action and bulk buttons

- base.html: container -> container-fluid (nav and content full width)
- Right pane: large buttons Publish / Process with local model /
  Rewrite / Reject (for new candidates)
- Left pane bottom: bulk operations panel with 4 visible buttons
  (Publish / Process / Reject / Stale) instead of hidden select
- bulk: action 'process' reclassifies selected with local model
  (impl_reclassify extracted); ids optional (empty selection ->
  no_selection redirect instead of 422); redirect preserves filters
- Tests: full width, buttons present, bulk empty/process, published/metrics — green
This commit is contained in:
kpa39l
2026-09-13 21:01:30 +00:00
parent 60617de0cb
commit 0e487f672a
3 changed files with 69 additions and 42 deletions
+34 -12
View File
@@ -280,13 +280,17 @@ def candidates_select(request: Request, id: int = Form(...), direction: str = Fo
@app.post("/candidates/bulk")
def candidates_bulk(request: Request, action: str = Form(...), ids: list[int] = Form(...),
def candidates_bulk(request: Request, action: str = Form(...), ids: list[int] = Form(default_factory=list),
direction: str = Form(""), status: str = Form(""), own: str = Form(""),
q: str = Form(""), group_by: str = Form("source")):
"""Bulk-операции: approve/reject/reject-old по отмеченным ids."""
"""Bulk-операции: approve/reject/reject-old/process по отмеченным ids."""
_require_auth(request)
back = f"/candidates?group_by={group_by}"
for k, v in (("direction", direction), ("status", status), ("own", own), ("q", q)):
if v:
back += f"&{k}={quote(v)}"
if not ids:
return RedirectResponse(url="/candidates?error=no_selection", status_code=302)
return RedirectResponse(url=back + "&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()
@@ -294,7 +298,7 @@ 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=f"/candidates?status=rejected&group_by={group_by}", status_code=302)
return RedirectResponse(url=back + "&status=rejected", status_code=302)
if action == "reject-old":
# все выбранные, кроме самых свежих (по 1 от источника) — «почистить старьё»
keep = {}
@@ -307,9 +311,22 @@ 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=f"/candidates?status=rejected&group_by={group_by}", status_code=302)
# approve — каждый через существующую логику (публикация + бандл)
return RedirectResponse(url=back + "&status=rejected", status_code=302)
conn.close()
if action == "process":
# обработка локальной моделью (переклассификация) по выбранным
errs = []
done = 0
for r in rows:
try:
impl_reclassify(int(r["id"]))
done += 1
except Exception as e:
errs.append(f"{r['id']}({e})")
if errs:
return RedirectResponse(url=f"/candidates?error=process_partial:{','.join(map(str,errs))[:120]}&group_by={group_by}", status_code=302)
return RedirectResponse(url=back + "&error=processed:" + str(done), status_code=302)
# approve — каждый через существующую логику (публикация + бандл)
errs = []
for r in rows:
if r["status"] == "published":
@@ -334,25 +351,30 @@ def post_comment(post_id: int, request: Request, comment: str = Form("")):
@app.post("/posts/{post_id}/reclassify")
def post_reclassify(post_id: int, request: Request):
"""Переклассификация кандидата локальной моделью (qwen)."""
_require_auth(request)
try:
impl_reclassify(post_id)
except Exception as e:
return RedirectResponse(url=f"/candidates?selected={post_id}&error=reclassify:{e}", status_code=302)
return RedirectResponse(url=f"/candidates?selected={post_id}", status_code=302)
def impl_reclassify(post_id: int):
"""Перезапуск классификации локальной моделью. Бросает исключение при неудаче."""
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:
raise ValueError("notfound")
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")
+3 -3
View File
@@ -6,14 +6,14 @@
<title>VESTI — {% block title %}Управление{% endblock %}</title>
<link href="/static/bootstrap.min.css" rel="stylesheet">
<style>
body { padding-top: 2rem; background: #f5f6fa; }
body { padding-top: 1.5rem; background: #f5f6fa; }
.card { box-shadow: 0 1px 4px rgba(0,0,0,.08); }
.post-text { white-space: pre-wrap; max-height: 140px; overflow-y: auto; font-size: .9rem; }
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark fixed-top">
<div class="container">
<div class="container-fluid">
<a class="navbar-brand" href="/candidates">📰 VESTI</a>
<div class="navbar-nav">
<a class="nav-link" href="/candidates">Кандидаты</a>
@@ -23,7 +23,7 @@
</div>
</div>
</nav>
<div class="container mt-4">
<div class="container-fluid mt-2 px-3">
{% block content %}{% endblock %}
</div>
</body>
+31 -26
View File
@@ -48,15 +48,16 @@
{% set selected_qs = '&selected=' ~ selected.id if selected else '' %}
<div class="row g-0">
<div class="col-md-5">
<div class="border rounded p-2 d-flex flex-column" style="max-height: 78vh; background:#fff;">
<div class="d-flex align-items-center mb-1">
<span class="me-2 text-muted small">Группировка:</span>
{% for val, label in [('source', 'по источнику'), ('date', 'по дате'), ('status', 'по статусу')] %}
<!-- ЛЕВАЯ ПАНЕЛЬ: список + группировка + массовые операции -->
<div class="col-md-4">
<div class="border rounded p-2 d-flex flex-column" style="max-height: 82vh; background:#fff;">
<div class="d-flex flex-wrap align-items-center mb-1 gap-1">
<span class="me-1 text-muted small">Группировка:</span>
{% for val, label in [('source', 'источник'), ('date', 'дата'), ('status', 'статус')] %}
{% if group_by == val %}
<span class="badge bg-primary text-white me-1">{{ label }}</span>
<span class="badge bg-primary text-white">{{ label }}</span>
{% else %}
<a class="badge bg-light text-dark border me-1 text-decoration-none"
<a class="badge bg-light text-dark border text-decoration-none"
href="/candidates?group_by={{ val }}{% if filters_qs %}&{{ filters_qs }}{% endif %}{{ selected_qs }}">{{ label }}</a>
{% endif %}
{% endfor %}
@@ -102,21 +103,23 @@
<input type="hidden" name="own" value="{{ own }}">
<input type="hidden" name="q" value="{{ q }}">
<input type="hidden" name="group_by" value="{{ group_by }}">
<div class="d-flex align-items-center gap-2 mt-2">
<select name="action" class="form-select form-select-sm" style="width:auto;">
<option value=""></option>
<option value="reject">🚫 Отклонить</option>
<option value="reject-old">🗑 Отклонить старьё</option>
<option value="approve">✅ Опубликовать</option>
</select>
<button class="btn btn-sm btn-primary" type="submit">Выполнить</button>
<div class="border-top pt-2 mt-1">
<div class="small text-muted mb-1">⚡ Массовые операции <span class="badge bg-secondary" id="sel-count">0</span></div>
<div class="btn-group btn-group-sm w-100 d-flex flex-wrap gap-1">
<button class="btn btn-sm btn-success flex-fill" name="action" value="approve">✅ Опубликовать</button>
<button class="btn btn-sm btn-info flex-fill text-dark" name="action" value="process">🤖 Обработать моделью</button>
<button class="btn btn-sm btn-outline-danger flex-fill" name="action" value="reject">🚫 Отклонить</button>
<button class="btn btn-sm btn-outline-secondary flex-fill" name="action" value="reject-old">🗑 Старьё</button>
</div>
<div class="form-text mt-1">Отметьте галочками слева и нажмите действие. «Обработать» — переклассификация локальной моделью (qwen).</div>
</div>
</form>
</div>
</div>
<div class="col-md-7">
<div class="border rounded p-3 bg-white" style="max-height: 78vh; overflow-y:auto;">
<!-- ПРАВАЯ ПАНЕЛЬ: детали выбранного -->
<div class="col-md-8">
<div class="border rounded p-3 bg-white" style="max-height: 82vh; overflow-y:auto;">
{% if selected %}
<div class="d-flex justify-content-between align-items-start">
<h5 class="mb-0">#{{ selected.id }} {{ selected.source_name or '?' }}</h5>
@@ -132,23 +135,25 @@
{% if selected.url %}<a class="btn btn-sm btn-outline-secondary ms-2" href="{{ selected.url }}" target="_blank">Исходник</a>{% endif %}
</div>
<div class="mb-3 d-flex gap-2">
<div class="mb-3 d-flex flex-wrap gap-2">
{% if selected.status != 'published' %}
<form method="post" action="/posts/{{ selected.id }}/approve">
<button class="btn btn-sm btn-success" type="submit">✅ Опубликовать</button>
</form>
{% endif %}
{% if selected.status != 'rejected' %}
<form method="post" action="/posts/{{ selected.id }}/reject">
<button class="btn btn-sm btn-outline-danger" type="submit">🚫 Отклонить</button>
<button class="btn btn-success" type="submit">✅ Опубликовать</button>
</form>
{% endif %}
{% if selected.status != 'published' %}
<form method="post" action="/posts/{{ selected.id }}/reclassify">
<button class="btn btn-sm btn-outline-info" type="submit">🔄 Переклассифицировать</button>
<button class="btn btn-info" type="submit">🤖 Обработать моделью</button>
</form>
{% endif %}
{% if selected.status != 'published' %}
<form method="post" action="/posts/{{ selected.id }}/rewrite">
<button class="btn btn-sm btn-outline-primary" type="submit">✍️ Переписать</button>
<button class="btn btn-outline-primary" type="submit">✍️ Переписать</button>
</form>
{% endif %}
{% if selected.status != 'rejected' and selected.status != 'published' %}
<form method="post" action="/posts/{{ selected.id }}/reject">
<button class="btn btn-outline-danger" type="submit">🚫 Отклонить</button>
</form>
{% endif %}
</div>