candidates: фикс смены группировки (422 при сабмите bulk-формы)

- Группировка вынесена из onchange-сабмита bulk-формы в GET-ссылки
  (/candidates?group_by=...&filters...&selected=...)
- Чекбоксы bulk привязаны к форме атрибутом form='bulk-form' (без JS,
  HTML5) — устранены вложенные формы (невалидный HTML)
- Выбор поста — ссылкой GET (selected=id) вместо POST /candidates/select
- app.py: добавлен filters_qs (query-string фильтров для ссылок),
  импорт urllib.parse.quote; /candidates/select оставлен для обратной
  совместимости (сабмит старых форм)
- Тест: смена группировки, bulk reject, выбор поста, фильтры — зелёные
This commit is contained in:
kpa39l
2026-09-13 20:57:48 +00:00
parent fc6d725bd1
commit 60617de0cb
2 changed files with 133 additions and 120 deletions
+10 -1
View File
@@ -8,6 +8,7 @@ import secrets
import sqlite3 import sqlite3
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from urllib.parse import quote
import markdown as md_lib import markdown as md_lib
from fastapi import FastAPI, Form, HTTPException, Request, Response from fastapi import FastAPI, Form, HTTPException, Request, Response
@@ -250,9 +251,17 @@ def candidates(request: Request, direction: str = "", status: str = "", own: str
continue continue
break break
conn.close() conn.close()
# строка параметров фильтров для ссылок (выбор поста, смена группировки)
qs = []
for k, v in (("direction", direction), ("status", status), ("own", own), ("q", q)):
if v:
qs.append(f"{k}={quote(v)}")
if not selected:
selected = sel["id"] if sel else 0
filters_qs = "&".join(qs)
html = tpl.get_template("candidates.html").render( html = tpl.get_template("candidates.html").render(
posts=grouped, counts=counts, status=status, direction=direction, own=own, posts=grouped, counts=counts, status=status, direction=direction, own=own,
q=q, group_by=group_by, selected=sel, error=error, q=q, group_by=group_by, selected=sel, error=error, filters_qs=filters_qs,
directions=DIRECTIONS, STATUS_LABELS=STATUS_LABELS, STATUS_COLORS=STATUS_COLORS, directions=DIRECTIONS, STATUS_LABELS=STATUS_LABELS, STATUS_COLORS=STATUS_COLORS,
) )
return HTMLResponse(html) return HTMLResponse(html)
+123 -119
View File
@@ -45,30 +45,24 @@
</div> </div>
</form> </form>
<form method="post" action="/candidates/bulk" id="bulk-form" {% set selected_qs = '&selected=' ~ selected.id if selected else '' %}
onsubmit="return confirm('Выполнить групповое действие над выбранными?')">
<input type="hidden" name="direction" value="{{ direction }}">
<input type="hidden" name="status" value="{{ status }}">
<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="row g-0"> <div class="row g-0">
<div class="col-md-5"> <div class="col-md-5">
<div class="border rounded p-2" style="max-height: 78vh; overflow-y:auto; background:#fff;"> <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"> <div class="d-flex align-items-center mb-1">
<span class="me-2 text-muted small">Группировка:</span> <span class="me-2 text-muted small">Группировка:</span>
<select name="group_by" class="form-select form-select-sm d-inline" style="width:auto;" {% for val, label in [('source', 'по источнику'), ('date', 'по дате'), ('status', 'по статусу')] %}
onchange="this.form.submit()"> {% if group_by == val %}
{% for val in ['source', 'date', 'status'] %} <span class="badge bg-primary text-white me-1">{{ label }}</span>
<option value="{{ val }}" {% if group_by == val %}selected{% endif %}> {% else %}
{% if val == 'source' %}по источнику{% elif val == 'date' %}по дате{% else %}по статусу{% endif %} <a class="badge bg-light text-dark border me-1 text-decoration-none"
</option> href="/candidates?group_by={{ val }}{% if filters_qs %}&{{ filters_qs }}{% endif %}{{ selected_qs }}">{{ label }}</a>
{% endfor %} {% endif %}
</select> {% endfor %}
<button class="btn btn-sm btn-outline-secondary ms-2" type="submit">➡️</button> </div>
</div>
<div style="overflow-y:auto; flex:1 1 auto;">
{% for g in posts %} {% for g in posts %}
<details {% if loop.first or (selected and selected.id in g.posts|map(attribute='id')|list) %}open{% endif %} class="mb-2"> <details {% if loop.first or (selected and selected.id in g.posts|map(attribute='id')|list) %}open{% endif %} class="mb-2">
<summary class="small text-secondary"> <summary class="small text-secondary">
@@ -81,25 +75,17 @@
<div class="list-group list-group-flush small"> <div class="list-group list-group-flush small">
{% for p in g.posts %} {% for p in g.posts %}
<div class="list-group-item d-flex align-items-start"> <div class="list-group-item d-flex align-items-start">
<input class="form-check-input me-2" type="checkbox" name="ids" value="{{ p.id }}" <a class="flex-grow-1 text-decoration-none{% if selected and selected.id == p.id %} fw-bold text-primary{% endif %}"
id="cb-{{ p.id }}" style="vertical-align:middle;"> href="/candidates?selected={{ p.id }}&group_by={{ group_by }}{% if filters_qs %}&{{ filters_qs }}{% endif %}">
<form class="d-inline flex-grow-1" method="post" action="/candidates/select"> <span class="d-block text-truncate">{{ (p.text or '')[:90] }}</span>
<input type="hidden" name="id" value="{{ p.id }}"> <span class="d-block text-muted">
<input type="hidden" name="group_by" value="{{ group_by }}"> {{ p.source_name }} · {{ (p.published_at or p.fetched_at) | dt }}
<input type="hidden" name="direction" value="{{ direction }}"> {% if p.is_own %} · ⭐{% endif %}
<input type="hidden" name="status" value="{{ status }}"> {% if p.direction %}<span class="badge bg-light">{{ p.direction }}</span>{% endif %}
<input type="hidden" name="own" value="{{ own }}"> </span>
<input type="hidden" name="q" value="{{ q }}"> </a>
<button type="submit" class="btn btn-link p-0 text-start w-100 text-decoration-none <input class="form-check-input ms-auto mt-1" type="checkbox" name="ids" value="{{ p.id }}"
{% if selected and selected.id == p.id %}fw-bold text-primary{% endif %}"> form="bulk-form" {% if selected and selected.id == p.id %}checked{% endif %}>
<span class="d-block text-truncate">{{ (p.text or '')[:90] }}</span>
<span class="d-block text-muted">
{{ p.source_name }} · {{ (p.published_at or p.fetched_at) | dt }}
{% if p.is_own %} · ⭐{% endif %}
{% if p.direction %}<span class="badge bg-light">{{ p.direction }}</span>{% endif %}
</span>
</button>
</form>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
@@ -108,86 +94,104 @@
<div class="alert alert-info py-2">Нет кандидатов по фильтру</div> <div class="alert alert-info py-2">Нет кандидатов по фильтру</div>
{% endfor %} {% endfor %}
</div> </div>
</div>
<div class="col-md-7"> <form method="post" action="/candidates/bulk" id="bulk-form"
<div class="border rounded p-3 bg-white" style="max-height: 78vh; overflow-y:auto;"> onsubmit="return confirm('Выполнить групповое действие над выбранными?')">
{% if selected %} <input type="hidden" name="direction" value="{{ direction }}">
<div class="d-flex justify-content-between align-items-start"> <input type="hidden" name="status" value="{{ status }}">
<h5 class="mb-0">#{{ selected.id }} {{ selected.source_name or '?' }}</h5> <input type="hidden" name="own" value="{{ own }}">
<small class="text-muted">{{ (selected.published_at or selected.fetched_at) | dt }}</small> <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> </div>
<div class="mb-2"> </form>
{% 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>
<div class="mb-3 d-flex 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>
</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>
</form>
<form method="post" action="/posts/{{ selected.id }}/rewrite">
<button class="btn btn-sm btn-outline-primary" type="submit">✍️ Переписать</button>
</form>
{% endif %}
</div>
<div class="mb-3">
<form method="post" action="/posts/{{ selected.id }}/comment">
<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>
</div>
{% endif %}
</div>
<hr>
<h6 class="text-muted">Оригинальный пост</h6>
{% if selected.text %}
<div class="post-text mt-1">{{ selected.text | markdown }}</div>
{% endif %}
{% if selected.media_path %}
{% set media_src = '/media/' ~ selected.media_path.split('/')[-1] %}
{% set media_ext = selected.media_path.lower().rsplit('.', 1)[-1] if '.' in selected.media_path else '' %}
{% if media_ext in ['jpg','jpeg','png','gif','webp'] %}
<a href="{{ media_src }}" target="_blank" title="Открыть полноразмер">
<img src="{{ media_src }}" class="img-fluid rounded mt-2 d-block" style="max-height:320px" alt="медиа поста">
</a>
{% elif media_ext in ['mp4','webm','mov'] %}
<video src="{{ media_src }}" controls class="mt-2 d-block" style="max-height:320px"></video>
{% else %}
<a class="btn btn-sm btn-outline-secondary mt-2" href="{{ media_src }}" target="_blank">📎 Медиафайл</a>
{% endif %}
{% endif %}
{% if selected.summary %}<div class="mt-2"><span class="badge bg-light text-dark">резюме</span> {{ selected.summary }}</div>{% endif %}
{% else %}
<div class="alert alert-info py-3">Выберите кандидата слева</div>
{% endif %}
</div>
</div> </div>
</div> </div>
</form>
<div class="col-md-7">
<div class="border rounded p-3 bg-white" style="max-height: 78vh; 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>
<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>
<div class="mb-3 d-flex 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>
</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>
</form>
<form method="post" action="/posts/{{ selected.id }}/rewrite">
<button class="btn btn-sm btn-outline-primary" type="submit">✍️ Переписать</button>
</form>
{% endif %}
</div>
<div class="mb-3">
<form method="post" action="/posts/{{ selected.id }}/comment">
<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>
</div>
{% endif %}
</div>
<hr>
<h6 class="text-muted">Оригинальный пост</h6>
{% if selected.text %}
<div class="post-text mt-1">{{ selected.text | markdown }}</div>
{% endif %}
{% if selected.media_path %}
{% set media_src = '/media/' ~ selected.media_path.split('/')[-1] %}
{% set media_ext = selected.media_path.lower().rsplit('.', 1)[-1] if '.' in selected.media_path else '' %}
{% if media_ext in ['jpg','jpeg','png','gif','webp'] %}
<a href="{{ media_src }}" target="_blank" title="Открыть полноразмер">
<img src="{{ media_src }}" class="img-fluid rounded mt-2 d-block" style="max-height:320px" alt="медиа поста">
</a>
{% elif media_ext in ['mp4','webm','mov'] %}
<video src="{{ media_src }}" controls class="mt-2 d-block" style="max-height:320px"></video>
{% else %}
<a class="btn btn-sm btn-outline-secondary mt-2" href="{{ media_src }}" target="_blank">📎 Медиафайл</a>
{% endif %}
{% endif %}
{% if selected.summary %}<div class="mt-2"><span class="badge bg-light text-dark">резюме</span> {{ selected.summary }}</div>{% endif %}
{% else %}
<div class="alert alert-info py-3">Выберите кандидата слева</div>
{% endif %}
</div>
</div>
</div>
{% endblock %} {% endblock %}