feat: release v1.8.0 — connections dashboard, VK OAuth, sync config, catalog browser

- Connections dashboard with add/remove flow and background health checks
- VK OAuth connection with profile info and health monitoring
- Sync configuration page with master toggle and filter summary
- Catalog browser with store/group/product tables, filter management, CSV export
- Alembic migrations for all new tables
- run/read_config.py for shell sync script DB integration
- CHANGELOG.md updated for v1.8.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mguschin
2026-03-06 16:08:19 +03:00
parent cfc7229daf
commit 9aeef73b10
20 changed files with 2010 additions and 85 deletions

View File

@@ -0,0 +1,262 @@
# Catalog Browser with Filter Management & CSV Export
## Context
Users need to browse their Evotor catalog (stores, groups, products) in a table view, manage sync whitelist/blacklist rules inline, and export data to CSV.
This feature **replaces** the separate `/sync/stores`, `/sync/groups`, `/sync/products` pages from the sync-configuration plan. The catalog browser becomes the unified place for both viewing data and managing filter rules.
Data is cached in DB with a refresh mechanism — not fetched live on every page load.
## Data Model
### Catalog Cache Tables
```
tablename: "cached_stores"
- id (Integer, PK)
- user_id (Integer, FK users.id CASCADE)
- evotor_id (String 255) # Evotor UUID
- name (String 255)
- address (String 500, nullable)
- fetched_at (DateTime) # when this snapshot was taken
UniqueConstraint: (user_id, evotor_id)
Index: user_id
```
```
tablename: "cached_groups"
- id (Integer, PK)
- user_id (Integer, FK users.id CASCADE)
- evotor_id (String 255) # Evotor UUID
- store_evotor_id (String 255) # parent store UUID
- name (String 255)
- fetched_at (DateTime)
UniqueConstraint: (user_id, evotor_id)
Index: (user_id, store_evotor_id)
```
```
tablename: "cached_products"
- id (Integer, PK)
- user_id (Integer, FK users.id CASCADE)
- evotor_id (String 255) # Evotor UUID
- store_evotor_id (String 255) # parent store UUID
- group_evotor_id (String 255, nullable) # parent group UUID
- name (String 255)
- price (Numeric(12,2), nullable)
- quantity (Numeric(12,3), nullable)
- measure_name (String 20, nullable)
- article_number (String 100, nullable)
- allow_to_sell (Boolean, nullable)
- fetched_at (DateTime)
UniqueConstraint: (user_id, evotor_id)
Index: (user_id, store_evotor_id, group_evotor_id)
```
### `SyncFilter` (from sync-configuration plan, unchanged)
```
tablename: "sync_filters"
- sync_config_id, entity_type, entity_id, entity_name, filter_mode, parent_entity_id
```
The catalog browser reads from cache tables for display and from `sync_filters` for the current filter state of each entity.
### Cache Refresh
`web/evotor_api.py` gets a new function:
```python
async def refresh_catalog_cache(user_id: int, access_token: str, db: Session):
"""Fetch all stores, groups, products from Evotor API and upsert into cache tables."""
```
Triggered by:
- Manual "Обновить" button on the catalog page
- Background job (optional, can reuse health_checker interval or separate setting)
- First visit to catalog if cache is empty
## Plan
### 1. New Models — `web/models.py`
Add `CachedStore`, `CachedGroup`, `CachedProduct` models as described above.
### 2. Alembic Migration
Create `cached_stores`, `cached_groups`, `cached_products` tables.
### 3. Evotor API Helper — `web/evotor_api.py`
Extend with:
```python
async def fetch_stores(access_token: str) -> list[dict]
async def fetch_groups(access_token: str, store_id: str) -> list[dict]
async def fetch_products(access_token: str, store_id: str) -> list[dict]
async def refresh_catalog_cache(user_id: int, access_token: str, db: Session)
```
`refresh_catalog_cache` does:
1. Fetch all stores
2. For each store, fetch groups and products
3. Upsert into cache tables (delete old rows for user, insert fresh)
4. Update `fetched_at` timestamps
### 4. Catalog Route — `web/routes/catalog.py` (new)
**`GET /catalog`** — Stores table. Requires auth + Evotor connection.
- Reads `cached_stores` for user
- If cache is empty, triggers refresh
- Shows table with columns: Название, Адрес, Статус фильтра, Действия
- Each row shows the store's current `SyncFilter` state (included/excluded/no rule)
- Link to drill into groups for each store
- "Обновить каталог" button, "Экспорт CSV" button, back link
**`GET /catalog/groups?store_id=UUID`** — Groups table for a store.
- Reads `cached_groups` filtered by `store_evotor_id`
- Table columns: Название, Статус фильтра, Кол-во товаров, Действия
- Each row shows group's `SyncFilter` state
- Link to drill into products for each group
- "Экспорт CSV" button, back to stores
**`GET /catalog/products?store_id=UUID&group_id=UUID`** — Products table for a group.
- Reads `cached_products` filtered by `store_evotor_id` and `group_evotor_id`
- Table columns: Название, Артикул, Цена, Кол-во, Ед. изм., В продаже, Статус фильтра, Действия
- Each row shows product's `SyncFilter` state
- "Экспорт CSV" button, back to groups
**`GET /catalog/products?store_id=UUID`** — All products for a store (no group filter).
- Same table, but shows all products in the store with a "Группа" column added
**`POST /catalog/filter`** — Toggle filter for an entity.
- Body: `entity_type`, `entity_id`, `entity_name`, `filter_mode` (include/exclude/none), `parent_entity_id`
- Creates, updates, or deletes the `SyncFilter` row
- Redirects back to the referring page
**`POST /catalog/refresh`** — Manual cache refresh.
- Calls `refresh_catalog_cache()`
- Redirects back to `/catalog`
**`GET /catalog/export?type=stores|groups|products&store_id=UUID&group_id=UUID`** — CSV export.
- Reads from cache tables
- Returns `StreamingResponse` with `text/csv` content type and `Content-Disposition: attachment`
- Filename: `{type}_{date}.csv`
### 5. Templates
**`web/templates/catalog_stores.html`** — Stores table:
```
┌──────────────────────────────────────────────────────────────┐
│ Каталог [Обновить] [Экспорт CSV] │
│ Последнее обновление: 06.03.2026 14:30 │
├──────────────────────────────────────────────────────────────┤
│ │
│ Магазины │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Название │ Адрес │ Фильтр │ │ │
│ ├──────────────────────────────────────────────────────┤ │
│ │ Чайная │ ул. Мира, 1 │ ✓ Вкл │ [→][▼] │ │
│ │ Склад │ — │ ✗ Выкл │ [→][▼] │ │
│ │ Точка 2 │ ул. Мира, 5 │ — Нет │ [→][▼] │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ [→] = перейти к группам │
│ [▼] = dropdown: Включить / Исключить / Убрать правило │
│ │
└──────────────────────────────────────────────────────────────┘
```
**Filter status column** shows:
- `✓ Включено` (green badge) — entity has an "include" rule
- `✗ Исключено` (red badge) — entity has an "exclude" rule
- `— Нет правила` (grey badge) — no filter rule (follows default behavior)
**Actions column** per row:
- Link icon → drill into children (groups for stores, products for groups)
- Dropdown button with filter actions: "Включить в синхронизацию" / "Исключить из синхронизации" / "Убрать правило". Each is a small POST form to `/catalog/filter`.
**`web/templates/catalog_groups.html`** — Groups table:
- Breadcrumb: Каталог > {Store name} > Группы
- Same table pattern, columns: Название, Кол-во товаров, Фильтр, Действия
- Drill-down link to products per group
**`web/templates/catalog_products.html`** — Products table:
- Breadcrumb: Каталог > {Store name} > {Group name} > Товары
- Columns: Название, Артикул, Цена, Кол-во, Ед. изм., В продаже, Фильтр, Действия
- "В продаже" column: green check / red cross based on `allow_to_sell`
All tables use Bootstrap table styling (`table table-striped table-hover`) with responsive wrapper.
### 6. CSV Export Format
**Stores CSV:**
```
Название,Адрес,ID,Фильтр
Чайная,"ул. Мира, 1",uuid-123,Включено
```
**Groups CSV:**
```
Магазин,Название,ID,Фильтр
Чайная,Белый чай,uuid-456,Включено
```
**Products CSV:**
```
Магазин,Группа,Название,Артикул,Цена,Количество,Ед. измерения,В продаже,ID,Фильтр
Чайная,Белый чай,Бай Му Дань,1005,350.00,180.0,г,Да,uuid-789,Включено
```
UTF-8 with BOM (`\ufeff`) for Excel compatibility. Delimiter: comma.
### 7. Update Sync Configuration Plan
The `/sync` page links to `/catalog` instead of separate filter pages:
- "Настроить фильтры" button → `/catalog`
- Filter summary on `/sync` reads from `SyncFilter` table (unchanged)
- Remove `/sync/stores`, `/sync/groups`, `/sync/products` routes from sync-configuration plan — replaced by catalog browser
### 8. Navbar / Navigation
Add "Каталог" link to navbar for logged-in users. Order: Подключения → Каталог → Синхронизация → Личный кабинет → Выход.
### 9. Register Route — `web/main.py`
```python
from web.routes import catalog
app.include_router(catalog.router)
```
## Files Summary
| File | Action |
|------|--------|
| `web/models.py` | Modify — add `CachedStore`, `CachedGroup`, `CachedProduct` |
| `web/evotor_api.py` | Create — API fetch + cache refresh functions |
| `web/routes/catalog.py` | Create — catalog routes (tables, filter toggle, refresh, CSV export) |
| `web/templates/catalog_stores.html` | Create — stores table |
| `web/templates/catalog_groups.html` | Create — groups table |
| `web/templates/catalog_products.html` | Create — products table |
| `web/templates/base.html` | Modify — add "Каталог" nav link |
| `web/main.py` | Modify — register catalog router |
| `docs/plans/sync-configuration.md` | Update — remove /sync/stores,groups,products; link to /catalog |
| Alembic migration | Create — cache tables |
## Verification
1. Run `alembic upgrade head`
2. Visit `/catalog` without Evotor connection → warning to connect first
3. Connect Evotor, visit `/catalog` → triggers first cache refresh, shows stores table
4. Click store → shows groups table with group names from Evotor
5. Click group → shows products table with full product details
6. Toggle filter on a product → badge changes, `SyncFilter` row created in DB
7. Go to `/sync` → filter summary reflects the change
8. Click "Экспорт CSV" on products page → downloads CSV, opens correctly in Excel
9. Click "Обновить каталог" → re-fetches from Evotor API, updates cache
10. Verify breadcrumb navigation works correctly through the hierarchy