Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48da26c270 | ||
|
|
bacfd8fe54 | ||
|
|
9aeef73b10 | ||
|
|
cfc7229daf | ||
|
|
379f781e1e | ||
|
|
9edb77efba | ||
|
|
865798967a | ||
|
|
d486ba1f83 |
40
CHANGELOG.md
40
CHANGELOG.md
@@ -5,16 +5,42 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [1.6.0] - 2025-06-15
|
## [1.8.0] - 2026-03-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Initial changelog implementation
|
|
||||||
- Version tracking system
|
- 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
|
### Changed
|
||||||
- Minor version bump from 1.5.2 to 1.6.0
|
|
||||||
|
|
||||||
## [1.5.2] - Previous Release
|
- 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
|
||||||
|
|
||||||
|
- Add user registration and auth web app
|
||||||
|
- Update docker-compose.yml: remove database service, adjust ports and host
|
||||||
|
- Integrate Bootstrap 5 and Bootstrap Icons into UI
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-02-02
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
- Initial commit
|
||||||
|
- V1.
|
||||||
|
|
||||||
|
|
||||||
### Notes
|
|
||||||
- Historical version before changelog implementation
|
|
||||||
@@ -6,5 +6,8 @@ COPY requirements.txt .
|
|||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY web/ ./web/
|
COPY web/ ./web/
|
||||||
|
COPY alembic.ini .
|
||||||
|
COPY docker-entrypoint.sh .
|
||||||
|
RUN chmod +x docker-entrypoint.sh
|
||||||
|
|
||||||
CMD ["uvicorn", "web.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["./docker-entrypoint.sh"]
|
||||||
|
|||||||
43
alembic.ini
Normal file
43
alembic.ini
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = web/migrations
|
||||||
|
prepend_sys_path = .
|
||||||
|
version_path_separator = os
|
||||||
|
|
||||||
|
# URL is set dynamically in env.py from DATABASE_URL env var
|
||||||
|
sqlalchemy.url =
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
45
cliff.toml
Normal file
45
cliff.toml
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
[changelog]
|
||||||
|
header = """# Changelog
|
||||||
|
|
||||||
|
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).\n
|
||||||
|
"""
|
||||||
|
body = """
|
||||||
|
{% if version %}\
|
||||||
|
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
|
||||||
|
{% else %}\
|
||||||
|
## [Unreleased]
|
||||||
|
{% endif %}\
|
||||||
|
{% for group, commits in commits | group_by(attribute="group") %}
|
||||||
|
### {{ group | striptags | trim | upper_first }}
|
||||||
|
{% for commit in commits %}
|
||||||
|
- {% if commit.scope %}**{{ commit.scope }}**: {% endif %}\
|
||||||
|
{{ commit.message | split(pat="\n") | first | upper_first }}\
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}\n
|
||||||
|
"""
|
||||||
|
trim = true
|
||||||
|
footer = ""
|
||||||
|
|
||||||
|
[git]
|
||||||
|
conventional_commits = true
|
||||||
|
filter_unconventional = false
|
||||||
|
split_commits = false
|
||||||
|
commit_parsers = [
|
||||||
|
{ message = "^feat", group = "Added" },
|
||||||
|
{ message = "^fix", group = "Fixed" },
|
||||||
|
{ message = "^doc", group = "Documentation" },
|
||||||
|
{ message = "^perf", group = "Performance" },
|
||||||
|
{ message = "^refactor", group = "Changed" },
|
||||||
|
{ message = "^style", group = "Styling" },
|
||||||
|
{ message = "^test", group = "Testing" },
|
||||||
|
{ message = "^chore\\(release\\)", skip = true },
|
||||||
|
{ message = "^chore", group = "Miscellaneous" },
|
||||||
|
{ message = "^ci", group = "CI/CD" },
|
||||||
|
{ body = ".*security", group = "Security" },
|
||||||
|
{ message = ".*", group = "Other" },
|
||||||
|
]
|
||||||
|
filter_commits = false
|
||||||
|
tag_pattern = "v[0-9].*"
|
||||||
@@ -5,21 +5,28 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.web
|
dockerfile: Dockerfile.web
|
||||||
ports:
|
expose:
|
||||||
- "8080:8000"
|
- "8000"
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=mysql+pymysql://${DB_USER}:${DB_PASSWORD}@172.25.0.1:3306/${DB_NAME}
|
- DATABASE_URL=mysql+pymysql://${DB_USER}:${DB_PASSWORD}@172.25.0.1:3306/${DB_NAME}
|
||||||
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}
|
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}
|
||||||
- BASE_URL=${BASE_URL:-http://localhost:8080}
|
- BASE_URL=${BASE_URL:-https://evosync.ru}
|
||||||
|
- EVOTOR_CLIENT_ID=${EVOTOR_CLIENT_ID}
|
||||||
|
- EVOTOR_CLIENT_SECRET=${EVOTOR_CLIENT_SECRET}
|
||||||
|
- VK_CLIENT_ID=${VK_CLIENT_ID}
|
||||||
|
- VK_CLIENT_SECRET=${VK_CLIENT_SECRET}
|
||||||
volumes:
|
volumes:
|
||||||
- ./web:/app/web
|
- ./web:/app/web
|
||||||
|
- ./alembic.ini:/app/alembic.ini
|
||||||
|
- ./docker-entrypoint.sh:/app/docker-entrypoint.sh
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
sync:
|
# sync:
|
||||||
build:
|
# build:
|
||||||
context: .
|
# context: .
|
||||||
dockerfile: Dockerfile
|
# dockerfile: Dockerfile
|
||||||
volumes:
|
# volumes:
|
||||||
- ./evo:/var/www/evo
|
# - ./evo:/var/www/evo
|
||||||
- ./vk:/var/www/vk
|
# - ./vk:/var/www/vk
|
||||||
- ./run:/var/www/run
|
# - ./run:/var/www/run
|
||||||
- ./logs:/var/www/logs
|
# - ./logs:/var/www/logs
|
||||||
|
|||||||
4
docker-entrypoint.sh
Executable file
4
docker-entrypoint.sh
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
alembic upgrade head
|
||||||
|
exec uvicorn web.main:app --host 0.0.0.0 --port 8000
|
||||||
262
docs/plans/catalog-browser.md
Normal file
262
docs/plans/catalog-browser.md
Normal 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
|
||||||
192
docs/plans/connections-dashboard.md
Normal file
192
docs/plans/connections-dashboard.md
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
# Connections Dashboard with Background Health Checks
|
||||||
|
|
||||||
|
## 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 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 `is_online` and `last_checked_at` to both `EvotorConnection` and `VkConnection`.
|
||||||
|
|
||||||
|
### 2. Alembic Migration
|
||||||
|
|
||||||
|
Add health check fields to both connection tables.
|
||||||
|
|
||||||
|
### 3. Config Addition — `web/config.py`
|
||||||
|
|
||||||
|
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, `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 connections router
|
||||||
|
|
||||||
|
### 6. Connections Route — `web/routes/connections.py` (new)
|
||||||
|
|
||||||
|
**`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
|
||||||
|
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"},
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
For each type, attach the connection record (or None). Template renders based on state.
|
||||||
|
|
||||||
|
**`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/VK Callback Updates
|
||||||
|
|
||||||
|
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/VK Template Back Links
|
||||||
|
|
||||||
|
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 `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`
|
||||||
|
2. Start the app, verify background task logs appear
|
||||||
|
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
|
||||||
151
docs/plans/sync-configuration.md
Normal file
151
docs/plans/sync-configuration.md
Normal file
@@ -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
|
||||||
189
docs/plans/vk-connection.md
Normal file
189
docs/plans/vk-connection.md
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
# VK OAuth Connection Feature
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
EvoSync syncs product catalogs from Evotor to VK. Users already connect their Evotor account via OAuth. Now we need the same for VK — users authorize via VK OAuth, we store the access token, and show connection status on the connections dashboard.
|
||||||
|
|
||||||
|
## VK OAuth Flow (Web)
|
||||||
|
|
||||||
|
- **Authorize URL**: `https://oauth.vk.com/authorize`
|
||||||
|
- **Token URL**: `https://oauth.vk.com/access_token`
|
||||||
|
- **Verify endpoint**: `GET https://api.vk.com/method/users.get?access_token={token}&v=5.131`
|
||||||
|
- Error code 5 = token invalid/expired
|
||||||
|
- **Scopes**: `market groups offline` (offline = permanent token, no expiry)
|
||||||
|
- **Token response fields**: `access_token`, `user_id`, `expires_in` (0 if offline scope used)
|
||||||
|
|
||||||
|
With `offline` scope, tokens don't expire — no refresh logic needed. If a user revokes access on VK's side, the health checker will detect it.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
### 1. New Model — `VkConnection` in `web/models.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
class VkConnection(Base):
|
||||||
|
__tablename__ = "vk_connections"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False)
|
||||||
|
access_token = Column(Text, nullable=False)
|
||||||
|
vk_user_id = Column(String(50), nullable=True) # VK user ID from token response
|
||||||
|
first_name = Column(String(255), nullable=True) # VK profile first name
|
||||||
|
last_name = Column(String(255), nullable=True) # VK profile last name
|
||||||
|
is_online = Column(Boolean, default=False, server_default="0", nullable=False)
|
||||||
|
last_checked_at = Column(DateTime, nullable=True)
|
||||||
|
connected_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="vk_connection")
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to `User` model:
|
||||||
|
```python
|
||||||
|
vk_connection = relationship("VkConnection", back_populates="user", uselist=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Alembic Migration
|
||||||
|
|
||||||
|
Generate migration for the new `vk_connections` table and the relationship.
|
||||||
|
|
||||||
|
### 3. Config — `web/config.py`
|
||||||
|
|
||||||
|
Add:
|
||||||
|
```python
|
||||||
|
VK_CLIENT_ID: str = ""
|
||||||
|
VK_CLIENT_SECRET: str = ""
|
||||||
|
VK_SCOPES: str = "market groups offline"
|
||||||
|
VK_API_VERSION: str = "5.131"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. VK Route — `web/routes/vk.py` (new)
|
||||||
|
|
||||||
|
Follow the same pattern as `web/routes/evotor.py`:
|
||||||
|
|
||||||
|
**Constants:**
|
||||||
|
```python
|
||||||
|
VK_AUTHORIZE_URL = "https://oauth.vk.com/authorize"
|
||||||
|
VK_TOKEN_URL = "https://oauth.vk.com/access_token"
|
||||||
|
VK_API_URL = "https://api.vk.com/method"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Endpoints:**
|
||||||
|
|
||||||
|
- `GET /vk` — Connection page. Shows connected state (VK profile name, user_id) or disconnected state with explanation and connect button.
|
||||||
|
|
||||||
|
- `GET /vk/connect` — Generate state token, save in session, redirect to:
|
||||||
|
```
|
||||||
|
https://oauth.vk.com/authorize?client_id={id}&response_type=code
|
||||||
|
&redirect_uri={BASE_URL}/vk/callback&scope={scopes}&state={state}
|
||||||
|
&display=page&v=5.131
|
||||||
|
```
|
||||||
|
|
||||||
|
- `GET /vk/callback` — OAuth callback:
|
||||||
|
1. Validate state from session
|
||||||
|
2. Exchange code for token via GET to `https://oauth.vk.com/access_token` with params: `client_id`, `client_secret`, `code`, `redirect_uri` (NOTE: VK uses GET, not POST, and params in query string, not body)
|
||||||
|
3. Response contains: `access_token`, `user_id`, `expires_in`
|
||||||
|
4. Fetch user profile via `users.get` to get first_name, last_name
|
||||||
|
5. Save/update `VkConnection` record with `is_online=True`, `last_checked_at=now()`
|
||||||
|
6. Redirect to `/connections`
|
||||||
|
|
||||||
|
- `POST /vk/disconnect` — Delete VkConnection record, redirect to `/vk`
|
||||||
|
|
||||||
|
### 5. VK Template — `web/templates/vk.html` (new)
|
||||||
|
|
||||||
|
Same structure as `evotor.html`:
|
||||||
|
|
||||||
|
**Connected state:**
|
||||||
|
- Status badge: "Подключено" (green)
|
||||||
|
- VK profile: first_name + last_name
|
||||||
|
- VK user ID (monospace)
|
||||||
|
- Connected timestamp
|
||||||
|
- Buttons: "Переподключить", "Отключить аккаунт ВКонтакте"
|
||||||
|
|
||||||
|
**Disconnected state:**
|
||||||
|
- Explanation text: "Подключите ваш аккаунт ВКонтакте, чтобы система могла автоматически синхронизировать каталог товаров из Эвотор в вашу группу ВКонтакте."
|
||||||
|
- Bullet points: redirect to VK for auth, auto-setup after confirmation, can disconnect anytime
|
||||||
|
- Button: "Подключить ВКонтакте"
|
||||||
|
|
||||||
|
**Error display:** same pattern as evotor.html (invalid_state, token_exchange, no_token)
|
||||||
|
|
||||||
|
**Back link:** "Вернуться к подключениям" → `/connections`
|
||||||
|
|
||||||
|
### 6. Register Route — `web/main.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from web.routes import vk
|
||||||
|
app.include_router(vk.router)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Add to Connections Dashboard — `web/routes/connections.py`
|
||||||
|
|
||||||
|
Add VK entry to the connections list:
|
||||||
|
```python
|
||||||
|
vk_conn = db.query(VkConnection).filter(VkConnection.user_id == user.id).first()
|
||||||
|
|
||||||
|
connections.append({
|
||||||
|
"name": "ВКонтакте",
|
||||||
|
"icon": "bi-chat-dots", # or another suitable Bootstrap icon
|
||||||
|
"connected": vk_conn is not None,
|
||||||
|
"is_online": vk_conn.is_online if vk_conn else False,
|
||||||
|
"last_checked_at": vk_conn.last_checked_at if vk_conn else None,
|
||||||
|
"details": f"{vk_conn.first_name} {vk_conn.last_name}" if vk_conn and vk_conn.first_name else None,
|
||||||
|
"connect_url": "/vk",
|
||||||
|
"disconnect_url": "/vk/disconnect",
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Background Health Check — `web/health_checker.py`
|
||||||
|
|
||||||
|
Add VK check alongside existing Evotor check:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def check_vk_connection(access_token: str) -> bool:
|
||||||
|
"""Call users.get to verify VK token is valid."""
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.get(
|
||||||
|
"https://api.vk.com/method/users.get",
|
||||||
|
params={"access_token": access_token, "v": "5.131"},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return False
|
||||||
|
data = resp.json()
|
||||||
|
# Error code 5 = invalid token
|
||||||
|
if "error" in data:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
```
|
||||||
|
|
||||||
|
In `run_health_checks()`, add a loop over `VkConnection` rows with the same pattern as Evotor checks.
|
||||||
|
|
||||||
|
## Files Summary
|
||||||
|
|
||||||
|
| File | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `web/models.py` | Modify — add `VkConnection` model + User relationship |
|
||||||
|
| `web/config.py` | Modify — add `VK_*` settings |
|
||||||
|
| `web/main.py` | Modify — register vk router |
|
||||||
|
| `web/routes/vk.py` | Create — OAuth flow (connect/callback/disconnect/page) |
|
||||||
|
| `web/routes/connections.py` | Modify — add VK to connections list |
|
||||||
|
| `web/health_checker.py` | Modify — add VK health check |
|
||||||
|
| `web/templates/vk.html` | Create — VK connection page |
|
||||||
|
| Alembic migration | Create — `vk_connections` table |
|
||||||
|
|
||||||
|
## Env Config Needed
|
||||||
|
|
||||||
|
```
|
||||||
|
VK_CLIENT_ID=your_vk_app_id
|
||||||
|
VK_CLIENT_SECRET=your_vk_app_secret
|
||||||
|
VK_SCOPES=market groups offline
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. Run `alembic upgrade head`
|
||||||
|
2. Visit `/connections` — should show VK as disconnected (grey)
|
||||||
|
3. Click VK → "Подключить ВКонтакте" → redirects to VK auth
|
||||||
|
4. After VK auth → callback saves token → redirects to `/connections` → VK shows green
|
||||||
|
5. Visit `/vk` — shows connected state with VK profile info
|
||||||
|
6. Disconnect → VK returns to grey on connections page
|
||||||
|
7. Wait for health check cycle — verify `is_online` and `last_checked_at` update
|
||||||
32
nginx/nginx.conf
Normal file
32
nginx/nginx.conf
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name evosync.ru www.evosync.ru;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name evosync.ru www.evosync.ru;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/evosync.ru/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/evosync.ru/privkey.pem;
|
||||||
|
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
ssl_prefer_server_ciphers on;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://web:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,3 +9,5 @@ passlib[bcrypt]==1.7.4
|
|||||||
bcrypt==4.2.0
|
bcrypt==4.2.0
|
||||||
pydantic-settings==2.5.2
|
pydantic-settings==2.5.2
|
||||||
itsdangerous==2.1.2
|
itsdangerous==2.1.2
|
||||||
|
httpx==0.27.2
|
||||||
|
alembic==1.13.3
|
||||||
|
|||||||
65
run/read_config.py
Normal file
65
run/read_config.py
Normal file
@@ -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 <user_id>
|
||||||
|
|
||||||
|
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 <user_id>", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
user_id = int(sys.argv[1])
|
||||||
|
print(json.dumps(read_config(user_id), ensure_ascii=False, indent=2))
|
||||||
57
scripts/init-letsencrypt.sh
Executable file
57
scripts/init-letsencrypt.sh
Executable file
@@ -0,0 +1,57 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Obtain TLS certificates from Let's Encrypt for evosync.ru
|
||||||
|
# Run once on first deploy: sudo ./scripts/init-letsencrypt.sh
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DOMAIN="evosync.ru"
|
||||||
|
EMAIL="${LETSENCRYPT_EMAIL:-admin@evosync.ru}"
|
||||||
|
COMPOSE="docker compose"
|
||||||
|
CERTBOT_DIR="./certbot"
|
||||||
|
|
||||||
|
echo "==> Creating certbot directories..."
|
||||||
|
mkdir -p "$CERTBOT_DIR/conf" "$CERTBOT_DIR/www"
|
||||||
|
|
||||||
|
echo "==> Starting nginx (HTTP only, for ACME challenge)..."
|
||||||
|
# Temporarily use a basic config that doesn't require certs
|
||||||
|
cat > nginx/nginx-temp.conf <<'TMPCONF'
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name evosync.ru www.evosync.ru;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 200 'Setting up TLS...';
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TMPCONF
|
||||||
|
|
||||||
|
$COMPOSE up -d nginx
|
||||||
|
|
||||||
|
echo "==> Requesting certificate from Let's Encrypt..."
|
||||||
|
docker run --rm \
|
||||||
|
-v "$(pwd)/$CERTBOT_DIR/conf:/etc/letsencrypt" \
|
||||||
|
-v "$(pwd)/$CERTBOT_DIR/www:/var/www/certbot" \
|
||||||
|
--network "${COMPOSE_PROJECT_NAME:-evo-syncgit}_default" \
|
||||||
|
certbot/certbot certonly \
|
||||||
|
--webroot \
|
||||||
|
--webroot-path=/var/www/certbot \
|
||||||
|
--email "$EMAIL" \
|
||||||
|
--agree-tos \
|
||||||
|
--no-eff-email \
|
||||||
|
-d "$DOMAIN" \
|
||||||
|
-d "www.$DOMAIN"
|
||||||
|
|
||||||
|
echo "==> Restoring production nginx config..."
|
||||||
|
rm -f nginx/nginx-temp.conf
|
||||||
|
|
||||||
|
echo "==> Restarting nginx with TLS..."
|
||||||
|
$COMPOSE restart nginx
|
||||||
|
|
||||||
|
echo "==> Done! TLS certificate installed for $DOMAIN"
|
||||||
|
echo " Set up auto-renewal with: sudo crontab -e"
|
||||||
|
echo " Add: 0 3 * * * cd $(pwd) && docker run --rm -v $(pwd)/$CERTBOT_DIR/conf:/etc/letsencrypt -v $(pwd)/$CERTBOT_DIR/www:/var/www/certbot certbot/certbot renew --quiet && docker compose restart nginx"
|
||||||
51
scripts/release.sh
Executable file
51
scripts/release.sh
Executable file
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Release script: bump version, generate changelog, commit, and tag.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/release.sh <major|minor|patch>
|
||||||
|
# ./scripts/release.sh 2.0.0 # explicit version
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION_FILE="version"
|
||||||
|
|
||||||
|
current_version=$(cat "$VERSION_FILE")
|
||||||
|
echo "Current version: $current_version"
|
||||||
|
|
||||||
|
IFS='.' read -r major minor patch <<< "$current_version"
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
major) new_version="$((major + 1)).0.0" ;;
|
||||||
|
minor) new_version="${major}.$((minor + 1)).0" ;;
|
||||||
|
patch) new_version="${major}.${minor}.$((patch + 1))" ;;
|
||||||
|
"")
|
||||||
|
echo "Usage: $0 <major|minor|patch|VERSION>"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
# Validate explicit semver
|
||||||
|
if [[ ! "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
echo "Error: '$1' is not a valid semver (X.Y.Z)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
new_version="$1"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "Bumping to: $new_version"
|
||||||
|
|
||||||
|
# Update version file
|
||||||
|
echo "$new_version" > "$VERSION_FILE"
|
||||||
|
|
||||||
|
# Generate changelog
|
||||||
|
git-cliff --tag "v${new_version}" --output CHANGELOG.md
|
||||||
|
|
||||||
|
# Commit and tag
|
||||||
|
git add "$VERSION_FILE" CHANGELOG.md
|
||||||
|
git commit -m "chore(release): v${new_version}"
|
||||||
|
git tag "v${new_version}"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Released v${new_version}"
|
||||||
|
echo "Don't forget to push: git push && git push --tags"
|
||||||
@@ -7,6 +7,23 @@ class Settings(BaseSettings):
|
|||||||
BASE_URL: str = "http://localhost:8000"
|
BASE_URL: str = "http://localhost:8000"
|
||||||
PASSWORD_RESET_EXPIRE_MINUTES: int = 60
|
PASSWORD_RESET_EXPIRE_MINUTES: int = 60
|
||||||
|
|
||||||
|
EVOTOR_CLIENT_ID: str = ""
|
||||||
|
EVOTOR_CLIENT_SECRET: str = ""
|
||||||
|
EVOTOR_SCOPES: str = "store:read product:read"
|
||||||
|
|
||||||
|
HEALTH_CHECK_INTERVAL_SECONDS: int = 600
|
||||||
|
|
||||||
|
VK_CLIENT_ID: str = ""
|
||||||
|
VK_CLIENT_SECRET: str = ""
|
||||||
|
VK_SCOPES: str = "market groups offline"
|
||||||
|
VK_API_VERSION: str = "5.131"
|
||||||
|
|
||||||
|
# Docker compose vars (ignored in app, kept for env compatibility)
|
||||||
|
DB_ROOT_PASSWORD: str = ""
|
||||||
|
DB_NAME: str = ""
|
||||||
|
DB_USER: str = ""
|
||||||
|
DB_PASSWORD: str = ""
|
||||||
|
|
||||||
model_config = {"env_file": ".env", "case_sensitive": False}
|
model_config = {"env_file": ".env", "case_sensitive": False}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
115
web/evotor_api.py
Normal file
115
web/evotor_api.py
Normal file
@@ -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()
|
||||||
134
web/health_checker.py
Normal file
134
web/health_checker.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from web.database import SessionLocal
|
||||||
|
from web.models import EvotorConnection, VkConnection
|
||||||
|
|
||||||
|
logger = logging.getLogger("uvicorn.error")
|
||||||
|
|
||||||
|
EVOTOR_STORES_URL = "https://api.evotor.ru/stores"
|
||||||
|
EVOTOR_TOKEN_URL = "https://oauth.evotor.ru/oauth/token"
|
||||||
|
VK_USERS_GET_URL = "https://api.vk.com/method/users.get"
|
||||||
|
VK_API_VERSION = "5.131"
|
||||||
|
|
||||||
|
# Refresh Evotor token if it expires within this window
|
||||||
|
REFRESH_BEFORE_EXPIRY = timedelta(hours=2)
|
||||||
|
|
||||||
|
|
||||||
|
async def _refresh_evotor_token(conn: EvotorConnection) -> str | None:
|
||||||
|
"""Attempt to refresh the Evotor access token. Returns new access token or None."""
|
||||||
|
from web.config import settings
|
||||||
|
if not conn.refresh_token:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
EVOTOR_TOKEN_URL,
|
||||||
|
data={
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"refresh_token": conn.refresh_token,
|
||||||
|
},
|
||||||
|
auth=(settings.EVOTOR_CLIENT_ID, settings.EVOTOR_CLIENT_SECRET),
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return None
|
||||||
|
data = resp.json()
|
||||||
|
return data if data.get("access_token") else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def check_evotor_connection(access_token: str) -> bool:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(
|
||||||
|
EVOTOR_STORES_URL,
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
return response.status_code == 200
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def check_vk_connection(access_token: str) -> bool:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.get(
|
||||||
|
VK_USERS_GET_URL,
|
||||||
|
params={"access_token": access_token, "v": VK_API_VERSION},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return False
|
||||||
|
data = resp.json()
|
||||||
|
return "error" not in data
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def run_health_checks() -> None:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
evotor_connections = db.query(EvotorConnection).all()
|
||||||
|
for conn in evotor_connections:
|
||||||
|
# Proactively refresh if token expires soon
|
||||||
|
needs_refresh = (
|
||||||
|
conn.refresh_token and
|
||||||
|
conn.token_expires_at and
|
||||||
|
conn.token_expires_at - now < REFRESH_BEFORE_EXPIRY
|
||||||
|
)
|
||||||
|
if needs_refresh:
|
||||||
|
token_data = await _refresh_evotor_token(conn)
|
||||||
|
if token_data:
|
||||||
|
conn.access_token = token_data["access_token"]
|
||||||
|
conn.refresh_token = token_data.get("refresh_token", conn.refresh_token)
|
||||||
|
expires_in = token_data.get("expires_in")
|
||||||
|
conn.token_expires_at = now + timedelta(seconds=expires_in) if expires_in else None
|
||||||
|
logger.info("Refreshed Evotor token for user_id=%d", conn.user_id)
|
||||||
|
|
||||||
|
is_online = await check_evotor_connection(conn.access_token)
|
||||||
|
|
||||||
|
# If offline and not yet tried refresh, attempt it now
|
||||||
|
if not is_online and conn.refresh_token and not needs_refresh:
|
||||||
|
token_data = await _refresh_evotor_token(conn)
|
||||||
|
if token_data:
|
||||||
|
conn.access_token = token_data["access_token"]
|
||||||
|
conn.refresh_token = token_data.get("refresh_token", conn.refresh_token)
|
||||||
|
expires_in = token_data.get("expires_in")
|
||||||
|
conn.token_expires_at = now + timedelta(seconds=expires_in) if expires_in else None
|
||||||
|
is_online = await check_evotor_connection(conn.access_token)
|
||||||
|
if is_online:
|
||||||
|
logger.info("Evotor token refreshed after failed check for user_id=%d", conn.user_id)
|
||||||
|
|
||||||
|
conn.is_online = is_online
|
||||||
|
conn.last_checked_at = now
|
||||||
|
|
||||||
|
vk_connections = db.query(VkConnection).all()
|
||||||
|
for conn in vk_connections:
|
||||||
|
conn.is_online = await check_vk_connection(conn.access_token)
|
||||||
|
conn.last_checked_at = now
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
logger.info(
|
||||||
|
"Health checks completed: %d Evotor, %d VK",
|
||||||
|
len(evotor_connections),
|
||||||
|
len(vk_connections),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error during health checks")
|
||||||
|
db.rollback()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def health_check_loop(interval: int) -> None:
|
||||||
|
while True:
|
||||||
|
await run_health_checks()
|
||||||
|
await asyncio.sleep(interval)
|
||||||
41
web/main.py
41
web/main.py
@@ -1,13 +1,31 @@
|
|||||||
from fastapi import FastAPI
|
import asyncio
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Depends, Request
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
|
from web.auth import get_current_user
|
||||||
from web.config import settings
|
from web.config import settings
|
||||||
from web.database import engine, Base
|
from web.health_checker import health_check_loop
|
||||||
from web.models import User # noqa: F401 — registers model with Base
|
from web.models import User
|
||||||
from web.routes import auth, profile, reset
|
from web.routes import auth, profile, reset, evotor, vk, sync, catalog
|
||||||
|
from web.routes import connections
|
||||||
|
|
||||||
app = FastAPI(title="EvoSync — Личный кабинет")
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
task = asyncio.create_task(health_check_loop(settings.HEALTH_CHECK_INTERVAL_SECONDS))
|
||||||
|
yield
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="EvoSync — Личный кабинет", lifespan=lifespan)
|
||||||
|
|
||||||
app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY)
|
app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY)
|
||||||
app.mount("/static", StaticFiles(directory="web/static"), name="static")
|
app.mount("/static", StaticFiles(directory="web/static"), name="static")
|
||||||
@@ -15,8 +33,15 @@ app.mount("/static", StaticFiles(directory="web/static"), name="static")
|
|||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(profile.router)
|
app.include_router(profile.router)
|
||||||
app.include_router(reset.router)
|
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.on_event("startup")
|
@app.get("/")
|
||||||
def on_startup():
|
def home(request: Request, user: User | None = Depends(get_current_user)):
|
||||||
Base.metadata.create_all(bind=engine)
|
if user:
|
||||||
|
return RedirectResponse("/profile", 302)
|
||||||
|
return RedirectResponse("/login", 302)
|
||||||
|
|||||||
1
web/migrations/README
Normal file
1
web/migrations/README
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Generic single-database configuration.
|
||||||
54
web/migrations/env.py
Normal file
54
web/migrations/env.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from sqlalchemy import engine_from_config
|
||||||
|
from sqlalchemy import pool
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
from web.config import settings
|
||||||
|
from web.database import Base
|
||||||
|
from web.models import User, EvotorConnection # noqa: F401 — register models with Base
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection, target_metadata=target_metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
26
web/migrations/script.py.mako
Normal file
26
web/migrations/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
60
web/migrations/versions/2c15000e752b_initial.py
Normal file
60
web/migrations/versions/2c15000e752b_initial.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""initial
|
||||||
|
|
||||||
|
Revision ID: 2c15000e752b
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-03-06 09:07:16.180639
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '2c15000e752b'
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"users",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("first_name", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("last_name", sa.String(length=100), nullable=False),
|
||||||
|
sa.Column("email", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("phone", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("password_hash", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("is_email_confirmed", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("email_confirm_token", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("password_reset_token", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("password_reset_expires", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
|
||||||
|
op.create_index(op.f("ix_users_phone"), "users", ["phone"], unique=True)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"evotor_connections",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("access_token", sa.Text(), nullable=False),
|
||||||
|
sa.Column("store_id", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("store_name", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("connected_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("user_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("evotor_connections")
|
||||||
|
op.drop_index(op.f("ix_users_phone"), table_name="users")
|
||||||
|
op.drop_index(op.f("ix_users_email"), table_name="users")
|
||||||
|
op.drop_table("users")
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""add is_online and last_checked_at to evotor_connections
|
||||||
|
|
||||||
|
Revision ID: a1b2c3d4e5f6
|
||||||
|
Revises: 2c15000e752b
|
||||||
|
Create Date: 2026-03-06 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = 'a1b2c3d4e5f6'
|
||||||
|
down_revision = '2c15000e752b'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('evotor_connections',
|
||||||
|
sa.Column('is_online', sa.Boolean(), nullable=False, server_default='0'))
|
||||||
|
op.add_column('evotor_connections',
|
||||||
|
sa.Column('last_checked_at', sa.DateTime(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('evotor_connections', 'last_checked_at')
|
||||||
|
op.drop_column('evotor_connections', 'is_online')
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""add vk_connections table
|
||||||
|
|
||||||
|
Revision ID: b2c3d4e5f6a7
|
||||||
|
Revises: a1b2c3d4e5f6
|
||||||
|
Create Date: 2026-03-06 00:01:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = 'b2c3d4e5f6a7'
|
||||||
|
down_revision = 'a1b2c3d4e5f6'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'vk_connections',
|
||||||
|
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('access_token', sa.Text(), nullable=False),
|
||||||
|
sa.Column('vk_user_id', sa.String(50), nullable=True),
|
||||||
|
sa.Column('first_name', sa.String(255), nullable=True),
|
||||||
|
sa.Column('last_name', sa.String(255), nullable=True),
|
||||||
|
sa.Column('is_online', sa.Boolean(), nullable=False, server_default='0'),
|
||||||
|
sa.Column('last_checked_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('connected_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('user_id'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('vk_connections')
|
||||||
@@ -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')
|
||||||
@@ -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')
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""add refresh_token and token_expires_at to evotor_connections
|
||||||
|
|
||||||
|
Revision ID: e5f6a7b8c9d0
|
||||||
|
Revises: d4e5f6a7b8c9
|
||||||
|
Create Date: 2026-03-06 00:04:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = 'e5f6a7b8c9d0'
|
||||||
|
down_revision = 'd4e5f6a7b8c9'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('evotor_connections', sa.Column('refresh_token', sa.Text(), nullable=True))
|
||||||
|
op.add_column('evotor_connections', sa.Column('token_expires_at', sa.DateTime(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('evotor_connections', 'token_expires_at')
|
||||||
|
op.drop_column('evotor_connections', 'refresh_token')
|
||||||
138
web/models.py
138
web/models.py
@@ -1,4 +1,5 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime
|
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, UniqueConstraint, Numeric, Index
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
from web.database import Base
|
from web.database import Base
|
||||||
@@ -19,3 +20,138 @@ class User(Base):
|
|||||||
password_reset_expires = Column(DateTime, nullable=True)
|
password_reset_expires = Column(DateTime, nullable=True)
|
||||||
created_at = Column(DateTime, server_default=func.now(), nullable=False)
|
created_at = Column(DateTime, server_default=func.now(), nullable=False)
|
||||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||||
|
|
||||||
|
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):
|
||||||
|
__tablename__ = "evotor_connections"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False)
|
||||||
|
access_token = Column(Text, nullable=False)
|
||||||
|
store_id = Column(String(255), nullable=True)
|
||||||
|
store_name = Column(String(255), nullable=True)
|
||||||
|
refresh_token = Column(Text, nullable=True)
|
||||||
|
token_expires_at = Column(DateTime, nullable=True)
|
||||||
|
is_online = Column(Boolean, default=False, server_default="0", nullable=False)
|
||||||
|
last_checked_at = Column(DateTime, nullable=True)
|
||||||
|
connected_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="evotor_connection")
|
||||||
|
|
||||||
|
|
||||||
|
class VkConnection(Base):
|
||||||
|
__tablename__ = "vk_connections"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False)
|
||||||
|
access_token = Column(Text, nullable=False)
|
||||||
|
vk_user_id = Column(String(50), nullable=True)
|
||||||
|
first_name = Column(String(255), nullable=True)
|
||||||
|
last_name = Column(String(255), nullable=True)
|
||||||
|
is_online = Column(Boolean, default=False, server_default="0", nullable=False)
|
||||||
|
last_checked_at = Column(DateTime, nullable=True)
|
||||||
|
connected_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="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")
|
||||||
|
|||||||
309
web/routes/catalog.py
Normal file
309
web/routes/catalog.py
Normal file
@@ -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}"},
|
||||||
|
)
|
||||||
126
web/routes/connections.py
Normal file
126
web/routes/connections.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
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(
|
||||||
|
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()
|
||||||
|
|
||||||
|
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": 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)
|
||||||
175
web/routes/evotor.py
Normal file
175
web/routes/evotor.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import secrets
|
||||||
|
import logging
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, Depends
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from web.auth import get_current_user
|
||||||
|
from web.config import settings
|
||||||
|
from web.database import get_db
|
||||||
|
from web.models import User, EvotorConnection
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/evotor")
|
||||||
|
templates = Jinja2Templates(directory="web/templates")
|
||||||
|
|
||||||
|
EVOTOR_AUTHORIZE_URL = "https://oauth.evotor.ru/oauth/authorize"
|
||||||
|
EVOTOR_TOKEN_URL = "https://oauth.evotor.ru/oauth/token"
|
||||||
|
EVOTOR_STORES_URL = "https://api.evotor.ru/stores"
|
||||||
|
|
||||||
|
|
||||||
|
def _redirect_uri() -> str:
|
||||||
|
return f"{settings.BASE_URL}/evotor/callback"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def evotor_page(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User | None = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
|
connection = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
|
||||||
|
error = request.query_params.get("error")
|
||||||
|
return templates.TemplateResponse("evotor.html", {
|
||||||
|
"request": request,
|
||||||
|
"user": user,
|
||||||
|
"connection": connection,
|
||||||
|
"error": error,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/connect")
|
||||||
|
def evotor_connect(request: Request, user: User | None = Depends(get_current_user)):
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
|
state = secrets.token_urlsafe(32)
|
||||||
|
request.session["evotor_oauth_state"] = state
|
||||||
|
|
||||||
|
params = (
|
||||||
|
f"?client_id={settings.EVOTOR_CLIENT_ID}"
|
||||||
|
f"&response_type=code"
|
||||||
|
f"&redirect_uri={_redirect_uri()}"
|
||||||
|
f"&scope={settings.EVOTOR_SCOPES.replace(' ', '%20')}"
|
||||||
|
f"&state={state}"
|
||||||
|
)
|
||||||
|
return RedirectResponse(EVOTOR_AUTHORIZE_URL + params, 302)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/callback")
|
||||||
|
async def evotor_callback(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User | None = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
|
code = request.query_params.get("code")
|
||||||
|
state = request.query_params.get("state")
|
||||||
|
saved_state = request.session.pop("evotor_oauth_state", None)
|
||||||
|
|
||||||
|
if not code or not state or state != saved_state:
|
||||||
|
return RedirectResponse("/evotor?error=invalid_state", 303)
|
||||||
|
|
||||||
|
# Exchange code for access token
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
token_response = await client.post(
|
||||||
|
EVOTOR_TOKEN_URL,
|
||||||
|
data={
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": _redirect_uri(),
|
||||||
|
"client_id": settings.EVOTOR_CLIENT_ID,
|
||||||
|
"client_secret": settings.EVOTOR_CLIENT_SECRET,
|
||||||
|
},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
token_response.raise_for_status()
|
||||||
|
token_data = token_response.json()
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.error("Evotor token exchange HTTP error %s: %s", e.response.status_code, e.response.text)
|
||||||
|
return RedirectResponse("/evotor?error=token_exchange", 303)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Evotor token exchange failed: %s", e, exc_info=True)
|
||||||
|
return RedirectResponse("/evotor?error=token_exchange", 303)
|
||||||
|
|
||||||
|
access_token = token_data.get("access_token")
|
||||||
|
refresh_token = token_data.get("refresh_token")
|
||||||
|
expires_in = token_data.get("expires_in")
|
||||||
|
if not access_token:
|
||||||
|
return RedirectResponse("/evotor?error=no_token", 303)
|
||||||
|
|
||||||
|
# Fetch first store to save store info
|
||||||
|
store_id = None
|
||||||
|
store_name = None
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
stores_response = await client.get(
|
||||||
|
EVOTOR_STORES_URL,
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
if stores_response.status_code == 200:
|
||||||
|
stores = stores_response.json()
|
||||||
|
items = stores.get("items", stores) if isinstance(stores, dict) else stores
|
||||||
|
if items:
|
||||||
|
store_id = items[0].get("uuid") or items[0].get("id")
|
||||||
|
store_name = items[0].get("name")
|
||||||
|
except Exception:
|
||||||
|
pass # Store info is optional; token is still saved
|
||||||
|
|
||||||
|
# Save or update connection
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
now = datetime.utcnow()
|
||||||
|
token_expires_at = now + timedelta(seconds=expires_in) if expires_in else None
|
||||||
|
|
||||||
|
connection = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
|
||||||
|
if connection:
|
||||||
|
connection.access_token = access_token
|
||||||
|
connection.refresh_token = refresh_token
|
||||||
|
connection.token_expires_at = token_expires_at
|
||||||
|
connection.store_id = store_id
|
||||||
|
connection.store_name = store_name
|
||||||
|
connection.is_online = True
|
||||||
|
connection.last_checked_at = now
|
||||||
|
else:
|
||||||
|
connection = EvotorConnection(
|
||||||
|
user_id=user.id,
|
||||||
|
access_token=access_token,
|
||||||
|
refresh_token=refresh_token,
|
||||||
|
token_expires_at=token_expires_at,
|
||||||
|
store_id=store_id,
|
||||||
|
store_name=store_name,
|
||||||
|
is_online=True,
|
||||||
|
last_checked_at=now,
|
||||||
|
)
|
||||||
|
db.add(connection)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return RedirectResponse("/connections", 303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/disconnect")
|
||||||
|
async def evotor_disconnect(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User | None = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
|
connection = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
|
||||||
|
if connection:
|
||||||
|
db.delete(connection)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return RedirectResponse("/connections", 303)
|
||||||
@@ -47,7 +47,7 @@ async def forgot_submit(request: Request, db: Session = Depends(get_db)):
|
|||||||
return templates.TemplateResponse("message.html", {
|
return templates.TemplateResponse("message.html", {
|
||||||
"request": request, "user": None,
|
"request": request, "user": None,
|
||||||
"title": "Сброс пароля",
|
"title": "Сброс пароля",
|
||||||
"message": "Если аккаунт с таким email существует, ссылка для сброса пароля выведена в консоль сервера.",
|
"message": "Если аккаунт с таким email существует, мы отправили письмо со ссылкой для сброса пароля.",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
102
web/routes/sync.py
Normal file
102
web/routes/sync.py
Normal file
@@ -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)
|
||||||
164
web/routes/vk.py
Normal file
164
web/routes/vk.py
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import secrets
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
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.config import settings
|
||||||
|
from web.database import get_db
|
||||||
|
from web.models import User, VkConnection
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/vk")
|
||||||
|
templates = Jinja2Templates(directory="web/templates")
|
||||||
|
|
||||||
|
VK_AUTHORIZE_URL = "https://oauth.vk.com/authorize"
|
||||||
|
VK_TOKEN_URL = "https://oauth.vk.com/access_token"
|
||||||
|
VK_API_URL = "https://api.vk.com/method"
|
||||||
|
|
||||||
|
|
||||||
|
def _redirect_uri() -> str:
|
||||||
|
return f"{settings.BASE_URL}/vk/callback"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def vk_page(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User | None = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
|
connection = db.query(VkConnection).filter(VkConnection.user_id == user.id).first()
|
||||||
|
error = request.query_params.get("error")
|
||||||
|
return templates.TemplateResponse("vk.html", {
|
||||||
|
"request": request,
|
||||||
|
"user": user,
|
||||||
|
"connection": connection,
|
||||||
|
"error": error,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/connect")
|
||||||
|
def vk_connect(request: Request, user: User | None = Depends(get_current_user)):
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
|
state = secrets.token_urlsafe(32)
|
||||||
|
request.session["vk_oauth_state"] = state
|
||||||
|
|
||||||
|
params = (
|
||||||
|
f"?client_id={settings.VK_CLIENT_ID}"
|
||||||
|
f"&response_type=code"
|
||||||
|
f"&redirect_uri={_redirect_uri()}"
|
||||||
|
f"&scope={settings.VK_SCOPES.replace(' ', '%20')}"
|
||||||
|
f"&state={state}"
|
||||||
|
f"&display=page"
|
||||||
|
f"&v={settings.VK_API_VERSION}"
|
||||||
|
)
|
||||||
|
return RedirectResponse(VK_AUTHORIZE_URL + params, 302)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/callback")
|
||||||
|
async def vk_callback(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User | None = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
|
code = request.query_params.get("code")
|
||||||
|
state = request.query_params.get("state")
|
||||||
|
saved_state = request.session.pop("vk_oauth_state", None)
|
||||||
|
|
||||||
|
if not code or not state or state != saved_state:
|
||||||
|
return RedirectResponse("/vk?error=invalid_state", 303)
|
||||||
|
|
||||||
|
# Exchange code for token (VK uses GET with query params)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
token_response = await client.get(
|
||||||
|
VK_TOKEN_URL,
|
||||||
|
params={
|
||||||
|
"client_id": settings.VK_CLIENT_ID,
|
||||||
|
"client_secret": settings.VK_CLIENT_SECRET,
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": _redirect_uri(),
|
||||||
|
},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
token_response.raise_for_status()
|
||||||
|
token_data = token_response.json()
|
||||||
|
except Exception:
|
||||||
|
return RedirectResponse("/vk?error=token_exchange", 303)
|
||||||
|
|
||||||
|
access_token = token_data.get("access_token")
|
||||||
|
vk_user_id = str(token_data.get("user_id", "")) or None
|
||||||
|
if not access_token:
|
||||||
|
return RedirectResponse("/vk?error=no_token", 303)
|
||||||
|
|
||||||
|
# Fetch VK profile info
|
||||||
|
first_name = None
|
||||||
|
last_name = None
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
profile_response = await client.get(
|
||||||
|
f"{VK_API_URL}/users.get",
|
||||||
|
params={"access_token": access_token, "v": settings.VK_API_VERSION},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
if profile_response.status_code == 200:
|
||||||
|
profile_data = profile_response.json()
|
||||||
|
items = profile_data.get("response", [])
|
||||||
|
if items:
|
||||||
|
first_name = items[0].get("first_name")
|
||||||
|
last_name = items[0].get("last_name")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Save or update connection
|
||||||
|
connection = db.query(VkConnection).filter(VkConnection.user_id == user.id).first()
|
||||||
|
if connection:
|
||||||
|
connection.access_token = access_token
|
||||||
|
connection.vk_user_id = vk_user_id
|
||||||
|
connection.first_name = first_name
|
||||||
|
connection.last_name = last_name
|
||||||
|
connection.is_online = True
|
||||||
|
connection.last_checked_at = datetime.utcnow()
|
||||||
|
else:
|
||||||
|
connection = VkConnection(
|
||||||
|
user_id=user.id,
|
||||||
|
access_token=access_token,
|
||||||
|
vk_user_id=vk_user_id,
|
||||||
|
first_name=first_name,
|
||||||
|
last_name=last_name,
|
||||||
|
is_online=True,
|
||||||
|
last_checked_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(connection)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return RedirectResponse("/connections", 303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/disconnect")
|
||||||
|
async def vk_disconnect(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User | None = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
|
connection = db.query(VkConnection).filter(VkConnection.user_id == user.id).first()
|
||||||
|
if connection:
|
||||||
|
db.delete(connection)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return RedirectResponse("/connections", 303)
|
||||||
@@ -11,8 +11,8 @@ def validate_registration(data: dict) -> list[str]:
|
|||||||
if not email or not re.match(r"^[^@]+@[^@]+\.[^@]+$", email):
|
if not email or not re.match(r"^[^@]+@[^@]+\.[^@]+$", email):
|
||||||
errors.append("Введите корректный email")
|
errors.append("Введите корректный email")
|
||||||
phone = data.get("phone", "").strip()
|
phone = data.get("phone", "").strip()
|
||||||
if not phone or not re.match(r"^\+?[\d\s\-()]{7,20}$", phone):
|
if not phone or not re.match(r"^\+7 \(\d{3}\) \d{3}-\d{2}-\d{2}$", phone):
|
||||||
errors.append("Введите корректный телефон")
|
errors.append("Введите телефон в формате +7 (XXX) XXX-XX-XX")
|
||||||
password = data.get("password", "")
|
password = data.get("password", "")
|
||||||
if len(password) < 8:
|
if len(password) < 8:
|
||||||
errors.append("Пароль должен быть не менее 8 символов")
|
errors.append("Пароль должен быть не менее 8 символов")
|
||||||
@@ -47,6 +47,6 @@ def validate_profile(data: dict) -> list[str]:
|
|||||||
if not data.get("last_name", "").strip():
|
if not data.get("last_name", "").strip():
|
||||||
errors.append("Введите фамилию")
|
errors.append("Введите фамилию")
|
||||||
phone = data.get("phone", "").strip()
|
phone = data.get("phone", "").strip()
|
||||||
if not phone or not re.match(r"^\+?[\d\s\-()]{7,20}$", phone):
|
if not phone or not re.match(r"^\+7 \(\d{3}\) \d{3}-\d{2}-\d{2}$", phone):
|
||||||
errors.append("Введите корректный телефон")
|
errors.append("Введите телефон в формате +7 (XXX) XXX-XX-XX")
|
||||||
return errors
|
return errors
|
||||||
|
|||||||
@@ -18,6 +18,15 @@
|
|||||||
<div class="collapse navbar-collapse" id="navbarNav">
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
<ul class="navbar-nav ms-auto">
|
<ul class="navbar-nav ms-auto">
|
||||||
{% if user %}
|
{% if user %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a href="/connections" class="nav-link">Подключения</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a href="/catalog" class="nav-link">Каталог</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a href="/sync" class="nav-link">Синхронизация</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a href="/profile" class="nav-link"><i class="bi bi-person-circle me-1"></i>Личный кабинет</a>
|
<a href="/profile" class="nav-link"><i class="bi bi-person-circle me-1"></i>Личный кабинет</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -56,5 +65,30 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/inputmask@5.0.9/dist/inputmask.min.js"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
var phoneInputs = document.querySelectorAll('input[name="phone"]');
|
||||||
|
if (phoneInputs.length) {
|
||||||
|
Inputmask('+7 (999) 999-99-99', {
|
||||||
|
placeholder: '_',
|
||||||
|
showMaskOnHover: false,
|
||||||
|
clearMaskOnLostFocus: false
|
||||||
|
}).mask(phoneInputs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('invalid', function(e) {
|
||||||
|
if (e.target.validity.valueMissing) {
|
||||||
|
e.target.setCustomValidity('Пожалуйста, заполните это поле');
|
||||||
|
} else if (e.target.validity.typeMismatch) {
|
||||||
|
e.target.setCustomValidity('Пожалуйста, введите корректное значение');
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
document.addEventListener('input', function(e) {
|
||||||
|
if (e.target.required) e.target.setCustomValidity('');
|
||||||
|
}, true);
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
108
web/templates/catalog_groups.html
Normal file
108
web/templates/catalog_groups.html
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Группы — {{ store.name }} — EvoSync{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<nav aria-label="breadcrumb" class="mb-3">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="/catalog">Каталог</a></li>
|
||||||
|
<li class="breadcrumb-item active">{{ store.name }}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||||
|
<h1 class="h4 mb-0">Группы товаров</h1>
|
||||||
|
<a href="/catalog/export?type=groups&store_id={{ store.evotor_id }}" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-download me-1"></i>Экспорт CSV
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not groups %}
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<i class="bi bi-folder fs-1 mb-3 d-block"></i>
|
||||||
|
<p>Группы не найдены в этом магазине.</p>
|
||||||
|
<a href="/catalog/products?store_id={{ store.evotor_id }}" class="btn btn-outline-primary btn-sm">
|
||||||
|
Посмотреть все товары магазина
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-hover align-middle">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Кол-во товаров</th>
|
||||||
|
<th>Фильтр</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for group in groups %}
|
||||||
|
{% set mode = filter_map.get(group.evotor_id) %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ group.name }}</td>
|
||||||
|
<td class="text-muted">{{ product_counts.get(group.evotor_id, 0) }}</td>
|
||||||
|
<td>
|
||||||
|
{% if mode == "include" %}
|
||||||
|
<span class="badge bg-success">✓ Включено</span>
|
||||||
|
{% elif mode == "exclude" %}
|
||||||
|
<span class="badge bg-danger">✗ Исключено</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-light text-muted border">— Нет правила</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<div class="d-flex gap-1 justify-content-end">
|
||||||
|
<a href="/catalog/products?store_id={{ store.evotor_id }}&group_id={{ group.evotor_id }}"
|
||||||
|
class="btn btn-outline-secondary btn-sm" title="Товары">
|
||||||
|
<i class="bi bi-arrow-right"></i>
|
||||||
|
</a>
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown">
|
||||||
|
<i class="bi bi-funnel"></i>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
<li>
|
||||||
|
<form method="post" action="/catalog/filter">
|
||||||
|
<input type="hidden" name="entity_type" value="group">
|
||||||
|
<input type="hidden" name="entity_id" value="{{ group.evotor_id }}">
|
||||||
|
<input type="hidden" name="entity_name" value="{{ group.name }}">
|
||||||
|
<input type="hidden" name="filter_mode" value="include">
|
||||||
|
<input type="hidden" name="parent_entity_id" value="{{ store.evotor_id }}">
|
||||||
|
<input type="hidden" name="redirect_to" value="/catalog/groups?store_id={{ store.evotor_id }}">
|
||||||
|
<button type="submit" class="dropdown-item">✓ Включить в синхронизацию</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<form method="post" action="/catalog/filter">
|
||||||
|
<input type="hidden" name="entity_type" value="group">
|
||||||
|
<input type="hidden" name="entity_id" value="{{ group.evotor_id }}">
|
||||||
|
<input type="hidden" name="entity_name" value="{{ group.name }}">
|
||||||
|
<input type="hidden" name="filter_mode" value="exclude">
|
||||||
|
<input type="hidden" name="parent_entity_id" value="{{ store.evotor_id }}">
|
||||||
|
<input type="hidden" name="redirect_to" value="/catalog/groups?store_id={{ store.evotor_id }}">
|
||||||
|
<button type="submit" class="dropdown-item">✗ Исключить из синхронизации</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li>
|
||||||
|
<form method="post" action="/catalog/filter">
|
||||||
|
<input type="hidden" name="entity_type" value="group">
|
||||||
|
<input type="hidden" name="entity_id" value="{{ group.evotor_id }}">
|
||||||
|
<input type="hidden" name="entity_name" value="{{ group.name }}">
|
||||||
|
<input type="hidden" name="filter_mode" value="none">
|
||||||
|
<input type="hidden" name="parent_entity_id" value="{{ store.evotor_id }}">
|
||||||
|
<input type="hidden" name="redirect_to" value="/catalog/groups?store_id={{ store.evotor_id }}">
|
||||||
|
<button type="submit" class="dropdown-item text-muted">— Убрать правило</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
123
web/templates/catalog_products.html
Normal file
123
web/templates/catalog_products.html
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Товары — EvoSync{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<nav aria-label="breadcrumb" class="mb-3">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="/catalog">Каталог</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="/catalog/groups?store_id={{ store.evotor_id }}">{{ store.name }}</a></li>
|
||||||
|
{% if group %}
|
||||||
|
<li class="breadcrumb-item active">{{ group.name }}</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="breadcrumb-item active">Все товары</li>
|
||||||
|
{% endif %}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||||
|
<h1 class="h4 mb-0">Товары{% if group %}: {{ group.name }}{% endif %}</h1>
|
||||||
|
<a href="/catalog/export?type=products&store_id={{ store.evotor_id }}{% if group %}&group_id={{ group.evotor_id }}{% endif %}"
|
||||||
|
class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-download me-1"></i>Экспорт CSV
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% set redirect_back %}/catalog/products?store_id={{ store.evotor_id }}{% if group %}&group_id={{ group.evotor_id }}{% endif %}{% endset %}
|
||||||
|
|
||||||
|
{% if not products %}
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<i class="bi bi-box fs-1 mb-3 d-block"></i>
|
||||||
|
<p>Товары не найдены.</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-hover align-middle small">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Артикул</th>
|
||||||
|
<th>Цена</th>
|
||||||
|
<th>Кол-во</th>
|
||||||
|
<th>Ед. изм.</th>
|
||||||
|
<th>В продаже</th>
|
||||||
|
<th>Фильтр</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for product in products %}
|
||||||
|
{% set mode = filter_map.get(product.evotor_id) %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ product.name }}</td>
|
||||||
|
<td class="text-muted font-monospace">{{ product.article_number or "—" }}</td>
|
||||||
|
<td>{% if product.price %}{{ "%.2f"|format(product.price) }} ₽{% else %}—{% endif %}</td>
|
||||||
|
<td>{% if product.quantity is not none %}{{ product.quantity }}{% else %}—{% endif %}</td>
|
||||||
|
<td class="text-muted">{{ product.measure_name or "—" }}</td>
|
||||||
|
<td>
|
||||||
|
{% if product.allow_to_sell is none %}
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
{% elif product.allow_to_sell %}
|
||||||
|
<i class="bi bi-check-circle-fill text-success"></i>
|
||||||
|
{% else %}
|
||||||
|
<i class="bi bi-x-circle-fill text-danger"></i>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if mode == "include" %}
|
||||||
|
<span class="badge bg-success">✓ Включено</span>
|
||||||
|
{% elif mode == "exclude" %}
|
||||||
|
<span class="badge bg-danger">✗ Исключено</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-light text-muted border">— Нет правила</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown">
|
||||||
|
<i class="bi bi-funnel"></i>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
<li>
|
||||||
|
<form method="post" action="/catalog/filter">
|
||||||
|
<input type="hidden" name="entity_type" value="product">
|
||||||
|
<input type="hidden" name="entity_id" value="{{ product.evotor_id }}">
|
||||||
|
<input type="hidden" name="entity_name" value="{{ product.name }}">
|
||||||
|
<input type="hidden" name="filter_mode" value="include">
|
||||||
|
<input type="hidden" name="parent_entity_id" value="{{ product.group_evotor_id or '' }}">
|
||||||
|
<input type="hidden" name="redirect_to" value="{{ redirect_back }}">
|
||||||
|
<button type="submit" class="dropdown-item">✓ Включить в синхронизацию</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<form method="post" action="/catalog/filter">
|
||||||
|
<input type="hidden" name="entity_type" value="product">
|
||||||
|
<input type="hidden" name="entity_id" value="{{ product.evotor_id }}">
|
||||||
|
<input type="hidden" name="entity_name" value="{{ product.name }}">
|
||||||
|
<input type="hidden" name="filter_mode" value="exclude">
|
||||||
|
<input type="hidden" name="parent_entity_id" value="{{ product.group_evotor_id or '' }}">
|
||||||
|
<input type="hidden" name="redirect_to" value="{{ redirect_back }}">
|
||||||
|
<button type="submit" class="dropdown-item">✗ Исключить из синхронизации</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li>
|
||||||
|
<form method="post" action="/catalog/filter">
|
||||||
|
<input type="hidden" name="entity_type" value="product">
|
||||||
|
<input type="hidden" name="entity_id" value="{{ product.evotor_id }}">
|
||||||
|
<input type="hidden" name="entity_name" value="{{ product.name }}">
|
||||||
|
<input type="hidden" name="filter_mode" value="none">
|
||||||
|
<input type="hidden" name="parent_entity_id" value="{{ product.group_evotor_id or '' }}">
|
||||||
|
<input type="hidden" name="redirect_to" value="{{ redirect_back }}">
|
||||||
|
<button type="submit" class="dropdown-item text-muted">— Убрать правило</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
113
web/templates/catalog_stores.html
Normal file
113
web/templates/catalog_stores.html
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Каталог — EvoSync{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||||
|
<h1 class="h4 mb-0">Каталог</h1>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
{% if evotor %}
|
||||||
|
<form method="post" action="/catalog/refresh">
|
||||||
|
<button type="submit" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-arrow-clockwise me-1"></i>Обновить
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<a href="/catalog/export?type=stores" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-download me-1"></i>Экспорт CSV
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not evotor %}
|
||||||
|
<div class="alert alert-warning d-flex align-items-center gap-2">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill"></i>
|
||||||
|
<span>Эвотор не подключён. <a href="/evotor">Подключить Эвотор</a></span>
|
||||||
|
</div>
|
||||||
|
{% elif not stores %}
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<i class="bi bi-shop fs-1 mb-3 d-block"></i>
|
||||||
|
<p>Магазины не найдены в вашем аккаунте Эвотор.</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{% if fetched_at %}
|
||||||
|
<p class="text-muted small mb-3">Последнее обновление: {{ fetched_at.strftime("%d.%m.%Y %H:%M") }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-hover align-middle">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Адрес</th>
|
||||||
|
<th>Фильтр</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for store in stores %}
|
||||||
|
{% set mode = filter_map.get(store.evotor_id) %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ store.name }}</td>
|
||||||
|
<td class="text-muted small">{{ store.address or "—" }}</td>
|
||||||
|
<td>
|
||||||
|
{% if mode == "include" %}
|
||||||
|
<span class="badge bg-success">✓ Включено</span>
|
||||||
|
{% elif mode == "exclude" %}
|
||||||
|
<span class="badge bg-danger">✗ Исключено</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-light text-muted border">— Нет правила</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<div class="d-flex gap-1 justify-content-end">
|
||||||
|
<a href="/catalog/groups?store_id={{ store.evotor_id }}"
|
||||||
|
class="btn btn-outline-secondary btn-sm" title="Группы">
|
||||||
|
<i class="bi bi-arrow-right"></i>
|
||||||
|
</a>
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown">
|
||||||
|
<i class="bi bi-funnel"></i>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
<li>
|
||||||
|
<form method="post" action="/catalog/filter">
|
||||||
|
<input type="hidden" name="entity_type" value="store">
|
||||||
|
<input type="hidden" name="entity_id" value="{{ store.evotor_id }}">
|
||||||
|
<input type="hidden" name="entity_name" value="{{ store.name }}">
|
||||||
|
<input type="hidden" name="filter_mode" value="include">
|
||||||
|
<input type="hidden" name="redirect_to" value="/catalog">
|
||||||
|
<button type="submit" class="dropdown-item">✓ Включить в синхронизацию</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<form method="post" action="/catalog/filter">
|
||||||
|
<input type="hidden" name="entity_type" value="store">
|
||||||
|
<input type="hidden" name="entity_id" value="{{ store.evotor_id }}">
|
||||||
|
<input type="hidden" name="entity_name" value="{{ store.name }}">
|
||||||
|
<input type="hidden" name="filter_mode" value="exclude">
|
||||||
|
<input type="hidden" name="redirect_to" value="/catalog">
|
||||||
|
<button type="submit" class="dropdown-item">✗ Исключить из синхронизации</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li>
|
||||||
|
<form method="post" action="/catalog/filter">
|
||||||
|
<input type="hidden" name="entity_type" value="store">
|
||||||
|
<input type="hidden" name="entity_id" value="{{ store.evotor_id }}">
|
||||||
|
<input type="hidden" name="entity_name" value="{{ store.name }}">
|
||||||
|
<input type="hidden" name="filter_mode" value="none">
|
||||||
|
<input type="hidden" name="redirect_to" value="/catalog">
|
||||||
|
<button type="submit" class="dropdown-item text-muted">— Убрать правило</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -8,8 +8,7 @@
|
|||||||
<div class="card-body p-5">
|
<div class="card-body p-5">
|
||||||
<i class="bi bi-envelope-check display-4 text-primary mb-3"></i>
|
<i class="bi bi-envelope-check display-4 text-primary mb-3"></i>
|
||||||
<h1 class="h4 mb-3">Подтвердите ваш email</h1>
|
<h1 class="h4 mb-3">Подтвердите ваш email</h1>
|
||||||
<p class="text-muted">Ссылка для подтверждения email выведена в консоль сервера.</p>
|
<p class="text-muted">Проверьте почту и нажмите на ссылку для подтверждения.</p>
|
||||||
<p class="text-muted">Скопируйте её и откройте в браузере.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
67
web/templates/connections.html
Normal file
67
web/templates/connections.html
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Подключения — EvoSync{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||||
|
<h1 class="h4 mb-0">Подключения</h1>
|
||||||
|
<a href="/connections/add" class="btn btn-primary btn-sm">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>Добавить
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if connections %}
|
||||||
|
<div class="row g-3">
|
||||||
|
{% for conn in connections %}
|
||||||
|
<div class="col-sm-6 col-lg-4">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
<i class="bi {{ conn.icon }} fs-2 me-3 text-secondary"></i>
|
||||||
|
<div>
|
||||||
|
<h5 class="mb-0">{{ conn.name }}</h5>
|
||||||
|
{% if conn.details %}
|
||||||
|
<small class="text-muted">{{ conn.details }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="ms-auto">
|
||||||
|
{% if conn.is_online %}
|
||||||
|
<i class="bi bi-circle-fill text-success fs-5" title="Онлайн"></i>
|
||||||
|
{% else %}
|
||||||
|
<i class="bi bi-circle-fill text-danger fs-5" title="Офлайн"></i>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{{ conn.configure_url }}" class="btn btn-outline-primary btn-sm flex-fill">Настроить</a>
|
||||||
|
<form method="post" action="/connections/delete?type={{ conn.type }}">
|
||||||
|
<button type="submit"
|
||||||
|
class="btn btn-outline-danger btn-sm"
|
||||||
|
onclick="return confirm('Вы уверены, что хотите отключить {{ conn.name }}?')">
|
||||||
|
Отключить
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer text-muted small">
|
||||||
|
{% if conn.last_checked_at %}
|
||||||
|
Проверено: {{ conn.last_checked_at.strftime("%d.%m.%Y %H:%M") }}
|
||||||
|
{% else %}
|
||||||
|
Статус ещё не проверялся
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<i class="bi bi-plug fs-1 mb-3 d-block"></i>
|
||||||
|
<p class="mb-3">Нет подключённых сервисов</p>
|
||||||
|
<a href="/connections/add" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>Добавить подключение
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
39
web/templates/connections_add.html
Normal file
39
web/templates/connections_add.html
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Добавить подключение — EvoSync{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex align-items-center mb-4">
|
||||||
|
<a href="/connections" class="text-muted me-3"><i class="bi bi-arrow-left fs-5"></i></a>
|
||||||
|
<h1 class="h4 mb-0">Добавить подключение</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if available %}
|
||||||
|
<div class="row g-3">
|
||||||
|
{% for svc in available %}
|
||||||
|
<div class="col-sm-6 col-lg-4">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-body d-flex flex-column">
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
<i class="bi {{ svc.icon }} fs-2 me-3 text-secondary"></i>
|
||||||
|
<h5 class="mb-0">{{ svc.name }}</h5>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted small flex-grow-1">{{ svc.description }}</p>
|
||||||
|
<a href="{{ svc.connect_url }}" class="btn btn-primary btn-sm mt-auto">
|
||||||
|
Подключить <i class="bi bi-arrow-right ms-1"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<i class="bi bi-check-circle fs-1 mb-3 d-block text-success"></i>
|
||||||
|
<p class="mb-3">Все доступные сервисы подключены</p>
|
||||||
|
<a href="/connections" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>Вернуться к подключениям
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
86
web/templates/evotor.html
Normal file
86
web/templates/evotor.html
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Подключение Эвотор — EvoSync{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-sm-10 col-md-7 col-lg-6">
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-danger mt-4">
|
||||||
|
{% if error == "invalid_state" %}
|
||||||
|
<i class="bi bi-exclamation-triangle me-2"></i>Ошибка безопасности. Попробуйте подключить аккаунт заново.
|
||||||
|
{% elif error == "token_exchange" %}
|
||||||
|
<i class="bi bi-exclamation-triangle me-2"></i>Не удалось получить токен доступа от Эвотор. Попробуйте позже.
|
||||||
|
{% elif error == "no_token" %}
|
||||||
|
<i class="bi bi-exclamation-triangle me-2"></i>Эвотор не вернул токен доступа. Попробуйте позже.
|
||||||
|
{% else %}
|
||||||
|
<i class="bi bi-exclamation-triangle me-2"></i>Произошла ошибка при подключении: {{ error }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card shadow-sm mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h1 class="h5 mb-0">Подключение Эвотор</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if connection %}
|
||||||
|
{# ── CONNECTED STATE ── #}
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<span class="text-muted small">Статус</span>
|
||||||
|
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Подключено</span>
|
||||||
|
</li>
|
||||||
|
{% if connection.store_name %}
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<span class="text-muted small">Магазин</span>
|
||||||
|
<span>{{ connection.store_name }}</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if connection.store_id %}
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<span class="text-muted small">ID магазина</span>
|
||||||
|
<span class="font-monospace small text-muted">{{ connection.store_id }}</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<span class="text-muted small">Подключено</span>
|
||||||
|
<span class="small">{{ connection.connected_at.strftime("%d.%m.%Y %H:%M") }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="card-body d-grid gap-2">
|
||||||
|
<a href="/evotor/connect" class="btn btn-primary">Переподключить</a>
|
||||||
|
<form method="post" action="/evotor/disconnect">
|
||||||
|
<button type="submit" class="btn btn-outline-danger w-100">Отключить аккаунт Эвотор</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
{# ── NOT CONNECTED STATE ── #}
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted mb-3">
|
||||||
|
Подключите ваш аккаунт Эвотор, чтобы система могла автоматически синхронизировать
|
||||||
|
каталог товаров из вашей кассы в ВКонтакте.
|
||||||
|
</p>
|
||||||
|
<ul class="text-muted small mb-4">
|
||||||
|
<li>Вы будете перенаправлены на сайт Эвотор для авторизации</li>
|
||||||
|
<li>После подтверждения доступа синхронизация будет настроена автоматически</li>
|
||||||
|
<li>Вы можете отключить доступ в любой момент</li>
|
||||||
|
</ul>
|
||||||
|
<div class="d-grid">
|
||||||
|
<a href="/evotor/connect" class="btn btn-primary btn-lg">Подключить Эвотор</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 text-center">
|
||||||
|
<a href="/connections" class="text-muted small">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>Вернуться к подключениям
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
110
web/templates/sync.html
Normal file
110
web/templates/sync.html
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Синхронизация — EvoSync{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1 class="h4 mb-4">Синхронизация</h1>
|
||||||
|
|
||||||
|
{% if not evotor %}
|
||||||
|
<div class="alert alert-warning d-flex align-items-center gap-2">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill"></i>
|
||||||
|
<span>Эвотор не подключён. <a href="/evotor">Подключить Эвотор</a></span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not vk %}
|
||||||
|
<div class="alert alert-warning d-flex align-items-center gap-2">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill"></i>
|
||||||
|
<span>ВКонтакте не подключён. <a href="/vk">Подключить ВКонтакте</a></span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
|
||||||
|
{# ── Status card ── #}
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title mb-3">Статус</h5>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
{% if status == "active" %}
|
||||||
|
<span class="badge bg-success fs-6"><i class="bi bi-play-fill me-1"></i>Активна</span>
|
||||||
|
{% elif status == "paused" %}
|
||||||
|
<span class="badge bg-secondary fs-6"><i class="bi bi-pause-fill me-1"></i>Приостановлена</span>
|
||||||
|
{% elif status == "pending" %}
|
||||||
|
<span class="badge bg-warning text-dark fs-6"><i class="bi bi-clock me-1"></i>Ожидает подтверждения</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-light text-dark border fs-6"><i class="bi bi-gear me-1"></i>Не настроено</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if config.confirmed_at %}
|
||||||
|
<p class="text-muted small mb-3">
|
||||||
|
Запущена: {{ config.confirmed_at.strftime("%d.%m.%Y %H:%M") }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="d-flex gap-2 flex-wrap">
|
||||||
|
{# Toggle enable/disable #}
|
||||||
|
<form method="post" action="/sync/toggle">
|
||||||
|
{% if config.is_enabled %}
|
||||||
|
<button type="submit" class="btn btn-outline-secondary btn-sm">
|
||||||
|
<i class="bi bi-pause-fill me-1"></i>Приостановить
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<button type="submit" class="btn btn-outline-primary btn-sm" {% if not evotor or not vk %}disabled{% endif %}>
|
||||||
|
<i class="bi bi-play-fill me-1"></i>Включить
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{# Confirm button #}
|
||||||
|
{% if config.is_enabled and summary.total > 0 %}
|
||||||
|
<form method="post" action="/sync/confirm">
|
||||||
|
<button type="submit" class="btn btn-success btn-sm">
|
||||||
|
<i class="bi bi-check-lg me-1"></i>Подтвердить и запустить
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if config.is_enabled and summary.total == 0 %}
|
||||||
|
<p class="text-muted small mt-2 mb-0">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>Настройте фильтры, чтобы подтвердить запуск.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Filters card ── #}
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<div class="card shadow-sm h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title mb-3">Фильтры</h5>
|
||||||
|
|
||||||
|
{% if summary.total > 0 %}
|
||||||
|
<ul class="list-unstyled mb-3">
|
||||||
|
{% if summary.stores > 0 %}
|
||||||
|
<li class="text-muted small"><i class="bi bi-shop me-1"></i>Магазины: {{ summary.stores }} правил</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if summary.groups > 0 %}
|
||||||
|
<li class="text-muted small"><i class="bi bi-folder me-1"></i>Группы: {{ summary.groups }} правил</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if summary.products > 0 %}
|
||||||
|
<li class="text-muted small"><i class="bi bi-box me-1"></i>Товары: {{ summary.products }} правил</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted small mb-3">Фильтры не настроены — будут синхронизированы все товары.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a href="/catalog" class="btn btn-outline-primary btn-sm" {% if not evotor %}disabled{% endif %}>
|
||||||
|
<i class="bi bi-sliders me-1"></i>Настроить фильтры
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
86
web/templates/vk.html
Normal file
86
web/templates/vk.html
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Подключение ВКонтакте — EvoSync{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-sm-10 col-md-7 col-lg-6">
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="alert alert-danger mt-4">
|
||||||
|
{% if error == "invalid_state" %}
|
||||||
|
<i class="bi bi-exclamation-triangle me-2"></i>Ошибка безопасности. Попробуйте подключить аккаунт заново.
|
||||||
|
{% elif error == "token_exchange" %}
|
||||||
|
<i class="bi bi-exclamation-triangle me-2"></i>Не удалось получить токен доступа от ВКонтакте. Попробуйте позже.
|
||||||
|
{% elif error == "no_token" %}
|
||||||
|
<i class="bi bi-exclamation-triangle me-2"></i>ВКонтакте не вернул токен доступа. Попробуйте позже.
|
||||||
|
{% else %}
|
||||||
|
<i class="bi bi-exclamation-triangle me-2"></i>Произошла ошибка при подключении: {{ error }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card shadow-sm mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h1 class="h5 mb-0">Подключение ВКонтакте</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if connection %}
|
||||||
|
{# ── CONNECTED STATE ── #}
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<span class="text-muted small">Статус</span>
|
||||||
|
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Подключено</span>
|
||||||
|
</li>
|
||||||
|
{% if connection.first_name or connection.last_name %}
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<span class="text-muted small">Профиль</span>
|
||||||
|
<span>{{ connection.first_name }} {{ connection.last_name }}</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if connection.vk_user_id %}
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<span class="text-muted small">ID пользователя</span>
|
||||||
|
<span class="font-monospace small text-muted">{{ connection.vk_user_id }}</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<span class="text-muted small">Подключено</span>
|
||||||
|
<span class="small">{{ connection.connected_at.strftime("%d.%m.%Y %H:%M") }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="card-body d-grid gap-2">
|
||||||
|
<a href="/vk/connect" class="btn btn-primary">Переподключить</a>
|
||||||
|
<form method="post" action="/vk/disconnect">
|
||||||
|
<button type="submit" class="btn btn-outline-danger w-100">Отключить аккаунт ВКонтакте</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
{# ── NOT CONNECTED STATE ── #}
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted mb-3">
|
||||||
|
Подключите ваш аккаунт ВКонтакте, чтобы система могла автоматически синхронизировать
|
||||||
|
каталог товаров из Эвотор в вашу группу ВКонтакте.
|
||||||
|
</p>
|
||||||
|
<ul class="text-muted small mb-4">
|
||||||
|
<li>Вы будете перенаправлены на сайт ВКонтакте для авторизации</li>
|
||||||
|
<li>После подтверждения доступа синхронизация будет настроена автоматически</li>
|
||||||
|
<li>Вы можете отключить доступ в любой момент</li>
|
||||||
|
</ul>
|
||||||
|
<div class="d-grid">
|
||||||
|
<a href="/vk/connect" class="btn btn-primary btn-lg">Подключить ВКонтакте</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 text-center">
|
||||||
|
<a href="/connections" class="text-muted small">
|
||||||
|
<i class="bi bi-arrow-left me-1"></i>Вернуться к подключениям
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user