mirror of
https://gitverse.ru/kpa39l/vesti.git
synced 2026-09-29 09:55:03 +00:00
feat(candidates): флаг прочитанности (is_read), жирные непрочитанные, голубая заливка выбранного, дефолтная группировка по дате
This commit is contained in:
@@ -159,6 +159,7 @@ CLASSIFY_TIMEOUT=20 .venv/bin/python -m classifier.classify --db db/vesti.db --l
|
|||||||
- Тихие OpenSpec-change: `candidates-only-external-new`, `keep-candidates-context` — задачи [x], валидны; архивировать после подтверждения.
|
- Тихие OpenSpec-change: `candidates-only-external-new`, `keep-candidates-context` — задачи [x], валидны; архивировать после подтверждения.
|
||||||
- Правый блок действий: кнопки «🤖 Обработать моделью / ✍️ Переписать / 🚫 Отклонить» вынесены в ОДИН ряд НАД полем комментария, «✅ Опубликовать» — в начале approve-формы, поле комментария + «💾 Сохранить комментарий» — ниже (change `candidates-buttons-order`).
|
- Правый блок действий: кнопки «🤖 Обработать моделью / ✍️ Переписать / 🚫 Отклонить» вынесены в ОДИН ряд НАД полем комментария, «✅ Опубликовать» — в начале approve-формы, поле комментария + «💾 Сохранить комментарий» — ниже (change `candidates-buttons-order`).
|
||||||
- После «🚫 Отклонить» (и bulk reject/reject-old) — остаёмся на странице кандидатов в текущей группировке, НЕ уходим в «🗑 Отклонённые» (change `reject-stay-candidates`); отклонённые — через фильтр статуса.
|
- После «🚫 Отклонить» (и bulk reject/reject-old) — остаёмся на странице кандидатов в текущей группировке, НЕ уходим в «🗑 Отклонённые» (change `reject-stay-candidates`); отклонённые — через фильтр статуса.
|
||||||
|
- Список кандидатов: флаг «прочитан» — непрочитанные карточки жирные, выбранный пост — светло-голубая заливка (класс active-row), открытие поста помечает is_read=1; группировка по умолчанию — «дата» (хронология получения) (change `candidates-read-flag`).
|
||||||
|
|
||||||
## Ключевые артефакты
|
## Ключевые артефакты
|
||||||
- /opt/vesti/AGENT.MD — правила проекта (обязательно: все изменения через OpenSpec)
|
- /opt/vesti/AGENT.MD — правила проекта (обязательно: все изменения через OpenSpec)
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
skip_specs: true
|
||||||
|
created: 2026-09-15
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
## Design
|
||||||
|
|
||||||
|
### 1. БД: колонка is_read
|
||||||
|
|
||||||
|
`db/schema.sql` — в CREATE TABLE posts добавить:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
is_read INTEGER DEFAULT 0, -- 1 = пост просмотрен (открыт в правой панели)
|
||||||
|
```
|
||||||
|
|
||||||
|
Миграция существующей БД — при старте веб-приложения (идемпотентно):
|
||||||
|
в начале `web/app.py` (после `DB_PATH`) или в `_db()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _migrate():
|
||||||
|
"""Идемпотентные миграции при старте (веб-сервис)."""
|
||||||
|
conn = sqlite3.connect(DB_PATH, timeout=10)
|
||||||
|
cols = [r[1] for r in conn.execute("PRAGMA table_info(posts)").fetchall()]
|
||||||
|
if "is_read" not in cols:
|
||||||
|
conn.execute("ALTER TABLE posts ADD COLUMN is_read INTEGER DEFAULT 0")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
Вызвать один раз при импорте (перед `app = FastAPI(...)` или после).
|
||||||
|
|
||||||
|
### 2. Дефолтная группировка — дата
|
||||||
|
|
||||||
|
- `GET /candidates`: параметр `group_by: str = "date"` (было `"source"`).
|
||||||
|
- `POST /candidates/select`: `group_by: str = Form("date")` (было `"source"`).
|
||||||
|
- `POST /candidates/bulk`: `group_by: str = Form("date")` — тоже, чтобы
|
||||||
|
формы левой панели (bulk) сохраняли выбранную группировку по умолчанию.
|
||||||
|
|
||||||
|
### 3. Прочтение при выборе поста
|
||||||
|
|
||||||
|
В `GET /candidates`: когда `selected` задан и пост найден — пометить
|
||||||
|
прочитанным ДО рендера (чтобы карточка сразу стала обычной):
|
||||||
|
|
||||||
|
```python
|
||||||
|
if selected:
|
||||||
|
conn.execute("UPDATE posts SET is_read=1 WHERE id=? AND is_read=0", (selected,))
|
||||||
|
conn.commit()
|
||||||
|
```
|
||||||
|
|
||||||
|
(селект уже найден выше; UPDATE до `conn.close()`).
|
||||||
|
|
||||||
|
В `POST /candidates/select` — после редиректа на
|
||||||
|
`/candidates?selected=id&group_by=...` это покроет выбор; но select —
|
||||||
|
обычная форма, редирект идёт на GET, который и помечает. Отдельный UPDATE
|
||||||
|
в select не нужен (GET сделает).
|
||||||
|
|
||||||
|
### 4. Шаблон: карточки списка
|
||||||
|
|
||||||
|
`web/templates/candidates.html`, блок `{% for p in g.posts %}`:
|
||||||
|
|
||||||
|
Сейчас (строки 78–92):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="list-group-item d-flex align-items-start">
|
||||||
|
<a class="flex-grow-1 text-decoration-none{% if selected and selected.id == p.id %} fw-bold text-primary{% endif %}"
|
||||||
|
href="...">
|
||||||
|
<span class="d-block text-truncate">{{ (p.text or '')[:90] }}</span>
|
||||||
|
...
|
||||||
|
</a>
|
||||||
|
<input type="checkbox" ...>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Стало:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="list-group-item d-flex align-items-start{% if selected and selected.id == p.id %} active-row{% endif %}{% if not p.is_read %} fw-bold{% endif %}">
|
||||||
|
<a class="flex-grow-1 text-decoration-none"
|
||||||
|
href="...">
|
||||||
|
<span class="d-block text-truncate">{{ (p.text or '')[:90] }}</span>
|
||||||
|
...
|
||||||
|
</a>
|
||||||
|
<input type="checkbox" ...>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `is_read=0` → `fw-bold` на всей карточке (текст жирный);
|
||||||
|
- `is_read=1` → обычный;
|
||||||
|
- выбранный → класс `active-row` → светло-голубая заливка.
|
||||||
|
|
||||||
|
Класс `active-row` — в base.html или candidates.html `<style>`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.list-group-item.active-row {
|
||||||
|
background-color: #e3f2fd; /* светло-голубой */
|
||||||
|
/* не перебиваем hover/оригинал: border, text — наследуются */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(используем кастомный класс, т.к. bootstrap `active` меняет цвет текста на
|
||||||
|
белый — не нужно; `bg-info`/`bg-light` — слишком сильные/неоднородные.)
|
||||||
|
|
||||||
|
### 5. Жирный шрифт только для непрочитанного
|
||||||
|
|
||||||
|
`fw-bold` на контейнере карточки делает жирным и текст, и метаданные —
|
||||||
|
это нормально для почтового клиента (непрочитанное письмо — весь блок
|
||||||
|
жирный). Ссылка остаётся `text-decoration-none`, цвет «primary» у
|
||||||
|
выбранного убираем (заливка вместо цвета).
|
||||||
|
|
||||||
|
## Верификация
|
||||||
|
|
||||||
|
- `openspec validate candidates-read-flag` — чисто.
|
||||||
|
- Миграция: запуск приложения (TestClient/import) создаёт колонку is_read;
|
||||||
|
существующие посты получают 0.
|
||||||
|
- Тест: GET /candidates?selected=N → в HTML карточка N имеет `active-row`
|
||||||
|
и НЕ `fw-bold` (после прочтения); у непрочитанного поста — `fw-bold`.
|
||||||
|
- Дефолт: GET /candidates (без group_by) → в шаблоне `group_by == 'date'`,
|
||||||
|
группы подписаны «Сегодня/Вчера/…».
|
||||||
|
- Перезапуск `sudo systemctl restart vesti-web`.
|
||||||
|
- Ручная проверка: список по умолчанию по дате; непрочитанные жирные;
|
||||||
|
открытый пост — голубая заливка; после открытия карточка перестаёт быть
|
||||||
|
жирной.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Список кандидатов — «почтовый клиент»: слева список карточек, справа детали.
|
||||||
|
Сейчас все карточки выглядят одинаково, не видно, какие посты уже смотрели,
|
||||||
|
а какой пост выбран (открыт) — не выделен, кроме цвета текста ссылки.
|
||||||
|
|
||||||
|
Пользователь хочет как в почтовых клиентах:
|
||||||
|
- непрочитанные посты — жирным шрифтом;
|
||||||
|
- прочитанные — обычным (как сейчас);
|
||||||
|
- выбранный (открытый) пост — светло-голубая заливка всей карточки.
|
||||||
|
|
||||||
|
Плюс: по умолчанию группировка должна быть ПО ДАТЕ (хронология получения),
|
||||||
|
а не по источнику.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- БД: колонка `posts.is_read INTEGER DEFAULT 0` (0 = не прочитан, 1 = прочитан).
|
||||||
|
- Web:
|
||||||
|
- выбор поста (GET /candidates?selected=N или POST /candidates/select)
|
||||||
|
помечает его `is_read=1`;
|
||||||
|
- карточка непрочитанного поста в списке — жирный текст (`fw-bold` на
|
||||||
|
заголовке/тексте), прочитанного — обычный (как сейчас);
|
||||||
|
- карточка выбранного поста — светло-голубая заливка всей карточки
|
||||||
|
(вместо текущего `fw-bold text-primary` на ссылке);
|
||||||
|
- дефолт `group_by` в GET /candidates и POST /candidates/select — `date`
|
||||||
|
вместо `source` (хронологический порядок получения: сегодня → вчера →
|
||||||
|
неделя → раньше).
|
||||||
|
|
||||||
|
## Why Not
|
||||||
|
|
||||||
|
- Не менять статус при публикации/отклонении: прочитанность — отдельный
|
||||||
|
флаг просмотра, не связан со статусом.
|
||||||
|
- Не делать авто-прочтение всех при заходе на страницу: только выбранный
|
||||||
|
пост становится прочитанным (как в почте).
|
||||||
|
- Группировка по дате — дефолт, но переключатель (источник/статус) остаётся.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Файлы: `db/schema.sql` (колонка), `web/app.py` (миграция при старте +
|
||||||
|
дефолт группировки + прочтение при выборе), `web/templates/candidates.html`
|
||||||
|
(классы карточек).
|
||||||
|
- Данные: ALTER TABLE (новые посты is_read=0 — непрочитанные; старые
|
||||||
|
после миграции тоже 0).
|
||||||
|
- Сервис: `vesti-web` (:8400) — перезапуск.
|
||||||
|
- Rollback: убрать колонку/правки и перезапустить.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# candidates-read-flag
|
||||||
|
|
||||||
|
- [x] Создан OpenSpec change (proposal/design) — `skip_specs: true`
|
||||||
|
- [x] `openspec validate candidates-read-flag` — чисто
|
||||||
|
- [x] db/schema.sql: колонка posts.is_read INTEGER DEFAULT 0
|
||||||
|
- [x] web/app.py: миграция is_read при старте (_migrate, идемпотентно)
|
||||||
|
- [x] web/app.py: GET /candidates и POST select/bulk: group_by по умолчанию 'date'
|
||||||
|
- [x] web/app.py: при selected → UPDATE posts SET is_read=1
|
||||||
|
- [x] candidates.html: непрочитанные — fw-bold, выбранный — active-row (светло-голубая заливка)
|
||||||
|
- [x] base.html: CSS .list-group-item.active-row (#dcecfc)
|
||||||
|
- [x] Тест (TestClient): миграция, прочтение при selected, дефолт date, классы карточек
|
||||||
|
- [x] Перезапуск `sudo systemctl restart vesti-web`
|
||||||
|
- [x] Ручная проверка в браузере: дата по умолчанию, жирные непрочитанные, голубая заливка выбранного
|
||||||
|
- [x] Обновить STATUS.md / TODO.md
|
||||||
|
- [x] Бэкап (крон 2:45 делает сам, вручную НЕ запускать)
|
||||||
+23
-3
@@ -75,6 +75,21 @@ def _db() -> sqlite3.Connection:
|
|||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate():
|
||||||
|
"""Идемпотентные миграции при старте веб-сервиса."""
|
||||||
|
conn = sqlite3.connect(DB_PATH, timeout=10)
|
||||||
|
try:
|
||||||
|
cols = [r[1] for r in conn.execute("PRAGMA table_info(posts)").fetchall()]
|
||||||
|
if "is_read" not in cols:
|
||||||
|
conn.execute("ALTER TABLE posts ADD COLUMN is_read INTEGER DEFAULT 0")
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
_migrate()
|
||||||
|
|
||||||
|
|
||||||
def _session_ok(request: Request) -> bool:
|
def _session_ok(request: Request) -> bool:
|
||||||
return request.cookies.get(SESSION_COOKIE) == os.getenv("VESTI_WEB_SESSION", "")
|
return request.cookies.get(SESSION_COOKIE) == os.getenv("VESTI_WEB_SESSION", "")
|
||||||
|
|
||||||
@@ -277,7 +292,7 @@ def _fetch_candidates(conn, direction: str, status: str, own: str, q: str, group
|
|||||||
|
|
||||||
@app.get("/candidates", response_class=HTMLResponse)
|
@app.get("/candidates", response_class=HTMLResponse)
|
||||||
def candidates(request: Request, direction: str = "", status: str = "", own: str = "",
|
def candidates(request: Request, direction: str = "", status: str = "", own: str = "",
|
||||||
q: str = "", group_by: str = "source", selected: int = 0, error: str = ""):
|
q: str = "", group_by: str = "date", selected: int = 0, error: str = ""):
|
||||||
_require_auth(request)
|
_require_auth(request)
|
||||||
conn = _db()
|
conn = _db()
|
||||||
grouped, counts, status = _fetch_candidates(conn, direction, status, own, q, group_by)
|
grouped, counts, status = _fetch_candidates(conn, direction, status, own, q, group_by)
|
||||||
@@ -294,6 +309,11 @@ def candidates(request: Request, direction: str = "", status: str = "", own: str
|
|||||||
else:
|
else:
|
||||||
continue
|
continue
|
||||||
break
|
break
|
||||||
|
# открытие поста = прочтение (как в почте): сразу помечаем, чтобы карточка перестала быть жирной
|
||||||
|
if sel is not None and sel["id"]:
|
||||||
|
conn.execute("UPDATE posts SET is_read=1 WHERE id=? AND is_read=0", (sel["id"],))
|
||||||
|
sel["is_read"] = 1
|
||||||
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
# строка параметров фильтров для ссылок (выбор поста, смена группировки)
|
# строка параметров фильтров для ссылок (выбор поста, смена группировки)
|
||||||
qs = []
|
qs = []
|
||||||
@@ -313,7 +333,7 @@ def candidates(request: Request, direction: str = "", status: str = "", own: str
|
|||||||
|
|
||||||
@app.post("/candidates/select")
|
@app.post("/candidates/select")
|
||||||
def candidates_select(request: Request, id: int = Form(...), direction: str = Form(""),
|
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")):
|
status: str = Form(""), own: str = Form(""), q: str = Form(""), group_by: str = Form("date")):
|
||||||
"""Выбор кандидата левой панели (обычная форма без JS)."""
|
"""Выбор кандидата левой панели (обычная форма без JS)."""
|
||||||
_require_auth(request)
|
_require_auth(request)
|
||||||
url = f"/candidates?selected={id}&group_by={group_by}"
|
url = f"/candidates?selected={id}&group_by={group_by}"
|
||||||
@@ -326,7 +346,7 @@ 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(default_factory=list),
|
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("date")):
|
||||||
"""Bulk-операции: approve/reject/reject-old/process по отмеченным ids."""
|
"""Bulk-операции: approve/reject/reject-old/process по отмеченным ids."""
|
||||||
_require_auth(request)
|
_require_auth(request)
|
||||||
back = f"/candidates?group_by={group_by}"
|
back = f"/candidates?group_by={group_by}"
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
body { padding-top: 4.5rem; background: #f5f6fa; }
|
body { padding-top: 4.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; }
|
||||||
|
/* список кандидатов: выбранный пост — светло-голубая заливка карточки */
|
||||||
|
.list-group-item.active-row { background-color: #dcecfc; }
|
||||||
|
.list-group-item.active-row:hover { background-color: #d0e4f8; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -19,6 +22,8 @@
|
|||||||
<a class="nav-link" href="/candidates">Кандидаты</a>
|
<a class="nav-link" href="/candidates">Кандидаты</a>
|
||||||
<a class="nav-link" href="/published">Опубликовано</a>
|
<a class="nav-link" href="/published">Опубликовано</a>
|
||||||
<a class="nav-link" href="/metrics">Метрики</a>
|
<a class="nav-link" href="/metrics">Метрики</a>
|
||||||
|
<a class="nav-link" href="/crawlers">Краулеры</a>
|
||||||
|
<a class="nav-link" href="/sources">Источники</a>
|
||||||
<a class="nav-link text-danger" href="/logout">Выйти</a>
|
<a class="nav-link text-danger" href="/logout">Выйти</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -75,8 +75,8 @@
|
|||||||
</summary>
|
</summary>
|
||||||
<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{% if selected and selected.id == p.id %} active-row{% endif %}{% if not p.is_read %} fw-bold{% endif %}">
|
||||||
<a class="flex-grow-1 text-decoration-none{% if selected and selected.id == p.id %} fw-bold text-primary{% endif %}"
|
<a class="flex-grow-1 text-decoration-none"
|
||||||
href="/candidates?selected={{ p.id }}&group_by={{ group_by }}{% if filters_qs %}&{{ filters_qs }}{% endif %}">
|
href="/candidates?selected={{ p.id }}&group_by={{ group_by }}{% if filters_qs %}&{{ filters_qs }}{% endif %}">
|
||||||
<span class="d-block text-truncate">{{ (p.text or '')[:90] }}</span>
|
<span class="d-block text-truncate">{{ (p.text or '')[:90] }}</span>
|
||||||
<span class="d-block text-muted">
|
<span class="d-block text-muted">
|
||||||
|
|||||||
Reference in New Issue
Block a user