mirror of
https://gitverse.ru/kpa39l/vesti.git
synced 2026-09-29 09:55:03 +00:00
fix(candidates): только внешние new-посты в списке кандидатов
- _fetch_candidates: в WHERE всегда is_own=0 AND status='new' → свои посты канала @dedinit (is_own=1, backfill) и published/rejected больше не показываются как кандидаты - counts табов: считаются по внешним постам (не по жёсткому where), чтобы табы статусов показывали реальные счётчики - change: candidates-only-external-new (skip_specs)
This commit is contained in:
@@ -1,8 +1,9 @@
|
|||||||
# VESTI — Статус
|
# VESTI — Статус
|
||||||
|
|
||||||
Обновлено: 2026-09-14 (фикс published-страницы)
|
Обновлено: 2026-09-14 (кандидаты — только внешние new)
|
||||||
<!--
|
<!--
|
||||||
История обновлений:
|
История обновлений:
|
||||||
|
2026-09-14 — кандидаты: в списке только внешние new-посты (is_own=0 AND status='new' в _fetch_candidates); свои посты канала (is_own=1) и published/rejected больше не показываются как кандидаты
|
||||||
2026-09-14 — фикс published-страницы: заголовок перекрывался fixed-top меню (padding-top 1.5rem→4.5rem в base.html); на карточках добавлен ID поста (#{{p.id}} · направление)
|
2026-09-14 — фикс published-страницы: заголовок перекрывался fixed-top меню (padding-top 1.5rem→4.5rem в base.html); на карточках добавлен ID поста (#{{p.id}} · направление)
|
||||||
2026-09-14 — фикс бага: статус группы в _fetch_candidates брался от первого поста → при group_by=date группа «Ранее на этой неделе» выглядела опубликованной; теперь доминирующий по группе (new > rejected > published)
|
2026-09-14 — фикс бага: статус группы в _fetch_candidates брался от первого поста → при group_by=date группа «Ранее на этой неделе» выглядела опубликованной; теперь доминирующий по группе (new > rejected > published)
|
||||||
2026-09-13 — fediverse: publisher умеет постить в GoToSocial (@vesti@dedinit.ru)
|
2026-09-13 — fediverse: publisher умеет постить в GoToSocial (@vesti@dedinit.ru)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
| 2026-09-08 | Инициализировать OpenSpec-проект /opt/vesti, change tg-crawler-publisher-prototype | ✅ закрыта | сессия старта |
|
| 2026-09-08 | Инициализировать OpenSpec-проект /opt/vesti, change tg-crawler-publisher-prototype | ✅ закрыта | сессия старта |
|
||||||
| 2026-09-14 | Фикс: статус группы при group_by=date брался от первого поста → группы с первым published выглядели опубликованными | ✅ закрыта | change fix-date-group-status |
|
| 2026-09-14 | Фикс: статус группы при group_by=date брался от первого поста → группы с первым published выглядели опубликованными | ✅ закрыта | change fix-date-group-status |
|
||||||
| 2026-09-14 | Фикс /published: заголовок перекрывался fixed-top меню; на карточках нет ID поста | ✅ закрыта | change fix-published-page |
|
| 2026-09-14 | Фикс /published: заголовок перекрывался fixed-top меню; на карточках нет ID поста | ✅ закрыта | change fix-published-page |
|
||||||
|
| 2026-09-14 | Кандидаты: в списке не должно быть одобренных/своих постов; при группировке по дате всё показывалось как «свои» | ✅ закрыта | change candidates-only-external-new (в списке только внешние new: is_own=0 AND status='new') |
|
||||||
| 2026-09-08 | Артефакты change: proposal, design, specs (5), tasks | ✅ закрыта | сессия старта |
|
| 2026-09-08 | Артефакты change: proposal, design, specs (5), tasks | ✅ закрыта | сессия старта |
|
||||||
| 2026-09-08 | PRD.md — требования, архитектура, стек, DoD | ✅ закрыта | сессия старта |
|
| 2026-09-08 | PRD.md — требования, архитектура, стек, DoD | ✅ закрыта | сессия старта |
|
||||||
| 2026-09-08 | Каркас проекта: каталоги, venv, requirements.txt | ✅ закрыта | сессия старта |
|
| 2026-09-08 | Каркас проекта: каталоги, venv, requirements.txt | ✅ закрыта | сессия старта |
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-09-14
|
||||||
|
skip_specs: true
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
## Design
|
||||||
|
|
||||||
|
Файл: `/opt/vesti/web/app.py`, функция `_fetch_candidates`.
|
||||||
|
|
||||||
|
Текущее (строки ~151-165):
|
||||||
|
```python
|
||||||
|
where = []
|
||||||
|
params = []
|
||||||
|
if direction:
|
||||||
|
where.append("p.direction=?")
|
||||||
|
params.append(direction)
|
||||||
|
if status:
|
||||||
|
where.append("p.status=?")
|
||||||
|
params.append(status)
|
||||||
|
if own in ("1", "0"):
|
||||||
|
where.append("p.is_own=?")
|
||||||
|
params.append(int(own))
|
||||||
|
if q:
|
||||||
|
where.append("(p.text LIKE ? OR p.summary LIKE ?)")
|
||||||
|
params += [f"%{q}%", f"%{q}%"]
|
||||||
|
w = ("WHERE " + " AND ".join(where)) if where else ""
|
||||||
|
```
|
||||||
|
|
||||||
|
Проблемы:
|
||||||
|
1. При `status=''` (по умолчанию) в кандидаты попадают published и rejected.
|
||||||
|
2. `own=''` (по умолчанию) не фильтрует is_own → в кандидаты попадают 846
|
||||||
|
постов собственного канала (is_own=1), которые не являются кандидатами.
|
||||||
|
|
||||||
|
Правка — два условия добавляются в WHERE всегда:
|
||||||
|
|
||||||
|
```python
|
||||||
|
where = []
|
||||||
|
params = []
|
||||||
|
# Кандидаты = только внешние (is_own=0) посты в статусе new.
|
||||||
|
# Свои посты канала (is_own=1) не являются кандидатами — это контент
|
||||||
|
# собственного канала, управляется отдельно (fan-out); published/rejected —
|
||||||
|
# уже решённые посты, им не место в очереди кандидатов.
|
||||||
|
where.append("p.is_own=0")
|
||||||
|
where.append("p.status='new'")
|
||||||
|
if direction:
|
||||||
|
where.append("p.direction=?")
|
||||||
|
params.append(direction)
|
||||||
|
if status and status != "new":
|
||||||
|
# Явный фильтр статуса (rejected/published) — просмотр решённых;
|
||||||
|
# для 'new' условие уже добавлено выше.
|
||||||
|
where.append("p.status=?")
|
||||||
|
params.append(status)
|
||||||
|
if own in ("1", "0"):
|
||||||
|
where.append("p.is_own=?")
|
||||||
|
params.append(int(own))
|
||||||
|
if q:
|
||||||
|
where.append("(p.text LIKE ? OR p.summary LIKE ?)")
|
||||||
|
params += [f"%{q}%", f"%{q}%"]
|
||||||
|
w = ("WHERE " + " AND ".join(where)) if where else ""
|
||||||
|
```
|
||||||
|
|
||||||
|
Примечание: `own='1'` (⭐ Свои) вернёт пустую выборку — это корректно:
|
||||||
|
свои посты не кандидаты. Фильтр own оставлен для обратной совместимости
|
||||||
|
(внешние = own='0' = все кандидаты).
|
||||||
|
|
||||||
|
## Верификация
|
||||||
|
|
||||||
|
- `openspec validate candidates-only-external-new` — чисто.
|
||||||
|
- Юнит:
|
||||||
|
`python -c "import sys; sys.path.insert(0,'/opt/vesti'); import web.app as A; c=A._db(); g,co,st=A._fetch_candidates(c,'','','','','date'); print([(x['key'],len(x['posts'])) for x in g]); print('new всего постов:', sum(len(x['posts']) for x in g)); c.close()"`
|
||||||
|
→ все группы: только внешние посты; is_own=1 посты отсутствуют, published/rejected отсутствуют.
|
||||||
|
- Живой: `GET /candidates` → в списке нет ⭐, нет «✅ Опубликованные», нет published/rejected.
|
||||||
|
- Рестарт `sudo systemctl restart vesti-web`.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Страница «Кандидаты» (`/candidates`) показывает посты, которые пользователь
|
||||||
|
должен рассмотреть и решить: публиковать или отклонить. Сейчас в списке
|
||||||
|
кандидатов отображаются посты, которым там не место:
|
||||||
|
|
||||||
|
1. **Опубликованные (`status='published'`)** — уже решённые посты. При
|
||||||
|
`status=''` (фильтр «Все», значение по умолчанию) запрос
|
||||||
|
`_fetch_candidates` НЕ фильтрует по статусу, поэтому published (6) и
|
||||||
|
rejected (3) попадают в список кандидатов. Пользователь: «в списке
|
||||||
|
кандидатов не должно быть одобренных к публикации постов».
|
||||||
|
|
||||||
|
2. **Свои посты канала @dedinit (`is_own=1`)** — 846 постов backfill'а
|
||||||
|
своего канала «Дед в АйТи» лежат в БД как `new`, но это контент из
|
||||||
|
собственного канала пользователя, а не кандидаты на публикацию.
|
||||||
|
Пользователь их не одобрял и не считает кандидатами: «при группировке
|
||||||
|
по дате все посты отображаются как „свои“, но они не мои, в канале их
|
||||||
|
нет, я их не одобрял». Отклонённые (rejected) — тоже уже решённые.
|
||||||
|
|
||||||
|
Итог: в списке кандидатов должны быть ТОЛЬКО внешние (is_own=0) посты в
|
||||||
|
статусе `new`.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
В `web/app.py`, функция `_fetch_candidates`:
|
||||||
|
|
||||||
|
- При пустом `status` (фильтр «Все», используется по умолчанию и для
|
||||||
|
группировок source/date) — жёстко добавлять `p.status='new'` в WHERE.
|
||||||
|
Это исключает published и rejected из списка кандидатов.
|
||||||
|
- ВСЕГДА добавлять `p.is_own=0` в WHERE (независимо от фильтра own).
|
||||||
|
Свои посты (is_own=1) не являются кандидатами — это контент собственного
|
||||||
|
канала, управляется отдельно (fan-out). Исключаем их из списка кандидатов.
|
||||||
|
|
||||||
|
Фильтры `own`, `status` в форме остаются (они по-прежнему работают в рамках
|
||||||
|
внешних new-постов; `own='1'` теперь вернёт 0 постов — это ок, т.к. свои
|
||||||
|
посты не кандидаты).
|
||||||
|
|
||||||
|
## Why Not
|
||||||
|
|
||||||
|
- Не показывать published/rejected в отдельном разделе списка кандидатов:
|
||||||
|
для них есть страница «Опубликованные» (/published) и фильтры. Кандидаты —
|
||||||
|
это очередь на решение, не архив.
|
||||||
|
- Не удалять is_own посты из БД: они нужны для fan-out и «Своих»; меняется
|
||||||
|
только фильтрация на веб-странице кандидатов.
|
||||||
|
- Не трогать краулер/backfill: семантика is_own корректна (свой канал),
|
||||||
|
проблема только в отображении кандидатов.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Файл: `web/app.py`, `_fetch_candidates` (WHERE-условия).
|
||||||
|
- Данные: без миграций БД.
|
||||||
|
- Сервис: `vesti-web` (:8400) — перезапуск.
|
||||||
|
- Rollback: откатить WHERE-правку, перезапустить.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# candidates-only-external-new
|
||||||
|
|
||||||
|
- [x] Создан OpenSpec change (proposal/design) — skip_specs: true
|
||||||
|
- [x] web/app.py `_fetch_candidates`: в WHERE всегда `p.is_own=0 AND p.status='new'`; явный статус (rejected/published) — только просмотр решённых
|
||||||
|
- [x] `openspec validate candidates-only-external-new` — чисто
|
||||||
|
- [x] Юнит: `_fetch_candidates(...,'date')` → группы только внешние new; нет ⭐, нет published/rejected
|
||||||
|
- [x] Живой: GET /candidates → нет опубликованных/отклонённых/своих в списке
|
||||||
|
- [x] Рестарт vesti-web
|
||||||
|
- [x] Обновить STATUS.md / TODO.md
|
||||||
|
- [x] Git push в gitverse
|
||||||
+12
-3
@@ -150,10 +150,18 @@ def _fetch_candidates(conn, direction: str, status: str, own: str, q: str, group
|
|||||||
flat — сгруппированный список; counts — {статус: число} для фильтра-табов."""
|
flat — сгруппированный список; counts — {статус: число} для фильтра-табов."""
|
||||||
where = []
|
where = []
|
||||||
params = []
|
params = []
|
||||||
|
# Кандидаты = только внешние (is_own=0) посты в статусе new.
|
||||||
|
# Свои посты канала (is_own=1) не являются кандидатами — это контент
|
||||||
|
# собственного канала, управляется отдельно (fan-out); published/rejected —
|
||||||
|
# уже решённые посты, им не место в очереди кандидатов.
|
||||||
|
where.append("p.is_own=0")
|
||||||
|
where.append("p.status='new'")
|
||||||
if direction:
|
if direction:
|
||||||
where.append("p.direction=?")
|
where.append("p.direction=?")
|
||||||
params.append(direction)
|
params.append(direction)
|
||||||
if status:
|
if status and status != "new":
|
||||||
|
# Явный фильтр статуса (rejected/published) — просмотр решённых;
|
||||||
|
# для 'new' условие уже добавлено выше.
|
||||||
where.append("p.status=?")
|
where.append("p.status=?")
|
||||||
params.append(status)
|
params.append(status)
|
||||||
if own in ("1", "0"):
|
if own in ("1", "0"):
|
||||||
@@ -168,9 +176,10 @@ def _fetch_candidates(conn, direction: str, status: str, own: str, q: str, group
|
|||||||
FROM posts p LEFT JOIN sources s ON s.id=p.source_id
|
FROM posts p LEFT JOIN sources s ON s.id=p.source_id
|
||||||
{w}"""
|
{w}"""
|
||||||
rows = conn.execute(sql + " ORDER BY COALESCE(p.published_at,p.fetched_at) DESC LIMIT ?", params + [limit]).fetchall()
|
rows = conn.execute(sql + " ORDER BY COALESCE(p.published_at,p.fetched_at) DESC LIMIT ?", params + [limit]).fetchall()
|
||||||
|
# counts для табов статуса: сколько кандидатов (внешних new) и сколько
|
||||||
|
# решённых (rejected/published) доступно для просмотра.
|
||||||
counts = {r["status"]: r["c"] for r in conn.execute(
|
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",
|
"SELECT p.status, COUNT(*) c FROM posts p WHERE p.is_own=0 GROUP BY p.status",
|
||||||
params,
|
|
||||||
)}
|
)}
|
||||||
counts[""] = sum(counts.values())
|
counts[""] = sum(counts.values())
|
||||||
# статус по умолчанию для табов: если в выборке нет ничего "нового" — смотрим rejected
|
# статус по умолчанию для табов: если в выборке нет ничего "нового" — смотрим rejected
|
||||||
|
|||||||
Reference in New Issue
Block a user