mirror of
https://gitverse.ru/kpa39l/vesti.git
synced 2026-09-29 09:55:03 +00:00
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:
+34
-12
@@ -280,13 +280,17 @@ def candidates_select(request: Request, id: int = Form(...), direction: str = Fo
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/candidates/bulk")
|
@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(""),
|
direction: str = Form(""), status: str = Form(""), own: str = Form(""),
|
||||||
q: str = Form(""), group_by: str = Form("source")):
|
q: str = Form(""), group_by: str = Form("source")):
|
||||||
"""Bulk-операции: approve/reject/reject-old по отмеченным ids."""
|
"""Bulk-операции: approve/reject/reject-old/process по отмеченным ids."""
|
||||||
_require_auth(request)
|
_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:
|
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()
|
conn = _db()
|
||||||
ph = ",".join("?" * len(ids))
|
ph = ",".join("?" * len(ids))
|
||||||
rows = conn.execute(f"SELECT * FROM posts WHERE id IN ({ph})", ids).fetchall()
|
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.execute(f"UPDATE posts SET status='rejected' WHERE id IN ({ph})", ids)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
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":
|
if action == "reject-old":
|
||||||
# все выбранные, кроме самых свежих (по 1 от источника) — «почистить старьё»
|
# все выбранные, кроме самых свежих (по 1 от источника) — «почистить старьё»
|
||||||
keep = {}
|
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.execute(f"UPDATE posts SET status='rejected' WHERE id IN ({ph2})", kill)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
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)
|
||||||
# approve — каждый через существующую логику (публикация + бандл)
|
|
||||||
conn.close()
|
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 = []
|
errs = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
if r["status"] == "published":
|
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")
|
@app.post("/posts/{post_id}/reclassify")
|
||||||
def post_reclassify(post_id: int, request: Request):
|
def post_reclassify(post_id: int, request: Request):
|
||||||
|
"""Переклассификация кандидата локальной моделью (qwen)."""
|
||||||
_require_auth(request)
|
_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
|
from classifier.classify import classify_text
|
||||||
conn = _db()
|
conn = _db()
|
||||||
p = conn.execute("SELECT * FROM posts WHERE id=?", (post_id,)).fetchone()
|
p = conn.execute("SELECT * FROM posts WHERE id=?", (post_id,)).fetchone()
|
||||||
if not p:
|
if not p:
|
||||||
conn.close()
|
conn.close()
|
||||||
return RedirectResponse(url="/candidates?error=notfound", status_code=302)
|
raise ValueError("notfound")
|
||||||
try:
|
|
||||||
res = classify_text(dict(p))
|
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(
|
conn.execute(
|
||||||
"UPDATE posts SET direction=?, relevance=?, interest=?, summary=?, classified=? WHERE id=?",
|
"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),
|
(res["direction"], res["relevance"], res["interest"], res["summary"], 1 if res["classified"] else 0, post_id),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
return RedirectResponse(url=f"/candidates?selected={post_id}", status_code=302)
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/posts/{post_id}/rewrite")
|
@app.post("/posts/{post_id}/rewrite")
|
||||||
|
|||||||
@@ -6,14 +6,14 @@
|
|||||||
<title>VESTI — {% block title %}Управление{% endblock %}</title>
|
<title>VESTI — {% block title %}Управление{% endblock %}</title>
|
||||||
<link href="/static/bootstrap.min.css" rel="stylesheet">
|
<link href="/static/bootstrap.min.css" rel="stylesheet">
|
||||||
<style>
|
<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); }
|
.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; }
|
.post-text { white-space: pre-wrap; max-height: 140px; overflow-y: auto; font-size: .9rem; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark fixed-top">
|
<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>
|
<a class="navbar-brand" href="/candidates">📰 VESTI</a>
|
||||||
<div class="navbar-nav">
|
<div class="navbar-nav">
|
||||||
<a class="nav-link" href="/candidates">Кандидаты</a>
|
<a class="nav-link" href="/candidates">Кандидаты</a>
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="container mt-4">
|
<div class="container-fluid mt-2 px-3">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -48,15 +48,16 @@
|
|||||||
{% set selected_qs = '&selected=' ~ selected.id if selected else '' %}
|
{% set selected_qs = '&selected=' ~ selected.id if selected else '' %}
|
||||||
|
|
||||||
<div class="row g-0">
|
<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="col-md-4">
|
||||||
<div class="d-flex align-items-center mb-1">
|
<div class="border rounded p-2 d-flex flex-column" style="max-height: 82vh; background:#fff;">
|
||||||
<span class="me-2 text-muted small">Группировка:</span>
|
<div class="d-flex flex-wrap align-items-center mb-1 gap-1">
|
||||||
{% for val, label in [('source', 'по источнику'), ('date', 'по дате'), ('status', 'по статусу')] %}
|
<span class="me-1 text-muted small">Группировка:</span>
|
||||||
|
{% for val, label in [('source', 'источник'), ('date', 'дата'), ('status', 'статус')] %}
|
||||||
{% if group_by == val %}
|
{% 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 %}
|
{% 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>
|
href="/candidates?group_by={{ val }}{% if filters_qs %}&{{ filters_qs }}{% endif %}{{ selected_qs }}">{{ label }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -102,21 +103,23 @@
|
|||||||
<input type="hidden" name="own" value="{{ own }}">
|
<input type="hidden" name="own" value="{{ own }}">
|
||||||
<input type="hidden" name="q" value="{{ q }}">
|
<input type="hidden" name="q" value="{{ q }}">
|
||||||
<input type="hidden" name="group_by" value="{{ group_by }}">
|
<input type="hidden" name="group_by" value="{{ group_by }}">
|
||||||
<div class="d-flex align-items-center gap-2 mt-2">
|
<div class="border-top pt-2 mt-1">
|
||||||
<select name="action" class="form-select form-select-sm" style="width:auto;">
|
<div class="small text-muted mb-1">⚡ Массовые операции <span class="badge bg-secondary" id="sel-count">0</span></div>
|
||||||
<option value=""></option>
|
<div class="btn-group btn-group-sm w-100 d-flex flex-wrap gap-1">
|
||||||
<option value="reject">🚫 Отклонить</option>
|
<button class="btn btn-sm btn-success flex-fill" name="action" value="approve">✅ Опубликовать</button>
|
||||||
<option value="reject-old">🗑 Отклонить старьё</option>
|
<button class="btn btn-sm btn-info flex-fill text-dark" name="action" value="process">🤖 Обработать моделью</button>
|
||||||
<option value="approve">✅ Опубликовать</option>
|
<button class="btn btn-sm btn-outline-danger flex-fill" name="action" value="reject">🚫 Отклонить</button>
|
||||||
</select>
|
<button class="btn btn-sm btn-outline-secondary flex-fill" name="action" value="reject-old">🗑 Старьё</button>
|
||||||
<button class="btn btn-sm btn-primary" type="submit">Выполнить</button>
|
</div>
|
||||||
|
<div class="form-text mt-1">Отметьте галочками слева и нажмите действие. «Обработать» — переклассификация локальной моделью (qwen).</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</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 %}
|
{% if selected %}
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
<h5 class="mb-0">#{{ selected.id }} {{ selected.source_name or '?' }}</h5>
|
<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 %}
|
{% if selected.url %}<a class="btn btn-sm btn-outline-secondary ms-2" href="{{ selected.url }}" target="_blank">Исходник</a>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3 d-flex gap-2">
|
<div class="mb-3 d-flex flex-wrap gap-2">
|
||||||
{% if selected.status != 'published' %}
|
{% if selected.status != 'published' %}
|
||||||
<form method="post" action="/posts/{{ selected.id }}/approve">
|
<form method="post" action="/posts/{{ selected.id }}/approve">
|
||||||
<button class="btn btn-sm btn-success" type="submit">✅ Опубликовать</button>
|
<button class="btn 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>
|
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if selected.status != 'published' %}
|
{% if selected.status != 'published' %}
|
||||||
<form method="post" action="/posts/{{ selected.id }}/reclassify">
|
<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>
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if selected.status != 'published' %}
|
||||||
<form method="post" action="/posts/{{ selected.id }}/rewrite">
|
<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>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user