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:
kpa39l
2026-09-14 05:11:54 +00:00
parent e2bdb4a87b
commit acb49cc702
7 changed files with 149 additions and 4 deletions
@@ -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