From 9aeef73b104c7a63376e0b7223f85775e2943c16 Mon Sep 17 00:00:00 2001 From: mguschin Date: Fri, 6 Mar 2026 16:08:19 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20release=20v1.8.0=20=E2=80=94=20connecti?= =?UTF-8?q?ons=20dashboard,=20VK=20OAuth,=20sync=20config,=20catalog=20bro?= =?UTF-8?q?wser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CHANGELOG.md | 23 ++ docs/plans/catalog-browser.md | 262 +++++++++++++++ docs/plans/connections-dashboard.md | 187 ++++++++--- docs/plans/sync-configuration.md | 151 +++++++++ run/read_config.py | 65 ++++ web/evotor_api.py | 115 +++++++ web/main.py | 4 +- ...6a7b8_add_sync_configs_and_sync_filters.py | 48 +++ .../d4e5f6a7b8c9_add_catalog_cache_tables.py | 70 ++++ web/models.py | 99 +++++- web/routes/catalog.py | 309 ++++++++++++++++++ web/routes/connections.py | 119 +++++-- web/routes/sync.py | 102 ++++++ web/templates/base.html | 6 + web/templates/catalog_groups.html | 108 ++++++ web/templates/catalog_products.html | 123 +++++++ web/templates/catalog_stores.html | 113 +++++++ web/templates/connections.html | 42 ++- web/templates/connections_add.html | 39 +++ web/templates/sync.html | 110 +++++++ 20 files changed, 2010 insertions(+), 85 deletions(-) create mode 100644 docs/plans/catalog-browser.md create mode 100644 docs/plans/sync-configuration.md create mode 100644 run/read_config.py create mode 100644 web/evotor_api.py create mode 100644 web/migrations/versions/c3d4e5f6a7b8_add_sync_configs_and_sync_filters.py create mode 100644 web/migrations/versions/d4e5f6a7b8c9_add_catalog_cache_tables.py create mode 100644 web/routes/catalog.py create mode 100644 web/routes/sync.py create mode 100644 web/templates/catalog_groups.html create mode 100644 web/templates/catalog_products.html create mode 100644 web/templates/catalog_stores.html create mode 100644 web/templates/connections_add.html create mode 100644 web/templates/sync.html diff --git a/CHANGELOG.md b/CHANGELOG.md index afcbc50..b49ac29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.8.0] - 2026-03-06 + +### Added + +- Connections dashboard (`/connections`) — unified page for all service integrations with online/offline status indicators +- Add-connection page (`/connections/add`) — shows only unconnected services; "all connected" state when none remain +- VK OAuth connection — connect VK account via OAuth, store access token and profile info +- Background health checker — runs every 10 minutes, checks Evotor and VK tokens, updates `is_online` / `last_checked_at` +- Sync configuration page (`/sync`) — master enable/disable toggle, confirm-and-start button, filter summary, warnings for missing connections +- Catalog browser (`/catalog`) — browse Evotor stores, groups, and products in table views with cache auto-refresh on first visit +- Catalog filter management — include/exclude rules per store/group/product via inline dropdown; rules stored in `sync_filters` table +- Catalog CSV export — download stores, groups, or products as UTF-8 BOM CSV (Excel-compatible) +- Alembic migrations for all new tables: `evotor_connections` (health fields), `vk_connections`, `sync_configs`, `sync_filters`, `cached_stores`, `cached_groups`, `cached_products` +- `run/read_config.py` — CLI helper for shell sync scripts to read per-user sync config as JSON + +### Changed + +- Navbar: replaced "Эвотор" link with "Подключения", added "Каталог" and "Синхронизация" links +- Evotor and VK connect/disconnect flows now redirect to `/connections` +- Back links on `/evotor` and `/vk` pages updated to "Вернуться к подключениям" +- VK connection card icon changed to `bi-bag` (shopping bag) to reflect VK Market use case +- Password reset and email confirmation pages: replaced dev-mode console instructions with user-facing copy + ## [1.7.2] - 2026-03-05 ### Other diff --git a/docs/plans/catalog-browser.md b/docs/plans/catalog-browser.md new file mode 100644 index 0000000..b729e33 --- /dev/null +++ b/docs/plans/catalog-browser.md @@ -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 diff --git a/docs/plans/connections-dashboard.md b/docs/plans/connections-dashboard.md index bdf7285..9e8bde1 100644 --- a/docs/plans/connections-dashboard.md +++ b/docs/plans/connections-dashboard.md @@ -2,19 +2,33 @@ ## Context -Users currently access Evotor connection via a dedicated `/evotor` page linked from the navbar. As more integrations are planned, we need a unified **Connections** page where users can see all their connections at a glance, with real-time status indicators powered by background health checks. +Users currently access Evotor connection via a dedicated `/evotor` page linked from the navbar. As more integrations are planned, we need a unified **Connections** page where users can manage all their connections: add new ones, view status, edit, and delete. The dashboard starts empty — users explicitly add each connection they need. + +Supported connection types: **Evotor**, **VK** (one per type per user). + +## Data Design + +### Current state (separate models) + +`EvotorConnection` and `VkConnection` remain as-is — they hold service-specific fields (store_id/store_name for Evotor, vk_user_id/first_name/last_name for VK). The connections dashboard reads from both tables. + +No new unified "connection" table needed. The dashboard builds a virtual list by querying both tables. The "add" flow is just a gateway to the existing per-service OAuth pages. + +### Model additions (both `EvotorConnection` and `VkConnection`) + +Already planned: +- `is_online` (Boolean, default=False, server_default="0") +- `last_checked_at` (DateTime, nullable) ## Plan ### 1. Model Changes — `web/models.py` -Add to `EvotorConnection`: -- `is_online` (Boolean, default=False, server_default="0") -- `last_checked_at` (DateTime, nullable) +Add `is_online` and `last_checked_at` to both `EvotorConnection` and `VkConnection`. ### 2. Alembic Migration -Generate migration for the two new columns on `evotor_connections` table. +Add health check fields to both connection tables. ### 3. Config Addition — `web/config.py` @@ -22,74 +36,157 @@ Add `HEALTH_CHECK_INTERVAL_SECONDS: int = 600` (10 minutes default). ### 4. Background Health Checker — `web/health_checker.py` (new) -- `check_evotor_connection(access_token) -> bool` — async, calls `GET https://api.evotor.ru/stores` with Bearer token, returns True if 200 -- `run_health_checks()` — queries all `EvotorConnection` rows using its own `SessionLocal()`, checks each, updates `is_online` and `last_checked_at` -- `health_check_loop(interval)` — infinite loop with `asyncio.sleep`, calls `run_health_checks()` +- `check_evotor_connection(access_token) -> bool` — async, `GET https://api.evotor.ru/stores` with Bearer token +- `check_vk_connection(access_token) -> bool` — async, `GET https://api.vk.com/method/users.get` with token +- `run_health_checks()` — queries all connection rows, checks each, updates `is_online` and `last_checked_at` +- `health_check_loop(interval)` — infinite loop with `asyncio.sleep` ### 5. Wire Background Task — `web/main.py` Add FastAPI lifespan context manager: - On startup: `asyncio.create_task(health_check_loop(...))` - On shutdown: cancel the task -- Register new connections router +- Register connections router ### 6. Connections Route — `web/routes/connections.py` (new) -`GET /connections` — requires auth, builds a list of connection descriptors: +**`GET /connections`** — Main dashboard. Requires auth. + +Queries both `EvotorConnection` and `VkConnection` for the current user. Builds a list of available service types and their connection state: + ```python -connections = [{ - "name": "Эвотор", - "icon": "bi-shop", - "connected": bool, - "is_online": bool, - "last_checked_at": datetime | None, - "details": store_name, - "connect_url": "/evotor", - "disconnect_url": "/evotor/disconnect", -}] +SERVICE_TYPES = [ + {"type": "evotor", "name": "Эвотор", "icon": "bi-shop", "connect_url": "/evotor", "disconnect_url": "/evotor/disconnect"}, + {"type": "vk", "name": "ВКонтакте", "icon": "bi-chat-dots", "connect_url": "/vk", "disconnect_url": "/vk/disconnect"}, +] ``` -Future connections just append another dict — template stays generic. -### 7. Connections Template — `web/templates/connections.html` (new) +For each type, attach the connection record (or None). Template renders based on state. -Card per connection showing: -- Icon + name + optional details (store name) -- Status: green `bi-circle-fill` (online), red `bi-circle-fill` (offline), grey `bi-circle` (not connected) -- Action button: "Подключить" (link to connect_url) or "Отключить" (POST form to disconnect_url) -- Card footer with last check timestamp +**`GET /connections/add`** — "Add connection" page. + +Shows only service types the user has NOT yet connected: +- Card per available type with service name, icon, short description +- "Подключить" button linking to the service's OAuth page (`/evotor` or `/vk`) +- If all types already connected — message "Все доступные сервисы подключены" +- Back link to `/connections` + +**`POST /connections/delete?type=evotor|vk`** — Delete a connection. + +Same as existing disconnect endpoints but accessed from the dashboard. Deletes the connection record, redirects to `/connections`. + +(The existing `/evotor/disconnect` and `/vk/disconnect` routes remain as aliases.) + +### 7. Templates + +**`web/templates/connections.html`** — Dashboard: + +``` +┌─────────────────────────────────────────────────┐ +│ Подключения [+ Добавить] │ +├─────────────────────────────────────────────────┤ +│ │ +│ ┌─ Card ─────────────────────────────────────┐ │ +│ │ 🏪 Эвотор ● (green) │ │ +│ │ Магазин "Чайная" │ │ +│ │ Последняя проверка: 06.03.2026 14:30 │ │ +│ │ │ │ +│ │ [Настроить] [Отключить] │ │ +│ └────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Card ─────────────────────────────────────┐ │ +│ │ 💬 ВКонтакте ● (green) │ │ +│ │ Иван Иванов │ │ +│ │ Последняя проверка: 06.03.2026 14:30 │ │ +│ │ │ │ +│ │ [Настроить] [Отключить] │ │ +│ └────────────────────────────────────────────┘ │ +│ │ +│ (Нет подключений — нажмите «Добавить») │ +│ │ +└─────────────────────────────────────────────────┘ +``` + +Each connection card: +- Icon + service name + status indicator (green/red/grey) +- Details line (store name for Evotor, profile name for VK) +- Last checked timestamp in card footer +- "Настроить" button → links to service page (`/evotor` or `/vk`) for reconnect/details +- "Отключить" button → POST to `/connections/delete?type=...` with confirmation + +Empty state: message prompting user to add their first connection. + +**`web/templates/connections_add.html`** — Add connection page: + +``` +┌─────────────────────────────────────────────────┐ +│ Добавить подключение │ +├─────────────────────────────────────────────────┤ +│ │ +│ ┌─ Card ─────────────────────────────────────┐ │ +│ │ 🏪 Эвотор │ │ +│ │ Подключите кассу Эвотор для синхронизации │ │ +│ │ каталога товаров. │ │ +│ │ [Подключить →] │ │ +│ └────────────────────────────────────────────┘ │ +│ │ +│ ┌─ Card ─────────────────────────────────────┐ │ +│ │ 💬 ВКонтакте │ │ +│ │ Подключите аккаунт ВКонтакте для │ │ +│ │ публикации товаров в вашу группу. │ │ +│ │ [Подключить →] │ │ +│ └────────────────────────────────────────────┘ │ +│ │ +│ ← Вернуться к подключениям │ +│ │ +└─────────────────────────────────────────────────┘ +``` ### 8. Navbar Update — `web/templates/base.html` Replace "Эвотор" link with "Подключения" → `/connections`. -### 9. Evotor Callback Update — `web/routes/evotor.py` +### 9. Evotor/VK Callback Updates -On successful OAuth callback, set `is_online=True` and `last_checked_at=func.now()`. +On successful OAuth callback in both `/evotor/callback` and `/vk/callback`: +- Set `is_online=True` and `last_checked_at=now()` +- Redirect to `/connections` (already done for Evotor) -### 10. Evotor Template Back Link — `web/templates/evotor.html` +### 10. Evotor/VK Template Back Links -Change "Вернуться в личный кабинет" → "Вернуться к подключениям" linking to `/connections`. +Change back links on `/evotor` and `/vk` pages: "Вернуться к подключениям" → `/connections`. + +### 11. Delete Confirmation + +The "Отключить" button on the dashboard uses a simple JS `confirm()` dialog: "Вы уверены, что хотите отключить {service name}?" before submitting the POST form. ## Files Summary | File | Action | |------|--------| -| `web/models.py` | Modify — add 2 fields | -| `web/config.py` | Modify — add interval setting | -| `web/main.py` | Modify — lifespan + router | -| `web/routes/evotor.py` | Modify — set online on callback | -| `web/routes/connections.py` | Create | -| `web/health_checker.py` | Create | -| `web/templates/connections.html` | Create | -| `web/templates/base.html` | Modify — navbar | -| `web/templates/evotor.html` | Modify — back link | +| `web/models.py` | Modify — add `is_online`, `last_checked_at` to both connection models | +| `web/config.py` | Modify — add `HEALTH_CHECK_INTERVAL_SECONDS` | +| `web/main.py` | Modify — lifespan + register connections router | +| `web/routes/evotor.py` | Modify — set online on callback, redirect to /connections | +| `web/routes/vk.py` | Modify — set online on callback, redirect to /connections | +| `web/routes/connections.py` | Create — dashboard, add page, delete endpoint | +| `web/health_checker.py` | Create — background checks for both Evotor and VK | +| `web/templates/connections.html` | Create — dashboard with cards | +| `web/templates/connections_add.html` | Create — add connection page | +| `web/templates/base.html` | Modify — navbar link | +| `web/templates/evotor.html` | Modify — back link to /connections | +| `web/templates/vk.html` | Modify — back link to /connections | | Alembic migration | Create | ## Verification -1. Run `alembic upgrade head` to apply migration +1. Run `alembic upgrade head` 2. Start the app, verify background task logs appear -3. Visit `/connections` — should show Evotor as disconnected (grey) -4. Connect Evotor via `/evotor` — should redirect back, connections page shows green status -5. Disconnect — status returns to grey -6. Wait for health check interval or trigger manually — verify `is_online` and `last_checked_at` update +3. Visit `/connections` — empty state, "Добавить" button visible +4. Click "Добавить" → shows Evotor and VK as available services +5. Add Evotor → goes through OAuth → returns to `/connections` with green status card +6. Add VK → same flow → both connections visible +7. Click "Добавить" again → shows "Все доступные сервисы подключены" +8. Click "Отключить" on Evotor → confirmation dialog → connection removed → card disappears +9. Click "Добавить" → Evotor is available again +10. Wait for health check cycle → verify `is_online` and `last_checked_at` update on remaining connections diff --git a/docs/plans/sync-configuration.md b/docs/plans/sync-configuration.md new file mode 100644 index 0000000..44b824c --- /dev/null +++ b/docs/plans/sync-configuration.md @@ -0,0 +1,151 @@ +# Sync Configuration Feature + +## Context + +EvoSync syncs product catalogs from Evotor → VK. Currently sync runs as a shell-based cron service with a hardcoded store ID and a flat-file whitelist of group names (`vk/whitelist`). This doesn't support multi-user or per-user configuration. + +Users need a web UI to: +- Enable/disable the whole sync process +- Configure which stores, groups, and products to sync (whitelist/blacklist) +- Explicitly confirm before sync starts + +The web app will store config in DB; the shell sync service will read from DB instead of flat files. + +## Data Model + +### `SyncConfig` — per-user master switch + +``` +tablename: "sync_configs" +- id (Integer, PK) +- user_id (Integer, FK users.id CASCADE, unique) +- is_enabled (Boolean, default=False) # master on/off +- confirmed_at (DateTime, nullable) # NULL = never confirmed/started +- created_at (DateTime, server_default=now) +- updated_at (DateTime, server_default=now, onupdate=now) + +Relationship: User.sync_config (one-to-one) +``` + +### `SyncFilter` — stores, groups, products filter rules + +``` +tablename: "sync_filters" +- id (Integer, PK) +- sync_config_id (Integer, FK sync_configs.id CASCADE) +- entity_type (String, enum: "store", "group", "product") +- entity_id (String 255) # Evotor UUID +- entity_name (String 255) # human-readable, cached +- filter_mode (String, enum: "include", "exclude") +- parent_entity_id (String 255, nullable) # store_id for groups, group_id for products +- created_at (DateTime, server_default=now) + +UniqueConstraint: (sync_config_id, entity_type, entity_id) +Relationship: SyncConfig.filters (one-to-many) +``` + +### Filter Logic + +The filter model uses **explicit include/exclude rules** with these semantics: +- **No rules for an entity type** = sync everything of that type (default permissive) +- **Any "include" rule exists for a type** = ONLY sync included entities (whitelist mode) +- **Only "exclude" rules for a type** = sync everything EXCEPT excluded (blacklist mode) +- Hierarchy: store filters → group filters → product filters. If a store is excluded, all its groups/products are excluded regardless of their individual rules. + +## Plan + +### 1. New Models — `web/models.py` + +Add `SyncConfig` and `SyncFilter` as described above. Add `sync_config` relationship to `User`. + +### 2. Alembic Migration + +Create `sync_configs` and `sync_filters` tables. + +### 3. Evotor API Helper — `web/evotor_api.py` (new) + +Async functions to fetch data from Evotor API using a user's stored access token: + +```python +async def fetch_stores(access_token: str) -> list[dict]: + """GET https://api.evotor.ru/stores → [{"id": "uuid", "name": "..."}]""" + +async def fetch_groups(access_token: str, store_id: str) -> list[dict]: + """GET https://api.evotor.ru/stores/{store_id}/product-groups → [{"id": "uuid", "name": "..."}]""" + +async def fetch_products(access_token: str, store_id: str) -> list[dict]: + """GET https://api.evotor.ru/stores/{store_id}/products → [{"id": "uuid", "name": "...", "parent_id": "..."}]""" +``` + +Uses `httpx.AsyncClient`. Returns simplified dicts. Raises on auth failure. + +### 4. Sync Config Route — `web/routes/sync.py` (new) + +**`GET /sync`** — Main sync configuration page. +- Requires auth + active Evotor connection +- Loads `SyncConfig` (creates default if missing) +- Shows: master enable/disable toggle, confirm button, link to filter config + +**`POST /sync/toggle`** — Enable/disable sync. +- Toggles `is_enabled`. If enabling for the first time and no filters configured, stays on page with message to configure filters first. + +**`POST /sync/confirm`** — Confirm and start sync. +- Sets `confirmed_at = now()`. Only works if `is_enabled=True` and at least one store is configured. + +**Filter management is handled by the Catalog Browser** (see `docs/plans/catalog-browser.md`). +The `/catalog` page provides table views of stores, groups, and products with inline filter toggle actions. No separate `/sync/stores`, `/sync/groups`, `/sync/products` routes needed. + +### 5. Templates + +**`web/templates/sync.html`** — Main sync page: +- Card with master toggle (on/off switch) +- Status: "Не настроено" / "Настроено, ожидает подтверждения" / "Активна" +- Warning if Evotor not connected (link to /evotor) +- Warning if VK not connected (link to /vk) +- "Настроить фильтры" button → `/catalog` (catalog browser) +- "Подтвердить и запустить" button (disabled until filters configured) +- Summary of current filter rules (X stores, Y groups, Z products) + +### 6. Navbar / Navigation + +Add "Синхронизация" link to navbar (for logged-in users), or add it as a card on the `/connections` page since sync depends on connections. + +### 7. Register Route — `web/main.py` + +```python +from web.routes import sync +app.include_router(sync.router) +``` + +### 8. Shell Script DB Integration + +Modify the sync service to read configuration from DB instead of flat files: + +- Add a Python helper script `run/read_config.py` that queries `sync_configs` + `sync_filters` for a given user and outputs JSON config +- Shell scripts call this helper to get: enabled flag, store IDs, whitelisted/blacklisted group names, product exclusions +- The sync service only runs for users where `is_enabled=True` AND `confirmed_at IS NOT NULL` +- Replaces the flat `vk/whitelist` file + +## Files Summary + +| File | Action | +|------|--------| +| `web/models.py` | Modify — add `SyncConfig`, `SyncFilter` + User relationship | +| `web/routes/sync.py` | Create — sync config routes (toggle, confirm) | +| `web/templates/sync.html` | Create — main sync config page | +| `web/templates/base.html` | Modify — add sync nav link | +| `web/main.py` | Modify — register sync router | +| `run/read_config.py` | Create — DB config reader for shell scripts | +| Alembic migration | Create — sync_configs + sync_filters tables | + +## Verification + +1. Run `alembic upgrade head` +2. Visit `/sync` without Evotor connection → shows warning to connect first +3. Connect Evotor, visit `/sync` → shows disabled state, "Настроить фильтры" button +4. Go to `/sync/stores` → fetches live stores from Evotor API, shows checkboxes +5. Select stores, save → drill into groups, select groups, save → drill into products +6. Back to `/sync` → shows summary of configured filters +7. Enable sync toggle → confirm → `confirmed_at` set +8. Verify `run/read_config.py` outputs correct JSON for the user's config +9. Disable sync → `is_enabled=False`, sync service stops processing this user diff --git a/run/read_config.py b/run/read_config.py new file mode 100644 index 0000000..85ae088 --- /dev/null +++ b/run/read_config.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +""" +Read sync configuration for a user from the database and output JSON. +Usage: python run/read_config.py + +Output JSON structure: +{ + "enabled": true, + "confirmed": true, + "filters": { + "stores": [{"id": "...", "name": "...", "mode": "include"}], + "groups": [{"id": "...", "name": "...", "mode": "include", "parent_store_id": "..."}], + "products": [{"id": "...", "name": "...", "mode": "exclude", "parent_group_id": "..."}] + } +} +""" +import json +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from web.database import SessionLocal +from web.models import SyncConfig, SyncFilter + + +def read_config(user_id: int) -> dict: + db = SessionLocal() + try: + config = db.query(SyncConfig).filter(SyncConfig.user_id == user_id).first() + if not config: + return {"enabled": False, "confirmed": False, "filters": {"stores": [], "groups": [], "products": []}} + + enabled = config.is_enabled + confirmed = config.confirmed_at is not None + + stores = [] + groups = [] + products = [] + + for f in config.filters: + entry = {"id": f.entity_id, "name": f.entity_name, "mode": f.filter_mode} + if f.entity_type == "store": + stores.append(entry) + elif f.entity_type == "group": + stores.append({**entry, "parent_store_id": f.parent_entity_id}) + elif f.entity_type == "product": + products.append({**entry, "parent_group_id": f.parent_entity_id}) + + return { + "enabled": enabled, + "confirmed": confirmed, + "filters": {"stores": stores, "groups": groups, "products": products}, + } + finally: + db.close() + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: read_config.py ", file=sys.stderr) + sys.exit(1) + + user_id = int(sys.argv[1]) + print(json.dumps(read_config(user_id), ensure_ascii=False, indent=2)) diff --git a/web/evotor_api.py b/web/evotor_api.py new file mode 100644 index 0000000..fd498db --- /dev/null +++ b/web/evotor_api.py @@ -0,0 +1,115 @@ +from datetime import datetime + +import httpx +from sqlalchemy.orm import Session + +EVOTOR_API_BASE = "https://api.evotor.ru" + + +async def fetch_stores(access_token: str) -> list[dict]: + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{EVOTOR_API_BASE}/stores", + headers={"Authorization": f"Bearer {access_token}"}, + timeout=15, + ) + resp.raise_for_status() + data = resp.json() + items = data.get("items", data) if isinstance(data, dict) else data + return [ + { + "id": s.get("uuid") or s.get("id"), + "name": s.get("name"), + "address": s.get("address"), + } + for s in items + ] + + +async def fetch_groups(access_token: str, store_id: str) -> list[dict]: + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{EVOTOR_API_BASE}/stores/{store_id}/product-groups", + headers={"Authorization": f"Bearer {access_token}"}, + timeout=15, + ) + resp.raise_for_status() + data = resp.json() + items = data.get("items", data) if isinstance(data, dict) else data + return [{"id": g.get("uuid") or g.get("id"), "name": g.get("name")} for g in items] + + +async def fetch_products(access_token: str, store_id: str) -> list[dict]: + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{EVOTOR_API_BASE}/stores/{store_id}/products", + headers={"Authorization": f"Bearer {access_token}"}, + timeout=15, + ) + resp.raise_for_status() + data = resp.json() + items = data.get("items", data) if isinstance(data, dict) else data + return [ + { + "id": p.get("uuid") or p.get("id"), + "name": p.get("name"), + "parent_id": p.get("parentUuid") or p.get("parent_id"), + "price": p.get("price"), + "quantity": p.get("quantity"), + "measure_name": p.get("measureName") or p.get("measure_name"), + "article_number": p.get("code") or p.get("article_number"), + "allow_to_sell": p.get("allowToSell") if p.get("allowToSell") is not None else p.get("allow_to_sell"), + } + for p in items + ] + + +async def refresh_catalog_cache(user_id: int, access_token: str, db: Session) -> None: + from web.models import CachedStore, CachedGroup, CachedProduct + + now = datetime.utcnow() + + # Delete old cache for user + db.query(CachedProduct).filter(CachedProduct.user_id == user_id).delete() + db.query(CachedGroup).filter(CachedGroup.user_id == user_id).delete() + db.query(CachedStore).filter(CachedStore.user_id == user_id).delete() + db.commit() + + stores = await fetch_stores(access_token) + for store in stores: + db.add(CachedStore( + user_id=user_id, + evotor_id=store["id"], + name=store["name"] or "", + address=store.get("address"), + fetched_at=now, + )) + db.commit() + + for store in stores: + groups = await fetch_groups(access_token, store["id"]) + for group in groups: + db.add(CachedGroup( + user_id=user_id, + evotor_id=group["id"], + store_evotor_id=store["id"], + name=group["name"] or "", + fetched_at=now, + )) + + products = await fetch_products(access_token, store["id"]) + for product in products: + db.add(CachedProduct( + user_id=user_id, + evotor_id=product["id"], + store_evotor_id=store["id"], + group_evotor_id=product.get("parent_id"), + name=product["name"] or "", + price=product.get("price"), + quantity=product.get("quantity"), + measure_name=product.get("measure_name"), + article_number=product.get("article_number"), + allow_to_sell=product.get("allow_to_sell"), + fetched_at=now, + )) + db.commit() diff --git a/web/main.py b/web/main.py index 545e9a0..358ac4a 100644 --- a/web/main.py +++ b/web/main.py @@ -10,7 +10,7 @@ from web.auth import get_current_user from web.config import settings from web.health_checker import health_check_loop from web.models import User -from web.routes import auth, profile, reset, evotor, vk +from web.routes import auth, profile, reset, evotor, vk, sync, catalog from web.routes import connections @@ -36,6 +36,8 @@ app.include_router(reset.router) app.include_router(evotor.router) app.include_router(connections.router) app.include_router(vk.router) +app.include_router(sync.router) +app.include_router(catalog.router) @app.get("/") diff --git a/web/migrations/versions/c3d4e5f6a7b8_add_sync_configs_and_sync_filters.py b/web/migrations/versions/c3d4e5f6a7b8_add_sync_configs_and_sync_filters.py new file mode 100644 index 0000000..547c017 --- /dev/null +++ b/web/migrations/versions/c3d4e5f6a7b8_add_sync_configs_and_sync_filters.py @@ -0,0 +1,48 @@ +"""add sync_configs and sync_filters tables + +Revision ID: c3d4e5f6a7b8 +Revises: b2c3d4e5f6a7 +Create Date: 2026-03-06 00:02:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +revision = 'c3d4e5f6a7b8' +down_revision = 'b2c3d4e5f6a7' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'sync_configs', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('is_enabled', sa.Boolean(), nullable=False, server_default='0'), + sa.Column('confirmed_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id'), + ) + op.create_table( + 'sync_filters', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('sync_config_id', sa.Integer(), nullable=False), + sa.Column('entity_type', sa.String(20), nullable=False), + sa.Column('entity_id', sa.String(255), nullable=False), + sa.Column('entity_name', sa.String(255), nullable=True), + sa.Column('filter_mode', sa.String(10), nullable=False), + sa.Column('parent_entity_id', sa.String(255), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.ForeignKeyConstraint(['sync_config_id'], ['sync_configs.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('sync_config_id', 'entity_type', 'entity_id', name='uq_sync_filter'), + ) + + +def downgrade() -> None: + op.drop_table('sync_filters') + op.drop_table('sync_configs') diff --git a/web/migrations/versions/d4e5f6a7b8c9_add_catalog_cache_tables.py b/web/migrations/versions/d4e5f6a7b8c9_add_catalog_cache_tables.py new file mode 100644 index 0000000..24920c3 --- /dev/null +++ b/web/migrations/versions/d4e5f6a7b8c9_add_catalog_cache_tables.py @@ -0,0 +1,70 @@ +"""add catalog cache tables + +Revision ID: d4e5f6a7b8c9 +Revises: c3d4e5f6a7b8 +Create Date: 2026-03-06 00:03:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +revision = 'd4e5f6a7b8c9' +down_revision = 'c3d4e5f6a7b8' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'cached_stores', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('evotor_id', sa.String(255), nullable=False), + sa.Column('name', sa.String(255), nullable=False), + sa.Column('address', sa.String(500), nullable=True), + sa.Column('fetched_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'evotor_id', name='uq_cached_stores'), + ) + op.create_index('ix_cached_stores_user_id', 'cached_stores', ['user_id']) + + op.create_table( + 'cached_groups', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('evotor_id', sa.String(255), nullable=False), + sa.Column('store_evotor_id', sa.String(255), nullable=False), + sa.Column('name', sa.String(255), nullable=False), + sa.Column('fetched_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'evotor_id', name='uq_cached_groups'), + ) + op.create_index('ix_cached_groups_user_store', 'cached_groups', ['user_id', 'store_evotor_id']) + + op.create_table( + 'cached_products', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('evotor_id', sa.String(255), nullable=False), + sa.Column('store_evotor_id', sa.String(255), nullable=False), + sa.Column('group_evotor_id', sa.String(255), nullable=True), + sa.Column('name', sa.String(255), nullable=False), + sa.Column('price', sa.Numeric(12, 2), nullable=True), + sa.Column('quantity', sa.Numeric(12, 3), nullable=True), + sa.Column('measure_name', sa.String(20), nullable=True), + sa.Column('article_number', sa.String(100), nullable=True), + sa.Column('allow_to_sell', sa.Boolean(), nullable=True), + sa.Column('fetched_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'evotor_id', name='uq_cached_products'), + ) + op.create_index('ix_cached_products_user_store_group', 'cached_products', ['user_id', 'store_evotor_id', 'group_evotor_id']) + + +def downgrade() -> None: + op.drop_table('cached_products') + op.drop_table('cached_groups') + op.drop_table('cached_stores') diff --git a/web/models.py b/web/models.py index 544d99c..56148ae 100644 --- a/web/models.py +++ b/web/models.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text +from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, UniqueConstraint, Numeric, Index from sqlalchemy.orm import relationship from sqlalchemy.sql import func @@ -23,6 +23,10 @@ class User(Base): evotor_connection = relationship("EvotorConnection", back_populates="user", uselist=False) vk_connection = relationship("VkConnection", back_populates="user", uselist=False) + sync_config = relationship("SyncConfig", back_populates="user", uselist=False) + cached_stores = relationship("CachedStore", back_populates="user", cascade="all, delete-orphan") + cached_groups = relationship("CachedGroup", back_populates="user", cascade="all, delete-orphan") + cached_products = relationship("CachedProduct", back_populates="user", cascade="all, delete-orphan") class EvotorConnection(Base): @@ -56,3 +60,96 @@ class VkConnection(Base): updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False) user = relationship("User", back_populates="vk_connection") + + +class SyncConfig(Base): + __tablename__ = "sync_configs" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False) + is_enabled = Column(Boolean, default=False, nullable=False) + confirmed_at = Column(DateTime, nullable=True) + created_at = Column(DateTime, server_default=func.now(), nullable=False) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False) + + user = relationship("User", back_populates="sync_config") + filters = relationship("SyncFilter", back_populates="sync_config", cascade="all, delete-orphan") + + +class SyncFilter(Base): + __tablename__ = "sync_filters" + + id = Column(Integer, primary_key=True, autoincrement=True) + sync_config_id = Column(Integer, ForeignKey("sync_configs.id", ondelete="CASCADE"), nullable=False) + entity_type = Column(String(20), nullable=False) # "store", "group", "product" + entity_id = Column(String(255), nullable=False) + entity_name = Column(String(255), nullable=True) + filter_mode = Column(String(10), nullable=False) # "include", "exclude" + parent_entity_id = Column(String(255), nullable=True) + created_at = Column(DateTime, server_default=func.now(), nullable=False) + + __table_args__ = ( + UniqueConstraint("sync_config_id", "entity_type", "entity_id"), + ) + + sync_config = relationship("SyncConfig", back_populates="filters") + + +class CachedStore(Base): + __tablename__ = "cached_stores" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + evotor_id = Column(String(255), nullable=False) + name = Column(String(255), nullable=False) + address = Column(String(500), nullable=True) + fetched_at = Column(DateTime, nullable=False) + + __table_args__ = ( + UniqueConstraint("user_id", "evotor_id"), + Index("ix_cached_stores_user_id", "user_id"), + ) + + user = relationship("User", back_populates="cached_stores") + + +class CachedGroup(Base): + __tablename__ = "cached_groups" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + evotor_id = Column(String(255), nullable=False) + store_evotor_id = Column(String(255), nullable=False) + name = Column(String(255), nullable=False) + fetched_at = Column(DateTime, nullable=False) + + __table_args__ = ( + UniqueConstraint("user_id", "evotor_id"), + Index("ix_cached_groups_user_store", "user_id", "store_evotor_id"), + ) + + user = relationship("User", back_populates="cached_groups") + + +class CachedProduct(Base): + __tablename__ = "cached_products" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + evotor_id = Column(String(255), nullable=False) + store_evotor_id = Column(String(255), nullable=False) + group_evotor_id = Column(String(255), nullable=True) + name = Column(String(255), nullable=False) + price = Column(Numeric(12, 2), nullable=True) + quantity = Column(Numeric(12, 3), nullable=True) + measure_name = Column(String(20), nullable=True) + article_number = Column(String(100), nullable=True) + allow_to_sell = Column(Boolean, nullable=True) + fetched_at = Column(DateTime, nullable=False) + + __table_args__ = ( + UniqueConstraint("user_id", "evotor_id"), + Index("ix_cached_products_user_store_group", "user_id", "store_evotor_id", "group_evotor_id"), + ) + + user = relationship("User", back_populates="cached_products") diff --git a/web/routes/catalog.py b/web/routes/catalog.py new file mode 100644 index 0000000..5465b48 --- /dev/null +++ b/web/routes/catalog.py @@ -0,0 +1,309 @@ +import csv +import io + +from fastapi import APIRouter, Request, Depends +from fastapi.responses import RedirectResponse, StreamingResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy.orm import Session + +from web.auth import get_current_user +from web.database import get_db +from web.evotor_api import refresh_catalog_cache +from web.models import User, EvotorConnection, SyncConfig, SyncFilter, CachedStore, CachedGroup, CachedProduct + +router = APIRouter(prefix="/catalog") +templates = Jinja2Templates(directory="web/templates") + + +def _get_or_create_sync_config(db: Session, user_id: int) -> SyncConfig: + config = db.query(SyncConfig).filter(SyncConfig.user_id == user_id).first() + if not config: + config = SyncConfig(user_id=user_id, is_enabled=False) + db.add(config) + db.commit() + db.refresh(config) + return config + + +def _filter_map(config: SyncConfig) -> dict: + """Returns {entity_id: filter_mode} for quick lookup.""" + return {f.entity_id: f.filter_mode for f in config.filters} + + +def _filter_label(mode: str | None) -> str: + if mode == "include": + return "include" + if mode == "exclude": + return "exclude" + return "none" + + +@router.get("") +async def catalog_stores( + request: Request, + db: Session = Depends(get_db), + user: User | None = Depends(get_current_user), +): + if not user: + return RedirectResponse("/login", 303) + + evotor = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first() + if not evotor: + return templates.TemplateResponse("catalog_stores.html", { + "request": request, "user": user, + "evotor": None, "stores": [], "filter_map": {}, "fetched_at": None, + }) + + stores = db.query(CachedStore).filter(CachedStore.user_id == user.id).order_by(CachedStore.name).all() + + # Auto-refresh if cache is empty + if not stores: + await refresh_catalog_cache(user.id, evotor.access_token, db) + stores = db.query(CachedStore).filter(CachedStore.user_id == user.id).order_by(CachedStore.name).all() + + config = _get_or_create_sync_config(db, user.id) + fmap = _filter_map(config) + fetched_at = stores[0].fetched_at if stores else None + + return templates.TemplateResponse("catalog_stores.html", { + "request": request, + "user": user, + "evotor": evotor, + "stores": stores, + "filter_map": fmap, + "fetched_at": fetched_at, + }) + + +@router.get("/groups") +def catalog_groups( + request: Request, + store_id: str, + db: Session = Depends(get_db), + user: User | None = Depends(get_current_user), +): + if not user: + return RedirectResponse("/login", 303) + + store = db.query(CachedStore).filter( + CachedStore.user_id == user.id, + CachedStore.evotor_id == store_id, + ).first() + if not store: + return RedirectResponse("/catalog", 303) + + groups = db.query(CachedGroup).filter( + CachedGroup.user_id == user.id, + CachedGroup.store_evotor_id == store_id, + ).order_by(CachedGroup.name).all() + + # Count products per group + product_counts = {} + for g in groups: + product_counts[g.evotor_id] = db.query(CachedProduct).filter( + CachedProduct.user_id == user.id, + CachedProduct.group_evotor_id == g.evotor_id, + ).count() + + config = _get_or_create_sync_config(db, user.id) + fmap = _filter_map(config) + + return templates.TemplateResponse("catalog_groups.html", { + "request": request, + "user": user, + "store": store, + "groups": groups, + "product_counts": product_counts, + "filter_map": fmap, + }) + + +@router.get("/products") +def catalog_products( + request: Request, + store_id: str, + group_id: str | None = None, + db: Session = Depends(get_db), + user: User | None = Depends(get_current_user), +): + if not user: + return RedirectResponse("/login", 303) + + store = db.query(CachedStore).filter( + CachedStore.user_id == user.id, + CachedStore.evotor_id == store_id, + ).first() + if not store: + return RedirectResponse("/catalog", 303) + + group = None + query = db.query(CachedProduct).filter( + CachedProduct.user_id == user.id, + CachedProduct.store_evotor_id == store_id, + ) + if group_id: + group = db.query(CachedGroup).filter( + CachedGroup.user_id == user.id, + CachedGroup.evotor_id == group_id, + ).first() + query = query.filter(CachedProduct.group_evotor_id == group_id) + + products = query.order_by(CachedProduct.name).all() + + config = _get_or_create_sync_config(db, user.id) + fmap = _filter_map(config) + + return templates.TemplateResponse("catalog_products.html", { + "request": request, + "user": user, + "store": store, + "group": group, + "products": products, + "filter_map": fmap, + }) + + +@router.post("/filter") +async def catalog_filter( + request: Request, + db: Session = Depends(get_db), + user: User | None = Depends(get_current_user), +): + if not user: + return RedirectResponse("/login", 303) + + form = await request.form() + entity_type = form.get("entity_type") + entity_id = form.get("entity_id") + entity_name = form.get("entity_name") + filter_mode = form.get("filter_mode") # "include", "exclude", "none" + parent_entity_id = form.get("parent_entity_id") or None + redirect_to = form.get("redirect_to", "/catalog") + + config = _get_or_create_sync_config(db, user.id) + + existing = db.query(SyncFilter).filter( + SyncFilter.sync_config_id == config.id, + SyncFilter.entity_type == entity_type, + SyncFilter.entity_id == entity_id, + ).first() + + if filter_mode == "none": + if existing: + db.delete(existing) + elif existing: + existing.filter_mode = filter_mode + existing.entity_name = entity_name + else: + db.add(SyncFilter( + sync_config_id=config.id, + entity_type=entity_type, + entity_id=entity_id, + entity_name=entity_name, + filter_mode=filter_mode, + parent_entity_id=parent_entity_id, + )) + db.commit() + + return RedirectResponse(redirect_to, 303) + + +@router.post("/refresh") +async def catalog_refresh( + request: Request, + db: Session = Depends(get_db), + user: User | None = Depends(get_current_user), +): + if not user: + return RedirectResponse("/login", 303) + + evotor = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first() + if evotor: + await refresh_catalog_cache(user.id, evotor.access_token, db) + + return RedirectResponse("/catalog", 303) + + +@router.get("/export") +def catalog_export( + request: Request, + type: str, + store_id: str | None = None, + group_id: str | None = None, + db: Session = Depends(get_db), + user: User | None = Depends(get_current_user), +): + if not user: + return RedirectResponse("/login", 303) + + config = _get_or_create_sync_config(db, user.id) + fmap = _filter_map(config) + + def filter_label(eid): + m = fmap.get(eid) + if m == "include": + return "Включено" + if m == "exclude": + return "Исключено" + return "Нет правила" + + output = io.StringIO() + output.write("\ufeff") # UTF-8 BOM for Excel + writer = csv.writer(output) + + from datetime import date + today = date.today().strftime("%Y%m%d") + + if type == "stores": + writer.writerow(["Название", "Адрес", "ID", "Фильтр"]) + stores = db.query(CachedStore).filter(CachedStore.user_id == user.id).order_by(CachedStore.name).all() + for s in stores: + writer.writerow([s.name, s.address or "", s.evotor_id, filter_label(s.evotor_id)]) + filename = f"stores_{today}.csv" + + elif type == "groups": + writer.writerow(["Магазин", "Название", "ID", "Фильтр"]) + q = db.query(CachedGroup, CachedStore).join( + CachedStore, + (CachedStore.evotor_id == CachedGroup.store_evotor_id) & (CachedStore.user_id == user.id) + ).filter(CachedGroup.user_id == user.id) + if store_id: + q = q.filter(CachedGroup.store_evotor_id == store_id) + for g, s in q.order_by(CachedGroup.name).all(): + writer.writerow([s.name, g.name, g.evotor_id, filter_label(g.evotor_id)]) + filename = f"groups_{today}.csv" + + else: # products + writer.writerow(["Магазин", "Группа", "Название", "Артикул", "Цена", "Количество", "Ед. измерения", "В продаже", "ID", "Фильтр"]) + q = db.query(CachedProduct, CachedStore, CachedGroup).join( + CachedStore, + (CachedStore.evotor_id == CachedProduct.store_evotor_id) & (CachedStore.user_id == user.id) + ).outerjoin( + CachedGroup, + (CachedGroup.evotor_id == CachedProduct.group_evotor_id) & (CachedGroup.user_id == user.id) + ).filter(CachedProduct.user_id == user.id) + if store_id: + q = q.filter(CachedProduct.store_evotor_id == store_id) + if group_id: + q = q.filter(CachedProduct.group_evotor_id == group_id) + for p, s, g in q.order_by(CachedProduct.name).all(): + writer.writerow([ + s.name, + g.name if g else "", + p.name, + p.article_number or "", + p.price or "", + p.quantity or "", + p.measure_name or "", + "Да" if p.allow_to_sell else ("Нет" if p.allow_to_sell is not None else ""), + p.evotor_id, + filter_label(p.evotor_id), + ]) + filename = f"products_{today}.csv" + + output.seek(0) + return StreamingResponse( + iter([output.getvalue()]), + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": f"attachment; filename={filename}"}, + ) diff --git a/web/routes/connections.py b/web/routes/connections.py index 57cbe7e..299baae 100644 --- a/web/routes/connections.py +++ b/web/routes/connections.py @@ -10,6 +10,43 @@ from web.models import User, EvotorConnection, VkConnection router = APIRouter() templates = Jinja2Templates(directory="web/templates") +SERVICE_TYPES = [ + { + "type": "evotor", + "name": "Эвотор", + "icon": "bi-shop", + "description": "Подключите кассу Эвотор для синхронизации каталога товаров.", + "configure_url": "/evotor", + "connect_url": "/evotor/connect", + }, + { + "type": "vk", + "name": "ВКонтакте", + "icon": "bi-bag", + "description": "Подключите аккаунт ВКонтакте для публикации товаров в вашу группу.", + "configure_url": "/vk", + "connect_url": "/vk/connect", + }, +] + + +def _get_connection(svc_type: str, evotor, vk): + if svc_type == "evotor": + return evotor + if svc_type == "vk": + return vk + return None + + +def _get_details(svc_type: str, conn): + if conn is None: + return None + if svc_type == "evotor": + return conn.store_name + if svc_type == "vk": + return f"{conn.first_name} {conn.last_name}".strip() if conn.first_name else None + return None + @router.get("/connections") def connections_page( @@ -23,31 +60,67 @@ def connections_page( evotor = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first() vk = db.query(VkConnection).filter(VkConnection.user_id == user.id).first() - connections = [ - { - "name": "Эвотор", - "icon": "bi-shop", - "connected": evotor is not None, - "is_online": evotor.is_online if evotor else False, - "last_checked_at": evotor.last_checked_at if evotor else None, - "details": evotor.store_name if evotor else None, - "connect_url": "/evotor/connect", - "disconnect_url": "/evotor/disconnect", - }, - { - "name": "ВКонтакте", - "icon": "bi-chat-dots", - "connected": vk is not None, - "is_online": vk.is_online if vk else False, - "last_checked_at": vk.last_checked_at if vk else None, - "details": f"{vk.first_name} {vk.last_name}".strip() if vk and vk.first_name else None, - "connect_url": "/vk", - "disconnect_url": "/vk/disconnect", - }, - ] + connected = [] + for svc in SERVICE_TYPES: + conn = _get_connection(svc["type"], evotor, vk) + if conn is not None: + connected.append({ + **svc, + "is_online": conn.is_online, + "last_checked_at": conn.last_checked_at, + "details": _get_details(svc["type"], conn), + }) return templates.TemplateResponse("connections.html", { "request": request, "user": user, - "connections": connections, + "connections": connected, }) + + +@router.get("/connections/add") +def connections_add_page( + request: Request, + db: Session = Depends(get_db), + user: User | None = Depends(get_current_user), +): + if not user: + return RedirectResponse("/login", 303) + + evotor = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first() + vk = db.query(VkConnection).filter(VkConnection.user_id == user.id).first() + + available = [ + svc for svc in SERVICE_TYPES + if _get_connection(svc["type"], evotor, vk) is None + ] + + return templates.TemplateResponse("connections_add.html", { + "request": request, + "user": user, + "available": available, + }) + + +@router.post("/connections/delete") +async def connections_delete( + request: Request, + db: Session = Depends(get_db), + user: User | None = Depends(get_current_user), +): + if not user: + return RedirectResponse("/login", 303) + + svc_type = request.query_params.get("type") + if svc_type == "evotor": + conn = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first() + elif svc_type == "vk": + conn = db.query(VkConnection).filter(VkConnection.user_id == user.id).first() + else: + conn = None + + if conn: + db.delete(conn) + db.commit() + + return RedirectResponse("/connections", 303) diff --git a/web/routes/sync.py b/web/routes/sync.py new file mode 100644 index 0000000..ddff7b6 --- /dev/null +++ b/web/routes/sync.py @@ -0,0 +1,102 @@ +from datetime import datetime + +from fastapi import APIRouter, Request, Depends +from fastapi.responses import RedirectResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy.orm import Session + +from web.auth import get_current_user +from web.database import get_db +from web.models import User, EvotorConnection, VkConnection, SyncConfig, SyncFilter + +router = APIRouter(prefix="/sync") +templates = Jinja2Templates(directory="web/templates") + + +def _get_or_create_sync_config(db: Session, user_id: int) -> SyncConfig: + config = db.query(SyncConfig).filter(SyncConfig.user_id == user_id).first() + if not config: + config = SyncConfig(user_id=user_id, is_enabled=False) + db.add(config) + db.commit() + db.refresh(config) + return config + + +def _filter_summary(config: SyncConfig) -> dict: + stores = [f for f in config.filters if f.entity_type == "store"] + groups = [f for f in config.filters if f.entity_type == "group"] + products = [f for f in config.filters if f.entity_type == "product"] + return { + "stores": len(stores), + "groups": len(groups), + "products": len(products), + "total": len(config.filters), + } + + +@router.get("") +def sync_page( + request: Request, + db: Session = Depends(get_db), + user: User | None = Depends(get_current_user), +): + if not user: + return RedirectResponse("/login", 303) + + evotor = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first() + vk = db.query(VkConnection).filter(VkConnection.user_id == user.id).first() + config = _get_or_create_sync_config(db, user.id) + summary = _filter_summary(config) + + if config.confirmed_at and config.is_enabled: + status = "active" + elif config.confirmed_at and not config.is_enabled: + status = "paused" + elif summary["total"] > 0: + status = "pending" + else: + status = "unconfigured" + + return templates.TemplateResponse("sync.html", { + "request": request, + "user": user, + "evotor": evotor, + "vk": vk, + "config": config, + "summary": summary, + "status": status, + }) + + +@router.post("/toggle") +def sync_toggle( + request: Request, + db: Session = Depends(get_db), + user: User | None = Depends(get_current_user), +): + if not user: + return RedirectResponse("/login", 303) + + config = _get_or_create_sync_config(db, user.id) + config.is_enabled = not config.is_enabled + db.commit() + + return RedirectResponse("/sync", 303) + + +@router.post("/confirm") +def sync_confirm( + request: Request, + db: Session = Depends(get_db), + user: User | None = Depends(get_current_user), +): + if not user: + return RedirectResponse("/login", 303) + + config = _get_or_create_sync_config(db, user.id) + if config.is_enabled and len(config.filters) > 0: + config.confirmed_at = datetime.utcnow() + db.commit() + + return RedirectResponse("/sync", 303) diff --git a/web/templates/base.html b/web/templates/base.html index 55a6efe..84990d8 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -21,6 +21,12 @@ + + diff --git a/web/templates/catalog_groups.html b/web/templates/catalog_groups.html new file mode 100644 index 0000000..d5113c7 --- /dev/null +++ b/web/templates/catalog_groups.html @@ -0,0 +1,108 @@ +{% extends "base.html" %} +{% block title %}Группы — {{ store.name }} — EvoSync{% endblock %} + +{% block content %} + + +
+

Группы товаров

+ + Экспорт CSV + +
+ +{% if not groups %} +
+ +

Группы не найдены в этом магазине.

+ + Посмотреть все товары магазина + +
+{% else %} +
+ + + + + + + + + + + {% for group in groups %} + {% set mode = filter_map.get(group.evotor_id) %} + + + + + + + {% endfor %} + +
НазваниеКол-во товаровФильтр
{{ group.name }}{{ product_counts.get(group.evotor_id, 0) }} + {% if mode == "include" %} + ✓ Включено + {% elif mode == "exclude" %} + ✗ Исключено + {% else %} + — Нет правила + {% endif %} + +
+ + + + +
+
+
+{% endif %} +{% endblock %} diff --git a/web/templates/catalog_products.html b/web/templates/catalog_products.html new file mode 100644 index 0000000..d39bb96 --- /dev/null +++ b/web/templates/catalog_products.html @@ -0,0 +1,123 @@ +{% extends "base.html" %} +{% block title %}Товары — EvoSync{% endblock %} + +{% block content %} + + +
+

Товары{% if group %}: {{ group.name }}{% endif %}

+ + Экспорт CSV + +
+ +{% set redirect_back %}/catalog/products?store_id={{ store.evotor_id }}{% if group %}&group_id={{ group.evotor_id }}{% endif %}{% endset %} + +{% if not products %} +
+ +

Товары не найдены.

+
+{% else %} +
+ + + + + + + + + + + + + + + {% for product in products %} + {% set mode = filter_map.get(product.evotor_id) %} + + + + + + + + + + + {% endfor %} + +
НазваниеАртикулЦенаКол-воЕд. изм.В продажеФильтр
{{ product.name }}{{ product.article_number or "—" }}{% if product.price %}{{ "%.2f"|format(product.price) }} ₽{% else %}—{% endif %}{% if product.quantity is not none %}{{ product.quantity }}{% else %}—{% endif %}{{ product.measure_name or "—" }} + {% if product.allow_to_sell is none %} + + {% elif product.allow_to_sell %} + + {% else %} + + {% endif %} + + {% if mode == "include" %} + ✓ Включено + {% elif mode == "exclude" %} + ✗ Исключено + {% else %} + — Нет правила + {% endif %} + + +
+
+{% endif %} +{% endblock %} diff --git a/web/templates/catalog_stores.html b/web/templates/catalog_stores.html new file mode 100644 index 0000000..212d6cf --- /dev/null +++ b/web/templates/catalog_stores.html @@ -0,0 +1,113 @@ +{% extends "base.html" %} +{% block title %}Каталог — EvoSync{% endblock %} + +{% block content %} +
+

Каталог

+
+ {% if evotor %} +
+ +
+ + Экспорт CSV + + {% endif %} +
+
+ +{% if not evotor %} +
+ + Эвотор не подключён. Подключить Эвотор +
+{% elif not stores %} +
+ +

Магазины не найдены в вашем аккаунте Эвотор.

+
+{% else %} +{% if fetched_at %} +

Последнее обновление: {{ fetched_at.strftime("%d.%m.%Y %H:%M") }}

+{% endif %} + +
+ + + + + + + + + + + {% for store in stores %} + {% set mode = filter_map.get(store.evotor_id) %} + + + + + + + {% endfor %} + +
НазваниеАдресФильтр
{{ store.name }}{{ store.address or "—" }} + {% if mode == "include" %} + ✓ Включено + {% elif mode == "exclude" %} + ✗ Исключено + {% else %} + — Нет правила + {% endif %} + +
+ + + + +
+
+
+{% endif %} +{% endblock %} diff --git a/web/templates/connections.html b/web/templates/connections.html index d870c38..791212c 100644 --- a/web/templates/connections.html +++ b/web/templates/connections.html @@ -2,8 +2,14 @@ {% block title %}Подключения — EvoSync{% endblock %} {% block content %} -

Подключения

+
+

Подключения

+ + Добавить + +
+{% if connections %}
{% for conn in connections %}
@@ -18,9 +24,7 @@ {% endif %}
- {% if not conn.connected %} - - {% elif conn.is_online %} + {% if conn.is_online %} {% else %} @@ -28,18 +32,17 @@
-
- {% if conn.connected %} - Переподключить -
- -
- {% else %} - Подключить - {% endif %} +
+ Настроить +
+ +
- {% if conn.connected %} - {% endif %} {% endfor %} + +{% else %} +
+ +

Нет подключённых сервисов

+ + Добавить подключение + +
+{% endif %} {% endblock %} diff --git a/web/templates/connections_add.html b/web/templates/connections_add.html new file mode 100644 index 0000000..ac3f365 --- /dev/null +++ b/web/templates/connections_add.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% block title %}Добавить подключение — EvoSync{% endblock %} + +{% block content %} +
+ +

Добавить подключение

+
+ +{% if available %} +
+ {% for svc in available %} +
+
+
+
+ +
{{ svc.name }}
+
+

{{ svc.description }}

+ + Подключить + +
+
+
+ {% endfor %} +
+ +{% else %} +
+ +

Все доступные сервисы подключены

+ + Вернуться к подключениям + +
+{% endif %} +{% endblock %} diff --git a/web/templates/sync.html b/web/templates/sync.html new file mode 100644 index 0000000..b53717d --- /dev/null +++ b/web/templates/sync.html @@ -0,0 +1,110 @@ +{% extends "base.html" %} +{% block title %}Синхронизация — EvoSync{% endblock %} + +{% block content %} +

Синхронизация

+ +{% if not evotor %} +
+ + Эвотор не подключён. Подключить Эвотор +
+{% endif %} + +{% if not vk %} +
+ + ВКонтакте не подключён. Подключить ВКонтакте +
+{% endif %} + +
+ + {# ── Status card ── #} +
+
+
+
Статус
+ +
+ {% if status == "active" %} + Активна + {% elif status == "paused" %} + Приостановлена + {% elif status == "pending" %} + Ожидает подтверждения + {% else %} + Не настроено + {% endif %} +
+ + {% if config.confirmed_at %} +

+ Запущена: {{ config.confirmed_at.strftime("%d.%m.%Y %H:%M") }} +

+ {% endif %} + +
+ {# Toggle enable/disable #} +
+ {% if config.is_enabled %} + + {% else %} + + {% endif %} +
+ + {# Confirm button #} + {% if config.is_enabled and summary.total > 0 %} +
+ +
+ {% endif %} +
+ + {% if config.is_enabled and summary.total == 0 %} +

+ Настройте фильтры, чтобы подтвердить запуск. +

+ {% endif %} +
+
+
+ + {# ── Filters card ── #} +
+
+
+
Фильтры
+ + {% if summary.total > 0 %} +
    + {% if summary.stores > 0 %} +
  • Магазины: {{ summary.stores }} правил
  • + {% endif %} + {% if summary.groups > 0 %} +
  • Группы: {{ summary.groups }} правил
  • + {% endif %} + {% if summary.products > 0 %} +
  • Товары: {{ summary.products }} правил
  • + {% endif %} +
+ {% else %} +

Фильтры не настроены — будут синхронизированы все товары.

+ {% endif %} + + + Настроить фильтры + +
+
+
+ +
+{% endblock %}