Compare commits
36 Commits
fc65e591b3
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75513e647d | ||
|
|
db0a3bb4d6 | ||
|
|
545c6aade6 | ||
|
|
98264d42af | ||
|
|
fca3ca115e | ||
|
|
52825f70de | ||
|
|
8549e98f8d | ||
|
|
5e7be16755 | ||
|
|
1729ff9b7b | ||
|
|
ebcca2a699 | ||
|
|
7860256c37 | ||
|
|
ddc3dc0a97 | ||
|
|
ff32812b61 | ||
|
|
dbb1f48da7 | ||
|
|
23e175d9a8 | ||
|
|
e816672e16 | ||
|
|
7df5da76d7 | ||
|
|
75b3872170 | ||
|
|
5c2b501749 | ||
|
|
72194131c7 | ||
|
|
02abddc587 | ||
|
|
e169a91146 | ||
|
|
fb3b6e2327 | ||
|
|
8d97f75fa1 | ||
|
|
e0e43f3fc3 | ||
|
|
3ad383d00b | ||
|
|
d25caa2b96 | ||
|
|
b926ca0b90 | ||
|
|
9960d760a0 | ||
|
|
cad0b10fbb | ||
|
|
bb9fc71ed8 | ||
|
|
83edac4200 | ||
|
|
7b4f52b005 | ||
|
|
4f4081c54c | ||
|
|
796cf49ff9 | ||
|
|
7a06045bef |
@@ -6,7 +6,8 @@ DB_PASSWORD=evosync
|
|||||||
|
|
||||||
# App
|
# App
|
||||||
SECRET_KEY=change-me-in-production
|
SECRET_KEY=change-me-in-production
|
||||||
BASE_URL=https://evosync.ru
|
DOMAIN=yourdomain.com
|
||||||
|
BASE_URL=https://${DOMAIN}
|
||||||
|
|
||||||
# Evotor
|
# Evotor
|
||||||
EVOTOR_APP_ID=
|
EVOTOR_APP_ID=
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -18,3 +18,5 @@ __pycache__/
|
|||||||
*.pyc
|
*.pyc
|
||||||
certbot
|
certbot
|
||||||
web-resources
|
web-resources
|
||||||
|
.coverage
|
||||||
|
password|*
|
||||||
|
|||||||
289
README.md
289
README.md
@@ -1,3 +1,288 @@
|
|||||||
# ЭВОСИНК (EvoSync)
|
# EvoSync
|
||||||
|
|
||||||
Сервис автоматической синхронизации товарного каталога из [Эвотор](https://evotor.ru) в магазин [ВКонтакте](https://vk.com).
|
Web service for syncing a product catalog from Evotor POS → VK Market. Users connect their Evotor account and a VK community; products from the cash register then appear automatically in the VK store.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Docker Compose │
|
||||||
|
│ │
|
||||||
|
Browser / Evotor ─────► web :8000 (FastAPI + Uvicorn) │
|
||||||
|
:8080 │ │ │
|
||||||
|
│ ├── MariaDB :3306 (primary DB) │
|
||||||
|
│ ├── Redis :6379 (Celery broker) │
|
||||||
|
│ │ │
|
||||||
|
│ ├── worker (Celery worker) │
|
||||||
|
│ ├── beat (Celery beat scheduler) │
|
||||||
|
│ └── flower :5555 (queue monitor) │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Services
|
||||||
|
|
||||||
|
| Service | Image / Dockerfile | Purpose | External port |
|
||||||
|
|----------|---------------------|-----------------------------------------------|---------------|
|
||||||
|
| `web` | `Dockerfile.web` | FastAPI app, runs Alembic migrations on start | 8080 → 8000 |
|
||||||
|
| `worker` | `Dockerfile.web` | Celery worker (sync, health, notifications…) | — |
|
||||||
|
| `beat` | `Dockerfile.web` | Celery Beat — periodic task scheduler | — |
|
||||||
|
| `flower` | `Dockerfile.web` | Celery queue monitoring UI | 5555 |
|
||||||
|
| `db` | `mariadb:11.4` | Primary relational database | — |
|
||||||
|
| `redis` | `redis:7-alpine` | Celery broker and result backend | — |
|
||||||
|
|
||||||
|
### Stack
|
||||||
|
|
||||||
|
- **Python 3.12**, FastAPI 0.115, Uvicorn
|
||||||
|
- **SQLAlchemy 2** + Alembic, MariaDB (PyMySQL)
|
||||||
|
- **Celery 5** + Redis — background tasks, periodic catalog sync
|
||||||
|
- **Jinja2** — server-side HTML rendering (`web/templates/`)
|
||||||
|
- **Pydantic Settings** — configuration from env vars / `.env`
|
||||||
|
- bcrypt — password hashing
|
||||||
|
- python-json-logger — structured JSON logs to stdout
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
| Table | Purpose |
|
||||||
|
|-----------------------|--------------------------------------------------------------------------------------|
|
||||||
|
| `users` | User accounts (roles: system / admin / user; statuses: pending / active / suspended) |
|
||||||
|
| `evotor_connections` | User ↔ Evotor link (access_token, api_token returned to Evotor webhooks) |
|
||||||
|
| `vk_connections` | User ↔ VK link (user access token + VK community ID) |
|
||||||
|
| `sync_configs` | Per-user sync settings |
|
||||||
|
| `sync_filters` | Store / group inclusion filters (entity_type: store / group) |
|
||||||
|
| `cached_stores` | Cached list of Evotor stores |
|
||||||
|
| `cached_groups` | Cached Evotor product groups |
|
||||||
|
| `cached_products` | Cached Evotor products; `vk_product_id` stores the VK market item ID after first push |
|
||||||
|
| `vk_cached_albums` | Cached VK Market albums (product groups) |
|
||||||
|
| `vk_cached_products` | Cached VK Market products |
|
||||||
|
| `roles` | RBAC roles |
|
||||||
|
| `permissions` | RBAC permissions |
|
||||||
|
| `role_permissions` | M2M: role ↔ permission |
|
||||||
|
| `user_roles` | M2M: user ↔ role |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Background Tasks
|
||||||
|
|
||||||
|
Periodic tasks run via **Celery Beat** and are executed by the **worker** service.
|
||||||
|
|
||||||
|
Beat fires a single **sync pipeline** every `CATALOG_REFRESH_INTERVAL_SECONDS`. The three tasks run as a Celery chain — each step starts only after the previous one completes:
|
||||||
|
|
||||||
|
```
|
||||||
|
run_sync_pipeline (beat entry)
|
||||||
|
└─► refresh_catalog — fetch Evotor stores / groups / products
|
||||||
|
└─► refresh_vk_catalog — fetch VK Market albums / products
|
||||||
|
└─► mirror_to_vk — push Evotor → VK
|
||||||
|
```
|
||||||
|
|
||||||
|
| Task | Queue | Description |
|
||||||
|
|------|-------|-------------|
|
||||||
|
| `web.tasks.celery_app.run_sync_pipeline` | default | Beat entry point; dispatches the chain |
|
||||||
|
| `web.tasks.catalog.refresh_catalog` | default | Fetches Evotor catalog for all connected users; upserts `cached_stores`, `cached_groups`, `cached_products` |
|
||||||
|
| `web.tasks.vk_catalog.refresh_vk_catalog` | default | Fetches VK Market albums and products for all connected users; upserts `vk_cached_albums`, `vk_cached_products` |
|
||||||
|
| `web.tasks.vk_sync.mirror_to_vk` | sync | Mirrors enabled Evotor groups/products → VK Market (create or conditional update) |
|
||||||
|
|
||||||
|
**Evotor fetch sequence per user:**
|
||||||
|
1. `GET /stores` → upsert `cached_stores`
|
||||||
|
2. For each store: `GET /stores/{id}/product-groups` → upsert `cached_groups`
|
||||||
|
3. For each store: `GET /stores/{id}/products` → upsert `cached_products`
|
||||||
|
|
||||||
|
**VK fetch sequence per user:**
|
||||||
|
1. `market.getAlbums` → upsert `vk_cached_albums`
|
||||||
|
2. `market.get` (extended=1, paginated) → upsert `vk_cached_products` with album membership
|
||||||
|
|
||||||
|
**Mirror logic per user (`mirror_to_vk`):**
|
||||||
|
- Skips stores not in enabled store filters; skips groups not in enabled group filters
|
||||||
|
- For each enabled group: ensures a matching VK album exists (creates via `market.addAlbum` if missing)
|
||||||
|
- For each product in the group:
|
||||||
|
- **Create** (`allow_to_sell=true`, no `vk_product_id` yet): uploads default photo once per run, calls `market.add`, assigns to album, stores returned `vk_product_id`
|
||||||
|
- **Update** (has `vk_product_id`): calls `market.edit` only if name, price, description, or stock_amount changed vs the cached VK state
|
||||||
|
- **Skip**: product disabled and never pushed, or nothing changed
|
||||||
|
- Price is multiplied by the per-user `price_multiplier` from `sync_configs` (configurable on the `/sync` page)
|
||||||
|
- Description is built as `"Name (цена за N unit.)"` when the product has a `measure_name`
|
||||||
|
|
||||||
|
Per-user failures are logged and skipped — one broken token does not block other users.
|
||||||
|
Evotor stores that return `402 Payment Required` (subscription limit) are silently skipped at debug log level.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
### Connections
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|-----------------------------------|------------------------------------------|
|
||||||
|
| GET | `/connections` | View Evotor and VK connection status |
|
||||||
|
| POST | `/connections/evotor` | Save / update Evotor API token manually |
|
||||||
|
| POST | `/connections/evotor/disconnect` | Remove Evotor connection |
|
||||||
|
| POST | `/connections/evotor/test` | Test Evotor connection (JSON) |
|
||||||
|
| POST | `/connections/vk` | Save / update VK token and group ID |
|
||||||
|
| POST | `/connections/vk/disconnect` | Remove VK connection |
|
||||||
|
| POST | `/connections/vk/test` | Test VK connection (JSON) |
|
||||||
|
|
||||||
|
### Public / Authentication
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|----------|--------------------|----------------------------------------|
|
||||||
|
| GET | `/` | Redirects to `/profile` or `/login` |
|
||||||
|
| GET | `/health` | Health check (JSON) |
|
||||||
|
| GET/POST | `/register` | User registration |
|
||||||
|
| GET | `/confirm-email` | Email confirmation via token |
|
||||||
|
| GET | `/resend-confirm` | Resend confirmation email |
|
||||||
|
| GET/POST | `/login` | Login |
|
||||||
|
| GET | `/logout` | Logout |
|
||||||
|
| GET/POST | `/forgot-password` | Request password reset |
|
||||||
|
| GET/POST | `/reset-password` | Reset password via token |
|
||||||
|
| GET/POST | `/invite` | Complete registration via invite link |
|
||||||
|
|
||||||
|
### Profile (requires session)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|----------|----------------------------|----------------------|
|
||||||
|
| GET | `/profile` | View profile |
|
||||||
|
| GET/POST | `/profile/edit` | Edit profile |
|
||||||
|
| GET/POST | `/profile/change-password` | Change password |
|
||||||
|
| GET/POST | `/profile/delete` | Delete account |
|
||||||
|
|
||||||
|
### Admin panel (`/admin`, roles: admin / system)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------------------------------------|---------------------------|
|
||||||
|
| GET | `/admin/users` | User list |
|
||||||
|
| GET | `/admin/users/{id}` | User detail |
|
||||||
|
| POST | `/admin/users/{id}/activate` | Activate user |
|
||||||
|
| POST | `/admin/users/{id}/suspend` | Suspend user |
|
||||||
|
| POST | `/admin/users/{id}/reset-password` | Reset user password |
|
||||||
|
| POST | `/admin/users/{id}/send-invite` | Send invite email |
|
||||||
|
| POST | `/admin/users/{id}/edit` | Edit user data |
|
||||||
|
| POST | `/admin/users/{id}/delete` | Delete user |
|
||||||
|
| GET | `/admin/roles` | Roles and permissions |
|
||||||
|
| POST | `/admin/roles/{id}/permissions` | Update role permissions |
|
||||||
|
|
||||||
|
### Evotor Webhooks (Bearer `EVOTOR_WEBHOOK_SECRET`)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|----------------|-----------------------------------------------------------|
|
||||||
|
| POST | `/user/create` | Evotor creates/links a user; returns api_token |
|
||||||
|
| POST | `/user/verify` | Evotor verifies user credentials; returns api_token |
|
||||||
|
| POST | `/user/token` | Evotor delivers its own access_token for a user |
|
||||||
|
|
||||||
|
### Evotor Catalog (requires session)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|---------------------------------------------------|--------------------------------------------|
|
||||||
|
| GET | `/catalog` | Redirects to `/catalog/stores` |
|
||||||
|
| GET | `/catalog/stores` | Evotor stores with per-store sync toggle |
|
||||||
|
| GET | `/catalog/stores/{id}/groups` | Product groups with per-group sync toggle |
|
||||||
|
| GET | `/catalog/stores/{id}/products` | Products (filterable by group) |
|
||||||
|
| POST | `/catalog/stores/{id}/toggle` | Enable / disable store sync |
|
||||||
|
| POST | `/catalog/stores/{id}/groups/{gid}/toggle` | Enable / disable group sync |
|
||||||
|
|
||||||
|
### Sync Settings (requires session)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|----------|------------------|------------------------------------------------------|
|
||||||
|
| GET | `/sync` | Sync settings: task on/off switches, price multiplier |
|
||||||
|
| POST | `/sync/settings` | Save sync settings |
|
||||||
|
|
||||||
|
### VK Catalog (requires session)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|---------------------------------------|------------------------------------|
|
||||||
|
| GET | `/vk-catalog/albums` | VK Market albums (product groups) |
|
||||||
|
| GET | `/vk-catalog/albums/{id}/products` | Products in a VK album |
|
||||||
|
|
||||||
|
### Admin Logs (`/admin`, roles: admin / system)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|---------------|-------------------------------------------------------|
|
||||||
|
| GET | `/admin/logs` | API request/response log viewer with filters and pagination |
|
||||||
|
|
||||||
|
### API Docs
|
||||||
|
|
||||||
|
| Path | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `/docs` | Swagger UI |
|
||||||
|
| `/redoc` | ReDoc |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All settings are read from environment variables or a `.env` file:
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|------------------------------------|-------------------------------------|---------------------------------------|
|
||||||
|
| `DATABASE_URL` | `mysql+pymysql://…@db:3306/evosync` | MariaDB connection string |
|
||||||
|
| `REDIS_URL` | `redis://redis:6379/0` | Redis connection string |
|
||||||
|
| `SECRET_KEY` | `change-me-in-production` | Session signing key |
|
||||||
|
| `DOMAIN` | — | Public domain name (e.g. `example.com`); used to derive `BASE_URL` and nginx config |
|
||||||
|
| `BASE_URL` | `https://${DOMAIN}` | Public URL of the service |
|
||||||
|
| `EVOTOR_APP_ID` | — | Evotor application ID |
|
||||||
|
| `EVOTOR_WEBHOOK_SECRET` | — | Bearer secret for webhook endpoints |
|
||||||
|
| `JIVOSITE_WIDGET_ID` | — | JivoSite widget ID |
|
||||||
|
| `VK_DEFAULT_PHOTO_PATH` | `/app/default_product.png` | Fallback image path for VK products |
|
||||||
|
| `VK_API_VERSION` | `5.199` | VK API version |
|
||||||
|
| `CATALOG_REFRESH_INTERVAL_SECONDS` | `3600` | Sync pipeline interval in seconds |
|
||||||
|
| `VK_CATEGORY_ID` | `40932` | VK Market category ID for all products |
|
||||||
|
| `VK_STOCK_AMOUNT` | `1000` | Stock amount set for in-sale products |
|
||||||
|
| `INVITE_EXPIRE_HOURS` | `48` | Invite link TTL in hours |
|
||||||
|
| `EMAIL_PROVIDER` | `console` | Email provider (console / smtp / …) |
|
||||||
|
| `SMS_PROVIDER` | `console` | SMS provider |
|
||||||
|
| `FLOWER_USER` / `FLOWER_PASSWORD` | `admin` / `changeme` | Basic Auth credentials for Flower |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # set DOMAIN and other values
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
App is available at `http://localhost:8080`.
|
||||||
|
Flower (queue monitor) at `http://localhost:5555`.
|
||||||
|
|
||||||
|
### First deploy (TLS)
|
||||||
|
|
||||||
|
Run once per domain (repeat for every domain pointing to this server):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Obtain TLS certificate
|
||||||
|
sudo ./scripts/init-letsencrypt.sh my-products.ru
|
||||||
|
sudo ./scripts/init-letsencrypt.sh мои-товары.рф
|
||||||
|
|
||||||
|
# 2. Generate nginx site config and symlink it into sites-enabled
|
||||||
|
sudo ./scripts/generate-nginx-conf.sh my-products.ru
|
||||||
|
sudo ./scripts/generate-nginx-conf.sh мои-товары.рф
|
||||||
|
|
||||||
|
# 3. Reload nginx
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
`generate-nginx-conf.sh` expands `nginx/nginx.conf.template` with the given domain and writes the result to `/etc/nginx/sites-available/<domain>.conf`, then creates a symlink in `sites-enabled`.
|
||||||
|
|
||||||
|
Set up auto-renewal (if not already configured by certbot):
|
||||||
|
|
||||||
|
```
|
||||||
|
0 3 * * * root certbot renew --quiet && systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
alembic upgrade head
|
||||||
|
uvicorn web.main:app --reload --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest --cov=web
|
||||||
|
```
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ services:
|
|||||||
db:
|
db:
|
||||||
image: mariadb:11.4
|
image: mariadb:11.4
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
command: --innodb-buffer-pool-size=128M --max-connections=20
|
||||||
environment:
|
environment:
|
||||||
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
|
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
|
||||||
MYSQL_DATABASE: ${DB_NAME}
|
MYSQL_DATABASE: ${DB_NAME}
|
||||||
@@ -39,7 +40,7 @@ services:
|
|||||||
DATABASE_URL: mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db:3306/${DB_NAME}
|
DATABASE_URL: mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db:3306/${DB_NAME}
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
|
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
|
||||||
BASE_URL: ${BASE_URL:-https://evosync.ru}
|
BASE_URL: ${BASE_URL:-https://${DOMAIN}}
|
||||||
EVOTOR_APP_ID: ${EVOTOR_APP_ID:-}
|
EVOTOR_APP_ID: ${EVOTOR_APP_ID:-}
|
||||||
EVOTOR_WEBHOOK_SECRET: ${EVOTOR_WEBHOOK_SECRET:-}
|
EVOTOR_WEBHOOK_SECRET: ${EVOTOR_WEBHOOK_SECRET:-}
|
||||||
JIVOSITE_WIDGET_ID: ${JIVOSITE_WIDGET_ID:-}
|
JIVOSITE_WIDGET_ID: ${JIVOSITE_WIDGET_ID:-}
|
||||||
@@ -73,7 +74,7 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
command: celery -A web.tasks.celery_app worker --loglevel=info --concurrency=2 --queues=default,sync,health,notifications
|
command: celery -A web.tasks.celery_app worker --loglevel=info --concurrency=1 --queues=default,sync,health,notifications -E
|
||||||
|
|
||||||
beat:
|
beat:
|
||||||
build:
|
build:
|
||||||
@@ -84,6 +85,7 @@ services:
|
|||||||
DATABASE_URL: mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db:3306/${DB_NAME}
|
DATABASE_URL: mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db:3306/${DB_NAME}
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
|
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
|
||||||
|
CATALOG_REFRESH_INTERVAL_SECONDS: ${CATALOG_REFRESH_INTERVAL_SECONDS:-3600}
|
||||||
depends_on:
|
depends_on:
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -96,6 +98,7 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.web
|
dockerfile: Dockerfile.web
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
profiles: [flower]
|
||||||
ports:
|
ports:
|
||||||
- "5555:5555"
|
- "5555:5555"
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ upstream web {
|
|||||||
server 127.0.0.1:8080;
|
server 127.0.0.1:8080;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── мои-товары.рф ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name evosync.ru www.evosync.ru;
|
server_name xn----8sbfwtmcso8g.xn--p1ai www.xn----8sbfwtmcso8g.xn--p1ai;
|
||||||
|
|
||||||
location /.well-known/acme-challenge/ {
|
location /.well-known/acme-challenge/ {
|
||||||
root /var/www/certbot;
|
root /var/www/certbot;
|
||||||
@@ -17,10 +19,45 @@ server {
|
|||||||
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl;
|
listen 443 ssl;
|
||||||
server_name evosync.ru www.evosync.ru;
|
server_name xn----8sbfwtmcso8g.xn--p1ai www.xn----8sbfwtmcso8g.xn--p1ai;
|
||||||
|
|
||||||
ssl_certificate /etc/letsencrypt/live/evosync.ru/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/xn----8sbfwtmcso8g.xn--p1ai/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/evosync.ru/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/xn----8sbfwtmcso8g.xn--p1ai/privkey.pem;
|
||||||
|
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
ssl_prefer_server_ciphers on;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://web;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── my-products.ru ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name my-products.ru www.my-products.ru;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name my-products.ru www.my-products.ru;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/my-products.ru/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/my-products.ru/privkey.pem;
|
||||||
|
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ jinja2==3.1.4
|
|||||||
sqlalchemy==2.0.36
|
sqlalchemy==2.0.36
|
||||||
alembic==1.14.0
|
alembic==1.14.0
|
||||||
pymysql==1.1.1
|
pymysql==1.1.1
|
||||||
|
itsdangerous>=2.1.0
|
||||||
cryptography>=44.0.0
|
cryptography>=44.0.0
|
||||||
bcrypt>=4.2.1
|
bcrypt>=4.2.1
|
||||||
pydantic-settings==2.6.1
|
pydantic-settings==2.6.1
|
||||||
|
|||||||
78
scripts/create_admin.py
Normal file
78
scripts/create_admin.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""
|
||||||
|
Create (or promote) an admin user with all permissions.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python -m scripts.create_admin --email admin@example.com --password secret
|
||||||
|
python -m scripts.create_admin --email admin@example.com --password secret --role system
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from web.auth.password import hash_password
|
||||||
|
from web.database import SessionLocal
|
||||||
|
from web.models.rbac import Permission, Role, UserRole, role_permissions
|
||||||
|
from web.models.user import User, UserRoleEnum, UserStatusEnum
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--email", required=True)
|
||||||
|
parser.add_argument("--password", required=True)
|
||||||
|
parser.add_argument("--first-name", default="Admin")
|
||||||
|
parser.add_argument("--last-name", default="")
|
||||||
|
parser.add_argument("--phone", default="")
|
||||||
|
parser.add_argument("--role", default="system", choices=["admin", "system"])
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
user = db.query(User).filter(User.email == args.email).first()
|
||||||
|
if user:
|
||||||
|
user.role = UserRoleEnum(args.role)
|
||||||
|
user.status = UserStatusEnum.active
|
||||||
|
user.is_email_confirmed = True
|
||||||
|
user.password_hash = hash_password(args.password)
|
||||||
|
print(f"Updated existing user: {args.email} → role={args.role}")
|
||||||
|
else:
|
||||||
|
user = User(
|
||||||
|
first_name=args.first_name,
|
||||||
|
last_name=args.last_name,
|
||||||
|
email=args.email,
|
||||||
|
phone=args.phone or None,
|
||||||
|
password_hash=hash_password(args.password),
|
||||||
|
role=UserRoleEnum(args.role),
|
||||||
|
status=UserStatusEnum.active,
|
||||||
|
is_email_confirmed=True,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.flush()
|
||||||
|
print(f"Created user: {args.email} role={args.role}")
|
||||||
|
|
||||||
|
# Assign all permissions via the system role
|
||||||
|
system_role = db.query(Role).filter(Role.name == args.role).first()
|
||||||
|
if system_role:
|
||||||
|
existing = db.query(UserRole).filter(
|
||||||
|
UserRole.user_id == user.id,
|
||||||
|
UserRole.role_id == system_role.id,
|
||||||
|
).first()
|
||||||
|
if not existing:
|
||||||
|
db.add(UserRole(user_id=user.id, role_id=system_role.id))
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Report permissions this user now has
|
||||||
|
perms = (
|
||||||
|
db.query(Permission.name)
|
||||||
|
.join(role_permissions, Permission.id == role_permissions.c.permission_id)
|
||||||
|
.join(UserRole, UserRole.role_id == role_permissions.c.role_id)
|
||||||
|
.filter(UserRole.user_id == user.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
print("Permissions:", [p.name for p in perms] or "(inherited via role)")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,19 +1,39 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Obtain TLS certificates from Let's Encrypt for evosync.ru
|
# Obtain a TLS certificate from Let's Encrypt for one domain.
|
||||||
# Run once on first deploy: sudo ./scripts/init-letsencrypt.sh
|
#
|
||||||
# Requires nginx running on the host with acme-challenge location configured
|
# Usage:
|
||||||
|
# sudo ./scripts/init-letsencrypt.sh my-products.ru
|
||||||
|
# sudo ./scripts/init-letsencrypt.sh xn----8sbfwtmcso8g.xn--p1ai
|
||||||
|
#
|
||||||
|
# For IDN/Cyrillic domains, pass the punycode form (certbot requires ASCII).
|
||||||
|
# If no argument is given, DOMAIN is read from .env.
|
||||||
|
# Run once per domain on first deploy.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
DOMAIN="evosync.ru"
|
# ── resolve domain ────────────────────────────────────────────────────────────
|
||||||
EMAIL="${LETSENCRYPT_EMAIL:-admin@evosync.ru}"
|
if [ -n "${1:-}" ]; then
|
||||||
CERTBOT_DIR="./certbot"
|
DOMAIN="$1"
|
||||||
|
else
|
||||||
|
if [ -f .env ]; then
|
||||||
|
DOMAIN_FROM_ENV=$(grep -E '^DOMAIN=' .env | cut -d= -f2- | tr -d '"'"'" | head -1)
|
||||||
|
DOMAIN="${DOMAIN:-${DOMAIN_FROM_ENV:-}}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${DOMAIN:-}" ]; then
|
||||||
|
echo "ERROR: no domain specified." >&2
|
||||||
|
echo "Usage: $0 <domain> or set DOMAIN= in .env" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
EMAIL="${LETSENCRYPT_EMAIL:-admin@$DOMAIN}"
|
||||||
ACME_DIR="/var/www/certbot"
|
ACME_DIR="/var/www/certbot"
|
||||||
|
|
||||||
echo "==> Creating certbot directories..."
|
echo "==> Obtaining certificate for: $DOMAIN (www.$DOMAIN)"
|
||||||
mkdir -p "$CERTBOT_DIR/conf" "$CERTBOT_DIR/www"
|
echo " Email: $EMAIL"
|
||||||
|
|
||||||
echo "==> Ensuring acme-challenge directory exists on host..."
|
echo "==> Ensuring acme-challenge directory exists..."
|
||||||
sudo mkdir -p "$ACME_DIR"
|
sudo mkdir -p "$ACME_DIR"
|
||||||
sudo chmod 755 "$ACME_DIR"
|
sudo chmod 755 "$ACME_DIR"
|
||||||
|
|
||||||
@@ -27,20 +47,14 @@ sudo certbot certonly \
|
|||||||
-d "$DOMAIN" \
|
-d "$DOMAIN" \
|
||||||
-d "www.$DOMAIN"
|
-d "www.$DOMAIN"
|
||||||
|
|
||||||
echo "==> Copying certificates to project directory..."
|
|
||||||
sudo cp "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" "$CERTBOT_DIR/conf/fullchain.pem"
|
|
||||||
sudo cp "/etc/letsencrypt/live/$DOMAIN/privkey.pem" "$CERTBOT_DIR/conf/privkey.pem"
|
|
||||||
sudo chown "$(whoami):$(whoami)" "$CERTBOT_DIR/conf"/*.pem
|
|
||||||
|
|
||||||
echo "==> Done! TLS certificate installed for $DOMAIN"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Certificate files:"
|
echo "==> Certificate obtained for $DOMAIN"
|
||||||
echo " - $CERTBOT_DIR/conf/fullchain.pem"
|
echo " /etc/letsencrypt/live/$DOMAIN/fullchain.pem"
|
||||||
echo " - $CERTBOT_DIR/conf/privkey.pem"
|
echo " /etc/letsencrypt/live/$DOMAIN/privkey.pem"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Configure nginx:"
|
echo "==> Generate nginx config and reload:"
|
||||||
echo " ssl_certificate $CERTBOT_DIR/conf/fullchain.pem;"
|
echo " sudo ./scripts/generate-nginx-conf.sh $DOMAIN"
|
||||||
echo " ssl_certificate_key $CERTBOT_DIR/conf/privkey.pem;"
|
echo " sudo nginx -t && sudo systemctl reload nginx"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Set up auto-renewal with: sudo crontab -e"
|
echo "==> Auto-renewal (add to /etc/cron.d/certbot if not already present):"
|
||||||
echo "Add: 0 3 * * * certbot renew --quiet && systemctl reload nginx"
|
echo " 0 3 * * * root certbot renew --quiet && systemctl reload nginx"
|
||||||
|
|||||||
275
tests/test_routes_catalog.py
Normal file
275
tests/test_routes_catalog.py
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
"""Integration tests for /catalog routes (stores, groups, products, toggles)."""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from web.models.connections import (
|
||||||
|
CachedGroup, CachedProduct, CachedStore, SyncConfig, SyncFilter,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _now():
|
||||||
|
return datetime.utcnow()
|
||||||
|
|
||||||
|
|
||||||
|
async def _login(client, user):
|
||||||
|
await client.post("/login", data={"email": user.email, "password": "testpass123"},
|
||||||
|
follow_redirects=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_store(db, user_id, evotor_id="s1", name="Магазин 1"):
|
||||||
|
s = CachedStore(user_id=user_id, evotor_id=evotor_id, name=name, fetched_at=_now())
|
||||||
|
db.add(s)
|
||||||
|
db.flush()
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def _make_group(db, user_id, store_id, evotor_id="g1", name="Группа 1"):
|
||||||
|
g = CachedGroup(user_id=user_id, store_evotor_id=store_id,
|
||||||
|
evotor_id=evotor_id, name=name, fetched_at=_now())
|
||||||
|
db.add(g)
|
||||||
|
db.flush()
|
||||||
|
return g
|
||||||
|
|
||||||
|
|
||||||
|
def _make_product(db, user_id, store_id, group_id=None, evotor_id="p1", name="Товар 1",
|
||||||
|
price=100, allow_to_sell=True):
|
||||||
|
p = CachedProduct(
|
||||||
|
user_id=user_id, store_evotor_id=store_id, group_evotor_id=group_id,
|
||||||
|
evotor_id=evotor_id, name=name, price=price, allow_to_sell=allow_to_sell,
|
||||||
|
fetched_at=_now(),
|
||||||
|
)
|
||||||
|
db.add(p)
|
||||||
|
db.flush()
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
# ── auth guards ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_catalog_stores_requires_login(client):
|
||||||
|
resp = await client.get("/catalog/stores", follow_redirects=False)
|
||||||
|
assert resp.status_code == 303
|
||||||
|
assert "/login" in resp.headers["location"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /catalog/stores ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_catalog_stores_empty(client, active_user):
|
||||||
|
await _login(client, active_user)
|
||||||
|
resp = await client.get("/catalog/stores")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "не загружены" in resp.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_catalog_stores_lists_stores(client, active_user, override_db):
|
||||||
|
await _login(client, active_user)
|
||||||
|
_make_store(override_db, active_user.id, "s1", "Главный магазин")
|
||||||
|
_make_store(override_db, active_user.id, "s2", "Второй магазин")
|
||||||
|
override_db.commit()
|
||||||
|
|
||||||
|
resp = await client.get("/catalog/stores")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "Главный магазин" in resp.text
|
||||||
|
assert "Второй магазин" in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_catalog_stores_not_shows_other_user(client, active_user, user_factory, override_db):
|
||||||
|
await _login(client, active_user)
|
||||||
|
other = user_factory.create()
|
||||||
|
_make_store(override_db, other.id, "s-other", "Чужой магазин")
|
||||||
|
override_db.commit()
|
||||||
|
|
||||||
|
resp = await client.get("/catalog/stores")
|
||||||
|
assert "Чужой магазин" not in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /catalog/stores/{id}/groups ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_catalog_groups_shows_product_count(client, active_user, override_db):
|
||||||
|
await _login(client, active_user)
|
||||||
|
store = _make_store(override_db, active_user.id, "s1")
|
||||||
|
group = _make_group(override_db, active_user.id, "s1", "g1", "Чай")
|
||||||
|
_make_product(override_db, active_user.id, "s1", "g1", "p1", "Пуэр")
|
||||||
|
_make_product(override_db, active_user.id, "s1", "g1", "p2", "Улун")
|
||||||
|
override_db.commit()
|
||||||
|
|
||||||
|
resp = await client.get(f"/catalog/stores/s1/groups")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "Чай" in resp.text
|
||||||
|
assert "2" in resp.text # product count
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_catalog_groups_zero_count_for_empty_group(client, active_user, override_db):
|
||||||
|
await _login(client, active_user)
|
||||||
|
_make_store(override_db, active_user.id, "s1")
|
||||||
|
_make_group(override_db, active_user.id, "s1", "g1", "Пустая группа")
|
||||||
|
override_db.commit()
|
||||||
|
|
||||||
|
resp = await client.get("/catalog/stores/s1/groups")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "Пустая группа" in resp.text
|
||||||
|
assert "0" in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_catalog_groups_unknown_store_redirects(client, active_user):
|
||||||
|
await _login(client, active_user)
|
||||||
|
resp = await client.get("/catalog/stores/no-such-store/groups", follow_redirects=False)
|
||||||
|
assert resp.status_code == 303
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /catalog/stores/{id}/products ────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_catalog_products_shows_all(client, active_user, override_db):
|
||||||
|
await _login(client, active_user)
|
||||||
|
_make_store(override_db, active_user.id, "s1")
|
||||||
|
_make_group(override_db, active_user.id, "s1", "g1")
|
||||||
|
_make_product(override_db, active_user.id, "s1", "g1", "p1", "Пуэр")
|
||||||
|
_make_product(override_db, active_user.id, "s1", "g1", "p2", "Улун")
|
||||||
|
override_db.commit()
|
||||||
|
|
||||||
|
resp = await client.get("/catalog/stores/s1/products")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "Пуэр" in resp.text
|
||||||
|
assert "Улун" in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_catalog_products_filtered_by_group(client, active_user, override_db):
|
||||||
|
await _login(client, active_user)
|
||||||
|
_make_store(override_db, active_user.id, "s1")
|
||||||
|
_make_group(override_db, active_user.id, "s1", "g1", "Группа А")
|
||||||
|
_make_group(override_db, active_user.id, "s1", "g2", "Группа Б")
|
||||||
|
_make_product(override_db, active_user.id, "s1", "g1", "p1", "Товар А")
|
||||||
|
_make_product(override_db, active_user.id, "s1", "g2", "p2", "Товар Б")
|
||||||
|
override_db.commit()
|
||||||
|
|
||||||
|
resp = await client.get("/catalog/stores/s1/products?group=g1")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "Товар А" in resp.text
|
||||||
|
assert "Товар Б" not in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_catalog_products_shows_group_column(client, active_user, override_db):
|
||||||
|
await _login(client, active_user)
|
||||||
|
_make_store(override_db, active_user.id, "s1")
|
||||||
|
_make_group(override_db, active_user.id, "s1", "g1", "МояГруппа")
|
||||||
|
_make_product(override_db, active_user.id, "s1", "g1", "p1", "Товар")
|
||||||
|
override_db.commit()
|
||||||
|
|
||||||
|
resp = await client.get("/catalog/stores/s1/products")
|
||||||
|
assert "МояГруппа" in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_catalog_products_ungrouped_shown(client, active_user, override_db):
|
||||||
|
await _login(client, active_user)
|
||||||
|
_make_store(override_db, active_user.id, "s1")
|
||||||
|
_make_product(override_db, active_user.id, "s1", None, "p1", "Без группы")
|
||||||
|
override_db.commit()
|
||||||
|
|
||||||
|
resp = await client.get("/catalog/stores/s1/products")
|
||||||
|
assert "Без группы" in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST /catalog/stores/{id}/toggle ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_store_toggle_first_disable_seeds_others(client, active_user, override_db):
|
||||||
|
"""First toggle on a store disables it by seeding include-filters for all other stores."""
|
||||||
|
await _login(client, active_user)
|
||||||
|
_make_store(override_db, active_user.id, "s1", "Магазин 1")
|
||||||
|
_make_store(override_db, active_user.id, "s2", "Магазин 2")
|
||||||
|
_make_store(override_db, active_user.id, "s3", "Магазин 3")
|
||||||
|
override_db.commit()
|
||||||
|
|
||||||
|
resp = await client.post("/catalog/stores/s1/toggle", follow_redirects=False)
|
||||||
|
assert resp.status_code == 303
|
||||||
|
|
||||||
|
cfg = override_db.query(SyncConfig).filter_by(user_id=active_user.id).first()
|
||||||
|
filters = override_db.query(SyncFilter).filter_by(
|
||||||
|
sync_config_id=cfg.id, entity_type="store", filter_mode="include"
|
||||||
|
).all()
|
||||||
|
ids = {f.entity_id for f in filters}
|
||||||
|
# s1 was toggled off → only s2 and s3 are in include list
|
||||||
|
assert "s1" not in ids
|
||||||
|
assert "s2" in ids
|
||||||
|
assert "s3" in ids
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_store_toggle_re_enable(client, active_user, override_db):
|
||||||
|
"""Toggling a disabled store re-adds it to the include list."""
|
||||||
|
await _login(client, active_user)
|
||||||
|
_make_store(override_db, active_user.id, "s1")
|
||||||
|
_make_store(override_db, active_user.id, "s2")
|
||||||
|
override_db.commit()
|
||||||
|
|
||||||
|
# Disable s1 first
|
||||||
|
await client.post("/catalog/stores/s1/toggle")
|
||||||
|
# Now re-enable s1
|
||||||
|
await client.post("/catalog/stores/s1/toggle")
|
||||||
|
|
||||||
|
cfg = override_db.query(SyncConfig).filter_by(user_id=active_user.id).first()
|
||||||
|
filters = override_db.query(SyncFilter).filter_by(
|
||||||
|
sync_config_id=cfg.id, entity_type="store", filter_mode="include"
|
||||||
|
).all()
|
||||||
|
ids = {f.entity_id for f in filters}
|
||||||
|
assert "s1" in ids
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_store_toggle_requires_login(client):
|
||||||
|
resp = await client.post("/catalog/stores/s1/toggle", follow_redirects=False)
|
||||||
|
assert resp.status_code == 303
|
||||||
|
assert "/login" in resp.headers["location"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST /catalog/stores/{id}/groups/{gid}/toggle ────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_group_toggle_first_disable(client, active_user, override_db):
|
||||||
|
await _login(client, active_user)
|
||||||
|
_make_store(override_db, active_user.id, "s1")
|
||||||
|
_make_group(override_db, active_user.id, "s1", "g1", "Группа 1")
|
||||||
|
_make_group(override_db, active_user.id, "s1", "g2", "Группа 2")
|
||||||
|
override_db.commit()
|
||||||
|
|
||||||
|
resp = await client.post("/catalog/stores/s1/groups/g1/toggle", follow_redirects=False)
|
||||||
|
assert resp.status_code == 303
|
||||||
|
|
||||||
|
cfg = override_db.query(SyncConfig).filter_by(user_id=active_user.id).first()
|
||||||
|
filters = override_db.query(SyncFilter).filter_by(
|
||||||
|
sync_config_id=cfg.id, entity_type="group", filter_mode="include",
|
||||||
|
parent_entity_id="s1",
|
||||||
|
).all()
|
||||||
|
ids = {f.entity_id for f in filters}
|
||||||
|
assert "g1" not in ids
|
||||||
|
assert "g2" in ids
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_group_toggle_re_enable(client, active_user, override_db):
|
||||||
|
await _login(client, active_user)
|
||||||
|
_make_store(override_db, active_user.id, "s1")
|
||||||
|
_make_group(override_db, active_user.id, "s1", "g1")
|
||||||
|
_make_group(override_db, active_user.id, "s1", "g2")
|
||||||
|
override_db.commit()
|
||||||
|
|
||||||
|
await client.post("/catalog/stores/s1/groups/g1/toggle")
|
||||||
|
await client.post("/catalog/stores/s1/groups/g1/toggle")
|
||||||
|
|
||||||
|
cfg = override_db.query(SyncConfig).filter_by(user_id=active_user.id).first()
|
||||||
|
filters = override_db.query(SyncFilter).filter_by(
|
||||||
|
sync_config_id=cfg.id, entity_type="group", parent_entity_id="s1"
|
||||||
|
).all()
|
||||||
|
ids = {f.entity_id for f in filters}
|
||||||
|
assert "g1" in ids
|
||||||
352
tests/test_routes_connections.py
Normal file
352
tests/test_routes_connections.py
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
"""Integration tests for /connections routes."""
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from web.models.connections import EvotorConnection, VkConnection
|
||||||
|
|
||||||
|
|
||||||
|
def _login(client, user):
|
||||||
|
client.cookies.clear()
|
||||||
|
return client.post("/login", data={"email": user.email, "password": "testpass123"},
|
||||||
|
follow_redirects=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ── auth guard ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_connections_requires_login(client):
|
||||||
|
resp = await client.get("/connections", follow_redirects=False)
|
||||||
|
assert resp.status_code == 303
|
||||||
|
assert "/login" in resp.headers["location"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /connections ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_connections_get_no_connections(client, active_user):
|
||||||
|
await _login(client, active_user)
|
||||||
|
resp = await client.get("/connections")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "Эвотор" in resp.text
|
||||||
|
assert "ВКонтакте" in resp.text
|
||||||
|
assert "Не подключено" in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_connections_get_shows_connected(client, active_user, override_db):
|
||||||
|
await _login(client, active_user)
|
||||||
|
conn = EvotorConnection(
|
||||||
|
user_id=active_user.id,
|
||||||
|
evotor_user_id="evo-123",
|
||||||
|
access_token="tok-abc",
|
||||||
|
api_token="api-tok",
|
||||||
|
connected_at=datetime.utcnow(),
|
||||||
|
updated_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
override_db.add(conn)
|
||||||
|
override_db.commit()
|
||||||
|
|
||||||
|
resp = await client.get("/connections")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "Подключено" in resp.text
|
||||||
|
assert "tok-abc"[:8] in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST /connections/evotor ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_connections_evotor_post_creates(client, active_user, override_db):
|
||||||
|
await _login(client, active_user)
|
||||||
|
resp = await client.post("/connections/evotor", data={
|
||||||
|
"access_token": "new-evotor-token",
|
||||||
|
"evotor_user_id": "",
|
||||||
|
}, follow_redirects=False)
|
||||||
|
assert resp.status_code == 303
|
||||||
|
assert "success=1" in resp.headers["location"]
|
||||||
|
|
||||||
|
conn = override_db.query(EvotorConnection).filter_by(user_id=active_user.id).first()
|
||||||
|
assert conn is not None
|
||||||
|
assert conn.access_token == "new-evotor-token"
|
||||||
|
assert conn.api_token is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_connections_evotor_post_updates(client, active_user, override_db):
|
||||||
|
await _login(client, active_user)
|
||||||
|
conn = EvotorConnection(
|
||||||
|
user_id=active_user.id, evotor_user_id="evo-upd",
|
||||||
|
access_token="old-token", api_token="api",
|
||||||
|
connected_at=datetime.utcnow(), updated_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
override_db.add(conn)
|
||||||
|
override_db.commit()
|
||||||
|
|
||||||
|
await client.post("/connections/evotor", data={"access_token": "updated-token"})
|
||||||
|
override_db.refresh(conn)
|
||||||
|
assert conn.access_token == "updated-token"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_connections_evotor_post_empty_token(client, active_user):
|
||||||
|
await _login(client, active_user)
|
||||||
|
resp = await client.post("/connections/evotor", data={"access_token": ""})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "обязателен" in resp.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST /connections/evotor/disconnect ───────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_connections_evotor_disconnect(client, active_user, override_db):
|
||||||
|
await _login(client, active_user)
|
||||||
|
conn = EvotorConnection(
|
||||||
|
user_id=active_user.id, evotor_user_id="evo-del",
|
||||||
|
access_token="tok", api_token="api",
|
||||||
|
connected_at=datetime.utcnow(), updated_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
override_db.add(conn)
|
||||||
|
override_db.commit()
|
||||||
|
|
||||||
|
resp = await client.post("/connections/evotor/disconnect", follow_redirects=False)
|
||||||
|
assert resp.status_code == 303
|
||||||
|
assert override_db.query(EvotorConnection).filter_by(user_id=active_user.id).first() is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST /connections/vk (manual token) ──────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_connections_vk_post_creates(client, active_user, override_db):
|
||||||
|
await _login(client, active_user)
|
||||||
|
resp = await client.post("/connections/vk", data={
|
||||||
|
"access_token": "vk1.a.testtoken",
|
||||||
|
"vk_group_id": "123456789",
|
||||||
|
}, follow_redirects=False)
|
||||||
|
assert resp.status_code == 303
|
||||||
|
assert "success=1" in resp.headers["location"]
|
||||||
|
|
||||||
|
conn = override_db.query(VkConnection).filter_by(user_id=active_user.id).first()
|
||||||
|
assert conn is not None
|
||||||
|
assert conn.access_token == "vk1.a.testtoken"
|
||||||
|
assert conn.vk_user_id == "123456789"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_connections_vk_post_empty_token(client, active_user):
|
||||||
|
await _login(client, active_user)
|
||||||
|
resp = await client.post("/connections/vk", data={"access_token": "", "vk_group_id": ""})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "обязателен" in resp.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST /connections/vk/disconnect ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_connections_vk_disconnect(client, active_user, override_db):
|
||||||
|
await _login(client, active_user)
|
||||||
|
conn = VkConnection(
|
||||||
|
user_id=active_user.id, access_token="vk-tok",
|
||||||
|
connected_at=datetime.utcnow(), updated_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
override_db.add(conn)
|
||||||
|
override_db.commit()
|
||||||
|
|
||||||
|
resp = await client.post("/connections/vk/disconnect", follow_redirects=False)
|
||||||
|
assert resp.status_code == 303
|
||||||
|
assert override_db.query(VkConnection).filter_by(user_id=active_user.id).first() is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /vk-auth ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_vk_auth_redirects_to_vk(client, active_user, monkeypatch):
|
||||||
|
monkeypatch.setattr("web.routes.connections.settings.VK_CLIENT_ID", "53265827")
|
||||||
|
monkeypatch.setattr("web.routes.connections.settings.BASE_URL", "http://test")
|
||||||
|
await _login(client, active_user)
|
||||||
|
resp = await client.get("/vk-auth", follow_redirects=False)
|
||||||
|
assert resp.status_code == 302
|
||||||
|
assert "oauth.vk.com/authorize" in resp.headers["location"]
|
||||||
|
assert "client_id=53265827" in resp.headers["location"]
|
||||||
|
assert "response_type=token" in resp.headers["location"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_vk_auth_no_client_id(client, active_user, monkeypatch):
|
||||||
|
monkeypatch.setattr("web.routes.connections.settings.VK_CLIENT_ID", "")
|
||||||
|
await _login(client, active_user)
|
||||||
|
resp = await client.get("/vk-auth", follow_redirects=False)
|
||||||
|
assert resp.status_code == 303
|
||||||
|
assert "error=vk_not_configured" in resp.headers["location"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /vk-callback ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_vk_callback_page_returns_html(client, active_user):
|
||||||
|
await _login(client, active_user)
|
||||||
|
resp = await client.get("/vk-callback")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "access_token" in resp.text
|
||||||
|
assert "fetch" in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST /vk-callback/save ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_vk_callback_save_valid(client, active_user, override_db):
|
||||||
|
await _login(client, active_user)
|
||||||
|
# Seed state into session via /vk-auth call
|
||||||
|
monkeypatch_state = "test-state-xyz"
|
||||||
|
# Manually set expected state in session by calling the save endpoint
|
||||||
|
# with a pre-seeded state — we bypass the session by mocking get_current_user
|
||||||
|
# Instead: call /vk-auth to seed the session state, then intercept
|
||||||
|
# Since we can't easily inspect session, test save with wrong state first
|
||||||
|
resp = await client.post("/vk-callback/save", json={
|
||||||
|
"access_token": "vk1.a.token",
|
||||||
|
"state": "wrong-state",
|
||||||
|
"user_id": "12345",
|
||||||
|
"expires_in": "86400",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["ok"] is False
|
||||||
|
assert "state" in data["message"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_vk_callback_save_no_token(client, active_user):
|
||||||
|
await _login(client, active_user)
|
||||||
|
resp = await client.post("/vk-callback/save", json={
|
||||||
|
"access_token": "",
|
||||||
|
"state": "",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["ok"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_vk_callback_save_unauthenticated(client):
|
||||||
|
resp = await client.post("/vk-callback/save", json={
|
||||||
|
"access_token": "tok", "state": "s",
|
||||||
|
})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST /connections/evotor/test ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_evotor_test_no_connection(client, active_user):
|
||||||
|
await _login(client, active_user)
|
||||||
|
resp = await client.post("/connections/evotor/test")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["ok"] is False
|
||||||
|
assert "не настроено" in resp.json()["message"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_evotor_test_success(client, active_user, override_db):
|
||||||
|
await _login(client, active_user)
|
||||||
|
conn = EvotorConnection(
|
||||||
|
user_id=active_user.id, evotor_user_id="evo-t",
|
||||||
|
access_token="tok", api_token="api",
|
||||||
|
connected_at=datetime.utcnow(), updated_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
override_db.add(conn)
|
||||||
|
override_db.commit()
|
||||||
|
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.status_code = 200
|
||||||
|
mock_resp.json.return_value = {"items": [{"id": "s1"}, {"id": "s2"}]}
|
||||||
|
|
||||||
|
with patch("web.routes.connections.httpx.get", return_value=mock_resp):
|
||||||
|
resp = await client.post("/connections/evotor/test")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["ok"] is True
|
||||||
|
assert "2" in data["message"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_evotor_test_invalid_token(client, active_user, override_db):
|
||||||
|
await _login(client, active_user)
|
||||||
|
conn = EvotorConnection(
|
||||||
|
user_id=active_user.id, evotor_user_id="evo-inv",
|
||||||
|
access_token="bad-tok", api_token="api",
|
||||||
|
connected_at=datetime.utcnow(), updated_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
override_db.add(conn)
|
||||||
|
override_db.commit()
|
||||||
|
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.status_code = 401
|
||||||
|
|
||||||
|
with patch("web.routes.connections.httpx.get", return_value=mock_resp):
|
||||||
|
resp = await client.post("/connections/evotor/test")
|
||||||
|
data = resp.json()
|
||||||
|
assert data["ok"] is False
|
||||||
|
assert "401" in data["message"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST /connections/vk/test ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_vk_test_no_group_id(client, active_user, override_db):
|
||||||
|
await _login(client, active_user)
|
||||||
|
conn = VkConnection(
|
||||||
|
user_id=active_user.id, access_token="vk-tok",
|
||||||
|
vk_user_id=None,
|
||||||
|
connected_at=datetime.utcnow(), updated_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
override_db.add(conn)
|
||||||
|
override_db.commit()
|
||||||
|
|
||||||
|
resp = await client.post("/connections/vk/test")
|
||||||
|
assert resp.json()["ok"] is False
|
||||||
|
assert "сообщества" in resp.json()["message"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_vk_test_success(client, active_user, override_db):
|
||||||
|
await _login(client, active_user)
|
||||||
|
conn = VkConnection(
|
||||||
|
user_id=active_user.id, access_token="vk-tok",
|
||||||
|
vk_user_id="229744980",
|
||||||
|
connected_at=datetime.utcnow(), updated_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
override_db.add(conn)
|
||||||
|
override_db.commit()
|
||||||
|
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.json.return_value = {"response": {"groups": [
|
||||||
|
{"name": "Тестовая чайная", "market": {"enabled": True}}
|
||||||
|
]}}
|
||||||
|
|
||||||
|
with patch("web.routes.connections.httpx.get", return_value=mock_resp):
|
||||||
|
resp = await client.post("/connections/vk/test")
|
||||||
|
data = resp.json()
|
||||||
|
assert data["ok"] is True
|
||||||
|
assert "Тестовая чайная" in data["message"]
|
||||||
|
assert "включён" in data["message"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_vk_test_api_error(client, active_user, override_db):
|
||||||
|
await _login(client, active_user)
|
||||||
|
conn = VkConnection(
|
||||||
|
user_id=active_user.id, access_token="vk-tok",
|
||||||
|
vk_user_id="229744980",
|
||||||
|
connected_at=datetime.utcnow(), updated_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
override_db.add(conn)
|
||||||
|
override_db.commit()
|
||||||
|
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.json.return_value = {"error": {"error_code": 5, "error_msg": "User authorization failed"}}
|
||||||
|
|
||||||
|
with patch("web.routes.connections.httpx.get", return_value=mock_resp):
|
||||||
|
resp = await client.post("/connections/vk/test")
|
||||||
|
data = resp.json()
|
||||||
|
assert data["ok"] is False
|
||||||
|
assert "5" in data["message"]
|
||||||
133
tests/test_tasks_catalog.py
Normal file
133
tests/test_tasks_catalog.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"""Unit tests for catalog task helpers and refresh_catalog logic."""
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from web.tasks.catalog import _fetch_groups, _fetch_products, _fetch_stores
|
||||||
|
|
||||||
|
|
||||||
|
# ── _fetch_stores ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_fetch_stores_list_response():
|
||||||
|
mock = MagicMock()
|
||||||
|
mock.raise_for_status = MagicMock()
|
||||||
|
mock.json.return_value = [{"id": "s1", "name": "Магазин"}]
|
||||||
|
with patch("web.tasks.catalog.httpx.get", return_value=mock):
|
||||||
|
result = _fetch_stores("tok")
|
||||||
|
assert result == [{"id": "s1", "name": "Магазин"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_stores_dict_with_items():
|
||||||
|
mock = MagicMock()
|
||||||
|
mock.raise_for_status = MagicMock()
|
||||||
|
mock.json.return_value = {"items": [{"id": "s1"}], "total": 1}
|
||||||
|
with patch("web.tasks.catalog.httpx.get", return_value=mock):
|
||||||
|
result = _fetch_stores("tok")
|
||||||
|
assert result == [{"id": "s1"}]
|
||||||
|
|
||||||
|
|
||||||
|
# ── _fetch_groups ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_fetch_groups_success():
|
||||||
|
mock = MagicMock()
|
||||||
|
mock.status_code = 200
|
||||||
|
mock.raise_for_status = MagicMock()
|
||||||
|
mock.json.return_value = {"items": [{"id": "g1", "name": "Чай"}]}
|
||||||
|
with patch("web.tasks.catalog.httpx.get", return_value=mock):
|
||||||
|
result = _fetch_groups("tok", "s1")
|
||||||
|
assert result == [{"id": "g1", "name": "Чай"}]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status_code", [402, 403])
|
||||||
|
def test_fetch_groups_returns_none_on_restricted(status_code):
|
||||||
|
mock = MagicMock()
|
||||||
|
mock.status_code = status_code
|
||||||
|
with patch("web.tasks.catalog.httpx.get", return_value=mock):
|
||||||
|
result = _fetch_groups("tok", "s1")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── _fetch_products ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_fetch_products_success():
|
||||||
|
mock = MagicMock()
|
||||||
|
mock.status_code = 200
|
||||||
|
mock.raise_for_status = MagicMock()
|
||||||
|
mock.json.return_value = [{"id": "p1", "name": "Пуэр", "price": {"sum": 15000}}]
|
||||||
|
with patch("web.tasks.catalog.httpx.get", return_value=mock):
|
||||||
|
result = _fetch_products("tok", "s1")
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["name"] == "Пуэр"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status_code", [402, 403])
|
||||||
|
def test_fetch_products_returns_none_on_restricted(status_code):
|
||||||
|
mock = MagicMock()
|
||||||
|
mock.status_code = status_code
|
||||||
|
with patch("web.tasks.catalog.httpx.get", return_value=mock):
|
||||||
|
result = _fetch_products("tok", "s1")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── refresh_catalog task (integration with mocked HTTP) ──────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refresh_catalog_upserts_stores(override_db):
|
||||||
|
from web.database import SessionLocal
|
||||||
|
from web.models.connections import CachedStore, EvotorConnection
|
||||||
|
from web.tasks.catalog import _sync_user
|
||||||
|
|
||||||
|
user_id = 1
|
||||||
|
token = "test-tok"
|
||||||
|
|
||||||
|
stores_data = [{"id": "s-new", "name": "Новый магазин", "address": "ул. Ленина 1"}]
|
||||||
|
groups_data = []
|
||||||
|
products_data = []
|
||||||
|
|
||||||
|
with patch("web.tasks.catalog._fetch_stores", return_value=stores_data), \
|
||||||
|
patch("web.tasks.catalog._fetch_groups", return_value=groups_data), \
|
||||||
|
patch("web.tasks.catalog._fetch_products", return_value=products_data):
|
||||||
|
_sync_user(override_db, user_id, token)
|
||||||
|
|
||||||
|
store = override_db.query(CachedStore).filter_by(user_id=user_id, evotor_id="s-new").first()
|
||||||
|
assert store is not None
|
||||||
|
assert store.name == "Новый магазин"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refresh_catalog_upserts_products(override_db):
|
||||||
|
from web.models.connections import CachedProduct
|
||||||
|
from web.tasks.catalog import _sync_user
|
||||||
|
|
||||||
|
user_id = 2
|
||||||
|
token = "tok"
|
||||||
|
|
||||||
|
stores_data = [{"id": "s1", "name": "Магазин"}]
|
||||||
|
groups_data = [{"id": "g1", "name": "Чай"}]
|
||||||
|
products_data = [{
|
||||||
|
"id": "p1", "name": "Пуэр", "price": 35000,
|
||||||
|
"quantity": 10, "measureName": "шт", "code": "001",
|
||||||
|
"allowToSell": True, "group": "g1",
|
||||||
|
}]
|
||||||
|
|
||||||
|
with patch("web.tasks.catalog._fetch_stores", return_value=stores_data), \
|
||||||
|
patch("web.tasks.catalog._fetch_groups", return_value=groups_data), \
|
||||||
|
patch("web.tasks.catalog._fetch_products", return_value=products_data):
|
||||||
|
_sync_user(override_db, user_id, token)
|
||||||
|
|
||||||
|
p = override_db.query(CachedProduct).filter_by(user_id=user_id, evotor_id="p1").first()
|
||||||
|
assert p is not None
|
||||||
|
assert p.name == "Пуэр"
|
||||||
|
assert p.group_evotor_id == "g1"
|
||||||
|
assert p.allow_to_sell is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refresh_catalog_skips_fetch_stores_failure(override_db):
|
||||||
|
from web.models.connections import CachedStore
|
||||||
|
from web.tasks.catalog import _sync_user
|
||||||
|
|
||||||
|
with patch("web.tasks.catalog._fetch_stores", side_effect=Exception("network error")):
|
||||||
|
_sync_user(override_db, user_id=99, token="tok")
|
||||||
|
|
||||||
|
assert override_db.query(CachedStore).filter_by(user_id=99).count() == 0
|
||||||
169
tests/test_tasks_vk_sync.py
Normal file
169
tests/test_tasks_vk_sync.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"""Unit tests for vk_sync task logic (price calc, name sanitization, orphan deletion)."""
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from unittest.mock import MagicMock, call, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from web.tasks.vk_sync import (
|
||||||
|
_build_description,
|
||||||
|
_calc_price,
|
||||||
|
_delete_orphans,
|
||||||
|
_is_weight,
|
||||||
|
_name_for_vk,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── _is_weight ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("measure,expected", [
|
||||||
|
("г", True),
|
||||||
|
("г.", True),
|
||||||
|
("гр", True),
|
||||||
|
("гр.", True),
|
||||||
|
("грамм", True),
|
||||||
|
("граммов", True),
|
||||||
|
(" Г ", True), # case-insensitive, stripped
|
||||||
|
("кг", False),
|
||||||
|
("шт", False),
|
||||||
|
("л", False),
|
||||||
|
(None, False),
|
||||||
|
("", False),
|
||||||
|
])
|
||||||
|
def test_is_weight(measure, expected):
|
||||||
|
assert _is_weight(measure) == expected
|
||||||
|
|
||||||
|
|
||||||
|
# ── _calc_price ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_calc_price_normal(monkeypatch):
|
||||||
|
monkeypatch.setattr("web.tasks.vk_sync.settings.VK_WEIGHT_PRICE_MULTIPLIER", 10)
|
||||||
|
assert _calc_price(Decimal("150"), "шт") == 15000 # 150 руб * 100 копеек
|
||||||
|
|
||||||
|
|
||||||
|
def test_calc_price_weight_multiplier(monkeypatch):
|
||||||
|
monkeypatch.setattr("web.tasks.vk_sync.settings.VK_WEIGHT_PRICE_MULTIPLIER", 10)
|
||||||
|
# 50 руб/г → 50 * 10 (multiplier) * 100 (kopecks) = 50000
|
||||||
|
assert _calc_price(Decimal("50"), "г") == 50000
|
||||||
|
|
||||||
|
|
||||||
|
def test_calc_price_none():
|
||||||
|
assert _calc_price(None, "шт") == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_calc_price_zero():
|
||||||
|
assert _calc_price(Decimal("0"), "шт") == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── _name_for_vk ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_name_replaces_semicolons():
|
||||||
|
assert _name_for_vk("Чай; зелёный; Китай") == "Чай, зелёный, Китай"
|
||||||
|
|
||||||
|
|
||||||
|
def test_name_no_semicolons():
|
||||||
|
assert _name_for_vk("Пуэр (выдержанный)") == "Пуэр (выдержанный)"
|
||||||
|
|
||||||
|
|
||||||
|
# ── _build_description ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_build_description_weight(monkeypatch):
|
||||||
|
monkeypatch.setattr("web.tasks.vk_sync.settings.VK_WEIGHT_PRICE_MULTIPLIER", 10)
|
||||||
|
desc = _build_description("Чай", "г", None)
|
||||||
|
assert "10г" in desc
|
||||||
|
assert "Чай" in desc
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_description_with_evo_desc(monkeypatch):
|
||||||
|
monkeypatch.setattr("web.tasks.vk_sync.settings.VK_WEIGHT_PRICE_MULTIPLIER", 10)
|
||||||
|
desc = _build_description("Чай", "шт", "Вкусный чай из Китая")
|
||||||
|
assert "Вкусный чай из Китая" in desc
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_description_no_evo_desc(monkeypatch):
|
||||||
|
monkeypatch.setattr("web.tasks.vk_sync.settings.VK_WEIGHT_PRICE_MULTIPLIER", 10)
|
||||||
|
desc = _build_description("Чай", "шт", None)
|
||||||
|
assert "Чай" in desc
|
||||||
|
|
||||||
|
|
||||||
|
# ── _delete_orphans ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_delete_orphans_removes_stale_vk_products():
|
||||||
|
from web.models.connections import VkCachedProduct, CachedProduct
|
||||||
|
|
||||||
|
# Build fake VK cached products
|
||||||
|
vk1 = MagicMock(spec=VkCachedProduct)
|
||||||
|
vk1.vk_product_id = "111"
|
||||||
|
vk1.name = "Существующий"
|
||||||
|
|
||||||
|
vk2 = MagicMock(spec=VkCachedProduct)
|
||||||
|
vk2.vk_product_id = "222"
|
||||||
|
vk2.name = "Удалённый из Эвотор"
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
# owned_ids contains only "111" — "222" is orphan
|
||||||
|
owned_ids = {"111"}
|
||||||
|
|
||||||
|
# query().filter_by().filter().all() chain
|
||||||
|
query_mock = MagicMock()
|
||||||
|
query_mock.filter_by.return_value.filter.return_value.all.return_value = [vk2]
|
||||||
|
# second query for stale cached_products
|
||||||
|
query_mock.filter.return_value.all.return_value = []
|
||||||
|
db.query.return_value = query_mock
|
||||||
|
|
||||||
|
results = {"deleted": 0, "errors": 0}
|
||||||
|
mock_post_resp = {"response": 1}
|
||||||
|
|
||||||
|
with patch("web.tasks.vk_sync._vk_post", return_value=mock_post_resp):
|
||||||
|
_delete_orphans(db, user_id=1, vk_group_id="99", owned_ids=owned_ids,
|
||||||
|
token="tok", results=results)
|
||||||
|
|
||||||
|
assert results["deleted"] == 1
|
||||||
|
db.delete.assert_called_once_with(vk2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_orphans_vk_api_error_counted():
|
||||||
|
from web.models.connections import VkCachedProduct
|
||||||
|
|
||||||
|
vk1 = MagicMock(spec=VkCachedProduct)
|
||||||
|
vk1.vk_product_id = "999"
|
||||||
|
vk1.name = "Сломанный"
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
query_mock = MagicMock()
|
||||||
|
query_mock.filter_by.return_value.filter.return_value.all.return_value = [vk1]
|
||||||
|
query_mock.filter.return_value.all.return_value = []
|
||||||
|
db.query.return_value = query_mock
|
||||||
|
|
||||||
|
results = {"deleted": 0, "errors": 0}
|
||||||
|
|
||||||
|
with patch("web.tasks.vk_sync._vk_post", return_value={"error": {"error_code": 15}}):
|
||||||
|
_delete_orphans(db, user_id=1, vk_group_id="99", owned_ids={"other"},
|
||||||
|
token="tok", results=results)
|
||||||
|
|
||||||
|
assert results["deleted"] == 0
|
||||||
|
assert results["errors"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_orphans_empty_owned_ids_deletes_all():
|
||||||
|
"""If no Evotor products exist (owned_ids empty), all VK products are orphans."""
|
||||||
|
from web.models.connections import VkCachedProduct
|
||||||
|
|
||||||
|
vk1 = MagicMock(spec=VkCachedProduct)
|
||||||
|
vk1.vk_product_id = "1"
|
||||||
|
vk1.name = "Лишний"
|
||||||
|
|
||||||
|
db = MagicMock()
|
||||||
|
query_mock = MagicMock()
|
||||||
|
# With empty owned_ids, query without .filter() is used
|
||||||
|
query_mock.filter_by.return_value.all.return_value = [vk1]
|
||||||
|
query_mock.filter.return_value.all.return_value = []
|
||||||
|
db.query.return_value = query_mock
|
||||||
|
|
||||||
|
results = {"deleted": 0, "errors": 0}
|
||||||
|
|
||||||
|
with patch("web.tasks.vk_sync._vk_post", return_value={"response": 1}):
|
||||||
|
_delete_orphans(db, user_id=1, vk_group_id="99", owned_ids=set(),
|
||||||
|
token="tok", results=results)
|
||||||
|
|
||||||
|
assert results["deleted"] == 1
|
||||||
@@ -3,7 +3,7 @@ from fastapi.responses import RedirectResponse
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
|
|
||||||
from web.models.user import User, UserStatusEnum
|
from web.models.user import User, UserRoleEnum, UserStatusEnum
|
||||||
|
|
||||||
|
|
||||||
def get_session_user_id(request: Request) -> int | None:
|
def get_session_user_id(request: Request) -> int | None:
|
||||||
@@ -21,5 +21,22 @@ def get_current_user(request: Request, db: Session) -> User:
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def get_viewed_user(request: Request, db: Session) -> tuple[User, User]:
|
||||||
|
"""Return (real_user, viewed_user).
|
||||||
|
|
||||||
|
Admins/system users can view another user's data by having
|
||||||
|
`viewed_user_id` set in the session (via /admin/users/{id}/view-as).
|
||||||
|
For regular users, both values are the same.
|
||||||
|
"""
|
||||||
|
real_user = get_current_user(request, db)
|
||||||
|
is_admin = real_user.role in (UserRoleEnum.admin, UserRoleEnum.system)
|
||||||
|
viewed_id = request.session.get("viewed_user_id") if is_admin else None
|
||||||
|
if viewed_id:
|
||||||
|
viewed = db.get(User, viewed_id)
|
||||||
|
if viewed:
|
||||||
|
return real_user, viewed
|
||||||
|
return real_user, real_user
|
||||||
|
|
||||||
|
|
||||||
def login_redirect() -> RedirectResponse:
|
def login_redirect() -> RedirectResponse:
|
||||||
return RedirectResponse("/login", status_code=303)
|
return RedirectResponse("/login", status_code=303)
|
||||||
|
|||||||
@@ -11,9 +11,14 @@ class Settings(BaseSettings):
|
|||||||
EVOTOR_APP_ID: str = ""
|
EVOTOR_APP_ID: str = ""
|
||||||
EVOTOR_WEBHOOK_SECRET: str = ""
|
EVOTOR_WEBHOOK_SECRET: str = ""
|
||||||
|
|
||||||
|
VK_CLIENT_ID: str = ""
|
||||||
|
VK_CLIENT_SECRET: str = ""
|
||||||
|
|
||||||
JIVOSITE_WIDGET_ID: str = ""
|
JIVOSITE_WIDGET_ID: str = ""
|
||||||
VK_DEFAULT_PHOTO_PATH: str = "/app/default_product.png"
|
VK_DEFAULT_PHOTO_PATH: str = "/app/default_product.png"
|
||||||
VK_API_VERSION: str = "5.199"
|
VK_API_VERSION: str = "5.199"
|
||||||
|
VK_CATEGORY_ID: int = 40932
|
||||||
|
VK_STOCK_AMOUNT: int = 1000
|
||||||
|
|
||||||
CATALOG_REFRESH_INTERVAL_SECONDS: int = 3600
|
CATALOG_REFRESH_INTERVAL_SECONDS: int = 3600
|
||||||
INVITE_EXPIRE_HOURS: int = 48
|
INVITE_EXPIRE_HOURS: int = 48
|
||||||
|
|||||||
82
web/lib/api_logger.py
Normal file
82
web/lib/api_logger.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
"""Thin wrapper around httpx that logs every outbound API call to api_logs."""
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from web.database import SessionLocal
|
||||||
|
from web.models.connections import ApiLog
|
||||||
|
|
||||||
|
_MAX_BODY = 8000 # truncate stored bodies beyond this
|
||||||
|
|
||||||
|
|
||||||
|
def _service_from_url(url: str) -> str:
|
||||||
|
host = urllib.parse.urlparse(url).netloc
|
||||||
|
if "evotor" in host:
|
||||||
|
return "evotor"
|
||||||
|
if "vk.com" in host:
|
||||||
|
return "vk"
|
||||||
|
return "other"
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate(text: str | None) -> str | None:
|
||||||
|
if text and len(text) > _MAX_BODY:
|
||||||
|
return text[:_MAX_BODY] + "…"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _record(
|
||||||
|
user_id: int | None,
|
||||||
|
method: str,
|
||||||
|
url: str,
|
||||||
|
request_body: str | None,
|
||||||
|
response_status: int | None,
|
||||||
|
response_body: str | None,
|
||||||
|
duration_ms: int,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
db = SessionLocal()
|
||||||
|
db.add(ApiLog(
|
||||||
|
user_id=user_id,
|
||||||
|
service=_service_from_url(url),
|
||||||
|
method=method.upper(),
|
||||||
|
url=url,
|
||||||
|
request_body=_truncate(request_body),
|
||||||
|
response_status=response_status,
|
||||||
|
response_body=_truncate(response_body),
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
except Exception:
|
||||||
|
pass # never let logging crash the caller
|
||||||
|
|
||||||
|
|
||||||
|
def get(url: str, *, user_id: int | None = None, **kwargs) -> httpx.Response:
|
||||||
|
t0 = time.monotonic()
|
||||||
|
resp = httpx.get(url, **kwargs)
|
||||||
|
ms = int((time.monotonic() - t0) * 1000)
|
||||||
|
try:
|
||||||
|
body = resp.text
|
||||||
|
except Exception:
|
||||||
|
body = None
|
||||||
|
_record(user_id, "GET", url, None, resp.status_code, body, ms)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def post(url: str, *, user_id: int | None = None, data: Any = None, json: Any = None, **kwargs) -> httpx.Response:
|
||||||
|
t0 = time.monotonic()
|
||||||
|
resp = httpx.post(url, data=data, json=json, **kwargs)
|
||||||
|
ms = int((time.monotonic() - t0) * 1000)
|
||||||
|
try:
|
||||||
|
req_body = resp.request.content.decode("utf-8", errors="replace") if resp.request.content else None
|
||||||
|
except Exception:
|
||||||
|
req_body = None
|
||||||
|
try:
|
||||||
|
body = resp.text
|
||||||
|
except Exception:
|
||||||
|
body = None
|
||||||
|
_record(user_id, "POST", url, req_body, resp.status_code, body, ms)
|
||||||
|
return resp
|
||||||
27
web/main.py
27
web/main.py
@@ -1,6 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import Depends, FastAPI, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
@@ -35,6 +35,13 @@ from web.routes.invite import router as invite_router # noqa: E402
|
|||||||
from web.routes.profile import router as profile_router # noqa: E402
|
from web.routes.profile import router as profile_router # noqa: E402
|
||||||
from web.routes.evotor_webhooks import router as evotor_webhooks_router # noqa: E402
|
from web.routes.evotor_webhooks import router as evotor_webhooks_router # noqa: E402
|
||||||
from web.routes.admin import router as admin_router # noqa: E402
|
from web.routes.admin import router as admin_router # noqa: E402
|
||||||
|
from web.routes.catalog import router as catalog_router # noqa: E402
|
||||||
|
from web.routes.connections import router as connections_router # noqa: E402
|
||||||
|
from web.routes.vk_catalog import router as vk_catalog_router # noqa: E402
|
||||||
|
from web.routes.logs import router as logs_router # noqa: E402
|
||||||
|
from web.routes.sync import router as sync_router # noqa: E402
|
||||||
|
from web.database import get_db # noqa: E402
|
||||||
|
from web.models.user import User # noqa: E402
|
||||||
|
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(reset_router)
|
app.include_router(reset_router)
|
||||||
@@ -42,6 +49,18 @@ app.include_router(invite_router)
|
|||||||
app.include_router(profile_router)
|
app.include_router(profile_router)
|
||||||
app.include_router(evotor_webhooks_router)
|
app.include_router(evotor_webhooks_router)
|
||||||
app.include_router(admin_router)
|
app.include_router(admin_router)
|
||||||
|
app.include_router(catalog_router)
|
||||||
|
app.include_router(connections_router)
|
||||||
|
app.include_router(vk_catalog_router)
|
||||||
|
app.include_router(logs_router)
|
||||||
|
app.include_router(sync_router)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Catalog redirect ─────────────────────────────────────────────────────────
|
||||||
|
@app.get("/catalog")
|
||||||
|
async def catalog_redirect():
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
return RedirectResponse("/catalog/stores", 302)
|
||||||
|
|
||||||
|
|
||||||
# ── Health ────────────────────────────────────────────────────────────────────
|
# ── Health ────────────────────────────────────────────────────────────────────
|
||||||
@@ -52,10 +71,14 @@ async def health():
|
|||||||
|
|
||||||
# ── Root redirect ─────────────────────────────────────────────────────────────
|
# ── Root redirect ─────────────────────────────────────────────────────────────
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root(request: Request):
|
async def root(request: Request, db=Depends(get_db)):
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
|
from web.models.user import UserRoleEnum
|
||||||
user_id = request.session.get("user_id")
|
user_id = request.session.get("user_id")
|
||||||
if user_id:
|
if user_id:
|
||||||
|
user = db.get(User, user_id)
|
||||||
|
if user and user.role in (UserRoleEnum.admin, UserRoleEnum.system):
|
||||||
|
return RedirectResponse("/admin/users", 303)
|
||||||
return RedirectResponse("/profile", 303)
|
return RedirectResponse("/profile", 303)
|
||||||
return RedirectResponse("/login", 303)
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
|
|||||||
56
web/migrations/versions/0004_vk_catalog_tables.py
Normal file
56
web/migrations/versions/0004_vk_catalog_tables.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""VK catalog tables (albums + products)
|
||||||
|
|
||||||
|
Revision ID: 0004
|
||||||
|
Revises: 0003
|
||||||
|
Create Date: 2026-05-01
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0004"
|
||||||
|
down_revision: Union[str, None] = "0003"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"vk_cached_albums",
|
||||||
|
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("vk_group_id", sa.String(50), nullable=False),
|
||||||
|
sa.Column("album_id", sa.String(50), nullable=False),
|
||||||
|
sa.Column("title", sa.String(255), nullable=False),
|
||||||
|
sa.Column("count", sa.Integer, nullable=True),
|
||||||
|
sa.Column("fetched_at", sa.DateTime, nullable=False),
|
||||||
|
sa.UniqueConstraint("user_id", "vk_group_id", "album_id", name="uq_vk_cached_albums"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_vk_cached_albums_user_group", "vk_cached_albums", ["user_id", "vk_group_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"vk_cached_products",
|
||||||
|
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("vk_group_id", sa.String(50), nullable=False),
|
||||||
|
sa.Column("vk_product_id", sa.String(50), nullable=False),
|
||||||
|
sa.Column("album_id", sa.String(50), nullable=True),
|
||||||
|
sa.Column("name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("description", sa.Text, nullable=True),
|
||||||
|
sa.Column("price", sa.Numeric(12, 2), nullable=True),
|
||||||
|
sa.Column("availability", sa.Integer, nullable=True),
|
||||||
|
sa.Column("thumb_url", sa.String(1024), nullable=True),
|
||||||
|
sa.Column("fetched_at", sa.DateTime, nullable=False),
|
||||||
|
sa.UniqueConstraint("user_id", "vk_group_id", "vk_product_id", name="uq_vk_cached_products"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_vk_cached_products_user_group_album",
|
||||||
|
"vk_cached_products", ["user_id", "vk_group_id", "album_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("vk_cached_products")
|
||||||
|
op.drop_table("vk_cached_albums")
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Add vk_product_id to cached_products
|
||||||
|
|
||||||
|
Revision ID: 0005
|
||||||
|
Revises: 0004
|
||||||
|
Create Date: 2026-05-01
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0005"
|
||||||
|
down_revision: Union[str, None] = "0004"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("cached_products", sa.Column("vk_product_id", sa.String(50), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("cached_products", "vk_product_id")
|
||||||
26
web/migrations/versions/0006_vk_connection_token_fields.py
Normal file
26
web/migrations/versions/0006_vk_connection_token_fields.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""Add refresh_token and token_expires_at to vk_connections
|
||||||
|
|
||||||
|
Revision ID: 0006
|
||||||
|
Revises: 0005
|
||||||
|
Create Date: 2026-05-12
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0006"
|
||||||
|
down_revision: Union[str, None] = "0005"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("vk_connections", sa.Column("refresh_token", sa.Text, nullable=True))
|
||||||
|
op.add_column("vk_connections", sa.Column("token_expires_at", sa.DateTime, nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("vk_connections", "token_expires_at")
|
||||||
|
op.drop_column("vk_connections", "refresh_token")
|
||||||
32
web/migrations/versions/0007_api_logs.py
Normal file
32
web/migrations/versions/0007_api_logs.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
"""Add api_logs table for request/response logging."""
|
||||||
|
revision = "0007"
|
||||||
|
down_revision = "0006"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table(
|
||||||
|
"api_logs",
|
||||||
|
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("service", sa.String(20), nullable=False),
|
||||||
|
sa.Column("method", sa.String(10), nullable=False),
|
||||||
|
sa.Column("url", sa.String(1024), nullable=False),
|
||||||
|
sa.Column("request_body", sa.Text, nullable=True),
|
||||||
|
sa.Column("response_status", sa.Integer, nullable=True),
|
||||||
|
sa.Column("response_body", sa.Text, nullable=True),
|
||||||
|
sa.Column("duration_ms", sa.Integer, nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_api_logs_user_service", "api_logs", ["user_id", "service"])
|
||||||
|
op.create_index("ix_api_logs_created_at", "api_logs", ["created_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_index("ix_api_logs_created_at", "api_logs")
|
||||||
|
op.drop_index("ix_api_logs_user_service", "api_logs")
|
||||||
|
op.drop_table("api_logs")
|
||||||
16
web/migrations/versions/0008_sync_config_price_postfix.py
Normal file
16
web/migrations/versions/0008_sync_config_price_postfix.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"""Add price_multiplier to sync_configs."""
|
||||||
|
revision = "0008"
|
||||||
|
down_revision = "0007"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column("sync_configs", sa.Column("price_multiplier", sa.Numeric(10, 4), nullable=False, server_default="1.0"))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_column("sync_configs", "price_multiplier")
|
||||||
18
web/migrations/versions/0009_sync_config_task_flags.py
Normal file
18
web/migrations/versions/0009_sync_config_task_flags.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"""Add evo_mirror_enabled and vk_mirror_enabled to sync_configs."""
|
||||||
|
revision = "0009"
|
||||||
|
down_revision = "0008"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column("sync_configs", sa.Column("evo_mirror_enabled", sa.Boolean, nullable=False, server_default="0"))
|
||||||
|
op.add_column("sync_configs", sa.Column("vk_mirror_enabled", sa.Boolean, nullable=False, server_default="0"))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_column("sync_configs", "vk_mirror_enabled")
|
||||||
|
op.drop_column("sync_configs", "evo_mirror_enabled")
|
||||||
17
web/migrations/versions/0010_users_phone_nullable.py
Normal file
17
web/migrations/versions/0010_users_phone_nullable.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
"""Make users.phone nullable."""
|
||||||
|
revision = "0010"
|
||||||
|
down_revision = "0009"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.alter_column("users", "phone", existing_type=sa.String(20), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.execute("UPDATE users SET phone = '' WHERE phone IS NULL")
|
||||||
|
op.alter_column("users", "phone", existing_type=sa.String(20), nullable=False)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Add store_filters_seeded and group_filters_seeded to sync_configs."""
|
||||||
|
revision = "0011"
|
||||||
|
down_revision = "0010"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column("sync_configs", sa.Column("store_filters_seeded", sa.Boolean(), nullable=False, server_default="0"))
|
||||||
|
op.add_column("sync_configs", sa.Column("group_filters_seeded", sa.Boolean(), nullable=False, server_default="0"))
|
||||||
|
|
||||||
|
# Mark existing rows as seeded if they already have filters
|
||||||
|
op.execute("""
|
||||||
|
UPDATE sync_configs sc
|
||||||
|
SET store_filters_seeded = 1
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM sync_filters sf
|
||||||
|
WHERE sf.sync_config_id = sc.id AND sf.entity_type = 'store'
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
op.execute("""
|
||||||
|
UPDATE sync_configs sc
|
||||||
|
SET group_filters_seeded = 1
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM sync_filters sf
|
||||||
|
WHERE sf.sync_config_id = sc.id AND sf.entity_type = 'group'
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_column("sync_configs", "group_filters_seeded")
|
||||||
|
op.drop_column("sync_configs", "store_filters_seeded")
|
||||||
@@ -3,6 +3,7 @@ from sqlalchemy import (
|
|||||||
Numeric, String, Text, UniqueConstraint, func,
|
Numeric, String, Text, UniqueConstraint, func,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
from web.database import Base
|
from web.database import Base
|
||||||
|
|
||||||
|
|
||||||
@@ -35,6 +36,8 @@ class VkConnection(Base):
|
|||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
access_token = Column(Text, nullable=False)
|
access_token = Column(Text, nullable=False)
|
||||||
|
refresh_token = Column(Text, nullable=True)
|
||||||
|
token_expires_at = Column(DateTime, nullable=True)
|
||||||
vk_user_id = Column(String(50), nullable=True)
|
vk_user_id = Column(String(50), nullable=True)
|
||||||
first_name = Column(String(255), nullable=True)
|
first_name = Column(String(255), nullable=True)
|
||||||
last_name = Column(String(255), nullable=True)
|
last_name = Column(String(255), nullable=True)
|
||||||
@@ -54,7 +57,12 @@ class SyncConfig(Base):
|
|||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
is_enabled = Column(Boolean, nullable=False, default=False)
|
is_enabled = Column(Boolean, nullable=False, default=False)
|
||||||
|
evo_mirror_enabled = Column(Boolean, nullable=False, default=False)
|
||||||
|
vk_mirror_enabled = Column(Boolean, nullable=False, default=False)
|
||||||
|
store_filters_seeded = Column(Boolean, nullable=False, default=False)
|
||||||
|
group_filters_seeded = Column(Boolean, nullable=False, default=False)
|
||||||
confirmed_at = Column(DateTime, nullable=True)
|
confirmed_at = Column(DateTime, nullable=True)
|
||||||
|
price_multiplier = Column(Numeric(10, 4), nullable=False, default=1.0)
|
||||||
created_at = Column(DateTime, nullable=False, server_default=func.now())
|
created_at = Column(DateTime, nullable=False, server_default=func.now())
|
||||||
updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now())
|
updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
@@ -81,6 +89,44 @@ class SyncFilter(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class VkCachedAlbum(Base):
|
||||||
|
__tablename__ = "vk_cached_albums"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
vk_group_id = Column(String(50), nullable=False)
|
||||||
|
album_id = Column(String(50), nullable=False)
|
||||||
|
title = Column(String(255), nullable=False)
|
||||||
|
count = Column(Integer, nullable=True)
|
||||||
|
fetched_at = Column(DateTime, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("user_id", "vk_group_id", "album_id", name="uq_vk_cached_albums"),
|
||||||
|
Index("ix_vk_cached_albums_user_group", "user_id", "vk_group_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class VkCachedProduct(Base):
|
||||||
|
__tablename__ = "vk_cached_products"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
vk_group_id = Column(String(50), nullable=False)
|
||||||
|
vk_product_id = Column(String(50), nullable=False)
|
||||||
|
album_id = Column(String(50), nullable=True)
|
||||||
|
name = Column(String(255), nullable=False)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
price = Column(Numeric(12, 2), nullable=True)
|
||||||
|
availability = Column(Integer, nullable=True)
|
||||||
|
thumb_url = Column(String(1024), nullable=True)
|
||||||
|
fetched_at = Column(DateTime, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("user_id", "vk_group_id", "vk_product_id", name="uq_vk_cached_products"),
|
||||||
|
Index("ix_vk_cached_products_user_group_album", "user_id", "vk_group_id", "album_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CachedStore(Base):
|
class CachedStore(Base):
|
||||||
__tablename__ = "cached_stores"
|
__tablename__ = "cached_stores"
|
||||||
|
|
||||||
@@ -129,8 +175,29 @@ class CachedProduct(Base):
|
|||||||
allow_to_sell = Column(Boolean, nullable=True)
|
allow_to_sell = Column(Boolean, nullable=True)
|
||||||
fetched_at = Column(DateTime, nullable=False)
|
fetched_at = Column(DateTime, nullable=False)
|
||||||
synced_at = Column(DateTime, nullable=True)
|
synced_at = Column(DateTime, nullable=True)
|
||||||
|
vk_product_id = Column(String(50), nullable=True) # VK market item ID after first push
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint("user_id", "evotor_id", name="uq_cached_products_user_evotor"),
|
UniqueConstraint("user_id", "evotor_id", name="uq_cached_products_user_evotor"),
|
||||||
Index("ix_cached_products_user_store_group", "user_id", "store_evotor_id", "group_evotor_id"),
|
Index("ix_cached_products_user_store_group", "user_id", "store_evotor_id", "group_evotor_id"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ApiLog(Base):
|
||||||
|
__tablename__ = "api_logs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||||
|
service = Column(String(20), nullable=False) # "evotor" | "vk"
|
||||||
|
method = Column(String(10), nullable=False) # "GET" | "POST"
|
||||||
|
url = Column(String(1024), nullable=False)
|
||||||
|
request_body = Column(Text, nullable=True)
|
||||||
|
response_status = Column(Integer, nullable=True)
|
||||||
|
response_body = Column(Text, nullable=True)
|
||||||
|
duration_ms = Column(Integer, nullable=True)
|
||||||
|
created_at = Column(DateTime, nullable=False, server_default=func.now())
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_api_logs_user_service", "user_id", "service"),
|
||||||
|
Index("ix_api_logs_created_at", "created_at"),
|
||||||
|
)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class User(Base):
|
|||||||
first_name = Column(String(100), nullable=False)
|
first_name = Column(String(100), nullable=False)
|
||||||
last_name = Column(String(100), nullable=False)
|
last_name = Column(String(100), nullable=False)
|
||||||
email = Column(String(255), nullable=False)
|
email = Column(String(255), nullable=False)
|
||||||
phone = Column(String(20), nullable=False)
|
phone = Column(String(20), nullable=True)
|
||||||
password_hash = Column(String(255), nullable=True)
|
password_hash = Column(String(255), nullable=True)
|
||||||
is_email_confirmed = Column(Boolean, nullable=False, default=False)
|
is_email_confirmed = Column(Boolean, nullable=False, default=False)
|
||||||
email_confirm_token = Column(String(255), nullable=True)
|
email_confirm_token = Column(String(255), nullable=True)
|
||||||
|
|||||||
@@ -101,6 +101,105 @@ async def admin_user_detail(user_id: int, request: Request, db: Session = Depend
|
|||||||
return _render(request, "admin/user_detail.html", {"user": admin, "target": target})
|
return _render(request, "admin/user_detail.html", {"user": admin, "target": target})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Create user ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/users/create")
|
||||||
|
async def admin_create_user(request: Request, db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
admin = _admin_user(request, db)
|
||||||
|
except Exception:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
|
form = await request.form()
|
||||||
|
first_name = str(form.get("first_name", "")).strip()
|
||||||
|
last_name = str(form.get("last_name", "")).strip()
|
||||||
|
email = str(form.get("email", "")).strip().lower()
|
||||||
|
phone = str(form.get("phone", "")).strip() or None
|
||||||
|
password = str(form.get("password", ""))
|
||||||
|
role_str = str(form.get("role", "user"))
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
if not first_name:
|
||||||
|
errors.append("Имя обязательно")
|
||||||
|
if not email:
|
||||||
|
errors.append("Email обязателен")
|
||||||
|
if not password or len(password) < 8:
|
||||||
|
errors.append("Пароль должен содержать минимум 8 символов")
|
||||||
|
if role_str not in ("user", "admin") and admin.role != UserRoleEnum.system:
|
||||||
|
role_str = "user"
|
||||||
|
|
||||||
|
if not errors:
|
||||||
|
existing = db.query(User).filter(User.email == email).first()
|
||||||
|
if existing:
|
||||||
|
errors.append("Пользователь с таким email уже существует")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
q = db.query(User)
|
||||||
|
total = q.count()
|
||||||
|
users = q.order_by(User.created_at.desc()).limit(PAGE_SIZE).all()
|
||||||
|
return _render(request, "admin/users.html", {
|
||||||
|
"user": admin,
|
||||||
|
"users": users,
|
||||||
|
"search": "",
|
||||||
|
"status_filter": "",
|
||||||
|
"role_filter": "",
|
||||||
|
"page": 1,
|
||||||
|
"total_pages": max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE),
|
||||||
|
"total": total,
|
||||||
|
"create_errors": errors,
|
||||||
|
"create_form": {
|
||||||
|
"first_name": first_name,
|
||||||
|
"last_name": last_name,
|
||||||
|
"email": email,
|
||||||
|
"phone": phone or "",
|
||||||
|
"role": role_str,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
try:
|
||||||
|
role = UserRoleEnum(role_str)
|
||||||
|
except ValueError:
|
||||||
|
role = UserRoleEnum.user
|
||||||
|
|
||||||
|
new_user = User(
|
||||||
|
first_name=first_name,
|
||||||
|
last_name=last_name,
|
||||||
|
email=email,
|
||||||
|
phone=phone,
|
||||||
|
password_hash=hash_password(password),
|
||||||
|
role=role,
|
||||||
|
status=UserStatusEnum.active,
|
||||||
|
is_email_confirmed=True,
|
||||||
|
)
|
||||||
|
db.add(new_user)
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse(f"/admin/users/{new_user.id}?success=saved", 303)
|
||||||
|
|
||||||
|
|
||||||
|
# ── View-as ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/users/{user_id}/view-as")
|
||||||
|
async def admin_view_as(user_id: int, request: Request, db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
_admin_user(request, db)
|
||||||
|
except Exception:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
target = db.get(User, user_id)
|
||||||
|
if target:
|
||||||
|
request.session["viewed_user_id"] = user_id
|
||||||
|
return RedirectResponse("/connections", 303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/view-as/stop")
|
||||||
|
async def admin_view_as_stop(request: Request, db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
_admin_user(request, db)
|
||||||
|
except Exception:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
request.session.pop("viewed_user_id", None)
|
||||||
|
return RedirectResponse("/admin/users", 303)
|
||||||
|
|
||||||
|
|
||||||
# ── User actions ──────────────────────────────────────────────────────────────
|
# ── User actions ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.post("/users/{user_id}/activate")
|
@router.post("/users/{user_id}/activate")
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||||
from sqlalchemy import or_
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from web.auth.password import hash_password, verify_password
|
from web.auth.password import verify_password
|
||||||
from web.auth.session import get_session_user_id
|
from web.auth.session import get_session_user_id
|
||||||
from web.config import settings
|
from web.config import settings
|
||||||
from web.database import get_db
|
from web.database import get_db
|
||||||
@@ -23,60 +22,13 @@ def _render(request: Request, template: str, ctx: dict) -> HTMLResponse:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/register")
|
@router.get("/register")
|
||||||
async def register_get(request: Request, db: Session = Depends(get_db)):
|
async def register_get(request: Request):
|
||||||
if get_session_user_id(request):
|
return Response(status_code=404)
|
||||||
return RedirectResponse("/profile", 303)
|
|
||||||
return _render(request, "register.html", {"user": None})
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register")
|
@router.post("/register")
|
||||||
async def register_post(request: Request, db: Session = Depends(get_db)):
|
async def register_post(request: Request):
|
||||||
form = await request.form()
|
return Response(status_code=404)
|
||||||
data = {k: str(v).strip() for k, v in form.items()}
|
|
||||||
errors = []
|
|
||||||
|
|
||||||
if not data.get("email"):
|
|
||||||
errors.append("Email обязателен")
|
|
||||||
if not data.get("phone"):
|
|
||||||
errors.append("Телефон обязателен")
|
|
||||||
if not data.get("password"):
|
|
||||||
errors.append("Пароль обязателен")
|
|
||||||
if len(data.get("password", "")) < 8:
|
|
||||||
errors.append("Пароль должен содержать минимум 8 символов")
|
|
||||||
if data.get("password") != data.get("password_confirm"):
|
|
||||||
errors.append("Пароли не совпадают")
|
|
||||||
|
|
||||||
if not errors:
|
|
||||||
existing = db.query(User).filter(
|
|
||||||
or_(User.email == data["email"], User.phone == data["phone"])
|
|
||||||
).first()
|
|
||||||
if existing:
|
|
||||||
if existing.email == data["email"]:
|
|
||||||
errors.append("Пользователь с таким email уже существует")
|
|
||||||
else:
|
|
||||||
errors.append("Пользователь с таким телефоном уже существует")
|
|
||||||
|
|
||||||
if errors:
|
|
||||||
return _render(request, "register.html", {"user": None, "errors": errors, "form": data})
|
|
||||||
|
|
||||||
token = secrets.token_urlsafe(32)
|
|
||||||
user = User(
|
|
||||||
first_name=data.get("first_name", ""),
|
|
||||||
last_name=data.get("last_name", ""),
|
|
||||||
email=data["email"],
|
|
||||||
phone=data["phone"],
|
|
||||||
password_hash=await _hash(data["password"]),
|
|
||||||
email_confirm_token=token,
|
|
||||||
status=UserStatusEnum.pending,
|
|
||||||
)
|
|
||||||
db.add(user)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
confirm_url = f"{settings.BASE_URL}/confirm-email?token={token}"
|
|
||||||
html = f'<p>Подтвердите email: <a href="{confirm_url}">{confirm_url}</a></p>'
|
|
||||||
send_email_task.delay(user.email, "Подтвердите ваш email — ЭВОСИНК", html)
|
|
||||||
|
|
||||||
return _render(request, "confirm_email.html", {"user": None})
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/confirm-email")
|
@router.get("/confirm-email")
|
||||||
@@ -167,4 +119,5 @@ async def logout(request: Request):
|
|||||||
|
|
||||||
async def _hash(plain: str) -> str:
|
async def _hash(plain: str) -> str:
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from web.auth.password import hash_password
|
||||||
return await asyncio.get_event_loop().run_in_executor(None, hash_password, plain)
|
return await asyncio.get_event_loop().run_in_executor(None, hash_password, plain)
|
||||||
|
|||||||
265
web/routes/catalog.py
Normal file
265
web/routes/catalog.py
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from web.auth.session import get_current_user, get_viewed_user
|
||||||
|
from web.config import settings
|
||||||
|
from web.database import get_db
|
||||||
|
from web.models.connections import CachedGroup, CachedProduct, CachedStore, SyncConfig, SyncFilter
|
||||||
|
from web.templates_env import templates
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create_sync_config(db: Session, user_id: int) -> SyncConfig:
|
||||||
|
cfg = db.query(SyncConfig).filter_by(user_id=user_id).first()
|
||||||
|
if not cfg:
|
||||||
|
cfg = SyncConfig(user_id=user_id, is_enabled=True)
|
||||||
|
db.add(cfg)
|
||||||
|
db.flush()
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def _enabled_store_ids(db: Session, user_id: int) -> set[str] | None:
|
||||||
|
"""Return set of enabled store evotor_ids, or None if filters not yet seeded (all enabled)."""
|
||||||
|
cfg = db.query(SyncConfig).filter_by(user_id=user_id).first()
|
||||||
|
if not cfg or not cfg.store_filters_seeded:
|
||||||
|
return None
|
||||||
|
filters = db.query(SyncFilter).filter_by(
|
||||||
|
sync_config_id=cfg.id, entity_type="store", filter_mode="include"
|
||||||
|
).all()
|
||||||
|
return {f.entity_id for f in filters}
|
||||||
|
|
||||||
|
|
||||||
|
def _enabled_group_ids(db: Session, user_id: int, store_evotor_id: str) -> set[str] | None:
|
||||||
|
"""Return set of enabled group evotor_ids for a store, or None if filters not yet seeded (all enabled)."""
|
||||||
|
cfg = db.query(SyncConfig).filter_by(user_id=user_id).first()
|
||||||
|
if not cfg or not cfg.group_filters_seeded:
|
||||||
|
return None
|
||||||
|
filters = db.query(SyncFilter).filter_by(
|
||||||
|
sync_config_id=cfg.id, entity_type="group", filter_mode="include",
|
||||||
|
parent_entity_id=store_evotor_id,
|
||||||
|
).all()
|
||||||
|
return {f.entity_id for f in filters}
|
||||||
|
|
||||||
|
|
||||||
|
def _render(request: Request, template: str, ctx: dict) -> HTMLResponse:
|
||||||
|
ctx["request"] = request
|
||||||
|
ctx.setdefault("jivosite_widget_id", settings.JIVOSITE_WIDGET_ID)
|
||||||
|
return templates.TemplateResponse(ctx.pop("request"), template, ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/catalog/stores")
|
||||||
|
async def catalog_stores(request: Request, db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
real_user, viewed_user = get_viewed_user(request, db)
|
||||||
|
except Exception:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
|
stores = (
|
||||||
|
db.query(CachedStore)
|
||||||
|
.filter(CachedStore.user_id == viewed_user.id)
|
||||||
|
.order_by(CachedStore.name)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
enabled_ids = _enabled_store_ids(db, viewed_user.id)
|
||||||
|
return _render(request, "catalog/stores.html", {
|
||||||
|
"user": real_user,
|
||||||
|
"viewed_user": viewed_user if viewed_user.id != real_user.id else None,
|
||||||
|
"stores": stores,
|
||||||
|
"enabled_ids": enabled_ids,
|
||||||
|
"refresh_interval": settings.CATALOG_REFRESH_INTERVAL_SECONDS,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/catalog/stores/{store_evotor_id}/groups")
|
||||||
|
async def catalog_groups(store_evotor_id: str, request: Request, db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
real_user, viewed_user = get_viewed_user(request, db)
|
||||||
|
except Exception:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
|
store = (
|
||||||
|
db.query(CachedStore)
|
||||||
|
.filter(CachedStore.user_id == viewed_user.id, CachedStore.evotor_id == store_evotor_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not store:
|
||||||
|
return RedirectResponse("/catalog/stores", 303)
|
||||||
|
|
||||||
|
groups = (
|
||||||
|
db.query(CachedGroup)
|
||||||
|
.filter(CachedGroup.user_id == viewed_user.id, CachedGroup.store_evotor_id == store_evotor_id)
|
||||||
|
.order_by(CachedGroup.name)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
enabled_ids = _enabled_group_ids(db, viewed_user.id, store_evotor_id)
|
||||||
|
|
||||||
|
counts_q = (
|
||||||
|
db.query(CachedProduct.group_evotor_id, func.count().label("cnt"))
|
||||||
|
.filter(CachedProduct.user_id == viewed_user.id, CachedProduct.store_evotor_id == store_evotor_id)
|
||||||
|
.group_by(CachedProduct.group_evotor_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
product_counts = {row.group_evotor_id: row.cnt for row in counts_q}
|
||||||
|
|
||||||
|
return _render(request, "catalog/groups.html", {
|
||||||
|
"user": real_user,
|
||||||
|
"viewed_user": viewed_user if viewed_user.id != real_user.id else None,
|
||||||
|
"store": store, "groups": groups,
|
||||||
|
"enabled_ids": enabled_ids,
|
||||||
|
"product_counts": product_counts,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/catalog/stores/{store_evotor_id}/products")
|
||||||
|
async def catalog_products(store_evotor_id: str, request: Request, db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
real_user, viewed_user = get_viewed_user(request, db)
|
||||||
|
except Exception:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
|
store = (
|
||||||
|
db.query(CachedStore)
|
||||||
|
.filter(CachedStore.user_id == viewed_user.id, CachedStore.evotor_id == store_evotor_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not store:
|
||||||
|
return RedirectResponse("/catalog/stores", 303)
|
||||||
|
|
||||||
|
group_id = request.query_params.get("group")
|
||||||
|
q = db.query(CachedProduct).filter(
|
||||||
|
CachedProduct.user_id == viewed_user.id,
|
||||||
|
CachedProduct.store_evotor_id == store_evotor_id,
|
||||||
|
)
|
||||||
|
if group_id:
|
||||||
|
q = q.filter(CachedProduct.group_evotor_id == group_id)
|
||||||
|
|
||||||
|
products = q.order_by(CachedProduct.name).all()
|
||||||
|
groups = (
|
||||||
|
db.query(CachedGroup)
|
||||||
|
.filter(CachedGroup.user_id == viewed_user.id, CachedGroup.store_evotor_id == store_evotor_id)
|
||||||
|
.order_by(CachedGroup.name)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
group_map = {g.evotor_id: g.name for g in groups}
|
||||||
|
return _render(request, "catalog/products.html", {
|
||||||
|
"user": real_user,
|
||||||
|
"viewed_user": viewed_user if viewed_user.id != real_user.id else None,
|
||||||
|
"store": store,
|
||||||
|
"products": products,
|
||||||
|
"groups": groups,
|
||||||
|
"group_id": group_id,
|
||||||
|
"group_map": group_map,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/catalog/stores/{store_evotor_id}/toggle")
|
||||||
|
async def catalog_store_toggle(store_evotor_id: str, request: Request, db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
user = get_current_user(request, db)
|
||||||
|
except Exception:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
|
cfg = _get_or_create_sync_config(db, user.id)
|
||||||
|
|
||||||
|
existing = db.query(SyncFilter).filter_by(
|
||||||
|
sync_config_id=cfg.id, entity_type="store", filter_mode="include"
|
||||||
|
).all()
|
||||||
|
existing_ids = {f.entity_id for f in existing}
|
||||||
|
|
||||||
|
if cfg.store_filters_seeded:
|
||||||
|
if store_evotor_id in existing_ids:
|
||||||
|
# Currently enabled → disable: remove its filter
|
||||||
|
db.query(SyncFilter).filter_by(
|
||||||
|
sync_config_id=cfg.id, entity_type="store",
|
||||||
|
entity_id=store_evotor_id, filter_mode="include",
|
||||||
|
).delete()
|
||||||
|
else:
|
||||||
|
# Currently disabled → re-enable: add its filter back
|
||||||
|
db.add(SyncFilter(
|
||||||
|
sync_config_id=cfg.id,
|
||||||
|
entity_type="store",
|
||||||
|
entity_id=store_evotor_id,
|
||||||
|
filter_mode="include",
|
||||||
|
created_at=datetime.now(timezone.utc).replace(tzinfo=None),
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
# First toggle ever: seed include-filters for all OTHER stores, mark seeded
|
||||||
|
all_stores = db.query(CachedStore).filter_by(user_id=user.id).all()
|
||||||
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
for s in all_stores:
|
||||||
|
if s.evotor_id == store_evotor_id:
|
||||||
|
continue
|
||||||
|
db.add(SyncFilter(
|
||||||
|
sync_config_id=cfg.id,
|
||||||
|
entity_type="store",
|
||||||
|
entity_id=s.evotor_id,
|
||||||
|
entity_name=s.name,
|
||||||
|
filter_mode="include",
|
||||||
|
created_at=now,
|
||||||
|
))
|
||||||
|
cfg.store_filters_seeded = True
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse("/catalog/stores", 303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/catalog/stores/{store_evotor_id}/groups/{group_evotor_id}/toggle")
|
||||||
|
async def catalog_group_toggle(
|
||||||
|
store_evotor_id: str, group_evotor_id: str,
|
||||||
|
request: Request, db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
user = get_current_user(request, db)
|
||||||
|
except Exception:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
|
cfg = _get_or_create_sync_config(db, user.id)
|
||||||
|
|
||||||
|
existing = db.query(SyncFilter).filter_by(
|
||||||
|
sync_config_id=cfg.id, entity_type="group", filter_mode="include",
|
||||||
|
parent_entity_id=store_evotor_id,
|
||||||
|
).all()
|
||||||
|
existing_ids = {f.entity_id for f in existing}
|
||||||
|
|
||||||
|
if cfg.group_filters_seeded:
|
||||||
|
if group_evotor_id in existing_ids:
|
||||||
|
db.query(SyncFilter).filter_by(
|
||||||
|
sync_config_id=cfg.id, entity_type="group",
|
||||||
|
entity_id=group_evotor_id, filter_mode="include",
|
||||||
|
).delete()
|
||||||
|
else:
|
||||||
|
db.add(SyncFilter(
|
||||||
|
sync_config_id=cfg.id,
|
||||||
|
entity_type="group",
|
||||||
|
entity_id=group_evotor_id,
|
||||||
|
filter_mode="include",
|
||||||
|
parent_entity_id=store_evotor_id,
|
||||||
|
created_at=datetime.now(timezone.utc).replace(tzinfo=None),
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
# First toggle ever: seed include-filters for all OTHER groups in this store, mark seeded
|
||||||
|
all_groups = db.query(CachedGroup).filter_by(
|
||||||
|
user_id=user.id, store_evotor_id=store_evotor_id,
|
||||||
|
).all()
|
||||||
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
for g in all_groups:
|
||||||
|
if g.evotor_id == group_evotor_id:
|
||||||
|
continue
|
||||||
|
db.add(SyncFilter(
|
||||||
|
sync_config_id=cfg.id,
|
||||||
|
entity_type="group",
|
||||||
|
entity_id=g.evotor_id,
|
||||||
|
entity_name=g.name,
|
||||||
|
filter_mode="include",
|
||||||
|
parent_entity_id=store_evotor_id,
|
||||||
|
created_at=now,
|
||||||
|
))
|
||||||
|
cfg.group_filters_seeded = True
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse(f"/catalog/stores/{store_evotor_id}/groups", 303)
|
||||||
383
web/routes/connections.py
Normal file
383
web/routes/connections.py
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import web.lib.api_logger as api_logger
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from web.auth.session import get_current_user, get_viewed_user
|
||||||
|
from web.config import settings
|
||||||
|
from web.database import get_db
|
||||||
|
from web.models.connections import EvotorConnection, VkConnection
|
||||||
|
from web.templates_env import templates
|
||||||
|
|
||||||
|
VK_SCOPE = 335876 # photos(4) + wall(8192) + groups(262144) + offline(65536)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _render(request: Request, template: str, ctx: dict) -> HTMLResponse:
|
||||||
|
ctx["request"] = request
|
||||||
|
ctx.setdefault("jivosite_widget_id", settings.JIVOSITE_WIDGET_ID)
|
||||||
|
return templates.TemplateResponse(ctx.pop("request"), template, ctx)
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/connections")
|
||||||
|
async def connections_get(request: Request, db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
real_user, viewed_user = get_viewed_user(request, db)
|
||||||
|
except Exception:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
|
evotor = db.query(EvotorConnection).filter_by(user_id=viewed_user.id).first()
|
||||||
|
vk = db.query(VkConnection).filter_by(user_id=viewed_user.id).first()
|
||||||
|
return _render(request, "connections.html", {
|
||||||
|
"user": real_user,
|
||||||
|
"viewed_user": viewed_user if viewed_user.id != real_user.id else None,
|
||||||
|
"evotor": evotor,
|
||||||
|
"vk": vk,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/connections/evotor")
|
||||||
|
async def connections_evotor_post(request: Request, db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
user = get_current_user(request, db)
|
||||||
|
except Exception:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
|
form = await request.form()
|
||||||
|
access_token = str(form.get("access_token", "")).strip()
|
||||||
|
evotor_user_id = str(form.get("evotor_user_id", "")).strip() or None
|
||||||
|
|
||||||
|
if not access_token:
|
||||||
|
evotor = db.query(EvotorConnection).filter_by(user_id=user.id).first()
|
||||||
|
return _render(request, "connections.html", {
|
||||||
|
"user": user,
|
||||||
|
"evotor": evotor,
|
||||||
|
"errors": ["API-токен обязателен"],
|
||||||
|
})
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
conn = db.query(EvotorConnection).filter_by(user_id=user.id).first()
|
||||||
|
if conn:
|
||||||
|
conn.access_token = access_token
|
||||||
|
if evotor_user_id:
|
||||||
|
conn.evotor_user_id = evotor_user_id
|
||||||
|
conn.updated_at = now
|
||||||
|
else:
|
||||||
|
conn = EvotorConnection(
|
||||||
|
user_id=user.id,
|
||||||
|
evotor_user_id=evotor_user_id,
|
||||||
|
access_token=access_token,
|
||||||
|
api_token=secrets.token_urlsafe(32),
|
||||||
|
connected_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
db.add(conn)
|
||||||
|
|
||||||
|
if evotor_user_id and not user.evotor_user_id:
|
||||||
|
user.evotor_user_id = evotor_user_id
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse("/connections?success=1", 303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/connections/evotor/disconnect")
|
||||||
|
async def connections_evotor_disconnect(request: Request, db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
user = get_current_user(request, db)
|
||||||
|
except Exception:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
|
conn = db.query(EvotorConnection).filter_by(user_id=user.id).first()
|
||||||
|
if conn:
|
||||||
|
db.delete(conn)
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse("/connections", 303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/connections/vk")
|
||||||
|
async def connections_vk_post(request: Request, db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
user = get_current_user(request, db)
|
||||||
|
except Exception:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
|
form = await request.form()
|
||||||
|
access_token = str(form.get("access_token", "")).strip()
|
||||||
|
vk_group_id = str(form.get("vk_group_id", "")).strip() or None
|
||||||
|
|
||||||
|
if not access_token:
|
||||||
|
evotor = db.query(EvotorConnection).filter_by(user_id=user.id).first()
|
||||||
|
vk = db.query(VkConnection).filter_by(user_id=user.id).first()
|
||||||
|
return _render(request, "connections.html", {
|
||||||
|
"user": user,
|
||||||
|
"evotor": evotor,
|
||||||
|
"vk": vk,
|
||||||
|
"errors": ["Токен VK обязателен"],
|
||||||
|
})
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
conn = db.query(VkConnection).filter_by(user_id=user.id).first()
|
||||||
|
if conn:
|
||||||
|
conn.access_token = access_token
|
||||||
|
if vk_group_id:
|
||||||
|
conn.vk_user_id = vk_group_id
|
||||||
|
conn.updated_at = now
|
||||||
|
else:
|
||||||
|
conn = VkConnection(
|
||||||
|
user_id=user.id,
|
||||||
|
access_token=access_token,
|
||||||
|
vk_user_id=vk_group_id,
|
||||||
|
connected_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
db.add(conn)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse("/connections?success=1", 303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vk-auth")
|
||||||
|
async def vk_auth(request: Request):
|
||||||
|
try:
|
||||||
|
get_current_user(request, next(get_db()))
|
||||||
|
except Exception:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
|
if not settings.VK_CLIENT_ID:
|
||||||
|
return RedirectResponse("/connections?error=vk_not_configured", 303)
|
||||||
|
|
||||||
|
state = secrets.token_urlsafe(16)
|
||||||
|
request.session["vk_oauth_state"] = state
|
||||||
|
|
||||||
|
redirect_uri = f"{settings.BASE_URL}/vk-callback"
|
||||||
|
params = urlencode({
|
||||||
|
"client_id": settings.VK_CLIENT_ID,
|
||||||
|
"redirect_uri": redirect_uri,
|
||||||
|
"scope": VK_SCOPE,
|
||||||
|
"response_type": "token",
|
||||||
|
"display": "page",
|
||||||
|
"state": state,
|
||||||
|
"revoke": "1",
|
||||||
|
})
|
||||||
|
return RedirectResponse(f"https://oauth.vk.com/authorize?{params}", 302)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vk-callback")
|
||||||
|
async def vk_callback_page(request: Request):
|
||||||
|
"""Serves the callback page that reads the token from the URL fragment and POSTs it."""
|
||||||
|
return HTMLResponse("""<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head><meta charset="utf-8"><title>VK авторизация…</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center;
|
||||||
|
min-height: 100vh; margin: 0; background: #f4f4f4; }
|
||||||
|
.box { background: #fff; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,.1);
|
||||||
|
text-align: center; max-width: 360px; }
|
||||||
|
.spinner { width: 36px; height: 36px; border: 4px solid #e0e0e0;
|
||||||
|
border-top-color: #0077ff; border-radius: 50%;
|
||||||
|
animation: spin .8s linear infinite; margin: 0 auto 1rem; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.error { color: #c0392b; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="box" id="box">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Завершаем авторизацию…</p>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const hash = window.location.hash.slice(1);
|
||||||
|
const params = new URLSearchParams(hash);
|
||||||
|
const token = params.get('access_token');
|
||||||
|
const state = params.get('state');
|
||||||
|
const userId = params.get('user_id');
|
||||||
|
const expiresIn = params.get('expires_in');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
document.getElementById('box').innerHTML =
|
||||||
|
'<p class="error">Токен не получен. <a href="/connections">Вернуться назад</a></p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/vk-callback/save', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ access_token: token, state: state,
|
||||||
|
user_id: userId, expires_in: expiresIn })
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.ok) {
|
||||||
|
window.location.href = '/connections?success=1';
|
||||||
|
} else {
|
||||||
|
document.getElementById('box').innerHTML =
|
||||||
|
'<p class="error">' + (data.message || 'Ошибка сохранения') +
|
||||||
|
' <a href="/connections">Вернуться назад</a></p>';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
document.getElementById('box').innerHTML =
|
||||||
|
'<p class="error">Ошибка сети. <a href="/connections">Вернуться назад</a></p>';
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>""")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/vk-callback/save")
|
||||||
|
async def vk_callback_save(request: Request, db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
user = get_current_user(request, db)
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse({"ok": False, "message": "Сессия истекла, войдите снова"}, status_code=401)
|
||||||
|
|
||||||
|
body = await request.json()
|
||||||
|
access_token = (body.get("access_token") or "").strip()
|
||||||
|
state = body.get("state") or ""
|
||||||
|
vk_user_id = str(body.get("user_id") or "").strip() or None
|
||||||
|
expires_in = body.get("expires_in")
|
||||||
|
|
||||||
|
expected_state = request.session.pop("vk_oauth_state", None)
|
||||||
|
if not expected_state or state != expected_state:
|
||||||
|
return JSONResponse({"ok": False, "message": "Недействительный state, попробуйте снова"})
|
||||||
|
|
||||||
|
if not access_token:
|
||||||
|
return JSONResponse({"ok": False, "message": "Токен не получен"})
|
||||||
|
|
||||||
|
token_expires_at = None
|
||||||
|
if expires_in and str(expires_in) != "0":
|
||||||
|
try:
|
||||||
|
token_expires_at = _now() + timedelta(seconds=int(expires_in))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
conn = db.query(VkConnection).filter_by(user_id=user.id).first()
|
||||||
|
if conn:
|
||||||
|
conn.access_token = access_token
|
||||||
|
conn.token_expires_at = token_expires_at
|
||||||
|
if vk_user_id:
|
||||||
|
conn.vk_user_id = vk_user_id
|
||||||
|
conn.updated_at = now
|
||||||
|
else:
|
||||||
|
conn = VkConnection(
|
||||||
|
user_id=user.id,
|
||||||
|
access_token=access_token,
|
||||||
|
token_expires_at=token_expires_at,
|
||||||
|
vk_user_id=vk_user_id,
|
||||||
|
connected_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
db.add(conn)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return JSONResponse({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/connections/vk/disconnect")
|
||||||
|
async def connections_vk_disconnect(request: Request, db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
user = get_current_user(request, db)
|
||||||
|
except Exception:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
|
conn = db.query(VkConnection).filter_by(user_id=user.id).first()
|
||||||
|
if conn:
|
||||||
|
db.delete(conn)
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse("/connections", 303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/connections/evotor/test")
|
||||||
|
async def connections_evotor_test(request: Request, db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
user = get_current_user(request, db)
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse({"ok": False, "message": "Не авторизован"}, status_code=401)
|
||||||
|
|
||||||
|
conn = db.query(EvotorConnection).filter_by(user_id=user.id).first()
|
||||||
|
if not conn:
|
||||||
|
return JSONResponse({"ok": False, "message": "Подключение не настроено"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = api_logger.get(
|
||||||
|
"https://api.evotor.ru/stores",
|
||||||
|
user_id=user.id,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {conn.access_token}",
|
||||||
|
"Accept": "application/vnd.evotor.v2+json",
|
||||||
|
},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if r.status_code == 200:
|
||||||
|
data = r.json()
|
||||||
|
items = data.get("items", data) if isinstance(data, dict) else data
|
||||||
|
count = len(items) if isinstance(items, list) else "?"
|
||||||
|
return JSONResponse({"ok": True, "message": f"Успешно. Найдено магазинов: {count}"})
|
||||||
|
elif r.status_code == 401:
|
||||||
|
return JSONResponse({"ok": False, "message": "Токен недействителен (401)"})
|
||||||
|
else:
|
||||||
|
return JSONResponse({"ok": False, "message": f"Ошибка API: HTTP {r.status_code}"})
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
return JSONResponse({"ok": False, "message": "Таймаут запроса к Эвотор"})
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse({"ok": False, "message": f"Ошибка: {e}"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/connections/vk/test")
|
||||||
|
async def connections_vk_test(request: Request, db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
user = get_current_user(request, db)
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse({"ok": False, "message": "Не авторизован"}, status_code=401)
|
||||||
|
|
||||||
|
conn = db.query(VkConnection).filter_by(user_id=user.id).first()
|
||||||
|
if not conn:
|
||||||
|
return JSONResponse({"ok": False, "message": "Подключение не настроено"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not conn.vk_user_id:
|
||||||
|
return JSONResponse({"ok": False, "message": "Укажите ID сообщества для проверки подключения."})
|
||||||
|
|
||||||
|
r = api_logger.get(
|
||||||
|
"https://api.vk.com/method/groups.getById",
|
||||||
|
user_id=user.id,
|
||||||
|
params={
|
||||||
|
"group_id": conn.vk_user_id,
|
||||||
|
"fields": "market",
|
||||||
|
"access_token": conn.access_token,
|
||||||
|
"v": settings.VK_API_VERSION,
|
||||||
|
},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
data = r.json()
|
||||||
|
if "error" in data:
|
||||||
|
code = data["error"].get("error_code")
|
||||||
|
msg = data["error"].get("error_msg", "Неизвестная ошибка")
|
||||||
|
return JSONResponse({"ok": False, "message": f"Ошибка VK API ({code}): {msg}"})
|
||||||
|
|
||||||
|
groups = data.get("response", {}).get("groups", [])
|
||||||
|
if not groups:
|
||||||
|
return JSONResponse({"ok": False, "message": "Сообщество не найдено"})
|
||||||
|
group = groups[0]
|
||||||
|
name = group.get("name", "—")
|
||||||
|
market = group.get("market", {})
|
||||||
|
market_status = "включён" if market.get("enabled") else "выключен"
|
||||||
|
return JSONResponse({"ok": True, "message": f"Успешно. Сообщество: «{name}», Маркет {market_status}"})
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
return JSONResponse({"ok": False, "message": "Таймаут запроса к VK"})
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse({"ok": False, "message": f"Ошибка: {e}"})
|
||||||
|
|
||||||
@@ -134,8 +134,8 @@ async def user_create(request: Request, db: Session = Depends(get_db)):
|
|||||||
user = User(
|
user = User(
|
||||||
first_name=first_name or "",
|
first_name=first_name or "",
|
||||||
last_name=last_name or "",
|
last_name=last_name or "",
|
||||||
email=email or f"{evotor_user_id}@evotor.placeholder",
|
email=email or f"{evotor_user_id}@evotor.invalid",
|
||||||
phone=phone or "",
|
phone=phone or None,
|
||||||
password_hash=None,
|
password_hash=None,
|
||||||
role=UserRoleEnum.user,
|
role=UserRoleEnum.user,
|
||||||
status=UserStatusEnum.pending,
|
status=UserStatusEnum.pending,
|
||||||
|
|||||||
81
web/routes/logs.py
Normal file
81
web/routes/logs.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""API request/response log viewer (admin only)."""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from web.auth.session import get_current_user
|
||||||
|
from web.database import get_db
|
||||||
|
from web.models.connections import ApiLog
|
||||||
|
from web.templates_env import templates
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
PAGE_SIZE = 50
|
||||||
|
|
||||||
|
|
||||||
|
def _render(request, template, ctx):
|
||||||
|
return templates.TemplateResponse(template, {"request": request, **ctx})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/logs")
|
||||||
|
async def admin_logs(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
service: str = "",
|
||||||
|
method: str = "",
|
||||||
|
status: str = "",
|
||||||
|
q: str = "",
|
||||||
|
page: int = 1,
|
||||||
|
hours: int = 168,
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
user = get_current_user(request, db)
|
||||||
|
except Exception:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
|
since = datetime.utcnow() - timedelta(hours=hours)
|
||||||
|
query = db.query(ApiLog).filter(ApiLog.created_at >= since)
|
||||||
|
|
||||||
|
if service:
|
||||||
|
query = query.filter(ApiLog.service == service)
|
||||||
|
if method:
|
||||||
|
query = query.filter(ApiLog.method == method)
|
||||||
|
if status:
|
||||||
|
try:
|
||||||
|
st = int(status)
|
||||||
|
query = query.filter(ApiLog.response_status == st)
|
||||||
|
except ValueError:
|
||||||
|
if status == "error":
|
||||||
|
query = query.filter(ApiLog.response_status >= 400)
|
||||||
|
elif status == "ok":
|
||||||
|
query = query.filter(ApiLog.response_status < 400)
|
||||||
|
if q:
|
||||||
|
like = f"%{q}%"
|
||||||
|
query = query.filter(
|
||||||
|
ApiLog.url.like(like) | ApiLog.response_body.like(like)
|
||||||
|
)
|
||||||
|
|
||||||
|
total = query.count()
|
||||||
|
logs = (
|
||||||
|
query.order_by(ApiLog.created_at.desc())
|
||||||
|
.offset((page - 1) * PAGE_SIZE)
|
||||||
|
.limit(PAGE_SIZE)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
total_pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
|
||||||
|
|
||||||
|
return _render(request, "admin/logs.html", {
|
||||||
|
"user": user,
|
||||||
|
"logs": logs,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"total_pages": total_pages,
|
||||||
|
"page_size": PAGE_SIZE,
|
||||||
|
"filter_service": service,
|
||||||
|
"filter_method": method,
|
||||||
|
"filter_status": status,
|
||||||
|
"filter_q": q,
|
||||||
|
"filter_hours": hours,
|
||||||
|
})
|
||||||
77
web/routes/sync.py
Normal file
77
web/routes/sync.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"""Sync settings page."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from web.auth.session import get_current_user, get_viewed_user
|
||||||
|
from web.config import settings
|
||||||
|
from web.database import get_db
|
||||||
|
from web.models.connections import SyncConfig
|
||||||
|
from web.templates_env import templates
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _render(request: Request, ctx: dict):
|
||||||
|
ctx["request"] = request
|
||||||
|
ctx.setdefault("jivosite_widget_id", settings.JIVOSITE_WIDGET_ID)
|
||||||
|
return templates.TemplateResponse(ctx.pop("request"), "sync.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sync")
|
||||||
|
async def sync_get(request: Request, db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
real_user, viewed_user = get_viewed_user(request, db)
|
||||||
|
except Exception:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
|
config = db.query(SyncConfig).filter_by(user_id=viewed_user.id).first()
|
||||||
|
return _render(request, {
|
||||||
|
"user": real_user,
|
||||||
|
"viewed_user": viewed_user if viewed_user.id != real_user.id else None,
|
||||||
|
"config": config,
|
||||||
|
"saved": request.query_params.get("saved"),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sync/settings")
|
||||||
|
async def sync_settings_post(request: Request, db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
user = get_current_user(request, db)
|
||||||
|
except Exception:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
|
form = await request.form()
|
||||||
|
|
||||||
|
evo_mirror_enabled = form.get("evo_mirror_enabled") == "1"
|
||||||
|
vk_mirror_enabled = form.get("vk_mirror_enabled") == "1"
|
||||||
|
sync_enabled = form.get("is_enabled") == "1"
|
||||||
|
|
||||||
|
raw_multiplier = str(form.get("price_multiplier", "1")).strip()
|
||||||
|
try:
|
||||||
|
multiplier = float(raw_multiplier)
|
||||||
|
if multiplier <= 0:
|
||||||
|
raise ValueError
|
||||||
|
except ValueError:
|
||||||
|
multiplier = 1.0
|
||||||
|
|
||||||
|
config = db.query(SyncConfig).filter_by(user_id=user.id).first()
|
||||||
|
if config:
|
||||||
|
config.evo_mirror_enabled = evo_mirror_enabled
|
||||||
|
config.vk_mirror_enabled = vk_mirror_enabled
|
||||||
|
config.is_enabled = sync_enabled
|
||||||
|
config.price_multiplier = multiplier
|
||||||
|
else:
|
||||||
|
config = SyncConfig(
|
||||||
|
user_id=user.id,
|
||||||
|
evo_mirror_enabled=evo_mirror_enabled,
|
||||||
|
vk_mirror_enabled=vk_mirror_enabled,
|
||||||
|
is_enabled=sync_enabled,
|
||||||
|
price_multiplier=multiplier,
|
||||||
|
)
|
||||||
|
db.add(config)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse("/sync?saved=1", 303)
|
||||||
65
web/routes/vk_catalog.py
Normal file
65
web/routes/vk_catalog.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from web.auth.session import get_viewed_user
|
||||||
|
from web.config import settings
|
||||||
|
from web.database import get_db
|
||||||
|
from web.models.connections import VkCachedAlbum, VkCachedProduct, VkConnection
|
||||||
|
from web.templates_env import templates
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _render(request: Request, template: str, ctx: dict) -> HTMLResponse:
|
||||||
|
ctx["request"] = request
|
||||||
|
ctx.setdefault("jivosite_widget_id", settings.JIVOSITE_WIDGET_ID)
|
||||||
|
return templates.TemplateResponse(ctx.pop("request"), template, ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vk-catalog/albums")
|
||||||
|
async def vk_catalog_albums(request: Request, db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
real_user, viewed_user = get_viewed_user(request, db)
|
||||||
|
except Exception:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
|
vk_conn = db.query(VkConnection).filter_by(user_id=viewed_user.id).first()
|
||||||
|
albums = (
|
||||||
|
db.query(VkCachedAlbum)
|
||||||
|
.filter(VkCachedAlbum.user_id == viewed_user.id)
|
||||||
|
.order_by(VkCachedAlbum.title)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return _render(request, "vk_catalog/albums.html", {
|
||||||
|
"user": real_user,
|
||||||
|
"viewed_user": viewed_user if viewed_user.id != real_user.id else None,
|
||||||
|
"albums": albums,
|
||||||
|
"vk_conn": vk_conn,
|
||||||
|
"refresh_interval": settings.CATALOG_REFRESH_INTERVAL_SECONDS,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vk-catalog/albums/{album_id}/products")
|
||||||
|
async def vk_catalog_products(album_id: str, request: Request, db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
real_user, viewed_user = get_viewed_user(request, db)
|
||||||
|
except Exception:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
|
album = db.query(VkCachedAlbum).filter_by(user_id=viewed_user.id, album_id=album_id).first()
|
||||||
|
if not album:
|
||||||
|
return RedirectResponse("/vk-catalog/albums", 303)
|
||||||
|
|
||||||
|
products = (
|
||||||
|
db.query(VkCachedProduct)
|
||||||
|
.filter(VkCachedProduct.user_id == viewed_user.id, VkCachedProduct.album_id == album_id)
|
||||||
|
.order_by(VkCachedProduct.name)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return _render(request, "vk_catalog/products.html", {
|
||||||
|
"user": real_user,
|
||||||
|
"viewed_user": viewed_user if viewed_user.id != real_user.id else None,
|
||||||
|
"album": album,
|
||||||
|
"products": products,
|
||||||
|
})
|
||||||
@@ -310,11 +310,24 @@ button.sm, a[role="button"].sm {
|
|||||||
/* Table */
|
/* Table */
|
||||||
.table-scroll {
|
.table-scroll {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
article.card {
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.align-middle {
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
table.align-middle td,
|
table.align-middle td,
|
||||||
table.align-middle th {
|
table.align-middle th {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Breadcrumb */
|
/* Breadcrumb */
|
||||||
|
|||||||
230
web/tasks/catalog.py
Normal file
230
web/tasks/catalog.py
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
"""
|
||||||
|
Periodic catalog sync: fetch stores / product-groups / products from Evotor
|
||||||
|
for every connected user and upsert into cached_* tables.
|
||||||
|
|
||||||
|
Beat schedule entry (set in celery_app.py):
|
||||||
|
refresh_catalog — runs every CATALOG_REFRESH_INTERVAL_SECONDS seconds
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from celery import shared_task
|
||||||
|
|
||||||
|
from web.config import settings
|
||||||
|
from web.database import SessionLocal
|
||||||
|
import web.lib.api_logger as api_logger
|
||||||
|
from web.models.connections import CachedGroup, CachedProduct, CachedStore, EvotorConnection, SyncConfig, SyncFilter
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
EVO_API = "https://api.evotor.ru"
|
||||||
|
|
||||||
|
|
||||||
|
def _headers(token: str) -> dict:
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Accept": "application/vnd.evotor.v2+json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
# ── per-user helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _fetch_stores(token: str, user_id: int | None = None) -> list[dict]:
|
||||||
|
r = api_logger.get(f"{EVO_API}/stores", user_id=user_id, headers=_headers(token), timeout=15)
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
return data.get("items", data) if isinstance(data, dict) else data
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_groups(token: str, store_id: str, user_id: int | None = None) -> list[dict] | None:
|
||||||
|
"""Returns None if the store is not accessible (402/403), list otherwise."""
|
||||||
|
r = api_logger.get(
|
||||||
|
f"{EVO_API}/stores/{store_id}/product-groups",
|
||||||
|
user_id=user_id, headers=_headers(token), timeout=15,
|
||||||
|
)
|
||||||
|
if r.status_code in (402, 403):
|
||||||
|
return None
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
return data.get("items", data) if isinstance(data, dict) else data
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_products(token: str, store_id: str, user_id: int | None = None) -> list[dict] | None:
|
||||||
|
"""Returns None if the store is not accessible (402/403), list otherwise."""
|
||||||
|
r = api_logger.get(
|
||||||
|
f"{EVO_API}/stores/{store_id}/products",
|
||||||
|
user_id=user_id, headers=_headers(token), timeout=30,
|
||||||
|
)
|
||||||
|
if r.status_code in (402, 403):
|
||||||
|
return None
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
return data.get("items", data) if isinstance(data, dict) else data
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_user(db, user_id: int, token: str) -> None:
|
||||||
|
now = _now()
|
||||||
|
|
||||||
|
# ── stores ────────────────────────────────────────────────────────────────
|
||||||
|
try:
|
||||||
|
stores = _fetch_stores(token)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("user=%s fetch stores failed: %s", user_id, e)
|
||||||
|
return
|
||||||
|
|
||||||
|
store_ids = []
|
||||||
|
for s in stores:
|
||||||
|
evo_id = s.get("id") or s.get("uuid")
|
||||||
|
if not evo_id:
|
||||||
|
continue
|
||||||
|
store_ids.append(evo_id)
|
||||||
|
row = db.query(CachedStore).filter_by(user_id=user_id, evotor_id=evo_id).first()
|
||||||
|
if row:
|
||||||
|
row.name = s.get("name", "")
|
||||||
|
row.address = s.get("address", {}).get("str") if isinstance(s.get("address"), dict) else s.get("address")
|
||||||
|
row.fetched_at = now
|
||||||
|
else:
|
||||||
|
db.add(CachedStore(
|
||||||
|
user_id=user_id,
|
||||||
|
evotor_id=evo_id,
|
||||||
|
name=s.get("name", ""),
|
||||||
|
address=s.get("address", {}).get("str") if isinstance(s.get("address"), dict) else s.get("address"),
|
||||||
|
fetched_at=now,
|
||||||
|
))
|
||||||
|
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# ── apply store filter ────────────────────────────────────────────────────
|
||||||
|
cfg = db.query(SyncConfig).filter_by(user_id=user_id).first()
|
||||||
|
if cfg:
|
||||||
|
include_filters = db.query(SyncFilter).filter_by(
|
||||||
|
sync_config_id=cfg.id, entity_type="store", filter_mode="include"
|
||||||
|
).all()
|
||||||
|
if include_filters:
|
||||||
|
allowed = {f.entity_id for f in include_filters}
|
||||||
|
store_ids = [s for s in store_ids if s in allowed]
|
||||||
|
|
||||||
|
# ── groups & products per store ───────────────────────────────────────────
|
||||||
|
for store_evo_id in store_ids:
|
||||||
|
# groups
|
||||||
|
try:
|
||||||
|
groups = _fetch_groups(token, store_evo_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("user=%s store=%s fetch groups failed: %s", user_id, store_evo_id, e)
|
||||||
|
groups = []
|
||||||
|
if groups is None:
|
||||||
|
logger.debug("user=%s store=%s groups not available (402/403), skipping", user_id, store_evo_id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
for g in groups:
|
||||||
|
evo_id = g.get("id") or g.get("uuid")
|
||||||
|
if not evo_id:
|
||||||
|
continue
|
||||||
|
row = db.query(CachedGroup).filter_by(user_id=user_id, evotor_id=evo_id).first()
|
||||||
|
if row:
|
||||||
|
row.name = g.get("name", "")
|
||||||
|
row.store_evotor_id = store_evo_id
|
||||||
|
row.fetched_at = now
|
||||||
|
else:
|
||||||
|
db.add(CachedGroup(
|
||||||
|
user_id=user_id,
|
||||||
|
evotor_id=evo_id,
|
||||||
|
store_evotor_id=store_evo_id,
|
||||||
|
name=g.get("name", ""),
|
||||||
|
fetched_at=now,
|
||||||
|
))
|
||||||
|
|
||||||
|
# products
|
||||||
|
try:
|
||||||
|
products = _fetch_products(token, store_evo_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("user=%s store=%s fetch products failed: %s", user_id, store_evo_id, e)
|
||||||
|
products = []
|
||||||
|
if products is None:
|
||||||
|
logger.debug("user=%s store=%s products not available (402/403), skipping", user_id, store_evo_id)
|
||||||
|
products = []
|
||||||
|
|
||||||
|
for p in products:
|
||||||
|
evo_id = p.get("id") or p.get("uuid")
|
||||||
|
if not evo_id:
|
||||||
|
continue
|
||||||
|
price = p.get("price")
|
||||||
|
quantity = p.get("quantity")
|
||||||
|
row = db.query(CachedProduct).filter_by(user_id=user_id, evotor_id=evo_id).first()
|
||||||
|
if row:
|
||||||
|
row.store_evotor_id = store_evo_id
|
||||||
|
row.group_evotor_id = p.get("group") or p.get("parentUuid") or p.get("parent_id")
|
||||||
|
row.name = p.get("name", "")
|
||||||
|
row.price = float(price) if price is not None else None
|
||||||
|
row.quantity = float(quantity) if quantity is not None else None
|
||||||
|
row.measure_name = p.get("measureName") or p.get("measure_name")
|
||||||
|
row.article_number = p.get("code") or p.get("article_number")
|
||||||
|
row.allow_to_sell = p.get("allowToSell") if p.get("allowToSell") is not None else p.get("allow_to_sell")
|
||||||
|
row.fetched_at = now
|
||||||
|
else:
|
||||||
|
db.add(CachedProduct(
|
||||||
|
user_id=user_id,
|
||||||
|
evotor_id=evo_id,
|
||||||
|
store_evotor_id=store_evo_id,
|
||||||
|
group_evotor_id=p.get("group") or p.get("parentUuid") or p.get("parent_id"),
|
||||||
|
name=p.get("name", ""),
|
||||||
|
price=float(price) if price is not None else None,
|
||||||
|
quantity=float(quantity) if quantity is not None else None,
|
||||||
|
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"),
|
||||||
|
fetched_at=now,
|
||||||
|
))
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
logger.info(
|
||||||
|
"user=%s catalog synced: %d stores, %d groups, %d products",
|
||||||
|
user_id,
|
||||||
|
len(stores),
|
||||||
|
sum(1 for _ in db.query(CachedGroup).filter_by(user_id=user_id)),
|
||||||
|
sum(1 for _ in db.query(CachedProduct).filter_by(user_id=user_id)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Celery task ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@shared_task(
|
||||||
|
name="web.tasks.catalog.refresh_catalog",
|
||||||
|
queue="default",
|
||||||
|
bind=True,
|
||||||
|
max_retries=2,
|
||||||
|
default_retry_delay=60,
|
||||||
|
)
|
||||||
|
def refresh_catalog(self) -> dict:
|
||||||
|
"""Fetch and cache stores/groups/products for all connected Evotor users."""
|
||||||
|
db = SessionLocal()
|
||||||
|
results = {"ok": 0, "failed": 0}
|
||||||
|
try:
|
||||||
|
connections = (
|
||||||
|
db.query(EvotorConnection)
|
||||||
|
.filter(
|
||||||
|
EvotorConnection.user_id.isnot(None),
|
||||||
|
EvotorConnection.access_token.isnot(None),
|
||||||
|
EvotorConnection.access_token != "",
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for conn in connections:
|
||||||
|
cfg = db.query(SyncConfig).filter_by(user_id=conn.user_id).first()
|
||||||
|
if not cfg or not cfg.evo_mirror_enabled:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
_sync_user(db, conn.user_id, conn.access_token)
|
||||||
|
results["ok"] += 1
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("catalog sync failed for user=%s: %s", conn.user_id, exc)
|
||||||
|
results["failed"] += 1
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
logger.info("refresh_catalog done: %s", results)
|
||||||
|
return results
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
from celery import Celery
|
from celery import Celery
|
||||||
|
from celery.schedules import timedelta
|
||||||
|
|
||||||
from web.config import settings
|
from web.config import settings
|
||||||
|
|
||||||
@@ -16,8 +17,45 @@ celery_app.conf.update(
|
|||||||
broker_connection_retry_on_startup=True,
|
broker_connection_retry_on_startup=True,
|
||||||
task_routes={
|
task_routes={
|
||||||
"web.tasks.sync.*": {"queue": "sync"},
|
"web.tasks.sync.*": {"queue": "sync"},
|
||||||
|
"web.tasks.vk_sync.*": {"queue": "sync"},
|
||||||
"web.tasks.health.*": {"queue": "health"},
|
"web.tasks.health.*": {"queue": "health"},
|
||||||
"web.tasks.catalog.*": {"queue": "default"},
|
"web.tasks.catalog.*": {"queue": "default"},
|
||||||
|
"web.tasks.vk_catalog.*": {"queue": "default"},
|
||||||
"web.notifications.tasks.*": {"queue": "notifications"},
|
"web.notifications.tasks.*": {"queue": "notifications"},
|
||||||
},
|
},
|
||||||
|
beat_schedule={
|
||||||
|
# Chain: fetch Evotor → fetch VK catalog → mirror Evotor→VK
|
||||||
|
# Beat fires the launcher task which chains all three sequentially.
|
||||||
|
"sync-pipeline": {
|
||||||
|
"task": "web.tasks.celery_app.run_sync_pipeline",
|
||||||
|
"schedule": timedelta(seconds=settings.CATALOG_REFRESH_INTERVAL_SECONDS),
|
||||||
|
},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Register task modules so beat/worker can discover them
|
||||||
|
celery_app.autodiscover_tasks([
|
||||||
|
"web.tasks.catalog",
|
||||||
|
"web.tasks.vk_catalog",
|
||||||
|
"web.tasks.vk_sync",
|
||||||
|
"web.tasks.celery_app",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="web.tasks.celery_app.run_sync_pipeline", queue="default")
|
||||||
|
def run_sync_pipeline() -> str:
|
||||||
|
"""
|
||||||
|
Beat entry point. Chains refresh_catalog → refresh_vk_catalog → mirror_to_vk
|
||||||
|
so that mirror only runs after both catalog fetches are complete.
|
||||||
|
"""
|
||||||
|
from celery import chain
|
||||||
|
from web.tasks.catalog import refresh_catalog
|
||||||
|
from web.tasks.vk_catalog import refresh_vk_catalog
|
||||||
|
from web.tasks.vk_sync import mirror_to_vk
|
||||||
|
|
||||||
|
chain(
|
||||||
|
refresh_catalog.si(),
|
||||||
|
refresh_vk_catalog.si(),
|
||||||
|
mirror_to_vk.si(),
|
||||||
|
).apply_async()
|
||||||
|
return "pipeline dispatched"
|
||||||
|
|||||||
187
web/tasks/vk_catalog.py
Normal file
187
web/tasks/vk_catalog.py
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
"""
|
||||||
|
Periodic VK catalog sync: fetch albums and products from VK Market
|
||||||
|
for every connected user and upsert into vk_cached_* tables.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from celery import shared_task
|
||||||
|
|
||||||
|
import web.lib.api_logger as api_logger
|
||||||
|
from web.config import settings
|
||||||
|
from web.database import SessionLocal
|
||||||
|
from web.models.connections import SyncConfig, VkCachedAlbum, VkCachedProduct, VkConnection
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
VK_API = "https://api.vk.com/method"
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _vk_get(method: str, params: dict, token: str, user_id: int | None = None) -> dict:
|
||||||
|
params = {**params, "access_token": token, "v": settings.VK_API_VERSION}
|
||||||
|
r = api_logger.get(f"{VK_API}/{method}", user_id=user_id, params=params, timeout=20)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_user(db, user_id: int, token: str, group_id: str) -> None:
|
||||||
|
now = _now()
|
||||||
|
owner_id = f"-{group_id}"
|
||||||
|
|
||||||
|
# ── albums ────────────────────────────────────────────────────────────────
|
||||||
|
try:
|
||||||
|
data = _vk_get("market.getAlbums", {"owner_id": owner_id, "count": 100}, token, user_id=user_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("user=%s vk fetch albums failed: %s", user_id, e)
|
||||||
|
return
|
||||||
|
|
||||||
|
if "error" in data:
|
||||||
|
logger.warning("user=%s vk albums error: %s", user_id, data["error"])
|
||||||
|
return
|
||||||
|
|
||||||
|
albums = data.get("response", {}).get("items", [])
|
||||||
|
album_ids = []
|
||||||
|
for a in albums:
|
||||||
|
aid = str(a["id"])
|
||||||
|
album_ids.append(aid)
|
||||||
|
row = db.query(VkCachedAlbum).filter_by(user_id=user_id, vk_group_id=group_id, album_id=aid).first()
|
||||||
|
if row:
|
||||||
|
row.title = a.get("title", "")
|
||||||
|
row.count = a.get("count")
|
||||||
|
row.fetched_at = now
|
||||||
|
else:
|
||||||
|
db.add(VkCachedAlbum(
|
||||||
|
user_id=user_id,
|
||||||
|
vk_group_id=group_id,
|
||||||
|
album_id=aid,
|
||||||
|
title=a.get("title", ""),
|
||||||
|
count=a.get("count"),
|
||||||
|
fetched_at=now,
|
||||||
|
))
|
||||||
|
# Delete cached albums that no longer exist in VK
|
||||||
|
(
|
||||||
|
db.query(VkCachedAlbum)
|
||||||
|
.filter(
|
||||||
|
VkCachedAlbum.user_id == user_id,
|
||||||
|
VkCachedAlbum.vk_group_id == group_id,
|
||||||
|
VkCachedAlbum.album_id.notin_(album_ids),
|
||||||
|
)
|
||||||
|
.delete(synchronize_session=False)
|
||||||
|
)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# ── products (extended=1 gives albums_ids per product) ───────────────────
|
||||||
|
offset = 0
|
||||||
|
all_products = []
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
data = _vk_get(
|
||||||
|
"market.get",
|
||||||
|
{"owner_id": owner_id, "count": 200, "offset": offset, "extended": 1},
|
||||||
|
token,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("user=%s vk fetch products (extended) failed: %s", user_id, e)
|
||||||
|
break
|
||||||
|
|
||||||
|
if "error" in data:
|
||||||
|
logger.warning("user=%s vk products (extended) error: %s", user_id, data["error"])
|
||||||
|
break
|
||||||
|
|
||||||
|
items = data.get("response", {}).get("items", [])
|
||||||
|
all_products.extend(items)
|
||||||
|
if len(items) < 200:
|
||||||
|
break
|
||||||
|
offset += 200
|
||||||
|
|
||||||
|
for p in all_products:
|
||||||
|
pid = str(p["id"])
|
||||||
|
album_id = str(p["albums_ids"][0]) if p.get("albums_ids") else None
|
||||||
|
price_field = p.get("price")
|
||||||
|
if isinstance(price_field, dict):
|
||||||
|
price = float(price_field.get("amount", 0)) / 100 if price_field.get("amount") is not None else None
|
||||||
|
else:
|
||||||
|
price = float(price_field) if price_field is not None else None
|
||||||
|
thumb = None
|
||||||
|
thumb_field = p.get("thumb_photo")
|
||||||
|
if isinstance(thumb_field, dict):
|
||||||
|
sizes = thumb_field.get("sizes", [])
|
||||||
|
if sizes:
|
||||||
|
thumb = sizes[-1].get("url")
|
||||||
|
elif isinstance(thumb_field, str):
|
||||||
|
thumb = thumb_field
|
||||||
|
|
||||||
|
row = db.query(VkCachedProduct).filter_by(
|
||||||
|
user_id=user_id, vk_group_id=group_id, vk_product_id=pid,
|
||||||
|
).first()
|
||||||
|
if row:
|
||||||
|
row.album_id = album_id
|
||||||
|
row.name = p.get("title", "")
|
||||||
|
row.description = p.get("description")
|
||||||
|
row.price = price
|
||||||
|
row.availability = p.get("availability")
|
||||||
|
row.thumb_url = thumb
|
||||||
|
row.fetched_at = now
|
||||||
|
else:
|
||||||
|
db.add(VkCachedProduct(
|
||||||
|
user_id=user_id,
|
||||||
|
vk_group_id=group_id,
|
||||||
|
vk_product_id=pid,
|
||||||
|
album_id=album_id,
|
||||||
|
name=p.get("title", ""),
|
||||||
|
description=p.get("description"),
|
||||||
|
price=price,
|
||||||
|
availability=p.get("availability"),
|
||||||
|
thumb_url=thumb,
|
||||||
|
fetched_at=now,
|
||||||
|
))
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
logger.info(
|
||||||
|
"user=%s vk catalog synced: group=%s albums=%d products=%d",
|
||||||
|
user_id, group_id, len(albums), len(all_products),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(
|
||||||
|
name="web.tasks.vk_catalog.refresh_vk_catalog",
|
||||||
|
queue="default",
|
||||||
|
bind=True,
|
||||||
|
max_retries=2,
|
||||||
|
default_retry_delay=60,
|
||||||
|
)
|
||||||
|
def refresh_vk_catalog(self) -> dict:
|
||||||
|
"""Fetch and cache VK Market albums and products for all connected users."""
|
||||||
|
db = SessionLocal()
|
||||||
|
results = {"ok": 0, "failed": 0}
|
||||||
|
try:
|
||||||
|
connections = (
|
||||||
|
db.query(VkConnection)
|
||||||
|
.filter(
|
||||||
|
VkConnection.user_id.isnot(None),
|
||||||
|
VkConnection.access_token.isnot(None),
|
||||||
|
VkConnection.access_token != "",
|
||||||
|
VkConnection.vk_user_id.isnot(None),
|
||||||
|
VkConnection.vk_user_id != "",
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for conn in connections:
|
||||||
|
cfg = db.query(SyncConfig).filter_by(user_id=conn.user_id).first()
|
||||||
|
if not cfg or not cfg.vk_mirror_enabled:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
_sync_user(db, conn.user_id, conn.access_token, conn.vk_user_id)
|
||||||
|
results["ok"] += 1
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("vk catalog sync failed for user=%s: %s", conn.user_id, exc)
|
||||||
|
results["failed"] += 1
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
logger.info("refresh_vk_catalog done: %s", results)
|
||||||
|
return results
|
||||||
438
web/tasks/vk_sync.py
Normal file
438
web/tasks/vk_sync.py
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
"""
|
||||||
|
Mirror Evotor product catalog → VK Market.
|
||||||
|
|
||||||
|
Runs after both refresh_catalog and refresh_vk_catalog complete.
|
||||||
|
Only processes stores and groups that have sync enabled (via SyncFilter).
|
||||||
|
Updates VK products only when at least one synced field has changed.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from celery import shared_task
|
||||||
|
|
||||||
|
import web.lib.api_logger as api_logger
|
||||||
|
from web.config import settings
|
||||||
|
from web.database import SessionLocal
|
||||||
|
from web.models.connections import (
|
||||||
|
CachedGroup, CachedProduct, CachedStore,
|
||||||
|
SyncConfig, SyncFilter,
|
||||||
|
VkCachedAlbum, VkConnection,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
VK_API = "https://api.vk.com/method"
|
||||||
|
|
||||||
|
_PHOTO_CACHE: dict[int, str] = {} # user_id → uploaded photo_id for this run
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _calc_price(price: Decimal | None) -> float:
|
||||||
|
"""Return price in rubles for VK Market API."""
|
||||||
|
if price is None:
|
||||||
|
return 0.0
|
||||||
|
return float(price)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _name_for_vk(name: str) -> str:
|
||||||
|
return name.replace(";", ",")
|
||||||
|
|
||||||
|
|
||||||
|
def _vk_post(method: str, data: dict, token: str, user_id: int | None = None) -> dict:
|
||||||
|
data = {**data, "access_token": token, "v": settings.VK_API_VERSION}
|
||||||
|
r = api_logger.post(f"{VK_API}/{method}", user_id=user_id, data=data, timeout=30)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _upload_photo(token: str, group_id: str, user_id: int | None = None) -> str | None:
|
||||||
|
"""Upload the default product photo and return photo_id, or None on failure."""
|
||||||
|
photo_path = settings.VK_DEFAULT_PHOTO_PATH
|
||||||
|
if not os.path.exists(photo_path):
|
||||||
|
logger.warning("Default photo not found at %s", photo_path)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
# Step 1: get upload URL
|
||||||
|
resp = _vk_post("market.getProductPhotoUploadServer", {"group_id": group_id}, token, user_id=user_id)
|
||||||
|
if "error" in resp:
|
||||||
|
logger.warning("getProductPhotoUploadServer error: %s", resp["error"])
|
||||||
|
return None
|
||||||
|
upload_url = resp["response"]["upload_url"]
|
||||||
|
|
||||||
|
# Step 2: upload file
|
||||||
|
with open(photo_path, "rb") as f:
|
||||||
|
up = api_logger.post(upload_url, user_id=user_id, files={"file": f}, timeout=30)
|
||||||
|
up.raise_for_status()
|
||||||
|
upload_obj = up.text
|
||||||
|
|
||||||
|
# Step 3: save
|
||||||
|
resp2 = _vk_post("market.saveProductPhoto", {"upload_response": upload_obj}, token, user_id=user_id)
|
||||||
|
if "error" in resp2:
|
||||||
|
logger.warning("saveProductPhoto error: %s", resp2["error"])
|
||||||
|
return None
|
||||||
|
return str(resp2["response"]["photo_id"])
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Photo upload failed: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_photo_id(user_id: int, token: str, group_id: str) -> str | None:
|
||||||
|
"""Upload photo once per sync run per user, cache the result."""
|
||||||
|
if user_id not in _PHOTO_CACHE:
|
||||||
|
_PHOTO_CACHE[user_id] = _upload_photo(token, group_id, user_id=user_id)
|
||||||
|
return _PHOTO_CACHE[user_id]
|
||||||
|
|
||||||
|
|
||||||
|
def _enabled_store_ids(db, user_id: int) -> set[str] | None:
|
||||||
|
cfg = db.query(SyncConfig).filter_by(user_id=user_id).first()
|
||||||
|
if not cfg:
|
||||||
|
return None
|
||||||
|
filters = db.query(SyncFilter).filter_by(
|
||||||
|
sync_config_id=cfg.id, entity_type="store", filter_mode="include",
|
||||||
|
).all()
|
||||||
|
return None if not filters else {f.entity_id for f in filters}
|
||||||
|
|
||||||
|
|
||||||
|
def _enabled_group_ids(db, user_id: int, store_evotor_id: str) -> set[str] | None:
|
||||||
|
cfg = db.query(SyncConfig).filter_by(user_id=user_id).first()
|
||||||
|
if not cfg:
|
||||||
|
return None
|
||||||
|
filters = db.query(SyncFilter).filter_by(
|
||||||
|
sync_config_id=cfg.id, entity_type="group", filter_mode="include",
|
||||||
|
parent_entity_id=store_evotor_id,
|
||||||
|
).all()
|
||||||
|
return None if not filters else {f.entity_id for f in filters}
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_album(db, user_id: int, vk_group_id: str, group_name: str, token: str) -> str | None:
|
||||||
|
"""Return VK album_id for the given group name, creating it if needed."""
|
||||||
|
album = db.query(VkCachedAlbum).filter_by(
|
||||||
|
user_id=user_id, vk_group_id=vk_group_id,
|
||||||
|
).filter(VkCachedAlbum.title == group_name).first()
|
||||||
|
|
||||||
|
if album:
|
||||||
|
return album.album_id
|
||||||
|
|
||||||
|
# Create album in VK
|
||||||
|
resp = _vk_post("market.addAlbum", {
|
||||||
|
"owner_id": f"-{vk_group_id}",
|
||||||
|
"title": group_name,
|
||||||
|
}, token, user_id=user_id)
|
||||||
|
if "error" in resp:
|
||||||
|
logger.warning("market.addAlbum error for '%s': %s", group_name, resp["error"])
|
||||||
|
return None
|
||||||
|
|
||||||
|
album_id = str(resp["response"]["market_album_id"])
|
||||||
|
db.add(VkCachedAlbum(
|
||||||
|
user_id=user_id,
|
||||||
|
vk_group_id=vk_group_id,
|
||||||
|
album_id=album_id,
|
||||||
|
title=group_name,
|
||||||
|
fetched_at=_now(),
|
||||||
|
))
|
||||||
|
db.flush()
|
||||||
|
logger.info("user=%s created VK album '%s' id=%s", user_id, group_name, album_id)
|
||||||
|
return album_id
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_product(
|
||||||
|
db,
|
||||||
|
user_id: int,
|
||||||
|
product: CachedProduct,
|
||||||
|
album_id: str,
|
||||||
|
vk_group_id: str,
|
||||||
|
token: str,
|
||||||
|
sync_config=None,
|
||||||
|
) -> None:
|
||||||
|
name = _name_for_vk(product.name)
|
||||||
|
multiplier = float(sync_config.price_multiplier) if sync_config and sync_config.price_multiplier else 1.0
|
||||||
|
price_rubles = _calc_price(product.price) * multiplier
|
||||||
|
measure = (product.measure_name or "").strip()
|
||||||
|
if measure:
|
||||||
|
qty = int(multiplier) if multiplier == int(multiplier) else multiplier
|
||||||
|
desc = f"{product.name} (цена за {qty} {measure}.)"
|
||||||
|
else:
|
||||||
|
desc = product.name
|
||||||
|
stock = settings.VK_STOCK_AMOUNT if product.allow_to_sell else 0
|
||||||
|
owner_id = f"-{vk_group_id}"
|
||||||
|
now = _now()
|
||||||
|
|
||||||
|
if product.vk_product_id:
|
||||||
|
# Delete from VK if product is no longer for sale
|
||||||
|
if not product.allow_to_sell:
|
||||||
|
resp = _vk_post("market.delete", {
|
||||||
|
"owner_id": owner_id,
|
||||||
|
"item_id": product.vk_product_id,
|
||||||
|
}, token, user_id=user_id)
|
||||||
|
if "error" not in resp:
|
||||||
|
from web.models.connections import VkCachedProduct
|
||||||
|
vk_p = db.query(VkCachedProduct).filter_by(
|
||||||
|
user_id=user_id, vk_group_id=vk_group_id, vk_product_id=product.vk_product_id,
|
||||||
|
).first()
|
||||||
|
if vk_p:
|
||||||
|
db.delete(vk_p)
|
||||||
|
product.vk_product_id = None
|
||||||
|
logger.info("user=%s deleted VK product '%s' (disabled)", user_id, name)
|
||||||
|
else:
|
||||||
|
logger.warning("market.delete error for disabled product %s: %s", product.evotor_id, resp["error"])
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if update needed
|
||||||
|
changed = False
|
||||||
|
# Re-read current VK state from cache
|
||||||
|
from web.models.connections import VkCachedProduct
|
||||||
|
vk_p = db.query(VkCachedProduct).filter_by(
|
||||||
|
user_id=user_id, vk_group_id=vk_group_id, vk_product_id=product.vk_product_id,
|
||||||
|
).first()
|
||||||
|
album_changed = False
|
||||||
|
if vk_p:
|
||||||
|
vk_price = float(vk_p.price) if vk_p.price is not None else 0.0
|
||||||
|
vk_stock = settings.VK_STOCK_AMOUNT if vk_p.availability == 0 else 0
|
||||||
|
vk_name = vk_p.name or ""
|
||||||
|
vk_desc = (vk_p.description or "").strip()
|
||||||
|
curr_desc = desc
|
||||||
|
album_changed = str(vk_p.album_id) != str(album_id) if album_id else False
|
||||||
|
changed = (
|
||||||
|
name != vk_name
|
||||||
|
or price_rubles != vk_price
|
||||||
|
or curr_desc != vk_desc
|
||||||
|
or stock != vk_stock
|
||||||
|
or album_changed
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
changed = True # cached VK product gone, push update
|
||||||
|
|
||||||
|
if not changed:
|
||||||
|
return
|
||||||
|
|
||||||
|
resp = _vk_post("market.edit", {
|
||||||
|
"owner_id": owner_id,
|
||||||
|
"item_id": product.vk_product_id,
|
||||||
|
"name": name,
|
||||||
|
"description": desc,
|
||||||
|
"category_id": settings.VK_CATEGORY_ID,
|
||||||
|
"price": price_rubles,
|
||||||
|
"stock_amount": stock,
|
||||||
|
}, token, user_id=user_id)
|
||||||
|
if "error" in resp:
|
||||||
|
logger.warning("market.edit error product=%s: %s", product.evotor_id, resp["error"])
|
||||||
|
return
|
||||||
|
|
||||||
|
if album_changed and album_id and vk_p:
|
||||||
|
old_album_id = str(vk_p.album_id)
|
||||||
|
_vk_post("market.removeFromAlbum", {
|
||||||
|
"owner_id": owner_id,
|
||||||
|
"item_id": product.vk_product_id,
|
||||||
|
"album_ids": old_album_id,
|
||||||
|
}, token, user_id=user_id)
|
||||||
|
resp_album = _vk_post("market.addToAlbum", {
|
||||||
|
"owner_id": owner_id,
|
||||||
|
"item_ids": product.vk_product_id,
|
||||||
|
"album_ids": album_id,
|
||||||
|
}, token, user_id=user_id)
|
||||||
|
if "error" in resp_album:
|
||||||
|
logger.warning("market.addToAlbum error product=%s: %s", product.evotor_id, resp_album["error"])
|
||||||
|
else:
|
||||||
|
logger.info("user=%s moved VK product '%s' album %s→%s", user_id, name, old_album_id, album_id)
|
||||||
|
|
||||||
|
product.synced_at = now
|
||||||
|
logger.info("user=%s updated VK product '%s' id=%s", user_id, name, product.vk_product_id)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Create — only if allow_to_sell
|
||||||
|
if not product.allow_to_sell:
|
||||||
|
return
|
||||||
|
|
||||||
|
photo_id = _get_photo_id(user_id, token, vk_group_id)
|
||||||
|
if not photo_id:
|
||||||
|
logger.warning("user=%s skipping create for '%s': no photo", user_id, name)
|
||||||
|
return
|
||||||
|
|
||||||
|
resp = _vk_post("market.add", {
|
||||||
|
"owner_id": owner_id,
|
||||||
|
"name": name,
|
||||||
|
"description": desc,
|
||||||
|
"category_id": settings.VK_CATEGORY_ID,
|
||||||
|
"price": price_rubles,
|
||||||
|
"main_photo_id": photo_id,
|
||||||
|
"stock_amount": stock,
|
||||||
|
}, token, user_id=user_id)
|
||||||
|
if "error" in resp:
|
||||||
|
logger.warning("market.add error product=%s: %s", product.evotor_id, resp["error"])
|
||||||
|
return
|
||||||
|
|
||||||
|
vk_item_id = str(resp["response"]["market_item_id"])
|
||||||
|
product.vk_product_id = vk_item_id
|
||||||
|
product.synced_at = now
|
||||||
|
|
||||||
|
# Add to album
|
||||||
|
resp2 = _vk_post("market.addToAlbum", {
|
||||||
|
"owner_id": owner_id,
|
||||||
|
"item_ids": vk_item_id,
|
||||||
|
"album_ids": album_id,
|
||||||
|
}, token, user_id=user_id)
|
||||||
|
if "error" in resp2:
|
||||||
|
logger.warning("market.addToAlbum error product=%s: %s", product.evotor_id, resp2["error"])
|
||||||
|
|
||||||
|
logger.info("user=%s created VK product '%s' id=%s", user_id, name, vk_item_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_product_list(db, user_id, products, album_id, vk_group_id, token, results, owned_ids, sync_config=None):
|
||||||
|
for product in products:
|
||||||
|
was_new = product.vk_product_id is None
|
||||||
|
try:
|
||||||
|
_sync_product(db, user_id, product, album_id, vk_group_id, token, sync_config)
|
||||||
|
if product.vk_product_id:
|
||||||
|
owned_ids.add(product.vk_product_id)
|
||||||
|
if product.synced_at:
|
||||||
|
if was_new and product.vk_product_id:
|
||||||
|
results["created"] += 1
|
||||||
|
elif not was_new:
|
||||||
|
results["updated"] += 1
|
||||||
|
else:
|
||||||
|
results["skipped"] += 1
|
||||||
|
else:
|
||||||
|
results["skipped"] += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("user=%s sync_product failed '%s': %s", user_id, product.name, e)
|
||||||
|
results["errors"] += 1
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_orphans(db, user_id, vk_group_id, owned_ids, token, results):
|
||||||
|
from web.models.connections import VkCachedProduct
|
||||||
|
orphans = db.query(VkCachedProduct).filter_by(
|
||||||
|
user_id=user_id, vk_group_id=vk_group_id,
|
||||||
|
).filter(VkCachedProduct.vk_product_id.notin_(owned_ids)).all() if owned_ids else \
|
||||||
|
db.query(VkCachedProduct).filter_by(user_id=user_id, vk_group_id=vk_group_id).all()
|
||||||
|
|
||||||
|
owner_id = f"-{vk_group_id}"
|
||||||
|
for vk_p in orphans:
|
||||||
|
try:
|
||||||
|
resp = _vk_post("market.delete", {
|
||||||
|
"owner_id": owner_id,
|
||||||
|
"item_id": vk_p.vk_product_id,
|
||||||
|
}, token, user_id=user_id)
|
||||||
|
if "error" in resp:
|
||||||
|
logger.warning("market.delete error id=%s: %s", vk_p.vk_product_id, resp["error"])
|
||||||
|
results["errors"] += 1
|
||||||
|
else:
|
||||||
|
logger.info("user=%s deleted VK product id=%s '%s'", user_id, vk_p.vk_product_id, vk_p.name)
|
||||||
|
db.delete(vk_p)
|
||||||
|
results["deleted"] += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("user=%s delete failed id=%s: %s", user_id, vk_p.vk_product_id, e)
|
||||||
|
results["errors"] += 1
|
||||||
|
|
||||||
|
# Also clear vk_product_id on any cached_products that pointed to deleted VK items
|
||||||
|
if orphans:
|
||||||
|
deleted_vk_ids = {vk_p.vk_product_id for vk_p in orphans}
|
||||||
|
stale = db.query(CachedProduct).filter(
|
||||||
|
CachedProduct.user_id == user_id,
|
||||||
|
CachedProduct.vk_product_id.in_(deleted_vk_ids),
|
||||||
|
).all()
|
||||||
|
for p in stale:
|
||||||
|
p.vk_product_id = None
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_user(db, user_id: int, token: str, vk_group_id: str) -> dict:
|
||||||
|
results = {"created": 0, "updated": 0, "skipped": 0, "deleted": 0, "errors": 0}
|
||||||
|
owned_ids: set[str] = set() # VK product IDs that Evotor owns this run
|
||||||
|
|
||||||
|
sync_config = db.query(SyncConfig).filter_by(user_id=user_id).first()
|
||||||
|
enabled_stores = _enabled_store_ids(db, user_id)
|
||||||
|
|
||||||
|
stores = db.query(CachedStore).filter_by(user_id=user_id).all()
|
||||||
|
for store in stores:
|
||||||
|
if enabled_stores is not None and store.evotor_id not in enabled_stores:
|
||||||
|
continue
|
||||||
|
|
||||||
|
enabled_groups = _enabled_group_ids(db, user_id, store.evotor_id)
|
||||||
|
|
||||||
|
groups = db.query(CachedGroup).filter_by(
|
||||||
|
user_id=user_id, store_evotor_id=store.evotor_id,
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for group in groups:
|
||||||
|
if enabled_groups is not None and group.evotor_id not in enabled_groups:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
album_id = _ensure_album(db, user_id, vk_group_id, group.name, token)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("user=%s ensure_album failed for '%s': %s", user_id, group.name, e)
|
||||||
|
results["errors"] += 1
|
||||||
|
continue
|
||||||
|
if not album_id:
|
||||||
|
results["errors"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
products = db.query(CachedProduct).filter_by(
|
||||||
|
user_id=user_id, store_evotor_id=store.evotor_id, group_evotor_id=group.evotor_id,
|
||||||
|
).all()
|
||||||
|
_sync_product_list(db, user_id, products, album_id, vk_group_id, token, results, owned_ids, sync_config)
|
||||||
|
|
||||||
|
# Ungrouped products → "Без категории" album
|
||||||
|
ungrouped = db.query(CachedProduct).filter_by(
|
||||||
|
user_id=user_id, store_evotor_id=store.evotor_id, group_evotor_id=None,
|
||||||
|
).all()
|
||||||
|
if ungrouped:
|
||||||
|
try:
|
||||||
|
fallback_album_id = _ensure_album(db, user_id, vk_group_id, "Без категории", token)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("user=%s ensure_album failed for 'Без категории': %s", user_id, e)
|
||||||
|
fallback_album_id = None
|
||||||
|
if fallback_album_id:
|
||||||
|
_sync_product_list(db, user_id, ungrouped, fallback_album_id, vk_group_id, token, results, owned_ids, sync_config)
|
||||||
|
|
||||||
|
# Delete VK products not owned by any Evotor product
|
||||||
|
_delete_orphans(db, user_id, vk_group_id, owned_ids, token, results)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(
|
||||||
|
name="web.tasks.vk_sync.mirror_to_vk",
|
||||||
|
queue="sync",
|
||||||
|
bind=True,
|
||||||
|
max_retries=1,
|
||||||
|
default_retry_delay=120,
|
||||||
|
)
|
||||||
|
def mirror_to_vk(self) -> dict:
|
||||||
|
"""Mirror Evotor catalog → VK Market for all users with both connections active."""
|
||||||
|
_PHOTO_CACHE.clear()
|
||||||
|
db = SessionLocal()
|
||||||
|
totals = {"ok": 0, "failed": 0}
|
||||||
|
try:
|
||||||
|
vk_connections = (
|
||||||
|
db.query(VkConnection)
|
||||||
|
.filter(
|
||||||
|
VkConnection.user_id.isnot(None),
|
||||||
|
VkConnection.access_token.isnot(None),
|
||||||
|
VkConnection.access_token != "",
|
||||||
|
VkConnection.vk_user_id.isnot(None),
|
||||||
|
VkConnection.vk_user_id != "",
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for conn in vk_connections:
|
||||||
|
cfg = db.query(SyncConfig).filter_by(user_id=conn.user_id).first()
|
||||||
|
if not cfg or not cfg.is_enabled:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
result = _sync_user(db, conn.user_id, conn.access_token, conn.vk_user_id)
|
||||||
|
logger.info("user=%s mirror_to_vk: %s", conn.user_id, result)
|
||||||
|
totals["ok"] += 1
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("mirror_to_vk failed for user=%s: %s", conn.user_id, exc)
|
||||||
|
totals["failed"] += 1
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
logger.info("mirror_to_vk done: %s", totals)
|
||||||
|
return totals
|
||||||
147
web/templates/admin/logs.html
Normal file
147
web/templates/admin/logs.html
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}API Логи — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-between align-center mb-3">
|
||||||
|
<h1 style="font-size:1.3rem; margin:0;"><i class="bi bi-journal-text me-2"></i>API Логи</h1>
|
||||||
|
<span class="text-muted small">Найдено: {{ total }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── filters ── #}
|
||||||
|
<form method="get" action="/admin/logs" class="mb-3" style="display:flex; flex-wrap:wrap; gap:0.5rem; align-items:center;">
|
||||||
|
<select name="service" style="width:auto;">
|
||||||
|
<option value="" {% if not filter_service %}selected{% endif %}>Все сервисы</option>
|
||||||
|
<option value="evotor" {% if filter_service == 'evotor' %}selected{% endif %}>Эвотор</option>
|
||||||
|
<option value="vk" {% if filter_service == 'vk' %}selected{% endif %}>ВКонтакте</option>
|
||||||
|
<option value="other" {% if filter_service == 'other' %}selected{% endif %}>Другое</option>
|
||||||
|
</select>
|
||||||
|
<select name="method" style="width:auto;">
|
||||||
|
<option value="" {% if not filter_method %}selected{% endif %}>Все методы</option>
|
||||||
|
<option value="GET" {% if filter_method == 'GET' %}selected{% endif %}>GET</option>
|
||||||
|
<option value="POST" {% if filter_method == 'POST' %}selected{% endif %}>POST</option>
|
||||||
|
</select>
|
||||||
|
<select name="status" style="width:auto;">
|
||||||
|
<option value="" {% if not filter_status %}selected{% endif %}>Любой статус</option>
|
||||||
|
<option value="ok" {% if filter_status == 'ok' %}selected{% endif %}>2xx / 3xx</option>
|
||||||
|
<option value="error" {% if filter_status == 'error' %}selected{% endif %}>4xx / 5xx</option>
|
||||||
|
<option value="200" {% if filter_status == '200' %}selected{% endif %}>200</option>
|
||||||
|
<option value="401" {% if filter_status == '401' %}selected{% endif %}>401</option>
|
||||||
|
<option value="403" {% if filter_status == '403' %}selected{% endif %}>403</option>
|
||||||
|
<option value="429" {% if filter_status == '429' %}selected{% endif %}>429</option>
|
||||||
|
<option value="500" {% if filter_status == '500' %}selected{% endif %}>500</option>
|
||||||
|
</select>
|
||||||
|
<select name="hours" style="width:auto;">
|
||||||
|
<option value="1" {% if filter_hours == 1 %}selected{% endif %}>Последний час</option>
|
||||||
|
<option value="6" {% if filter_hours == 6 %}selected{% endif %}>6 часов</option>
|
||||||
|
<option value="24" {% if filter_hours == 24 %}selected{% endif %}>24 часа</option>
|
||||||
|
<option value="168" {% if filter_hours == 168 or (not filter_hours) %}selected{% endif %}>7 дней</option>
|
||||||
|
<option value="720" {% if filter_hours == 720 %}selected{% endif %}>30 дней</option>
|
||||||
|
</select>
|
||||||
|
<input type="search" name="q" value="{{ filter_q }}" placeholder="URL или тело ответа…" style="flex:1; min-width:160px;">
|
||||||
|
<button type="submit">Применить</button>
|
||||||
|
{% if filter_service or filter_method or filter_status or filter_q or filter_hours != 24 %}
|
||||||
|
<a href="/admin/logs" role="button" class="outline secondary">Сбросить</a>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<article class="card" style="padding:0;">
|
||||||
|
{% if logs %}
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table class="align-middle" style="font-size:0.82rem;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:140px;">Время</th>
|
||||||
|
<th style="width:60px;">Сервис</th>
|
||||||
|
<th style="width:40px;">Метод</th>
|
||||||
|
<th style="width:50px;">Статус</th>
|
||||||
|
<th style="width:60px;">Мс</th>
|
||||||
|
<th>URL</th>
|
||||||
|
<th style="width:30px;"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for log in logs %}
|
||||||
|
{% set is_error = log.response_status and log.response_status >= 400 %}
|
||||||
|
<tr class="{{ 'text-danger' if is_error else '' }}" style="cursor:pointer;" onclick="toggleDetail({{ log.id }})">
|
||||||
|
<td class="text-muted">{{ log.created_at | datefmt }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge {{ 'badge-evotor' if log.service == 'evotor' else 'badge-vk' if log.service == 'vk' else '' }}">
|
||||||
|
{{ log.service }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td><code>{{ log.method }}</code></td>
|
||||||
|
<td>
|
||||||
|
{% if log.response_status %}
|
||||||
|
<span class="{{ 'text-danger' if is_error else 'text-muted' }}">{{ log.response_status }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-muted">{{ log.duration_ms if log.duration_ms is not none else '—' }}</td>
|
||||||
|
<td style="max-width:400px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
|
||||||
|
<span title="{{ log.url }}">{{ log.url }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-muted"><i class="bi bi-chevron-down"></i></td>
|
||||||
|
</tr>
|
||||||
|
<tr id="detail-{{ log.id }}" style="display:none; background:var(--pico-card-background-color);">
|
||||||
|
<td colspan="7" style="padding:0.75rem 1rem;">
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:1rem;">
|
||||||
|
<div>
|
||||||
|
<div class="text-muted small mb-1"><strong>URL</strong></div>
|
||||||
|
<code style="word-break:break-all; font-size:0.78rem;">{{ log.url }}</code>
|
||||||
|
{% if log.request_body %}
|
||||||
|
<div class="text-muted small mt-2 mb-1"><strong>Request body</strong></div>
|
||||||
|
<pre style="font-size:0.75rem; max-height:200px; overflow:auto; margin:0; background:var(--pico-code-background-color); padding:0.5rem; border-radius:4px;">{{ log.request_body }}</pre>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-muted small mb-1"><strong>Response ({{ log.response_status }})</strong></div>
|
||||||
|
{% if log.response_body %}
|
||||||
|
<pre style="font-size:0.75rem; max-height:200px; overflow:auto; margin:0; background:var(--pico-code-background-color); padding:0.5rem; border-radius:4px;">{{ log.response_body }}</pre>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── pagination ── #}
|
||||||
|
{% if total_pages > 1 %}
|
||||||
|
<div style="display:flex; justify-content:center; gap:0.5rem; padding:1rem;">
|
||||||
|
{% if page > 1 %}
|
||||||
|
<a href="?service={{ filter_service }}&method={{ filter_method }}&status={{ filter_status }}&q={{ filter_q }}&hours={{ filter_hours }}&page={{ page - 1 }}" role="button" class="outline secondary sm">← Назад</a>
|
||||||
|
{% endif %}
|
||||||
|
<span class="text-muted" style="line-height:2.2rem;">Стр. {{ page }} / {{ total_pages }}</span>
|
||||||
|
{% if page < total_pages %}
|
||||||
|
<a href="?service={{ filter_service }}&method={{ filter_method }}&status={{ filter_status }}&q={{ filter_q }}&hours={{ filter_hours }}&page={{ page + 1 }}" role="button" class="outline secondary sm">Вперёд →</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<i class="bi bi-journal-x" style="font-size:2rem;"></i>
|
||||||
|
<p class="mt-2">Записей не найдено за выбранный период.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.badge { display:inline-block; padding:0.1rem 0.4rem; border-radius:4px; font-size:0.75rem; font-weight:600; }
|
||||||
|
.badge-evotor { background:#e8f4fd; color:#0986E2; }
|
||||||
|
.badge-vk { background:#e8f0fe; color:#3b5998; }
|
||||||
|
.text-danger { color:#dc3545; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleDetail(id) {
|
||||||
|
const row = document.getElementById('detail-' + id);
|
||||||
|
row.style.display = row.style.display === 'none' ? 'table-row' : 'none';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -96,6 +96,11 @@
|
|||||||
<i class="bi bi-envelope me-1"></i>Отправить приглашение
|
<i class="bi bi-envelope me-1"></i>Отправить приглашение
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
<form method="post" action="/admin/users/{{ target.id }}/view-as">
|
||||||
|
<button type="submit" class="w-100 outline">
|
||||||
|
<i class="bi bi-eye me-1"></i>Просмотр от имени пользователя
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
{% if user.role == 'system' and target.id != user.id %}
|
{% if user.role == 'system' and target.id != user.id %}
|
||||||
<form method="post" action="/admin/users/{{ target.id }}/delete"
|
<form method="post" action="/admin/users/{{ target.id }}/delete"
|
||||||
onsubmit="return confirm('Удалить пользователя {{ target.email }}? Это действие необратимо.')">
|
onsubmit="return confirm('Удалить пользователя {{ target.email }}? Это действие необратимо.')">
|
||||||
|
|||||||
@@ -4,9 +4,64 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-between align-center mb-3">
|
<div class="d-flex justify-between align-center mb-3">
|
||||||
<h1 style="font-size:1.3rem; margin:0;"><i class="bi bi-people me-2"></i>Пользователи</h1>
|
<h1 style="font-size:1.3rem; margin:0;"><i class="bi bi-people me-2"></i>Пользователи</h1>
|
||||||
<span class="text-muted small">Всего: {{ total }}</span>
|
<button onclick="document.getElementById('create-user-dialog').showModal()" class="sm">
|
||||||
|
<i class="bi bi-person-plus me-1"></i>Создать пользователя
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<dialog id="create-user-dialog">
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<button aria-label="Закрыть" rel="prev" onclick="document.getElementById('create-user-dialog').close()"></button>
|
||||||
|
<h3>Создать пользователя</h3>
|
||||||
|
</header>
|
||||||
|
{% if create_errors %}
|
||||||
|
<div role="alert" class="alert alert-danger mb-3">
|
||||||
|
{% for e in create_errors %}<p>{{ e }}</p>{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="/admin/users/create" novalidate>
|
||||||
|
<div class="row gap-2 mb-2">
|
||||||
|
<div class="col">
|
||||||
|
<label for="cu_first_name">Имя
|
||||||
|
<input type="text" id="cu_first_name" name="first_name" value="{{ create_form.first_name if create_form else '' }}" required>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label for="cu_last_name">Фамилия
|
||||||
|
<input type="text" id="cu_last_name" name="last_name" value="{{ create_form.last_name if create_form else '' }}">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label for="cu_email">Email
|
||||||
|
<input type="text" id="cu_email" name="email" value="{{ create_form.email if create_form else '' }}" required>
|
||||||
|
</label>
|
||||||
|
<label for="cu_phone">Телефон
|
||||||
|
<input type="tel" id="cu_phone" name="phone" value="{{ create_form.phone if create_form else '' }}" placeholder="+7 (999) 999-99-99">
|
||||||
|
</label>
|
||||||
|
<label for="cu_password">Пароль
|
||||||
|
<input type="password" id="cu_password" name="password" required>
|
||||||
|
</label>
|
||||||
|
{% if user.role == 'system' %}
|
||||||
|
<label for="cu_role">Роль
|
||||||
|
<select id="cu_role" name="role">
|
||||||
|
<option value="user" {% if not create_form or create_form.role == 'user' %}selected{% endif %}>Пользователь</option>
|
||||||
|
<option value="admin" {% if create_form and create_form.role == 'admin' %}selected{% endif %}>Администратор</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{% endif %}
|
||||||
|
<footer class="d-flex gap-2 justify-end">
|
||||||
|
<button type="button" class="outline secondary" onclick="document.getElementById('create-user-dialog').close()">Отмена</button>
|
||||||
|
<button type="submit">Создать</button>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
{% if create_errors %}
|
||||||
|
<script>document.addEventListener('DOMContentLoaded', () => document.getElementById('create-user-dialog').showModal());</script>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<article class="card mb-3">
|
<article class="card mb-3">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="get" action="/admin/users" class="d-flex gap-2 flex-wrap align-center">
|
<form method="get" action="/admin/users" class="d-flex gap-2 flex-wrap align-center">
|
||||||
@@ -43,7 +98,7 @@
|
|||||||
<th>Роль</th>
|
<th>Роль</th>
|
||||||
<th>Статус</th>
|
<th>Статус</th>
|
||||||
<th>Эвотор</th>
|
<th>Эвотор</th>
|
||||||
<th>Регистрация</th>
|
<th>Дата</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -52,13 +107,13 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="text-muted small">{{ u.id }}</td>
|
<td class="text-muted small">{{ u.id }}</td>
|
||||||
<td>{{ u.first_name }} {{ u.last_name }}</td>
|
<td>{{ u.first_name }} {{ u.last_name }}</td>
|
||||||
<td>
|
<td title="{{ u.email }}">
|
||||||
{{ u.email }}
|
{{ u.email }}
|
||||||
{% if not u.is_email_confirmed %}
|
{% if not u.is_email_confirmed %}
|
||||||
<span class="badge badge-warning ms-1" title="Email не подтверждён"><i class="bi bi-exclamation-circle"></i></span>
|
<span class="badge badge-warning ms-1" title="Email не подтверждён"><i class="bi bi-exclamation-circle"></i></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ u.phone }}</td>
|
<td>{{ u.phone or '—' }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if u.role == 'system' %}<span class="badge badge-danger">Системный</span>
|
{% if u.role == 'system' %}<span class="badge badge-danger">Системный</span>
|
||||||
{% elif u.role == 'admin' %}<span class="badge badge-warning">Админ</span>
|
{% elif u.role == 'admin' %}<span class="badge badge-warning">Админ</span>
|
||||||
|
|||||||
@@ -16,28 +16,35 @@
|
|||||||
</ul>
|
</ul>
|
||||||
<ul class="nav-links">
|
<ul class="nav-links">
|
||||||
{% if user %}
|
{% if user %}
|
||||||
|
{% if user.role not in ('admin', 'system') or viewed_user %}
|
||||||
<li><a href="/connections">Подключения</a></li>
|
<li><a href="/connections">Подключения</a></li>
|
||||||
<li><a href="/catalog">Каталог</a></li>
|
<li><a href="/catalog">Каталог Эвотор</a></li>
|
||||||
|
<li><a href="/vk-catalog/albums">Каталог ВК</a></li>
|
||||||
<li><a href="/sync">Синхронизация</a></li>
|
<li><a href="/sync">Синхронизация</a></li>
|
||||||
|
{% endif %}
|
||||||
{% if user.role in ('admin', 'system') %}
|
{% if user.role in ('admin', 'system') %}
|
||||||
<li><a href="/admin/users"><i class="bi bi-shield-lock"></i> Админ</a></li>
|
<li><a href="/admin/users"><i class="bi bi-shield-lock"></i> Админ</a></li>
|
||||||
|
<li><a href="/admin/logs"><i class="bi bi-journal-text"></i> Логи</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<li><a href="/profile"><i class="bi bi-person-circle"></i> Личный кабинет</a></li>
|
<li><a href="/profile"><i class="bi bi-person-circle"></i> Личный кабинет</a></li>
|
||||||
<li><a href="/logout" class="secondary">Выход</a></li>
|
<li><a href="/logout" class="secondary">Выход</a></li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li><a href="/login">Вход</a></li>
|
<li><a href="/login">Вход</a></li>
|
||||||
<li><a href="/register">Регистрация</a></li>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
{% if user %}
|
{% if user %}
|
||||||
<details class="mobile-menu">
|
<details class="mobile-menu">
|
||||||
<summary role="button" class="outline secondary icon-btn"><i class="bi bi-list"></i></summary>
|
<summary role="button" class="outline secondary icon-btn"><i class="bi bi-list"></i></summary>
|
||||||
<ul>
|
<ul>
|
||||||
|
{% if user.role not in ('admin', 'system') or viewed_user %}
|
||||||
<li><a href="/connections">Подключения</a></li>
|
<li><a href="/connections">Подключения</a></li>
|
||||||
<li><a href="/catalog">Каталог</a></li>
|
<li><a href="/catalog">Каталог Эвотор</a></li>
|
||||||
|
<li><a href="/vk-catalog/albums">Каталог ВК</a></li>
|
||||||
<li><a href="/sync">Синхронизация</a></li>
|
<li><a href="/sync">Синхронизация</a></li>
|
||||||
|
{% endif %}
|
||||||
{% if user.role in ('admin', 'system') %}
|
{% if user.role in ('admin', 'system') %}
|
||||||
<li><a href="/admin/users">Админ</a></li>
|
<li><a href="/admin/users">Админ</a></li>
|
||||||
|
<li><a href="/admin/logs">Логи</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<li><a href="/profile">Личный кабинет</a></li>
|
<li><a href="/profile">Личный кабинет</a></li>
|
||||||
<li><a href="/logout">Выход</a></li>
|
<li><a href="/logout">Выход</a></li>
|
||||||
@@ -48,13 +55,21 @@
|
|||||||
<summary role="button" class="outline secondary icon-btn"><i class="bi bi-list"></i></summary>
|
<summary role="button" class="outline secondary icon-btn"><i class="bi bi-list"></i></summary>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="/login">Вход</a></li>
|
<li><a href="/login">Вход</a></li>
|
||||||
<li><a href="/register">Регистрация</a></li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{% if viewed_user %}
|
||||||
|
<div style="background:#e65c00;color:#fff;text-align:center;padding:0.4rem 1rem;font-size:0.9rem;">
|
||||||
|
<i class="bi bi-eye me-1"></i>Просмотр от имени: <strong>{{ viewed_user.first_name }} {{ viewed_user.last_name }}</strong> ({{ viewed_user.email }})
|
||||||
|
<form method="post" action="/admin/view-as/stop" style="display:inline;margin-left:1rem;">
|
||||||
|
<button type="submit" style="background:none;border:1px solid #fff;color:#fff;padding:0.1rem 0.6rem;font-size:0.85rem;cursor:pointer;border-radius:4px;">Выйти</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<main class="container py-4">
|
<main class="container py-4">
|
||||||
{% if errors %}
|
{% if errors %}
|
||||||
<div role="alert" class="alert alert-danger">
|
<div role="alert" class="alert alert-danger">
|
||||||
|
|||||||
71
web/templates/catalog/groups.html
Normal file
71
web/templates/catalog/groups.html
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Группы — {{ store.name }} — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<nav aria-label="breadcrumb" class="mb-3">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li><a href="/catalog/stores">Магазины</a></li>
|
||||||
|
<li>{{ store.name }}</li>
|
||||||
|
<li>Группы</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="d-flex justify-between align-center mb-3">
|
||||||
|
<h1 style="font-size:1.3rem; margin:0;"><i class="bi bi-folder me-2"></i>Группы товаров — {{ store.name }}</h1>
|
||||||
|
<span class="text-muted small">Всего: {{ groups | length }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article class="card">
|
||||||
|
{% if groups %}
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table class="align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Синхронизация</th>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Количество товаров</th>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Обновлено</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for g in groups %}
|
||||||
|
{% set is_enabled = (enabled_ids is none) or (g.evotor_id in enabled_ids) %}
|
||||||
|
<tr class="{% if not is_enabled %}text-muted{% endif %}">
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/catalog/stores/{{ store.evotor_id }}/groups/{{ g.evotor_id }}/toggle" style="margin:0;">
|
||||||
|
<button type="submit"
|
||||||
|
class="outline sm {% if is_enabled %}success{% else %}secondary{% endif %}"
|
||||||
|
title="{% if is_enabled %}Отключить синхронизацию{% else %}Включить синхронизацию{% endif %}"
|
||||||
|
style="padding:0.2rem 0.6rem;">
|
||||||
|
{% if is_enabled %}
|
||||||
|
<i class="bi bi-toggle-on"></i>
|
||||||
|
{% else %}
|
||||||
|
<i class="bi bi-toggle-off"></i>
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td><i class="bi bi-folder2 me-1 text-muted"></i> <strong>{{ g.name }}</strong></td>
|
||||||
|
<td class="text-muted small">{{ product_counts.get(g.evotor_id, 0) }}</td>
|
||||||
|
<td class="text-muted small">{{ g.evotor_id }}</td>
|
||||||
|
<td class="text-muted small">{{ g.fetched_at | datefmt }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="/catalog/stores/{{ store.evotor_id }}/products?group={{ g.evotor_id }}" role="button" class="outline sm">
|
||||||
|
<i class="bi bi-box-seam"></i> Товары
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<i class="bi bi-folder" style="font-size:2rem;"></i>
|
||||||
|
<p class="mt-2">Группы для этого магазина ещё не загружены.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
83
web/templates/catalog/products.html
Normal file
83
web/templates/catalog/products.html
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Товары — {{ store.name }} — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<nav aria-label="breadcrumb" class="mb-3">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li><a href="/catalog/stores">Магазины</a></li>
|
||||||
|
<li>{{ store.name }}</li>
|
||||||
|
<li>Товары</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="d-flex justify-between align-center mb-3">
|
||||||
|
<h1 style="font-size:1.3rem; margin:0;"><i class="bi bi-box-seam me-2"></i>Товары — {{ store.name }}</h1>
|
||||||
|
<span class="text-muted small">Всего: {{ products | length }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if groups %}
|
||||||
|
<article class="card mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="get" class="d-flex gap-2 align-center flex-wrap">
|
||||||
|
<select name="group" style="width:auto; margin:0;" onchange="this.form.submit()">
|
||||||
|
<option value="">Все группы</option>
|
||||||
|
{% for g in groups %}
|
||||||
|
<option value="{{ g.evotor_id }}" {% if group_id == g.evotor_id %}selected{% endif %}>{{ g.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% if group_id %}
|
||||||
|
<a href="/catalog/stores/{{ store.evotor_id }}/products" role="button" class="outline secondary sm">Сбросить</a>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<article class="card">
|
||||||
|
{% if products %}
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table class="align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Группа</th>
|
||||||
|
<th>Артикул</th>
|
||||||
|
<th>Цена</th>
|
||||||
|
<th>Остаток</th>
|
||||||
|
<th>Ед.</th>
|
||||||
|
<th>Продаётся</th>
|
||||||
|
<th>Обновлено</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for p in products %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ p.name }}</td>
|
||||||
|
<td class="text-muted small">{{ group_map.get(p.group_evotor_id) or '—' }}</td>
|
||||||
|
<td class="text-muted small">{{ p.article_number or '—' }}</td>
|
||||||
|
<td>{% if p.price is not none %}{{ p.price | price }}{% else %}—{% endif %}</td>
|
||||||
|
<td>{% if p.quantity is not none %}{{ p.quantity }}{% else %}—{% endif %}</td>
|
||||||
|
<td class="text-muted small">{{ p.measure_name or '—' }}</td>
|
||||||
|
<td>
|
||||||
|
{% if p.allow_to_sell %}
|
||||||
|
<i class="bi bi-check-circle text-success"></i>
|
||||||
|
{% elif p.allow_to_sell == false %}
|
||||||
|
<i class="bi bi-x-circle text-danger"></i>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-muted small">{{ p.fetched_at | datefmt }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<i class="bi bi-box-seam" style="font-size:2rem;"></i>
|
||||||
|
<p class="mt-2">Товары не найдены.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
66
web/templates/catalog/stores.html
Normal file
66
web/templates/catalog/stores.html
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Магазины — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-between align-center mb-3">
|
||||||
|
<h1 style="font-size:1.3rem; margin:0;"><i class="bi bi-shop me-2"></i>Магазины Эвотор</h1>
|
||||||
|
<span class="text-muted small">Всего: {{ stores | length }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article class="card">
|
||||||
|
{% if stores %}
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table class="align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Синхронизация</th>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Адрес</th>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Обновлено</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in stores %}
|
||||||
|
{% set is_enabled = (enabled_ids is none) or (s.evotor_id in enabled_ids) %}
|
||||||
|
<tr class="{% if not is_enabled %}text-muted{% endif %}">
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/catalog/stores/{{ s.evotor_id }}/toggle" style="margin:0;">
|
||||||
|
<button type="submit"
|
||||||
|
class="outline sm {% if is_enabled %}success{% else %}secondary{% endif %}"
|
||||||
|
title="{% if is_enabled %}Отключить синхронизацию{% else %}Включить синхронизацию{% endif %}"
|
||||||
|
style="padding:0.2rem 0.6rem;">
|
||||||
|
{% if is_enabled %}
|
||||||
|
<i class="bi bi-toggle-on"></i>
|
||||||
|
{% else %}
|
||||||
|
<i class="bi bi-toggle-off"></i>
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td><strong>{{ s.name }}</strong></td>
|
||||||
|
<td class="text-muted">{{ s.address or '—' }}</td>
|
||||||
|
<td class="text-muted small">{{ s.evotor_id }}</td>
|
||||||
|
<td class="text-muted small">{{ s.fetched_at | datefmt }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="/catalog/stores/{{ s.evotor_id }}/products" role="button" class="outline sm" title="Товары">
|
||||||
|
<i class="bi bi-box-seam"></i> Товары
|
||||||
|
</a>
|
||||||
|
<a href="/catalog/stores/{{ s.evotor_id }}/groups" role="button" class="outline secondary sm" title="Группы">
|
||||||
|
<i class="bi bi-folder"></i> Группы
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<i class="bi bi-shop" style="font-size:2rem;"></i>
|
||||||
|
<p class="mt-2">Магазины ещё не загружены.<br>Синхронизация выполняется каждые {{ refresh_interval }} сек. автоматически.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
210
web/templates/connections.html
Normal file
210
web/templates/connections.html
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Подключения — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-center">
|
||||||
|
<div class="col-sm-10 col-md-8 col-lg-6">
|
||||||
|
|
||||||
|
<h1 style="font-size:1.3rem; margin-bottom:1.5rem;">
|
||||||
|
<i class="bi bi-plug me-2"></i>Подключения
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{% if request.query_params.get('success') %}
|
||||||
|
<div role="alert" class="alert alert-success mb-3">
|
||||||
|
<p>Подключение сохранено.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ── Evotor ── #}
|
||||||
|
<article class="card mb-4">
|
||||||
|
<header class="d-flex align-center justify-between">
|
||||||
|
<span><i class="bi bi-cpu me-2"></i><strong>Эвотор</strong></span>
|
||||||
|
{% if evotor %}
|
||||||
|
<span class="badge badge-success"><i class="bi bi-check-circle me-1"></i>Подключено</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-secondary">Не подключено</span>
|
||||||
|
{% endif %}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% if evotor %}
|
||||||
|
<ul class="list-group mb-3">
|
||||||
|
<li class="list-group-item">
|
||||||
|
<span class="text-muted small">Токен</span>
|
||||||
|
<span class="font-monospace small">{{ evotor.access_token[:8] }}••••••••</span>
|
||||||
|
</li>
|
||||||
|
{% if evotor.evotor_user_id %}
|
||||||
|
<li class="list-group-item">
|
||||||
|
<span class="text-muted small">Evotor User ID</span>
|
||||||
|
<span class="font-monospace small">{{ evotor.evotor_user_id }}</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li class="list-group-item">
|
||||||
|
<span class="text-muted small">Подключено</span>
|
||||||
|
<span>{{ evotor.connected_at | datefmt }}</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item">
|
||||||
|
<span class="text-muted small">Обновлено</span>
|
||||||
|
<span>{{ evotor.updated_at | datefmt }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<details {% if not evotor %}open{% endif %}>
|
||||||
|
<summary>
|
||||||
|
{% if evotor %}Обновить токен{% else %}Ввести API-токен{% endif %}
|
||||||
|
</summary>
|
||||||
|
<form method="post" action="/connections/evotor" class="mt-3">
|
||||||
|
<label>
|
||||||
|
API-токен Эвотор
|
||||||
|
<input type="text" name="access_token"
|
||||||
|
placeholder="Вставьте токен из личного кабинета Эвотор"
|
||||||
|
value="{{ evotor.access_token if evotor else '' }}"
|
||||||
|
required autocomplete="off">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Evotor User ID <span class="text-muted small">(необязательно)</span>
|
||||||
|
<input type="text" name="evotor_user_id"
|
||||||
|
placeholder="Например: 01234567-89ab-cdef-0123-456789abcdef"
|
||||||
|
value="{{ evotor.evotor_user_id if evotor and evotor.evotor_user_id else '' }}"
|
||||||
|
autocomplete="off">
|
||||||
|
</label>
|
||||||
|
<button type="submit">
|
||||||
|
<i class="bi bi-save me-1"></i>Сохранить
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{% if evotor %}
|
||||||
|
<div class="d-flex gap-2 mt-3" style="flex-wrap:wrap; align-items:center;">
|
||||||
|
<button type="button" class="outline sm" onclick="testConnection('evotor', this)">
|
||||||
|
<i class="bi bi-wifi me-1"></i>Проверить соединение
|
||||||
|
</button>
|
||||||
|
<span id="evotor-test-result" class="small"></span>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/connections/evotor/disconnect"
|
||||||
|
class="mt-2"
|
||||||
|
onsubmit="return confirm('Отключить Эвотор? Кешированные данные каталога останутся.')">
|
||||||
|
<button type="submit" class="outline danger sm">
|
||||||
|
<i class="bi bi-plug me-1"></i>Отключить
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{# ── VK ── #}
|
||||||
|
<article class="card mb-4">
|
||||||
|
<header class="d-flex align-center justify-between">
|
||||||
|
<span><i class="bi bi-badge-vr me-2"></i><strong>ВКонтакте (Маркет)</strong></span>
|
||||||
|
{% if vk %}
|
||||||
|
<span class="badge badge-success"><i class="bi bi-check-circle me-1"></i>Подключено</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-secondary">Не подключено</span>
|
||||||
|
{% endif %}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% if vk %}
|
||||||
|
<ul class="list-group mb-3">
|
||||||
|
<li class="list-group-item">
|
||||||
|
<span class="text-muted small">Токен</span>
|
||||||
|
<span class="font-monospace small">{{ vk.access_token[:8] }}••••••••</span>
|
||||||
|
</li>
|
||||||
|
{% if vk.vk_user_id %}
|
||||||
|
<li class="list-group-item">
|
||||||
|
<span class="text-muted small">ID сообщества</span>
|
||||||
|
<span class="font-monospace small">{{ vk.vk_user_id }}</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if vk.first_name or vk.last_name %}
|
||||||
|
<li class="list-group-item">
|
||||||
|
<span class="text-muted small">Аккаунт</span>
|
||||||
|
<span>{{ vk.first_name }} {{ vk.last_name }}</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li class="list-group-item">
|
||||||
|
<span class="text-muted small">Подключено</span>
|
||||||
|
<span>{{ vk.connected_at | datefmt }}</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item">
|
||||||
|
<span class="text-muted small">Обновлено</span>
|
||||||
|
<span>{{ vk.updated_at | datefmt }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mt-3">
|
||||||
|
<a href="/vk-auth" role="button">
|
||||||
|
<i class="bi bi-box-arrow-in-right me-1"></i>
|
||||||
|
{% if vk %}Переподключить ВКонтакте{% else %}Войти через ВКонтакте{% endif %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="mt-3">
|
||||||
|
<summary class="text-muted small">Ввести токен вручную</summary>
|
||||||
|
<form method="post" action="/connections/vk" class="mt-2">
|
||||||
|
<label>
|
||||||
|
Токен доступа VK
|
||||||
|
<input type="text" name="access_token"
|
||||||
|
placeholder="vk1.a.xxxxxxxxxxxxxxxx…"
|
||||||
|
value="{{ vk.access_token if vk else '' }}"
|
||||||
|
required autocomplete="off">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
ID сообщества ВКонтакте
|
||||||
|
<input type="text" name="vk_group_id"
|
||||||
|
placeholder="Например: 229744980"
|
||||||
|
value="{{ vk.vk_user_id if vk and vk.vk_user_id else '' }}"
|
||||||
|
autocomplete="off">
|
||||||
|
<small class="text-muted">Числовой ID группы/паблика с включённым Маркетом (без минуса)</small>
|
||||||
|
</label>
|
||||||
|
<button type="submit">
|
||||||
|
<i class="bi bi-save me-1"></i>Сохранить
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{% if vk %}
|
||||||
|
<div class="d-flex gap-2 mt-3" style="flex-wrap:wrap; align-items:center;">
|
||||||
|
<button type="button" class="outline sm" onclick="testConnection('vk', this)">
|
||||||
|
<i class="bi bi-wifi me-1"></i>Проверить соединение
|
||||||
|
</button>
|
||||||
|
<span id="vk-test-result" class="small"></span>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/connections/vk/disconnect"
|
||||||
|
class="mt-2"
|
||||||
|
onsubmit="return confirm('Отключить ВКонтакте?')">
|
||||||
|
<button type="submit" class="outline danger sm">
|
||||||
|
<i class="bi bi-plug me-1"></i>Отключить
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
async function testConnection(provider, btn) {
|
||||||
|
const resultEl = document.getElementById(provider + '-test-result');
|
||||||
|
btn.disabled = true;
|
||||||
|
resultEl.textContent = 'Проверяем…';
|
||||||
|
resultEl.style.color = '';
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/connections/' + provider + '/test', {method: 'POST'});
|
||||||
|
const data = await resp.json();
|
||||||
|
resultEl.textContent = data.message;
|
||||||
|
resultEl.style.color = data.ok ? 'var(--pico-color-green-500, #2d8a4e)' : 'var(--pico-color-red-500, #c0392b)';
|
||||||
|
} catch (e) {
|
||||||
|
resultEl.textContent = 'Ошибка сети';
|
||||||
|
resultEl.style.color = 'var(--pico-color-red-500, #c0392b)';
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -17,8 +17,7 @@
|
|||||||
<button type="submit" class="w-100">Войти</button>
|
<button type="submit" class="w-100">Войти</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="text-center small mt-3">
|
<div class="text-center small mt-3">
|
||||||
<a href="/forgot-password">Забыли пароль?</a><br>
|
<a href="/forgot-password">Забыли пароль?</a>
|
||||||
<a href="/register">Зарегистрироваться</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
71
web/templates/sync.html
Normal file
71
web/templates/sync.html
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Синхронизация — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-between align-center mb-3">
|
||||||
|
<h1 style="font-size:1.3rem; margin:0;"><i class="bi bi-arrow-repeat me-2"></i>Синхронизация</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if saved %}
|
||||||
|
<div role="alert" class="alert alert-success"><p>Настройки сохранены.</p></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/sync/settings">
|
||||||
|
|
||||||
|
<article class="card mb-3">
|
||||||
|
<h2 style="font-size:1.1rem; margin-bottom:1.25rem;">Фоновые задачи</h2>
|
||||||
|
<p class="text-muted small" style="margin-bottom:1.25rem;">Включайте поочерёдно: сначала проверьте зеркало Эвотор, затем ВК, затем синхронизацию.</p>
|
||||||
|
|
||||||
|
<div style="display:flex; flex-direction:column; gap:1rem;">
|
||||||
|
|
||||||
|
<label style="display:flex; align-items:flex-start; gap:0.75rem; cursor:pointer;">
|
||||||
|
<input type="hidden" name="evo_mirror_enabled" value="0">
|
||||||
|
<input type="checkbox" name="evo_mirror_enabled" value="1" role="switch"
|
||||||
|
{% if config and config.evo_mirror_enabled %}checked{% endif %}
|
||||||
|
style="margin-top:0.2rem; flex-shrink:0;">
|
||||||
|
<span>
|
||||||
|
<strong>Зеркало Эвотор</strong><br>
|
||||||
|
<span class="text-muted small">Периодически импортирует товары, группы и магазины из Эвотор в локальную базу.</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style="display:flex; align-items:flex-start; gap:0.75rem; cursor:pointer;">
|
||||||
|
<input type="hidden" name="vk_mirror_enabled" value="0">
|
||||||
|
<input type="checkbox" name="vk_mirror_enabled" value="1" role="switch"
|
||||||
|
{% if config and config.vk_mirror_enabled %}checked{% endif %}
|
||||||
|
style="margin-top:0.2rem; flex-shrink:0;">
|
||||||
|
<span>
|
||||||
|
<strong>Зеркало ВК</strong><br>
|
||||||
|
<span class="text-muted small">Периодически импортирует альбомы и товары из VK Market в локальный кэш.</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style="display:flex; align-items:flex-start; gap:0.75rem; cursor:pointer;">
|
||||||
|
<input type="hidden" name="is_enabled" value="0">
|
||||||
|
<input type="checkbox" name="is_enabled" value="1" role="switch"
|
||||||
|
{% if config and config.is_enabled %}checked{% endif %}
|
||||||
|
style="margin-top:0.2rem; flex-shrink:0;">
|
||||||
|
<span>
|
||||||
|
<strong>Синхронизация</strong><br>
|
||||||
|
<span class="text-muted small">Зеркалит каталог Эвотор в VK Market: создаёт, обновляет и удаляет товары.</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card mb-3">
|
||||||
|
<h2 style="font-size:1.1rem; margin-bottom:1.25rem;">Настройки цены</h2>
|
||||||
|
<label style="max-width:320px;">
|
||||||
|
Множитель цены
|
||||||
|
<input type="number" name="price_multiplier" step="0.0001" min="0.0001"
|
||||||
|
value="{{ config.price_multiplier if config else '1' }}"
|
||||||
|
placeholder="1">
|
||||||
|
<small class="text-muted">Цена из Эвотор умножается на это значение перед отправкой в ВК. По умолчанию: 1.</small>
|
||||||
|
</label>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<button type="submit">Сохранить</button>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
52
web/templates/vk_catalog/albums.html
Normal file
52
web/templates/vk_catalog/albums.html
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Альбомы ВК — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-between align-center mb-3">
|
||||||
|
<h1 style="font-size:1.3rem; margin:0;"><i class="bi bi-badge-vr me-2"></i>Каталог ВКонтакте — Альбомы</h1>
|
||||||
|
<span class="text-muted small">Всего: {{ albums | length }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article class="card">
|
||||||
|
{% if not vk_conn %}
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<i class="bi bi-plug" style="font-size:2rem;"></i>
|
||||||
|
<p class="mt-2">ВКонтакте не подключён.<br><a href="/connections">Перейти к подключениям</a></p>
|
||||||
|
</div>
|
||||||
|
{% elif albums %}
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table class="align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Товаров</th>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Обновлено</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for a in albums %}
|
||||||
|
<tr>
|
||||||
|
<td><i class="bi bi-collection me-1 text-muted"></i> <strong>{{ a.title }}</strong></td>
|
||||||
|
<td class="text-muted">{{ a.count if a.count is not none else '—' }}</td>
|
||||||
|
<td class="text-muted small">{{ a.album_id }}</td>
|
||||||
|
<td class="text-muted small">{{ a.fetched_at | datefmt }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="/vk-catalog/albums/{{ a.album_id }}/products" role="button" class="outline sm">
|
||||||
|
<i class="bi bi-box-seam"></i> Товары
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<i class="bi bi-collection" style="font-size:2rem;"></i>
|
||||||
|
<p class="mt-2">Альбомы ещё не загружены.<br>Синхронизация выполняется каждые {{ refresh_interval }} сек. автоматически.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
75
web/templates/vk_catalog/products.html
Normal file
75
web/templates/vk_catalog/products.html
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Товары ВК — {{ album.title }} — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<nav aria-label="breadcrumb" class="mb-3">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li><a href="/vk-catalog/albums">Альбомы ВК</a></li>
|
||||||
|
<li>{{ album.title }}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="d-flex justify-between align-center mb-3">
|
||||||
|
<h1 style="font-size:1.3rem; margin:0;"><i class="bi bi-box-seam me-2"></i>{{ album.title }}</h1>
|
||||||
|
<span class="text-muted small">Всего: {{ products | length }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article class="card">
|
||||||
|
{% if products %}
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table class="align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Цена</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Обновлено</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for p in products %}
|
||||||
|
<tr>
|
||||||
|
<td style="width:48px;">
|
||||||
|
{% if p.thumb_url %}
|
||||||
|
<img src="{{ p.thumb_url }}" alt="" style="width:40px;height:40px;object-fit:cover;border-radius:4px;">
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted"><i class="bi bi-image" style="font-size:1.5rem;"></i></span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<strong>{{ p.name }}</strong>
|
||||||
|
{% if p.description %}
|
||||||
|
<br><span class="text-muted small">{{ p.description[:80] }}{% if p.description|length > 80 %}…{% endif %}</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-muted">
|
||||||
|
{% if p.price is not none %}{{ p.price | price }}{% else %}—{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if p.availability == 0 %}
|
||||||
|
<span class="badge badge-success">В наличии</span>
|
||||||
|
{% elif p.availability == 1 %}
|
||||||
|
<span class="badge badge-secondary">Удалён</span>
|
||||||
|
{% elif p.availability == 2 %}
|
||||||
|
<span class="badge badge-warning">Недоступен</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted small">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-muted small">{{ p.vk_product_id }}</td>
|
||||||
|
<td class="text-muted small">{{ p.fetched_at | datefmt }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<i class="bi bi-box-seam" style="font-size:2rem;"></i>
|
||||||
|
<p class="mt-2">Товары в этом альбоме не найдены.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user