mirror of
https://gitverse.ru/kpa39l/vesti.git
synced 2026-09-29 09:55:03 +00:00
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:
@@ -125,10 +125,10 @@ CLASSIFY_TIMEOUT=20 .venv/bin/python -m classifier.classify --db db/vesti.db --l
|
||||
- [x] **git-репозиторий** /opt/vesti: origin gitverse.ru `kpa39l/vesti` (main, private), зеркало gitea bigbox `estorozhenko/vesti` (mirror 8h). .gitignore: backups/ media/ db/ bundles/ logs/ секреты.
|
||||
- [x] **Тест публикации с медиа сквозь веб**: сообщения ушли в @dedinit_vesti (11/12) и удалены. Обнаружен и исправлен фейковый `dry_run` в publisher (поле уехало в PublishResponse вместо PublishRequest; собыл 500). Change `publisher-dry-run-fix` архивирован: `dry_run=true` → эмуляция без TG.
|
||||
- [x] **OpenSpec changes архивированы** (8): fix-approve-dirn, web-media-preview, web-format-datetime, web-list-filter-no-js, deexternalize-web-assets, web-render-markdown, publisher-dry-run-fix, unify-directions.
|
||||
- [x] **Новый change `candidates-mail-ui`** (двухпанельный интерфейс кандидатов, готовится; proposal/design/tasks/spec — валиден).
|
||||
- [x] **Реализован change `candidates-mail-ui`** (двухпанельный интерфейс кандидатов как почтовый клиент): левая панель — список с группировкой (source/date/status, свертываемые <details>) + чекбоксы bulk (approve/reject/reject-old), правая — детали поста (меню действий, комментарий, черновик пересказа, оригинал с медиа). Роуты: POST /candidates/select, /candidates/bulk, /posts/{id}/comment, /posts/{id}/reclassify (qwen), /posts/{id}/rewrite (qwen). БД: добавлены `comment TEXT`, `rewritten_text TEXT`. Без JS (только данные формы). Протестировано TestClient (группировка, bulk-reject, comment, reclassify, rewrite, поиск) — зелёные. Change архивирован (2026-09-13).
|
||||
|
||||
## Текущие задачи (из TODO, открытые)
|
||||
- Интерфейс кандидатов как почтовый клиент (2 панели, группировка, bulk) — change candidates-mail-ui (проработать с пользователем)
|
||||
- Интерфейс кандидатов как почтовый клиент (2 панели, группировка, bulk) — ✅ реализован в change candidates-mail-ui (см. выше).
|
||||
- Ручное указание/изменение направления (значок «?» у неклассифицированных)
|
||||
- Переписывание новостей от себя (LLM-пересказ, персональный контент)
|
||||
- Извлечение источников из чужих постов + сервис поиска источников в интернете (дообогащение ТОЛЬКО одобренных)
|
||||
|
||||
+1
-1
@@ -434,7 +434,7 @@ sudo systemctl restart vesti-web # active; login → 200, candidates/published
|
||||
- Change **candidates-mail-ui** (готовится): 2 панели (список слева + детально справа), группировка по source/date/status, bulk approve/reject, comment, reclassify, rewrite, медиа. proposal/design/tasks/spec — валидны.
|
||||
|
||||
### Следующая сессия
|
||||
1. Проработать детали **candidates-mail-ui** с пользователем (открытые вопросы: комментарий — поле/таблица?, переписывание — qwen черновик?, переклассификация — перезапуск?, группировка — одна/несколько) → реализовать.
|
||||
1. ✅ **candidates-mail-ui реализован** (2026-09-13): двухпанельный интерфейс (список слева с группировкой source/date/status + чекбоксы bulk, детали справа с действиями: approve/reject/reclassify/rewrite/comment), комментарий — поле `posts.comment`, переписывание — qwen пишет черновик в `posts.rewritten_text`, переклассификация — перезапуск classify с qwen. Без JS. Архив: 2026-09-13-candidates-mail-ui.
|
||||
2. Ручное указание направления (для 53 без direction + «?» на карточке).
|
||||
3. Переписывание новостей от себя (LLM-пересказ) + пайплайн «выбранные → дообогащение → публикация».
|
||||
4. Извлечение источников из постов + сервис поиска в интернете (только одобренные).
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# tasks.md — candidates-mail-ui
|
||||
|
||||
## Proposal
|
||||
- [x] proposal.md — двухпанельный интерфейс кандидатов (почтовый клиент), группировка, bulk
|
||||
- [x] design.md — архитектура (роуты, БД, шаблоны, минимум JS)
|
||||
|
||||
## Implement
|
||||
- [x] Проверить схему posts (status, поля) — уточнить, какие статусы есть (new/rejected/published)
|
||||
- [x] Миграция: `ALTER TABLE posts ADD COLUMN comment TEXT` + `rewritten_text TEXT` (сделано в БД)
|
||||
- [x] GET /candidates — двухпанельный рендер (левая колонка список+группировка, правая — детально)
|
||||
- [x] Левая панель: группировка по source/date/status (сворачиваемые <details>), чекбоксы
|
||||
- [x] POST /candidates/bulk — approve/reject/reject-old по ids[] (транзакция)
|
||||
- [x] POST /posts/{id}/comment — сохранить комментарий (comment TEXT)
|
||||
- [x] POST /posts/{id}/reclassify — перезапуск классификатора на посте (реальный qwen, протестирован)
|
||||
- [x] POST /posts/{id}/rewrite — LLM-пересказ (qwen), возврат черновика для редактирования (реальный qwen, протестирован)
|
||||
- [x] Правая панель: меню действий, блок комментария, оригинальный пост со всеми медиа
|
||||
- [x] Шаблоны: candidates.html (двухколоночная), формы новых действий
|
||||
- [x] Тест: группировка, bulk-отклонение, коммент, реклассификация, rewrite, поиск (TestClient, все зелёные)
|
||||
- [x] `openspec validate candidates-mail-ui` — чисто
|
||||
- [x] Обновить STATUS.md / WALKTHROUGH.md
|
||||
@@ -1,20 +0,0 @@
|
||||
# tasks.md — candidates-mail-ui
|
||||
|
||||
## Proposal
|
||||
- [x] proposal.md — двухпанельный интерфейс кандидатов (почтовый клиент), группировка, bulk
|
||||
- [x] design.md — архитектура (роуты, БД, шаблоны, минимум JS)
|
||||
|
||||
## Implement
|
||||
- [ ] Проверить схему posts (status, поля) — уточнить, какие статусы есть
|
||||
- [ ] Миграция: `ALTER TABLE posts ADD COLUMN comment TEXT` (или таблица comments)
|
||||
- [ ] GET /candidates — двухпанельный рендер (левая колонка список+группировка, правая — детально)
|
||||
- [ ] Левая панель: группировка по source/date/status (сворачиваемые <details>), чекбоксы
|
||||
- [ ] POST /candidates/bulk — approve/reject по ids[] (транзакция)
|
||||
- [ ] POST /posts/{id}/comment — сохранить комментарий (comment TEXT)
|
||||
- [ ] POST /posts/{id}/reclassify — перезапуск классификатора на посте
|
||||
- [ ] POST /posts/{id}/rewrite — LLM-пересказ (qwen), возврат черновика для редактирования
|
||||
- [ ] Правая панель: меню действий, блок комментария, оригинальный пост со всеми медиа
|
||||
- [ ] Шаблоны: candidates.html (двухколоночная), формы новых действий
|
||||
- [ ] Тест: создание 2-3 тестовых постов, группировка, bulk-отклонение, коммент, реклассификация
|
||||
- [ ] `openspec validate candidates-mail-ui` — чисто
|
||||
- [ ] Обновить STATUS.md / WALKTHROUGH.md
|
||||
@@ -0,0 +1,111 @@
|
||||
# candidates-mail-ui Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change candidates-mail-ui. Update Purpose after archive.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Двухпанельный интерфейс кандидатов
|
||||
|
||||
Страница кандидатов разделена на две панели: слева список кандидатов (с группировкой),
|
||||
справа — детальный просмотр выбранного кандидата с меню действий.
|
||||
|
||||
#### Scenario: Пользователь открывает список кандидатов
|
||||
|
||||
Given на странице `GET /candidates`
|
||||
When страница загружается
|
||||
Then левая панель показывает список кандидатов, правая — детали первого/последнего выбранного
|
||||
And активный кандидат подсвечивается
|
||||
|
||||
#### Scenario: Группировка списка по источнику
|
||||
|
||||
Given список кандидатов
|
||||
When пользователь выбирает группировку «по источнику»
|
||||
Then кандидаты сгруппированы по источнику (канал/сайт)
|
||||
And каждая группа сворачивается/разворачивается (без JS, через `<details>`)
|
||||
|
||||
#### Scenario: Группировка по дате поступления
|
||||
|
||||
When пользователь выбирает группировку «по дате поступления»
|
||||
Then кандидаты сгруппированы по дате поступления (сегодня, вчера, ранее)
|
||||
And порядок: новые сверху, старые ниже
|
||||
|
||||
### Requirement: Bulk-операции над выбранными кандидатами
|
||||
|
||||
Пользователь может выбрать несколько кандидатов (чекбоксы) и выполнить массовое
|
||||
действие: одобрить все выбранные / отклонить все выбранные.
|
||||
|
||||
#### Scenario: Массовое отклонение выбранных
|
||||
|
||||
Given на левой панели несколько кандидатов с отмеченными чекбоксами
|
||||
When пользователь нажимает «Отклонить выбранные»
|
||||
Then все отмеченные кандидаты получают статус «отклонён»
|
||||
And действие выполняется транзакционно (все или ни одного)
|
||||
|
||||
#### Scenario: Массовое одобрение выбранных
|
||||
|
||||
When пользователь нажимает «Одобрить выбранные»
|
||||
Then каждый отмеченный кандидат публикуется (вызывается approve-логика)
|
||||
|
||||
#### Scenario: Отклонение всех старых постов группы
|
||||
|
||||
Given группа «ранее» (старые посты)
|
||||
When пользователь выбирает «отклонить все в группе»
|
||||
Then все посты группы получают статус «отклонён»
|
||||
|
||||
### Requirement: Действия над конкретным кандидатом
|
||||
|
||||
Правая панель содержит меню действий: Опубликовать, Отклонить, Переклассифицировать,
|
||||
Переписать новость под себя, блок «Мой комментарий», оригинальный пост со всеми медиа.
|
||||
|
||||
#### Scenario: Опубликовать кандидата
|
||||
|
||||
Given выбран кандидат в правой панели
|
||||
When пользователь нажимает «Опубликовать»
|
||||
Then пост публикуется (существующая логика approve, publisher)
|
||||
|
||||
#### Scenario: Отклонить кандидата
|
||||
|
||||
When пользователь нажимает «Отклонить»
|
||||
Then пост получает статус «отклонён» и исчезает из активных кандидатов
|
||||
|
||||
#### Scenario: Переклассифицировать кандидата
|
||||
|
||||
When пользователь нажимает «Переклассифицировать»
|
||||
Then запускается классификатор для данного поста
|
||||
And направление/relevance/interest обновляются, изменения видны на карточке
|
||||
|
||||
#### Scenario: Переписать новость под себя
|
||||
|
||||
When пользователь нажимает «Переписать новость»
|
||||
Then LLM генерирует черновик пересказа от первого лица (без копипасты)
|
||||
And черновик отображается в текстовом поле для редактирования
|
||||
And пользователь может отредактировать и затем опубликовать
|
||||
|
||||
#### Scenario: Комментарий к кандидату
|
||||
|
||||
Given в правой панели блок «Мой комментарий»
|
||||
When пользователь вводит текст и сохраняет
|
||||
Then комментарий сохраняется и отображается при повторном открытии кандидата
|
||||
|
||||
#### Scenario: Оригинальный пост со всеми медиа
|
||||
|
||||
Given выбран кандидат с медиа
|
||||
Then правая панель показывает оригинальный текст поста, все изображения/видео
|
||||
(через роут /media/), мета источника (канал, время, views/reactions)
|
||||
|
||||
### Requirement: Фильтрация и поиск по списку
|
||||
|
||||
Список кандидатов можно фильтровать по статусу (новые/одобренные/отклонённые)
|
||||
и искать по тексту.
|
||||
|
||||
#### Scenario: Фильтр по статусу
|
||||
|
||||
Given на странице кандидатов
|
||||
When пользователь выбирает фильтр «новые»
|
||||
Then в списке остаются только новые кандидаты
|
||||
|
||||
#### Scenario: Поиск по тексту
|
||||
|
||||
When пользователь вводит текст в поиск
|
||||
Then список фильтруется по вхождению в текст поста
|
||||
+303
-68
@@ -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)
|
||||
|
||||
|
||||
|
||||
+164
-68
@@ -1,13 +1,32 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Кандидаты{% endblock %}
|
||||
{% block content %}
|
||||
<h4 class="mb-3">Кандидаты</h4>
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h4 class="mb-0">Кандидаты</h4>
|
||||
<div class="text-muted small">
|
||||
{% if selected %}
|
||||
#{{ selected.id }} · {{ selected.source_name or selected.tg_channel or '?' }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if error %}<div class="alert alert-danger py-2">❌ {{ error }}</div>{% endif %}
|
||||
{% if error %}
|
||||
<div class="alert alert-danger py-2">❌ {{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form class="row g-2 mb-3" method="get" action="/candidates">
|
||||
<form class="row g-2 mb-2" method="get" action="/candidates">
|
||||
<div class="col-auto">
|
||||
<select name="direction" class="form-select" onchange="this.form.submit()">
|
||||
<input type="text" name="q" value="{{ q }}" class="form-control form-control-sm" placeholder="поиск…">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<select name="status" class="form-select form-select-sm">
|
||||
{% for val, label in [('', 'Все'), ('new', '💎 Новые'), ('rejected', '🗑 Отклонённые'), ('published', '✅ Опубл.')] %}
|
||||
<option value="{{ val }}" {% if status == val %}selected{% endif %}>{{ label }}{% if counts.get(val) %} ({{ counts[val] }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<select name="direction" class="form-select form-select-sm">
|
||||
<option value="">Все направления</option>
|
||||
{% for d in directions %}
|
||||
<option value="{{ d }}" {% if direction == d %}selected{% endif %}>{{ d }}</option>
|
||||
@@ -15,17 +34,10 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<select name="status" class="form-select" onchange="this.form.submit()">
|
||||
<option value="new" {% if status=='new' %}selected{% endif %}>Черновики (new)</option>
|
||||
<option value="rejected" {% if status=='rejected' %}selected{% endif %}>Отклонённые</option>
|
||||
<option value="published" {% if status=='published' %}selected{% endif %}>Опубликованные</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<select name="own" class="form-select" onchange="this.form.submit()">
|
||||
<option value="" {% if own=='' %}selected{% endif %}>Все посты</option>
|
||||
<option value="1" {% if own=='1' %}selected{% endif %}>⭐ Только свои</option>
|
||||
<option value="0" {% if own=='0' %}selected{% endif %}>Только внешние</option>
|
||||
<select name="own" class="form-select form-select-sm">
|
||||
<option value="">Все посты</option>
|
||||
<option value="1" {% if own == '1' %}selected{% endif %}>⭐ Свои</option>
|
||||
<option value="0" {% if own == '0' %}selected{% endif %}>Внешние</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
@@ -33,65 +45,149 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="post-list">
|
||||
{% for p in posts %}
|
||||
<div class="card mb-3" id="post-{{ p.id }}">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span class="badge bg-secondary">{{ p.direction or '?' }}</span>
|
||||
<small class="text-muted">{{ p.source_name }} · {{ p.published_at | dt }}</small>
|
||||
</div>
|
||||
<div class="post-text mt-2">{{ (p.text or '')[:2000] | markdown }}</div>
|
||||
{% if p.is_own %}<span class="badge bg-warning text-dark">⭐ СВОЙ</span>{% endif %}
|
||||
{% if p.relevance %}<span class="badge bg-{{ 'success' if p.relevance=='critical' else 'info' }}">{{ p.relevance }}</span>{% endif %}
|
||||
{% if p.interest %}<span class="badge bg-warning text-dark">interest {{ p.interest }}</span>{% endif %}
|
||||
<small class="text-muted ms-2">👁 {{ p.views }} 💬 {{ p.reactions_total }}</small>
|
||||
{% if p.media_path %}
|
||||
<span class="badge bg-light text-dark ms-2">🖼 медиа</span>
|
||||
{% set media_src = '/media/' ~ p.media_path.split('/')[-1] %}
|
||||
{% set media_ext = p.media_path.lower().rsplit('.', 1)[-1] if '.' in p.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:180px" alt="медиа поста">
|
||||
</a>
|
||||
{% elif media_ext in ['mp4','webm','mov'] %}
|
||||
<video src="{{ media_src }}" controls class="mt-2 d-block" style="max-height:180px"></video>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if p.fwd_from_channel_id %}<span class="badge bg-light text-dark ms-2">↩︎ форвард</span>{% endif %}
|
||||
<div class="mt-2">
|
||||
{% if p.status == 'new' %}
|
||||
<form class="d-inline" method="post" action="/posts/{{ p.id }}/approve">
|
||||
<div class="mb-2">
|
||||
<textarea class="form-control form-control-sm" name="comment" rows="2"
|
||||
placeholder="Комментарий к посту (по желанию) — будет первым в канале"></textarea>
|
||||
</div>
|
||||
{% if p.is_own %}
|
||||
<div class="mb-2">
|
||||
<small class="text-muted">Рассылка (fan-out):</small>
|
||||
{% for d in directions %}
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" name="dirs" value="{{ d }}" id="dir-{{ p.id }}-{{ d }}"
|
||||
{% if d == (p.direction or 'linux') %}checked{% endif %}>
|
||||
<label class="form-check-label" for="dir-{{ p.id }}-{{ d }}">{{ d }}</label>
|
||||
<form method="post" action="/candidates/bulk" id="bulk-form"
|
||||
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="col-md-5">
|
||||
<div class="border rounded p-2" style="max-height: 78vh; overflow-y:auto; background:#fff;">
|
||||
<div class="d-flex align-items-center mb-1">
|
||||
<span class="me-2 text-muted small">Группировка:</span>
|
||||
<select name="group_by" class="form-select form-select-sm d-inline" style="width:auto;"
|
||||
onchange="this.form.submit()">
|
||||
{% for val in ['source', 'date', 'status'] %}
|
||||
<option value="{{ val }}" {% if group_by == val %}selected{% endif %}>
|
||||
{% if val == 'source' %}по источнику{% elif val == 'date' %}по дате{% else %}по статусу{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="btn btn-sm btn-outline-secondary ms-2" type="submit">➡️</button>
|
||||
</div>
|
||||
|
||||
{% 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">
|
||||
<summary class="small text-secondary">
|
||||
<span class="badge bg-light text-dark border">{{ g.key }}</span>
|
||||
<span class="text-muted">— {{ g.posts|length }}</span>
|
||||
{% if g.status == 'new' %}<span class="badge bg-success">новые</span>
|
||||
{% elif g.status == 'rejected' %}<span class="badge bg-danger">откл.</span>
|
||||
{% endif %}
|
||||
</summary>
|
||||
<div class="list-group list-group-flush small">
|
||||
{% for p in g.posts %}
|
||||
<div class="list-group-item d-flex align-items-start">
|
||||
<input class="form-check-input me-2" type="checkbox" name="ids" value="{{ p.id }}"
|
||||
id="cb-{{ p.id }}" style="vertical-align:middle;">
|
||||
<form class="d-inline flex-grow-1" method="post" action="/candidates/select">
|
||||
<input type="hidden" name="id" value="{{ p.id }}">
|
||||
<input type="hidden" name="group_by" value="{{ group_by }}">
|
||||
<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 }}">
|
||||
<button type="submit" class="btn btn-link p-0 text-start w-100 text-decoration-none
|
||||
{% if selected and selected.id == p.id %}fw-bold text-primary{% 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>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% else %}
|
||||
<div class="alert alert-info py-2">Нет кандидатов по фильтру</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 %}
|
||||
<button class="btn btn-sm btn-success" type="submit">✅ Опубликовать</button>
|
||||
</form>
|
||||
<form class="d-inline" method="post" action="/posts/{{ p.id }}/reject">
|
||||
<button class="btn btn-sm btn-outline-danger" type="submit">🚫 Отклонить</button>
|
||||
</form>
|
||||
{% elif p.status == 'rejected' %}
|
||||
<span class="text-danger">Отклонён</span>
|
||||
{% 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 %}
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{{ p.url }}" target="_blank">Исходник</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info">Нет постов</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user