Compare commits
104 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2670a34504 | ||
|
|
403d8bce4d | ||
|
|
0c71497522 | ||
|
|
cd777d2bc1 | ||
|
|
41280fad45 | ||
|
|
e5a55a02d1 | ||
|
|
1d268a6b58 | ||
|
|
a597639aa7 | ||
|
|
04ca914971 | ||
|
|
5b82f1bc02 | ||
|
|
fa8167af4d | ||
|
|
5a67be2c81 | ||
|
|
74f613a4c3 | ||
|
|
052c3b610f | ||
|
|
dc32bef7e8 | ||
|
|
a3f6697bc4 | ||
|
|
175f1f4c27 | ||
|
|
9f87458e0c | ||
|
|
e0594f67a8 | ||
|
|
eb4165e48b | ||
|
|
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 | ||
|
|
fc65e591b3 | ||
|
|
5ead89e0cf | ||
| ba34adbbcf | |||
|
|
2df4898098 | ||
|
|
15a362ca42 | ||
|
|
049e82654d | ||
|
|
854c912a88 | ||
|
|
db0c1cbed3 | ||
|
|
fd7d0022ea | ||
|
|
1bf82adbfc | ||
|
|
9a68c083e3 | ||
|
|
aaeaa4f658 | ||
|
|
aea28ead9c | ||
|
|
cde2069d74 | ||
|
|
debb2efb3d | ||
|
|
4d4d5b0118 | ||
|
|
00b74b8aa9 | ||
|
|
577c5de200 | ||
|
|
0926757b7a | ||
|
|
13c32e9181 | ||
|
|
6b9eb562ba | ||
|
|
9558333c94 | ||
|
|
40e7abd012 | ||
|
|
3d7a456299 | ||
|
|
5acf597944 | ||
|
|
3f4bbcbb0d | ||
|
|
c8beeaf1b1 | ||
|
|
5ee8419c7c | ||
|
|
e376c86fbe | ||
|
|
69e21a18c9 | ||
|
|
90a2f7be1f | ||
|
|
d4633a0f46 | ||
|
|
b72b0e78b0 | ||
|
|
2a04099f95 | ||
|
|
58f9b74a1c | ||
|
|
8c9c328302 | ||
|
|
784bb27958 | ||
|
|
1add9fa299 | ||
|
|
b8696793f4 | ||
|
|
37e2df1fef | ||
|
|
48da26c270 | ||
|
|
bacfd8fe54 | ||
|
|
9aeef73b10 | ||
|
|
cfc7229daf | ||
|
|
379f781e1e | ||
|
|
9edb77efba | ||
|
|
865798967a | ||
|
|
d486ba1f83 |
20
.env.example
20
.env.example
@@ -1,8 +1,18 @@
|
||||
DATABASE_URL=mysql+pymysql://evosync:evosync@db:3306/evosync
|
||||
SECRET_KEY=your-random-secret-key-here
|
||||
BASE_URL=http://localhost:8000
|
||||
|
||||
DB_ROOT_PASSWORD=rootpass
|
||||
# Database
|
||||
DB_ROOT_PASSWORD=rootpassword
|
||||
DB_NAME=evosync
|
||||
DB_USER=evosync
|
||||
DB_PASSWORD=evosync
|
||||
|
||||
# App
|
||||
SECRET_KEY=change-me-in-production
|
||||
DOMAIN=yourdomain.com
|
||||
BASE_URL=https://${DOMAIN}
|
||||
|
||||
# Evotor
|
||||
EVOTOR_APP_ID=
|
||||
EVOTOR_WEBHOOK_SECRET=
|
||||
|
||||
# Celery Flower
|
||||
FLOWER_USER=admin
|
||||
FLOWER_PASSWORD=changeme
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -16,3 +16,7 @@ passwords.txt
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
certbot
|
||||
web-resources
|
||||
.coverage
|
||||
password|*
|
||||
|
||||
20
CHANGELOG.md
20
CHANGELOG.md
@@ -1,20 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.6.0] - 2025-06-15
|
||||
|
||||
### Added
|
||||
- Initial changelog implementation
|
||||
- Version tracking system
|
||||
|
||||
### Changed
|
||||
- Minor version bump from 1.5.2 to 1.6.0
|
||||
|
||||
## [1.5.2] - Previous Release
|
||||
|
||||
### Notes
|
||||
- Historical version before changelog implementation
|
||||
@@ -1,10 +1,16 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY web/ ./web/
|
||||
COPY requirements.txt .
|
||||
RUN pip install --upgrade pip && pip install -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uvicorn", "web.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
289
README.md
289
README.md
@@ -1,3 +1,288 @@
|
||||
# evo-sync
|
||||
# EvoSync
|
||||
|
||||
evo-sync is a command-line synchronization tool that fetches product, group, and store data from the Evo platform and syncs it with VK (VKontakte).
|
||||
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
|
||||
```
|
||||
|
||||
38
alembic.ini
Normal file
38
alembic.ini
Normal file
@@ -0,0 +1,38 @@
|
||||
[alembic]
|
||||
script_location = web/migrations
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -1,25 +1,113 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
db:
|
||||
image: mariadb:11.4
|
||||
restart: unless-stopped
|
||||
command: --innodb-buffer-pool-size=128M --max-connections=20
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
|
||||
MYSQL_DATABASE: ${DB_NAME}
|
||||
MYSQL_USER: ${DB_USER}
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD}
|
||||
volumes:
|
||||
- db_data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
command: redis-server --save 60 1 --loglevel warning
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8000"
|
||||
environment:
|
||||
- DATABASE_URL=mysql+pymysql://${DB_USER}:${DB_PASSWORD}@172.25.0.1:3306/${DB_NAME}
|
||||
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}
|
||||
- BASE_URL=${BASE_URL:-http://localhost:8080}
|
||||
DATABASE_URL: mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db:3306/${DB_NAME}
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
|
||||
BASE_URL: ${BASE_URL:-https://${DOMAIN}}
|
||||
EVOTOR_APP_ID: ${EVOTOR_APP_ID:-}
|
||||
EVOTOR_WEBHOOK_SECRET: ${EVOTOR_WEBHOOK_SECRET:-}
|
||||
JIVOSITE_WIDGET_ID: ${JIVOSITE_WIDGET_ID:-}
|
||||
VK_DEFAULT_PHOTO_PATH: /app/default_product.png
|
||||
volumes:
|
||||
- ./web:/app/web
|
||||
- ./5393364294319597854.png:/app/default_product.png:ro
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
command: >
|
||||
sh -c "alembic upgrade head && uvicorn web.main:app --host 0.0.0.0 --port 8000"
|
||||
|
||||
sync:
|
||||
worker:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
dockerfile: Dockerfile.web
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATABASE_URL: mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db:3306/${DB_NAME}
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
|
||||
EVOTOR_APP_ID: ${EVOTOR_APP_ID:-}
|
||||
EVOTOR_WEBHOOK_SECRET: ${EVOTOR_WEBHOOK_SECRET:-}
|
||||
VK_DEFAULT_PHOTO_PATH: /app/default_product.png
|
||||
volumes:
|
||||
- ./evo:/var/www/evo
|
||||
- ./vk:/var/www/vk
|
||||
- ./run:/var/www/run
|
||||
- ./logs:/var/www/logs
|
||||
- ./5393364294319597854.png:/app/default_product.png:ro
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
db:
|
||||
condition: service_healthy
|
||||
command: celery -A web.tasks.celery_app worker --loglevel=info --concurrency=1 --queues=default,sync,health,notifications -E
|
||||
|
||||
beat:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.web
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATABASE_URL: mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db:3306/${DB_NAME}
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
|
||||
CATALOG_REFRESH_INTERVAL_SECONDS: ${CATALOG_REFRESH_INTERVAL_SECONDS:-3600}
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
db:
|
||||
condition: service_healthy
|
||||
command: celery -A web.tasks.celery_app beat --loglevel=info --scheduler celery.beat:PersistentScheduler --schedule /tmp/celerybeat-schedule
|
||||
|
||||
flower:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.web
|
||||
restart: unless-stopped
|
||||
profiles: [flower]
|
||||
ports:
|
||||
- "5555:5555"
|
||||
environment:
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
FLOWER_BASIC_AUTH: ${FLOWER_USER:-admin}:${FLOWER_PASSWORD:-changeme}
|
||||
depends_on:
|
||||
- redis
|
||||
command: celery -A web.tasks.celery_app flower --port=5555 --basic_auth=${FLOWER_USER:-admin}:${FLOWER_PASSWORD:-changeme}
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
redis_data:
|
||||
|
||||
4
docker-entrypoint.sh
Executable file
4
docker-entrypoint.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
alembic upgrade head
|
||||
exec uvicorn web.main:app --host 0.0.0.0 --port 8000
|
||||
296
docs/PLAN_evotor_user_lifecycle.md
Normal file
296
docs/PLAN_evotor_user_lifecycle.md
Normal file
@@ -0,0 +1,296 @@
|
||||
# Plan: Evotor User Lifecycle + RBAC + Admin Panel
|
||||
|
||||
## Context
|
||||
|
||||
Evotor (Russian POS ecosystem) sends webhook calls to register and verify users who buy the EvoSync app on their marketplace. Currently the web app has no webhook receivers for user lifecycle events, no role-based access control, and no admin panel. We need to:
|
||||
|
||||
1. Receive Evotor user webhooks (`/user/create`, `/user/verify`, `/user/token`)
|
||||
2. Create users in `pending` status, link them to a local account, send an invite to set password
|
||||
3. Abstract email/SMS providers (no concrete provider chosen yet — console output for now)
|
||||
4. Add role-based access control (system / admin / user) with a full roles+permissions table structure
|
||||
5. Build an admin panel for user management
|
||||
6. Extend the user profile card with role/status/Evotor metadata
|
||||
|
||||
**Target:** Python/FastAPI on current HEAD (`15a362c`). The v3 scaffold is minimal — `main.py` is a bare FastAPI app, `web/models/` is empty, `web/tasks/celery_app.py` is a stub.
|
||||
|
||||
**Reference:** The Node.js commit `854c912` has all existing features (auth, profile, catalog, sync) — use it as the schema and UI reference for porting.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
- **Session:** Starlette `SessionMiddleware` (already a FastAPI dep) — `request.session["user_id"]` cookie, mirroring Node.js pattern. No extra package.
|
||||
- **RBAC:** `role` enum directly on `users` table (fast path in middleware) **plus** `roles` / `permissions` / `user_roles` / `role_permissions` tables for fine-grained, admin-configurable permissions.
|
||||
- **Evotor app token:** Store a random UUID token in `evotor_connections.api_token` (new column). No `itsdangerous` dependency needed. Return this token in webhook responses.
|
||||
- **`password_hash` nullable:** Users arriving via `/user/create` have no password yet; they set it via invite link. Migration must `ALTER COLUMN` to allow NULL.
|
||||
- **Notifications:** Celery tasks on a new `notifications` queue → abstract `EmailProvider`/`SMSProvider` → `ConsoleEmailProvider`/`ConsoleSMSProvider` by default.
|
||||
- **Migrations:** Three incremental migrations. 0002 creates the full schema (conditionally, since live DB may have Node.js tables). 0003 creates RBAC tables + seeds default roles/permissions. 0004 is optional seed for a system user.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
web/
|
||||
├── main.py — add SessionMiddleware, Jinja2, static, router includes
|
||||
├── config.py — add INVITE_EXPIRE_HOURS, EMAIL_PROVIDER, SMS_PROVIDER
|
||||
├── database.py — add get_db() dependency (sync Session)
|
||||
├── templates_env.py — NEW: Jinja2Templates singleton with datefmt/price filters
|
||||
│
|
||||
├── models/
|
||||
│ ├── __init__.py — import all models (for Alembic autogenerate)
|
||||
│ ├── user.py — NEW: User, UserRoleEnum, UserStatusEnum
|
||||
│ ├── rbac.py — NEW: Role, Permission, role_permissions, UserRole
|
||||
│ └── connections.py — NEW: EvotorConnection, VkConnection, SyncConfig, etc.
|
||||
│
|
||||
├── auth/
|
||||
│ ├── __init__.py
|
||||
│ ├── session.py — NEW: get_current_user() helper, session utils
|
||||
│ ├── password.py — NEW: hash_password(), verify_password() via passlib
|
||||
│ └── rbac.py — NEW: require_role(), require_permission() FastAPI deps
|
||||
│
|
||||
├── notifications/
|
||||
│ ├── __init__.py
|
||||
│ ├── base.py — NEW: abstract EmailProvider, SMSProvider
|
||||
│ ├── console.py — NEW: ConsoleEmailProvider, ConsoleSMSProvider
|
||||
│ ├── registry.py — NEW: get_email_provider(), get_sms_provider() factories
|
||||
│ └── tasks.py — NEW: send_email_task, send_sms_task Celery tasks
|
||||
│
|
||||
├── routes/
|
||||
│ ├── __init__.py
|
||||
│ ├── auth.py — NEW: register, login, logout, confirm-email
|
||||
│ ├── reset.py — NEW: forgot-password, reset-password
|
||||
│ ├── invite.py — NEW: GET/POST /invite (Evotor invite flow)
|
||||
│ ├── profile.py — NEW: /profile, /profile/edit, change-password, delete
|
||||
│ ├── connections.py — NEW: /connections (port from Node.js)
|
||||
│ ├── evotor.py — NEW: /evotor UI + /evotor/token (port from Node.js)
|
||||
│ ├── evotor_webhooks.py — NEW: POST /user/create, /user/verify, /user/token
|
||||
│ ├── vk.py — NEW: /vk + /vk/callback (port from Node.js)
|
||||
│ ├── catalog.py — NEW: /catalog (port from Node.js)
|
||||
│ ├── sync.py — NEW: /sync (port from Node.js)
|
||||
│ └── admin.py — NEW: /admin/* panel
|
||||
│
|
||||
├── tasks/
|
||||
│ ├── celery_app.py — add notifications queue + route
|
||||
│ ├── sync.py — NEW: sync Celery tasks
|
||||
│ └── health.py — NEW: health-check tasks
|
||||
│
|
||||
├── templates/
|
||||
│ ├── base.html — port base.njk; nav shows Admin link if role=admin/system
|
||||
│ ├── login.html, register.html, confirm_email.html, email_confirmed.html
|
||||
│ ├── forgot_password.html, reset_password.html
|
||||
│ ├── invite.html — NEW: set-password form for invite flow
|
||||
│ ├── message.html, profile_view.html, profile_edit.html
|
||||
│ ├── profile_change_password.html, profile_delete.html
|
||||
│ ├── connections.html, connections_add.html
|
||||
│ ├── evotor.html, vk.html, vk_callback.html
|
||||
│ ├── catalog_stores.html, catalog_groups.html, catalog_products.html
|
||||
│ ├── sync.html
|
||||
│ └── admin/
|
||||
│ ├── users.html — paginated user list with filters
|
||||
│ ├── user_detail.html — user card + Evotor meta JSON + action buttons
|
||||
│ └── roles.html — roles and permission assignment
|
||||
│
|
||||
├── static/
|
||||
│ └── style.css — port from 854c912:web/static/style.css
|
||||
│
|
||||
└── migrations/versions/
|
||||
├── 0001_initial.py — exists (empty placeholder)
|
||||
├── 0002_users_and_connections.py — NEW: full schema + new user fields
|
||||
├── 0003_rbac_tables.py — NEW: RBAC tables + seed roles/permissions
|
||||
└── 0004_seed_system_user.py — NEW (optional): seed system user from env vars
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DB Schema Additions
|
||||
|
||||
### `web/models/user.py`
|
||||
|
||||
```python
|
||||
class UserRoleEnum(str, enum.Enum):
|
||||
system = "system"
|
||||
admin = "admin"
|
||||
user = "user"
|
||||
|
||||
class UserStatusEnum(str, enum.Enum):
|
||||
pending = "pending"
|
||||
active = "active"
|
||||
suspended = "suspended"
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
# existing fields (port from 854c912 schema.ts)
|
||||
id, first_name, last_name, email, phone
|
||||
password_hash = Column(String(255), nullable=True) # nullable: invite flow
|
||||
is_email_confirmed, email_confirm_token
|
||||
password_reset_token, password_reset_expires
|
||||
created_at, updated_at
|
||||
# new fields
|
||||
role = Column(Enum(UserRoleEnum), default=UserRoleEnum.user, index=True)
|
||||
status = Column(Enum(UserStatusEnum), default=UserStatusEnum.pending, index=True)
|
||||
evotor_user_id = Column(String(255), unique=True, nullable=True)
|
||||
evotor_meta = Column(JSON, nullable=True)
|
||||
invite_token = Column(String(255), nullable=True)
|
||||
invite_expires = Column(DateTime, nullable=True)
|
||||
phone_otp = Column(String(10), nullable=True)
|
||||
phone_otp_expires = Column(DateTime, nullable=True)
|
||||
```
|
||||
|
||||
### `web/models/connections.py`
|
||||
|
||||
Port all tables from `854c912:web/src/db/schema.ts` verbatim:
|
||||
`evotor_connections` (add `api_token varchar(255)` column for our app token), `vk_connections`, `sync_configs`, `sync_filters`, `cached_stores`, `cached_groups`, `cached_products`.
|
||||
|
||||
### `web/models/rbac.py`
|
||||
|
||||
```python
|
||||
class Role(Base): # name: system/admin/user
|
||||
class Permission(Base): # name: e.g. "admin.users.edit"
|
||||
role_permissions = Table(...) # join table Role ↔ Permission
|
||||
class UserRole(Base): # join table User ↔ Role (fine-grained assignment)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Route List
|
||||
|
||||
### Evotor Webhooks (`web/routes/evotor_webhooks.py`)
|
||||
| Method | Path | Auth |
|
||||
|--------|------|------|
|
||||
| POST | `/user/create` | Bearer `EVOTOR_WEBHOOK_SECRET` |
|
||||
| POST | `/user/verify` | Bearer `EVOTOR_WEBHOOK_SECRET` |
|
||||
| POST | `/user/token` | Bearer `EVOTOR_WEBHOOK_SECRET` |
|
||||
|
||||
**`/user/create` flow:**
|
||||
1. Verify Bearer token (401 if wrong, skip check if secret is unset — dev mode)
|
||||
2. Parse `{userId, customField}` — attempt JSON parse of `customField`; extract `email`, `phone`, `first_name`, `last_name`
|
||||
3. Try to find existing user by email → phone → `evotor_user_id`
|
||||
4. If found: set `evotor_user_id`, update `evotor_meta`, set `status=active` if was pending
|
||||
5. If not found: create `User(status=pending, role=user, evotor_user_id=..., password_hash=None)`
|
||||
6. Generate `invite_token = secrets.token_urlsafe(32)`, `invite_expires = now + 48h`
|
||||
7. Dispatch `send_email_task.delay(email, "Приглашение в ЭвоСинк", html)` via Celery
|
||||
8. Generate a random `api_token = secrets.token_urlsafe(32)`, upsert into `evotor_connections`
|
||||
9. Return `{"userId": payload.userId, "token": api_token}`
|
||||
|
||||
**`/user/verify` flow:**
|
||||
1. Verify Bearer; parse `{userId, username, password}`
|
||||
2. Find user by email OR phone (username field)
|
||||
3. Check: `password_hash` not None, status not suspended, `verify_password(password, hash)`
|
||||
4. Return `{"userId": user.evotor_user_id, "token": evotor_connection.api_token}`
|
||||
|
||||
**`/user/token` flow:**
|
||||
1. Verify Bearer; parse `{userId, token}`
|
||||
2. Find user by `evotor_user_id`; upsert `evotor_connections.access_token = token`
|
||||
3. Return HTTP 200 `{}`
|
||||
|
||||
### Admin Panel (`web/routes/admin.py`) — all require `Depends(require_role("admin", "system"))`
|
||||
| Method | Path |
|
||||
|--------|------|
|
||||
| GET | `/admin/users` — paginated list with role/status/search filters |
|
||||
| GET | `/admin/users/{id}` — user card, evotor_meta JSON display |
|
||||
| POST | `/admin/users/{id}/activate` — set status=active |
|
||||
| POST | `/admin/users/{id}/suspend` — set status=suspended |
|
||||
| POST | `/admin/users/{id}/reset-password` — generate token, dispatch email task |
|
||||
| POST | `/admin/users/{id}/send-invite` — regenerate invite_token, dispatch email task |
|
||||
| POST | `/admin/users/{id}/edit` — update name/email/phone/role |
|
||||
| POST | `/admin/users/{id}/delete` — hard delete (system only) |
|
||||
| GET | `/admin/roles` — role + permission list (system only) |
|
||||
| POST | `/admin/roles/{id}/permissions` — set permissions for role |
|
||||
|
||||
### User Profile (`web/routes/profile.py`)
|
||||
Extend existing profile card with: `role`, `status`, Evotor connection info, "Confirm email" action (if `is_email_confirmed=False`).
|
||||
|
||||
### Auth/Invite (new)
|
||||
| Method | Path |
|
||||
|--------|------|
|
||||
| GET/POST | `/invite?token=...` — Evotor-invited user sets password + activates account |
|
||||
|
||||
---
|
||||
|
||||
## Notifications Layer
|
||||
|
||||
```python
|
||||
# base.py
|
||||
class EmailProvider(ABC):
|
||||
@abstractmethod
|
||||
def send(self, to: str, subject: str, html_body: str) -> None: ...
|
||||
|
||||
class SMSProvider(ABC):
|
||||
@abstractmethod
|
||||
def send(self, to: str, text: str) -> None: ...
|
||||
|
||||
# console.py — prints formatted block to stdout (like Node.js "="*40 pattern)
|
||||
# registry.py — factory: EMAIL_PROVIDER env var selects implementation
|
||||
# tasks.py
|
||||
@celery_app.task(queue="notifications")
|
||||
def send_email_task(to, subject, html_body): get_email_provider().send(...)
|
||||
|
||||
@celery_app.task(queue="notifications")
|
||||
def send_sms_task(to, text): get_sms_provider().send(...)
|
||||
```
|
||||
|
||||
Add to docker-compose worker command: `--queues=default,sync,health,notifications`
|
||||
|
||||
---
|
||||
|
||||
## RBAC Middleware
|
||||
|
||||
```python
|
||||
# auth/rbac.py
|
||||
def require_role(*roles):
|
||||
def dep(request, db=Depends(get_db)):
|
||||
user = get_current_user(request, db)
|
||||
if user.role.value not in roles: raise HTTPException(403)
|
||||
return user
|
||||
return Depends(dep)
|
||||
|
||||
def require_permission(permission_name):
|
||||
def dep(request, db=Depends(get_db)):
|
||||
user = get_current_user(request, db)
|
||||
if user.role == UserRoleEnum.system: return user # bypass
|
||||
# walk user_roles → role_permissions → permissions
|
||||
...
|
||||
return Depends(dep)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
**0002:** Full schema (conditionally — `IF NOT EXISTS` / `ADD COLUMN IF NOT EXISTS` for live DBs with Node.js tables). Includes `ALTER COLUMN password_hash` to nullable. Adds `api_token` to `evotor_connections`. Adds `role`, `status`, `evotor_user_id`, `evotor_meta`, `invite_token`, `invite_expires`, `phone_otp`, `phone_otp_expires` to `users`.
|
||||
|
||||
**0003:** RBAC tables (`roles`, `permissions`, `role_permissions`, `user_roles`). Seeds three default Role rows and baseline permission set (`admin.users.view`, `admin.users.edit`, `admin.users.delete`, `admin.roles.manage`).
|
||||
|
||||
**0004 (optional):** Seeds a system user from `SYSTEM_USER_EMAIL` / `SYSTEM_USER_PASSWORD` env vars.
|
||||
|
||||
---
|
||||
|
||||
## Order of Implementation
|
||||
|
||||
1. **Models** — `user.py`, `connections.py`, `rbac.py`, `__init__.py`
|
||||
2. **Migrations** — 0002, 0003 (run `alembic upgrade head` to verify)
|
||||
3. **Auth foundation** — `auth/password.py`, `auth/session.py`, `templates_env.py`, update `main.py`
|
||||
4. **Core auth routes** — `routes/auth.py`, `routes/reset.py` + templates (`base.html`, login, register, etc.)
|
||||
5. **Notifications** — `notifications/` package + `tasks.py` + update `celery_app.py`
|
||||
6. **Invite flow** — `routes/invite.py` + `invite.html`
|
||||
7. **Evotor webhooks** — `routes/evotor_webhooks.py` (the most novel piece)
|
||||
8. **Profile + Connections** — port from Node.js `854c912`
|
||||
9. **RBAC middleware** — `auth/rbac.py`
|
||||
10. **Admin panel** — `routes/admin.py` + admin templates
|
||||
11. **Catalog + Sync** — port remaining routes and Celery tasks from Node.js
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
1. `alembic upgrade head` — all migrations run clean on a fresh DB
|
||||
2. `POST /user/create` with `curl` + Bearer token → check user created in DB, invite email printed to console
|
||||
3. `GET /invite?token=<token>` → set password → check `status=active`, `is_email_confirmed=True`
|
||||
4. `POST /login` with the set password → session created, redirect to `/profile`
|
||||
5. `POST /user/verify` with username+password → returns `{userId, token}`
|
||||
6. `POST /user/token` with `{userId, token}` → 200, evotor_connection updated
|
||||
7. Login as admin user, visit `/admin/users` — user list renders
|
||||
8. Admin activates/suspends a user — status changes in DB
|
||||
9. `POST /login` as suspended user → rejected
|
||||
10. `uvicorn web.main:app --reload` — no import errors, health check returns 200
|
||||
73
nginx/nginx.conf
Normal file
73
nginx/nginx.conf
Normal file
@@ -0,0 +1,73 @@
|
||||
upstream web {
|
||||
server 127.0.0.1:8080;
|
||||
}
|
||||
|
||||
# ── мои-товары.рф ─────────────────────────────────────────────────────────────
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name xn----8sbfwtmcso8g.xn--p1ai www.xn----8sbfwtmcso8g.xn--p1ai;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name xn----8sbfwtmcso8g.xn--p1ai www.xn----8sbfwtmcso8g.xn--p1ai;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/xn----8sbfwtmcso8g.xn--p1ai/fullchain.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_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;
|
||||
}
|
||||
}
|
||||
11
pyproject.toml
Normal file
11
pyproject.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
addopts = "--tb=short"
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["web"]
|
||||
omit = ["web/migrations/*"]
|
||||
|
||||
[tool.coverage.report]
|
||||
show_missing = true
|
||||
@@ -1,11 +1,20 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.0
|
||||
sqlalchemy==2.0.35
|
||||
pymysql==1.1.1
|
||||
cryptography>=41.0.0
|
||||
jinja2==3.1.4
|
||||
fastapi==0.115.5
|
||||
uvicorn[standard]==0.32.1
|
||||
python-multipart==0.0.12
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.2.0
|
||||
pydantic-settings==2.5.2
|
||||
itsdangerous==2.1.2
|
||||
jinja2==3.1.4
|
||||
sqlalchemy==2.0.36
|
||||
alembic==1.14.0
|
||||
pymysql==1.1.1
|
||||
itsdangerous>=2.1.0
|
||||
cryptography>=44.0.0
|
||||
bcrypt>=4.2.1
|
||||
pydantic-settings==2.6.1
|
||||
httpx==0.28.1
|
||||
celery[redis]==5.4.0
|
||||
redis==5.2.1
|
||||
flower==2.0.1
|
||||
python-json-logger==3.2.1
|
||||
pytest==8.3.4
|
||||
pytest-asyncio==0.24.0
|
||||
pytest-cov==6.0.0
|
||||
factory-boy==3.3.1
|
||||
|
||||
65
run/read_config.py
Normal file
65
run/read_config.py
Normal file
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Read sync configuration for a user from the database and output JSON.
|
||||
Usage: python run/read_config.py <user_id>
|
||||
|
||||
Output JSON structure:
|
||||
{
|
||||
"enabled": true,
|
||||
"confirmed": true,
|
||||
"filters": {
|
||||
"stores": [{"id": "...", "name": "...", "mode": "include"}],
|
||||
"groups": [{"id": "...", "name": "...", "mode": "include", "parent_store_id": "..."}],
|
||||
"products": [{"id": "...", "name": "...", "mode": "exclude", "parent_group_id": "..."}]
|
||||
}
|
||||
}
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from web.database import SessionLocal
|
||||
from web.models import SyncConfig, SyncFilter
|
||||
|
||||
|
||||
def read_config(user_id: int) -> dict:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
config = db.query(SyncConfig).filter(SyncConfig.user_id == user_id).first()
|
||||
if not config:
|
||||
return {"enabled": False, "confirmed": False, "filters": {"stores": [], "groups": [], "products": []}}
|
||||
|
||||
enabled = config.is_enabled
|
||||
confirmed = config.confirmed_at is not None
|
||||
|
||||
stores = []
|
||||
groups = []
|
||||
products = []
|
||||
|
||||
for f in config.filters:
|
||||
entry = {"id": f.entity_id, "name": f.entity_name, "mode": f.filter_mode}
|
||||
if f.entity_type == "store":
|
||||
stores.append(entry)
|
||||
elif f.entity_type == "group":
|
||||
stores.append({**entry, "parent_store_id": f.parent_entity_id})
|
||||
elif f.entity_type == "product":
|
||||
products.append({**entry, "parent_group_id": f.parent_entity_id})
|
||||
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"confirmed": confirmed,
|
||||
"filters": {"stores": stores, "groups": groups, "products": products},
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 2:
|
||||
print("Usage: read_config.py <user_id>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
user_id = int(sys.argv[1])
|
||||
print(json.dumps(read_config(user_id), ensure_ascii=False, indent=2))
|
||||
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()
|
||||
57
scripts/evotor-get-token.sh
Normal file
57
scripts/evotor-get-token.sh
Normal file
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
# Obtain an Evotor developer access token via password grant (no browser required).
|
||||
# Uses dev.evotor.ru credentials (your Evotor developer account).
|
||||
#
|
||||
# Usage: ./scripts/evotor-get-token.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Load .env if present
|
||||
if [[ -f .env ]]; then
|
||||
set -a; source .env; set +a
|
||||
fi
|
||||
|
||||
EVOTOR_TOKEN_URL="https://dev.evotor.ru/oauth/token"
|
||||
|
||||
# Prompt for credentials
|
||||
read -rp "Evotor developer login (email): " EVOTOR_LOGIN
|
||||
read -rsp "Evotor developer password: " EVOTOR_PASSWORD
|
||||
echo
|
||||
|
||||
echo
|
||||
echo "A 2FA code will be sent to your email if this IP is not recognized."
|
||||
read -rp "2FA code (leave blank if not required): " EVOTOR_2FA
|
||||
|
||||
# Build request body
|
||||
BODY="type=LOGIN&grant_type=password&client_id=Evo-UI&username=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$EVOTOR_LOGIN")&password=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$EVOTOR_PASSWORD")"
|
||||
|
||||
EXTRA_HEADERS=()
|
||||
if [[ -n "$EVOTOR_2FA" ]]; then
|
||||
EXTRA_HEADERS+=(-H "2fa_confirmation: $EVOTOR_2FA")
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Requesting token..."
|
||||
RESPONSE=$(curl -s -X POST "$EVOTOR_TOKEN_URL" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
"${EXTRA_HEADERS[@]}" \
|
||||
-d "$BODY")
|
||||
|
||||
echo
|
||||
echo "Response:"
|
||||
echo "$RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$RESPONSE"
|
||||
|
||||
ACCESS_TOKEN=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('access_token',''))" 2>/dev/null || true)
|
||||
|
||||
if [[ -z "$ACCESS_TOKEN" ]]; then
|
||||
echo
|
||||
echo "ERROR: No access_token in response." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Access token:"
|
||||
echo "$ACCESS_TOKEN"
|
||||
echo
|
||||
echo "To save this token to .env, add or update:"
|
||||
echo " EVOTOR_ACCESS_TOKEN=$ACCESS_TOKEN"
|
||||
60
scripts/init-letsencrypt.sh
Executable file
60
scripts/init-letsencrypt.sh
Executable file
@@ -0,0 +1,60 @@
|
||||
#!/bin/bash
|
||||
# Obtain a TLS certificate from Let's Encrypt for one domain.
|
||||
#
|
||||
# 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
|
||||
|
||||
# ── resolve domain ────────────────────────────────────────────────────────────
|
||||
if [ -n "${1:-}" ]; then
|
||||
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"
|
||||
|
||||
echo "==> Obtaining certificate for: $DOMAIN (www.$DOMAIN)"
|
||||
echo " Email: $EMAIL"
|
||||
|
||||
echo "==> Ensuring acme-challenge directory exists..."
|
||||
sudo mkdir -p "$ACME_DIR"
|
||||
sudo chmod 755 "$ACME_DIR"
|
||||
|
||||
echo "==> Requesting certificate from Let's Encrypt..."
|
||||
sudo certbot certonly \
|
||||
--webroot \
|
||||
--webroot-path="$ACME_DIR" \
|
||||
--email "$EMAIL" \
|
||||
--agree-tos \
|
||||
--no-eff-email \
|
||||
-d "$DOMAIN" \
|
||||
-d "www.$DOMAIN"
|
||||
|
||||
echo ""
|
||||
echo "==> Certificate obtained for $DOMAIN"
|
||||
echo " /etc/letsencrypt/live/$DOMAIN/fullchain.pem"
|
||||
echo " /etc/letsencrypt/live/$DOMAIN/privkey.pem"
|
||||
echo ""
|
||||
echo "==> Generate nginx config and reload:"
|
||||
echo " sudo ./scripts/generate-nginx-conf.sh $DOMAIN"
|
||||
echo " sudo nginx -t && sudo systemctl reload nginx"
|
||||
echo ""
|
||||
echo "==> Auto-renewal (add to /etc/cron.d/certbot if not already present):"
|
||||
echo " 0 3 * * * root certbot renew --quiet && systemctl reload nginx"
|
||||
51
scripts/release.sh
Executable file
51
scripts/release.sh
Executable file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Release script: bump version, generate changelog, commit, and tag.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/release.sh <major|minor|patch>
|
||||
# ./scripts/release.sh 2.0.0 # explicit version
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
VERSION_FILE="version"
|
||||
|
||||
current_version=$(cat "$VERSION_FILE")
|
||||
echo "Current version: $current_version"
|
||||
|
||||
IFS='.' read -r major minor patch <<< "$current_version"
|
||||
|
||||
case "${1:-}" in
|
||||
major) new_version="$((major + 1)).0.0" ;;
|
||||
minor) new_version="${major}.$((minor + 1)).0" ;;
|
||||
patch) new_version="${major}.${minor}.$((patch + 1))" ;;
|
||||
"")
|
||||
echo "Usage: $0 <major|minor|patch|VERSION>"
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
# Validate explicit semver
|
||||
if [[ ! "$1" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Error: '$1' is not a valid semver (X.Y.Z)"
|
||||
exit 1
|
||||
fi
|
||||
new_version="$1"
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Bumping to: $new_version"
|
||||
|
||||
# Update version file
|
||||
echo "$new_version" > "$VERSION_FILE"
|
||||
|
||||
# Generate changelog
|
||||
git-cliff --tag "v${new_version}" --output CHANGELOG.md
|
||||
|
||||
# Commit and tag
|
||||
git add "$VERSION_FILE" CHANGELOG.md
|
||||
git commit -m "chore(release): v${new_version}"
|
||||
git tag "v${new_version}"
|
||||
|
||||
echo ""
|
||||
echo "Released v${new_version}"
|
||||
echo "Don't forget to push: git push && git push --tags"
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
103
tests/conftest.py
Normal file
103
tests/conftest.py
Normal file
@@ -0,0 +1,103 @@
|
||||
import pytest
|
||||
import factory
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
import web.models # noqa: F401 — ensure all tables are registered on Base.metadata
|
||||
from web.auth.password import hash_password
|
||||
from web.database import Base, get_db
|
||||
from web.main import app
|
||||
from web.models.user import User, UserRoleEnum, UserStatusEnum
|
||||
|
||||
|
||||
# ── Database fixtures ─────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def engine():
|
||||
eng = create_engine(
|
||||
"sqlite:///:memory:",
|
||||
echo=False,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
Base.metadata.create_all(eng)
|
||||
yield eng
|
||||
Base.metadata.drop_all(eng)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_session(engine):
|
||||
connection = engine.connect()
|
||||
transaction = connection.begin()
|
||||
Session = sessionmaker(bind=connection)
|
||||
session = Session()
|
||||
yield session
|
||||
session.close()
|
||||
transaction.rollback()
|
||||
connection.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def override_db(db_session):
|
||||
"""Override FastAPI's get_db dependency with the transactional test session."""
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
yield db_session
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
# ── HTTP client ───────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
async def client(override_db):
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
) as c:
|
||||
yield c
|
||||
|
||||
|
||||
# ── Factories ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class UserFactory(factory.alchemy.SQLAlchemyModelFactory):
|
||||
class Meta:
|
||||
model = User
|
||||
sqlalchemy_session_persistence = "commit"
|
||||
|
||||
first_name = factory.Faker("first_name")
|
||||
last_name = factory.Faker("last_name")
|
||||
email = factory.Sequence(lambda n: f"user{n}@test.com")
|
||||
phone = factory.Sequence(lambda n: f"+7900{n:07d}")
|
||||
password_hash = factory.LazyFunction(lambda: hash_password("testpass123"))
|
||||
status = UserStatusEnum.active
|
||||
is_email_confirmed = True
|
||||
role = UserRoleEnum.user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_factory(db_session):
|
||||
UserFactory._meta.sqlalchemy_session = db_session
|
||||
return UserFactory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def active_user(user_factory):
|
||||
return user_factory.create()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_user(user_factory):
|
||||
return user_factory.create(role=UserRoleEnum.admin)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def system_user(user_factory):
|
||||
return user_factory.create(role=UserRoleEnum.system)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pending_user(user_factory):
|
||||
return user_factory.create(
|
||||
status=UserStatusEnum.pending,
|
||||
is_email_confirmed=False,
|
||||
password_hash=None,
|
||||
)
|
||||
26
tests/test_auth_password.py
Normal file
26
tests/test_auth_password.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from web.auth.password import hash_password, verify_password
|
||||
|
||||
|
||||
def test_hash_is_not_plaintext():
|
||||
h = hash_password("secret123")
|
||||
assert h != "secret123"
|
||||
assert len(h) > 20
|
||||
|
||||
|
||||
def test_verify_correct_password():
|
||||
h = hash_password("mysecret")
|
||||
assert verify_password("mysecret", h) is True
|
||||
|
||||
|
||||
def test_verify_wrong_password():
|
||||
h = hash_password("mysecret")
|
||||
assert verify_password("wrongpassword", h) is False
|
||||
|
||||
|
||||
def test_two_hashes_differ():
|
||||
# bcrypt uses random salt — same plaintext produces different hashes
|
||||
h1 = hash_password("same")
|
||||
h2 = hash_password("same")
|
||||
assert h1 != h2
|
||||
assert verify_password("same", h1)
|
||||
assert verify_password("same", h2)
|
||||
12
tests/test_health.py
Normal file
12
tests/test_health.py
Normal file
@@ -0,0 +1,12 @@
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from web.main import app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_returns_ok():
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||
resp = await client.get("/health")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"status": "ok"}
|
||||
47
tests/test_notifications.py
Normal file
47
tests/test_notifications.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from web.notifications.console import ConsoleEmailProvider, ConsoleSMSProvider
|
||||
from web.notifications.registry import get_email_provider, get_sms_provider
|
||||
|
||||
|
||||
def test_console_email_logs(caplog):
|
||||
provider = ConsoleEmailProvider()
|
||||
with caplog.at_level(logging.INFO, logger="web.notifications.console"):
|
||||
provider.send("user@example.com", "Тест", '<a href="http://example.com/link">click</a>')
|
||||
assert "user@example.com" in caplog.text
|
||||
assert "Тест" in caplog.text
|
||||
assert "http://example.com/link" in caplog.text
|
||||
|
||||
|
||||
def test_console_sms_logs(caplog):
|
||||
provider = ConsoleSMSProvider()
|
||||
with caplog.at_level(logging.INFO, logger="web.notifications.console"):
|
||||
provider.send("+79001234567", "Ваш код: 1234")
|
||||
assert "+79001234567" in caplog.text
|
||||
assert "Ваш код: 1234" in caplog.text
|
||||
|
||||
|
||||
def test_registry_returns_console_email(monkeypatch):
|
||||
monkeypatch.setattr("web.notifications.registry.settings.EMAIL_PROVIDER", "console")
|
||||
provider = get_email_provider()
|
||||
assert isinstance(provider, ConsoleEmailProvider)
|
||||
|
||||
|
||||
def test_registry_returns_console_sms(monkeypatch):
|
||||
monkeypatch.setattr("web.notifications.registry.settings.SMS_PROVIDER", "console")
|
||||
provider = get_sms_provider()
|
||||
assert isinstance(provider, ConsoleSMSProvider)
|
||||
|
||||
|
||||
def test_registry_unknown_email_provider_raises(monkeypatch):
|
||||
monkeypatch.setattr("web.notifications.registry.settings.EMAIL_PROVIDER", "sendgrid")
|
||||
with pytest.raises(ValueError, match="sendgrid"):
|
||||
get_email_provider()
|
||||
|
||||
|
||||
def test_registry_unknown_sms_provider_raises(monkeypatch):
|
||||
monkeypatch.setattr("web.notifications.registry.settings.SMS_PROVIDER", "twilio")
|
||||
with pytest.raises(ValueError, match="twilio"):
|
||||
get_sms_provider()
|
||||
181
tests/test_routes_admin.py
Normal file
181
tests/test_routes_admin.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""Integration tests for admin panel routes."""
|
||||
import pytest
|
||||
|
||||
from web.models.user import User, UserRoleEnum, UserStatusEnum
|
||||
|
||||
|
||||
def _set_session(client, user_id: int):
|
||||
"""Inject a session cookie so the client appears logged in as user_id."""
|
||||
client.cookies.set("session", "") # will be overwritten by actual login
|
||||
# We inject directly into the app's session via a helper request
|
||||
# The simplest approach: use the login endpoint to set the real session cookie
|
||||
return user_id
|
||||
|
||||
|
||||
async def _login(client, user):
|
||||
"""Log in as user via the /login endpoint to get a real session cookie."""
|
||||
resp = await client.post("/login", data={
|
||||
"email": user.email,
|
||||
"password": "testpass123",
|
||||
}, follow_redirects=False)
|
||||
assert resp.status_code == 303, f"Login failed: {resp.text}"
|
||||
|
||||
|
||||
# ── Access control ────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_users_requires_auth(client):
|
||||
resp = await client.get("/admin/users", follow_redirects=False)
|
||||
# Unauthenticated → redirect to login
|
||||
assert resp.status_code in (302, 303, 307)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_users_requires_admin_role(client, active_user):
|
||||
await _login(client, active_user)
|
||||
resp = await client.get("/admin/users", follow_redirects=False)
|
||||
# Regular user → redirect (not admin)
|
||||
assert resp.status_code in (302, 303, 307)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_users_accessible_by_admin(client, admin_user):
|
||||
await _login(client, admin_user)
|
||||
resp = await client.get("/admin/users")
|
||||
assert resp.status_code == 200
|
||||
assert "Пользователи" in resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_users_accessible_by_system(client, system_user):
|
||||
await _login(client, system_user)
|
||||
resp = await client.get("/admin/users")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ── User list + filters ───────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_users_shows_all_users(client, admin_user, user_factory):
|
||||
extra = user_factory.create(email="findme@test.com")
|
||||
await _login(client, admin_user)
|
||||
resp = await client.get("/admin/users")
|
||||
assert resp.status_code == 200
|
||||
assert extra.email in resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_users_search_filter(client, admin_user, user_factory):
|
||||
target = user_factory.create(email="searchable@test.com")
|
||||
await _login(client, admin_user)
|
||||
resp = await client.get("/admin/users?search=searchable")
|
||||
assert resp.status_code == 200
|
||||
assert target.email in resp.text
|
||||
|
||||
|
||||
# ── User detail ───────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_user_detail(client, admin_user, active_user):
|
||||
await _login(client, admin_user)
|
||||
resp = await client.get(f"/admin/users/{active_user.id}")
|
||||
assert resp.status_code == 200
|
||||
assert active_user.email in resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_user_detail_not_found(client, admin_user):
|
||||
await _login(client, admin_user)
|
||||
resp = await client.get("/admin/users/99999", follow_redirects=False)
|
||||
assert resp.status_code in (302, 303, 307)
|
||||
|
||||
|
||||
# ── Activate / suspend ────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_activate_user(client, admin_user, user_factory, override_db):
|
||||
target = user_factory.create(status=UserStatusEnum.suspended)
|
||||
await _login(client, admin_user)
|
||||
resp = await client.post(f"/admin/users/{target.id}/activate", follow_redirects=False)
|
||||
assert resp.status_code == 303
|
||||
override_db.refresh(target)
|
||||
assert target.status == UserStatusEnum.active
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_suspend_user(client, admin_user, active_user, override_db):
|
||||
await _login(client, admin_user)
|
||||
resp = await client.post(f"/admin/users/{active_user.id}/suspend", follow_redirects=False)
|
||||
assert resp.status_code == 303
|
||||
override_db.refresh(active_user)
|
||||
assert active_user.status == UserStatusEnum.suspended
|
||||
|
||||
|
||||
# ── Delete (system only) ──────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_delete_user_by_system(client, system_user, user_factory, override_db):
|
||||
target = user_factory.create()
|
||||
target_id = target.id
|
||||
await _login(client, system_user)
|
||||
resp = await client.post(f"/admin/users/{target_id}/delete", follow_redirects=False)
|
||||
assert resp.status_code == 303
|
||||
assert override_db.get(User, target_id) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_delete_blocked_for_admin_role(client, admin_user, active_user, override_db):
|
||||
target_id = active_user.id
|
||||
await _login(client, admin_user)
|
||||
resp = await client.post(f"/admin/users/{target_id}/delete", follow_redirects=False)
|
||||
assert resp.status_code == 303
|
||||
# Admin cannot delete — user still exists
|
||||
assert override_db.get(User, target_id) is not None
|
||||
|
||||
|
||||
# ── Reset password / send invite ──────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_reset_password_generates_token(client, admin_user, active_user, override_db):
|
||||
from unittest.mock import patch
|
||||
with patch("web.routes.admin.send_email_task") as mock_task:
|
||||
await _login(client, admin_user)
|
||||
resp = await client.post(
|
||||
f"/admin/users/{active_user.id}/reset-password", follow_redirects=False
|
||||
)
|
||||
assert resp.status_code == 303
|
||||
override_db.refresh(active_user)
|
||||
assert active_user.password_reset_token is not None
|
||||
mock_task.delay.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_send_invite(client, admin_user, active_user, override_db):
|
||||
from unittest.mock import patch
|
||||
with patch("web.routes.admin.send_email_task") as mock_task:
|
||||
await _login(client, admin_user)
|
||||
resp = await client.post(
|
||||
f"/admin/users/{active_user.id}/send-invite", follow_redirects=False
|
||||
)
|
||||
assert resp.status_code == 303
|
||||
override_db.refresh(active_user)
|
||||
assert active_user.invite_token is not None
|
||||
mock_task.delay.assert_called_once()
|
||||
|
||||
|
||||
# ── Roles page (system only) ──────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_roles_accessible_by_system(client, system_user):
|
||||
await _login(client, system_user)
|
||||
resp = await client.get("/admin/roles")
|
||||
assert resp.status_code == 200
|
||||
assert "Роли" in resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_roles_blocked_for_admin(client, admin_user):
|
||||
await _login(client, admin_user)
|
||||
resp = await client.get("/admin/roles", follow_redirects=False)
|
||||
# Admin is redirected away from roles page
|
||||
assert resp.status_code in (302, 303, 307)
|
||||
182
tests/test_routes_auth.py
Normal file
182
tests/test_routes_auth.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""Integration tests for auth routes (register / login / confirm-email / logout)."""
|
||||
import secrets
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from web.models.user import User, UserStatusEnum
|
||||
|
||||
|
||||
# ── /register ────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_get(client):
|
||||
resp = await client.get("/register")
|
||||
assert resp.status_code == 200
|
||||
assert "Регистрация" in resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("web.routes.auth.send_email_task")
|
||||
async def test_register_creates_pending_user(mock_task, client, override_db):
|
||||
resp = await client.post("/register", data={
|
||||
"first_name": "Иван",
|
||||
"last_name": "Иванов",
|
||||
"email": "ivan@test.com",
|
||||
"phone": "+79001234567",
|
||||
"password": "password123",
|
||||
"password_confirm": "password123",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert "Подтвердите" in resp.text
|
||||
|
||||
user = override_db.query(User).filter(User.email == "ivan@test.com").first()
|
||||
assert user is not None
|
||||
assert user.status == UserStatusEnum.pending
|
||||
assert user.is_email_confirmed is False
|
||||
assert user.email_confirm_token is not None
|
||||
mock_task.delay.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("web.routes.auth.send_email_task")
|
||||
async def test_register_duplicate_email(mock_task, client, active_user):
|
||||
resp = await client.post("/register", data={
|
||||
"first_name": "X",
|
||||
"last_name": "Y",
|
||||
"email": active_user.email,
|
||||
"phone": "+79999999999",
|
||||
"password": "password123",
|
||||
"password_confirm": "password123",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert "уже существует" in resp.text
|
||||
mock_task.delay.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_password_mismatch(client):
|
||||
resp = await client.post("/register", data={
|
||||
"email": "new@test.com",
|
||||
"phone": "+79000000001",
|
||||
"password": "password123",
|
||||
"password_confirm": "different",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert "не совпадают" in resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_short_password(client):
|
||||
resp = await client.post("/register", data={
|
||||
"email": "new@test.com",
|
||||
"phone": "+79000000002",
|
||||
"password": "short",
|
||||
"password_confirm": "short",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert "минимум 8" in resp.text
|
||||
|
||||
|
||||
# ── /confirm-email ────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_email_valid_token(client, override_db, user_factory):
|
||||
token = secrets.token_urlsafe(32)
|
||||
user = user_factory.create(
|
||||
is_email_confirmed=False,
|
||||
email_confirm_token=token,
|
||||
status=UserStatusEnum.pending,
|
||||
)
|
||||
resp = await client.get(f"/confirm-email?token={token}")
|
||||
assert resp.status_code == 200
|
||||
assert "подтвержден" in resp.text.lower()
|
||||
|
||||
override_db.refresh(user)
|
||||
assert user.is_email_confirmed is True
|
||||
assert user.status == UserStatusEnum.active
|
||||
assert user.email_confirm_token is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_email_invalid_token(client):
|
||||
resp = await client.get("/confirm-email?token=bogustoken")
|
||||
assert resp.status_code == 200
|
||||
assert "Ошибка" in resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirm_email_missing_token(client):
|
||||
resp = await client.get("/confirm-email")
|
||||
assert resp.status_code == 200
|
||||
assert "Ошибка" in resp.text
|
||||
|
||||
|
||||
# ── /login ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_get(client):
|
||||
resp = await client.get("/login")
|
||||
assert resp.status_code == 200
|
||||
assert "Вход" in resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_success(client, active_user):
|
||||
resp = await client.post("/login", data={
|
||||
"email": active_user.email,
|
||||
"password": "testpass123",
|
||||
}, follow_redirects=False)
|
||||
assert resp.status_code == 303
|
||||
assert resp.headers["location"] == "/profile"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_wrong_password(client, active_user):
|
||||
resp = await client.post("/login", data={
|
||||
"email": active_user.email,
|
||||
"password": "wrongpassword",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert "Неверный" in resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_unknown_email(client):
|
||||
resp = await client.post("/login", data={
|
||||
"email": "nobody@test.com",
|
||||
"password": "testpass123",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert "Неверный" in resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_suspended_user(client, user_factory):
|
||||
user = user_factory.create(status=UserStatusEnum.suspended)
|
||||
resp = await client.post("/login", data={
|
||||
"email": user.email,
|
||||
"password": "testpass123",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert "заблокирован" in resp.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_unconfirmed_email(client, user_factory):
|
||||
user = user_factory.create(is_email_confirmed=False, status=UserStatusEnum.pending)
|
||||
resp = await client.post("/login", data={
|
||||
"email": user.email,
|
||||
"password": "testpass123",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert "подтвердите" in resp.text.lower()
|
||||
|
||||
|
||||
# ── /logout ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_logout_redirects(client):
|
||||
resp = await client.get("/logout", follow_redirects=False)
|
||||
assert resp.status_code == 303
|
||||
assert resp.headers["location"] == "/login"
|
||||
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"]
|
||||
196
tests/test_routes_evotor_webhooks.py
Normal file
196
tests/test_routes_evotor_webhooks.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""Integration tests for Evotor webhook endpoints."""
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from web.models.connections import EvotorConnection
|
||||
from web.models.user import User, UserStatusEnum
|
||||
|
||||
|
||||
WEBHOOK_SECRET = "test-secret-abc"
|
||||
|
||||
|
||||
def auth_headers(secret=WEBHOOK_SECRET):
|
||||
return {"Authorization": f"Bearer {secret}"}
|
||||
|
||||
|
||||
# ── /user/create ──────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("web.routes.evotor_webhooks.send_email_task")
|
||||
async def test_user_create_new_user(mock_task, client, override_db, monkeypatch):
|
||||
monkeypatch.setattr("web.routes.evotor_webhooks.settings.EVOTOR_WEBHOOK_SECRET", WEBHOOK_SECRET)
|
||||
monkeypatch.setattr("web.routes.evotor_webhooks.settings.INVITE_EXPIRE_HOURS", 48)
|
||||
monkeypatch.setattr("web.routes.evotor_webhooks.settings.BASE_URL", "http://test")
|
||||
|
||||
payload = {
|
||||
"userId": "evo-001",
|
||||
"customField": json.dumps({"email": "newuser@test.com", "phone": "+79001234501"}),
|
||||
}
|
||||
resp = await client.post("/user/create", json=payload, headers=auth_headers())
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["userId"] == "evo-001"
|
||||
assert "token" in data
|
||||
assert len(data["token"]) > 10
|
||||
|
||||
user = override_db.query(User).filter(User.evotor_user_id == "evo-001").first()
|
||||
assert user is not None
|
||||
assert user.status == UserStatusEnum.pending
|
||||
assert user.invite_token is not None
|
||||
assert user.password_hash is None
|
||||
|
||||
conn = override_db.query(EvotorConnection).filter(
|
||||
EvotorConnection.evotor_user_id == "evo-001"
|
||||
).first()
|
||||
assert conn is not None
|
||||
assert conn.api_token == data["token"]
|
||||
|
||||
mock_task.delay.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("web.routes.evotor_webhooks.send_email_task")
|
||||
async def test_user_create_links_existing_user_by_email(mock_task, client, override_db, user_factory, monkeypatch):
|
||||
monkeypatch.setattr("web.routes.evotor_webhooks.settings.EVOTOR_WEBHOOK_SECRET", WEBHOOK_SECRET)
|
||||
monkeypatch.setattr("web.routes.evotor_webhooks.settings.INVITE_EXPIRE_HOURS", 48)
|
||||
monkeypatch.setattr("web.routes.evotor_webhooks.settings.BASE_URL", "http://test")
|
||||
|
||||
existing = user_factory.create(email="existing@test.com")
|
||||
assert existing.evotor_user_id is None
|
||||
|
||||
payload = {
|
||||
"userId": "evo-link-001",
|
||||
"customField": json.dumps({"email": "existing@test.com"}),
|
||||
}
|
||||
resp = await client.post("/user/create", json=payload, headers=auth_headers())
|
||||
assert resp.status_code == 200
|
||||
|
||||
override_db.refresh(existing)
|
||||
assert existing.evotor_user_id == "evo-link-001"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_create_wrong_secret(client, monkeypatch):
|
||||
monkeypatch.setattr("web.routes.evotor_webhooks.settings.EVOTOR_WEBHOOK_SECRET", WEBHOOK_SECRET)
|
||||
resp = await client.post("/user/create", json={"userId": "x"}, headers={"Authorization": "Bearer wrong"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_create_missing_user_id(client, monkeypatch):
|
||||
monkeypatch.setattr("web.routes.evotor_webhooks.settings.EVOTOR_WEBHOOK_SECRET", "")
|
||||
resp = await client.post("/user/create", json={"customField": "{}"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("web.routes.evotor_webhooks.send_email_task")
|
||||
async def test_user_create_no_secret_dev_mode(mock_task, client, override_db, monkeypatch):
|
||||
monkeypatch.setattr("web.routes.evotor_webhooks.settings.EVOTOR_WEBHOOK_SECRET", "")
|
||||
monkeypatch.setattr("web.routes.evotor_webhooks.settings.INVITE_EXPIRE_HOURS", 48)
|
||||
monkeypatch.setattr("web.routes.evotor_webhooks.settings.BASE_URL", "http://test")
|
||||
|
||||
resp = await client.post("/user/create", json={"userId": "evo-dev-001"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["userId"] == "evo-dev-001"
|
||||
|
||||
|
||||
# ── /user/verify ──────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_verify_success(client, override_db, user_factory, monkeypatch):
|
||||
monkeypatch.setattr("web.routes.evotor_webhooks.settings.EVOTOR_WEBHOOK_SECRET", WEBHOOK_SECRET)
|
||||
user = user_factory.create(evotor_user_id="evo-verify-001")
|
||||
conn = EvotorConnection(
|
||||
user_id=user.id,
|
||||
evotor_user_id="evo-verify-001",
|
||||
access_token="evotor-access-token",
|
||||
api_token="my-api-token-xyz",
|
||||
)
|
||||
override_db.add(conn)
|
||||
override_db.commit()
|
||||
|
||||
resp = await client.post("/user/verify", json={
|
||||
"userId": "evo-verify-001",
|
||||
"username": user.email,
|
||||
"password": "testpass123",
|
||||
}, headers=auth_headers())
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["userId"] == "evo-verify-001"
|
||||
assert "token" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_verify_wrong_password(client, user_factory, monkeypatch):
|
||||
monkeypatch.setattr("web.routes.evotor_webhooks.settings.EVOTOR_WEBHOOK_SECRET", WEBHOOK_SECRET)
|
||||
user = user_factory.create()
|
||||
resp = await client.post("/user/verify", json={
|
||||
"userId": "x",
|
||||
"username": user.email,
|
||||
"password": "wrongpass",
|
||||
}, headers=auth_headers())
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_verify_suspended(client, user_factory, monkeypatch):
|
||||
monkeypatch.setattr("web.routes.evotor_webhooks.settings.EVOTOR_WEBHOOK_SECRET", WEBHOOK_SECRET)
|
||||
user = user_factory.create(status=UserStatusEnum.suspended)
|
||||
resp = await client.post("/user/verify", json={
|
||||
"userId": "x",
|
||||
"username": user.email,
|
||||
"password": "testpass123",
|
||||
}, headers=auth_headers())
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_verify_no_password_hash(client, user_factory, monkeypatch):
|
||||
monkeypatch.setattr("web.routes.evotor_webhooks.settings.EVOTOR_WEBHOOK_SECRET", WEBHOOK_SECRET)
|
||||
user = user_factory.create(password_hash=None)
|
||||
resp = await client.post("/user/verify", json={
|
||||
"userId": "x",
|
||||
"username": user.email,
|
||||
"password": "anything",
|
||||
}, headers=auth_headers())
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ── /user/token ───────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_token_updates_connection(client, override_db, user_factory, monkeypatch):
|
||||
monkeypatch.setattr("web.routes.evotor_webhooks.settings.EVOTOR_WEBHOOK_SECRET", WEBHOOK_SECRET)
|
||||
user = user_factory.create(evotor_user_id="evo-token-001")
|
||||
old_conn = EvotorConnection(
|
||||
user_id=user.id,
|
||||
evotor_user_id="evo-token-001",
|
||||
access_token="old-token",
|
||||
api_token="api-tok",
|
||||
)
|
||||
override_db.add(old_conn)
|
||||
override_db.commit()
|
||||
|
||||
resp = await client.post("/user/token", json={
|
||||
"userId": "evo-token-001",
|
||||
"token": "new-evotor-token-xyz",
|
||||
}, headers=auth_headers())
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {}
|
||||
|
||||
override_db.refresh(old_conn)
|
||||
assert old_conn.access_token == "new-evotor-token-xyz"
|
||||
assert old_conn.is_online is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_token_unknown_user(client, monkeypatch):
|
||||
monkeypatch.setattr("web.routes.evotor_webhooks.settings.EVOTOR_WEBHOOK_SECRET", WEBHOOK_SECRET)
|
||||
resp = await client.post("/user/token", json={
|
||||
"userId": "does-not-exist",
|
||||
"token": "some-token",
|
||||
}, headers=auth_headers())
|
||||
assert resp.status_code == 404
|
||||
95
tests/test_routes_invite.py
Normal file
95
tests/test_routes_invite.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Integration tests for the Evotor invite flow (/invite)."""
|
||||
import secrets
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from web.models.user import User, UserStatusEnum
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invite_get_valid_token(client, override_db, user_factory):
|
||||
token = secrets.token_urlsafe(32)
|
||||
user = user_factory.create(
|
||||
invite_token=token,
|
||||
invite_expires=datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(hours=48),
|
||||
password_hash=None,
|
||||
status=UserStatusEnum.pending,
|
||||
is_email_confirmed=False,
|
||||
)
|
||||
resp = await client.get(f"/invite?token={token}")
|
||||
assert resp.status_code == 200
|
||||
assert "Завершение регистрации" in resp.text or "ЭВОСИНК" in resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invite_get_expired_token(client, user_factory):
|
||||
token = secrets.token_urlsafe(32)
|
||||
user_factory.create(
|
||||
invite_token=token,
|
||||
invite_expires=datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(hours=1),
|
||||
password_hash=None,
|
||||
status=UserStatusEnum.pending,
|
||||
)
|
||||
resp = await client.get(f"/invite?token={token}")
|
||||
assert resp.status_code == 200
|
||||
assert "недействительна" in resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invite_get_bogus_token(client):
|
||||
resp = await client.get("/invite?token=notexist")
|
||||
assert resp.status_code == 200
|
||||
assert "недействительна" in resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invite_post_activates_user(client, override_db, user_factory):
|
||||
token = secrets.token_urlsafe(32)
|
||||
user = user_factory.create(
|
||||
email="invite@test.com",
|
||||
phone="+79001119999",
|
||||
invite_token=token,
|
||||
invite_expires=datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(hours=48),
|
||||
password_hash=None,
|
||||
status=UserStatusEnum.pending,
|
||||
is_email_confirmed=False,
|
||||
)
|
||||
resp = await client.post(f"/invite?token={token}", data={
|
||||
"first_name": "Петр",
|
||||
"last_name": "Петров",
|
||||
"email": "invite@test.com",
|
||||
"phone": "+79001119999",
|
||||
"password": "newpassword1",
|
||||
"password_confirm": "newpassword1",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert "активирован" in resp.text.lower()
|
||||
|
||||
override_db.refresh(user)
|
||||
assert user.status == UserStatusEnum.active
|
||||
assert user.is_email_confirmed is True
|
||||
assert user.password_hash is not None
|
||||
assert user.invite_token is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invite_post_password_mismatch(client, user_factory):
|
||||
token = secrets.token_urlsafe(32)
|
||||
user_factory.create(
|
||||
invite_token=token,
|
||||
invite_expires=datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(hours=48),
|
||||
password_hash=None,
|
||||
status=UserStatusEnum.pending,
|
||||
)
|
||||
resp = await client.post(f"/invite?token={token}", data={
|
||||
"first_name": "А",
|
||||
"last_name": "Б",
|
||||
"email": "x@test.com",
|
||||
"phone": "+79001112233",
|
||||
"password": "password123",
|
||||
"password_confirm": "different",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert "не совпадают" in resp.text
|
||||
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
|
||||
40
tests/test_webhook_parsing.py
Normal file
40
tests/test_webhook_parsing.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Unit tests for _parse_custom_fields — no DB or HTTP needed."""
|
||||
import json
|
||||
|
||||
from web.routes.evotor_webhooks import _parse_custom_fields
|
||||
|
||||
|
||||
def test_none_returns_empty():
|
||||
assert _parse_custom_fields(None) == {}
|
||||
|
||||
|
||||
def test_dict_passthrough():
|
||||
d = {"email": "a@b.com", "phone": "+79001234567"}
|
||||
assert _parse_custom_fields(d) == d
|
||||
|
||||
|
||||
def test_json_string_parsed():
|
||||
raw = json.dumps({"email": "a@b.com", "firstName": "Иван"})
|
||||
result = _parse_custom_fields(raw)
|
||||
assert result["email"] == "a@b.com"
|
||||
assert result["firstName"] == "Иван"
|
||||
|
||||
|
||||
def test_plain_string_returns_empty():
|
||||
# A plain non-JSON string cannot be parsed into fields
|
||||
assert _parse_custom_fields("just some text") == {}
|
||||
|
||||
|
||||
def test_json_array_returns_empty():
|
||||
# A JSON array is not a dict — treat as unparseable
|
||||
assert _parse_custom_fields("[1, 2, 3]") == {}
|
||||
|
||||
|
||||
def test_empty_string_returns_empty():
|
||||
assert _parse_custom_fields("") == {}
|
||||
|
||||
|
||||
def test_nested_values_preserved():
|
||||
raw = {"email": "x@y.com", "meta": {"key": "val"}}
|
||||
result = _parse_custom_fields(raw)
|
||||
assert result["meta"]["key"] == "val"
|
||||
23
web/auth.py
23
web/auth.py
@@ -1,23 +0,0 @@
|
||||
from fastapi import Request, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from web.database import get_db
|
||||
from web.models import User
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
|
||||
def get_current_user(request: Request, db: Session = Depends(get_db)) -> User | None:
|
||||
user_id = request.session.get("user_id")
|
||||
if not user_id:
|
||||
return None
|
||||
return db.query(User).filter(User.id == user_id).first()
|
||||
0
web/auth/__init__.py
Normal file
0
web/auth/__init__.py
Normal file
12
web/auth/password.py
Normal file
12
web/auth/password.py
Normal file
@@ -0,0 +1,12 @@
|
||||
import bcrypt
|
||||
|
||||
|
||||
def hash_password(plain: str) -> str:
|
||||
return bcrypt.hashpw(plain.encode(), bcrypt.gensalt(rounds=12)).decode()
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
try:
|
||||
return bcrypt.checkpw(plain.encode(), hashed.encode())
|
||||
except Exception:
|
||||
return False
|
||||
35
web/auth/rbac.py
Normal file
35
web/auth/rbac.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from fastapi import Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.requests import Request
|
||||
|
||||
from web.auth.session import get_current_user
|
||||
from web.database import get_db
|
||||
from web.models.rbac import Permission, UserRole, role_permissions
|
||||
from web.models.user import User, UserRoleEnum
|
||||
|
||||
|
||||
def require_role(*roles: str):
|
||||
def dep(request: Request, db: Session = Depends(get_db)) -> User:
|
||||
user = get_current_user(request, db)
|
||||
if user.role.value not in roles:
|
||||
raise HTTPException(status_code=403, detail="Недостаточно прав")
|
||||
return user
|
||||
return Depends(dep)
|
||||
|
||||
|
||||
def require_permission(permission_name: str):
|
||||
def dep(request: Request, db: Session = Depends(get_db)) -> User:
|
||||
user = get_current_user(request, db)
|
||||
if user.role == UserRoleEnum.system:
|
||||
return user
|
||||
has = (
|
||||
db.query(Permission)
|
||||
.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, Permission.name == permission_name)
|
||||
.first()
|
||||
)
|
||||
if not has:
|
||||
raise HTTPException(status_code=403, detail="Недостаточно прав")
|
||||
return user
|
||||
return Depends(dep)
|
||||
42
web/auth/session.py
Normal file
42
web/auth/session.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.requests import Request
|
||||
|
||||
from web.models.user import User, UserRoleEnum, UserStatusEnum
|
||||
|
||||
|
||||
def get_session_user_id(request: Request) -> int | None:
|
||||
return request.session.get("user_id")
|
||||
|
||||
|
||||
def get_current_user(request: Request, db: Session) -> User:
|
||||
user_id = get_session_user_id(request)
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=307, headers={"Location": "/login"})
|
||||
user = db.get(User, user_id)
|
||||
if not user or user.status == UserStatusEnum.suspended:
|
||||
request.session.clear()
|
||||
raise HTTPException(status_code=307, headers={"Location": "/login"})
|
||||
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:
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
@@ -2,12 +2,40 @@ from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
DATABASE_URL: str = "mysql+pymysql://evosync:evosync@localhost:3306/evosync"
|
||||
DATABASE_URL: str = "mysql+pymysql://evosync:evosync@db:3306/evosync"
|
||||
REDIS_URL: str = "redis://redis:6379/0"
|
||||
SECRET_KEY: str = "change-me-in-production"
|
||||
BASE_URL: str = "http://localhost:8000"
|
||||
PASSWORD_RESET_EXPIRE_MINUTES: int = 60
|
||||
|
||||
model_config = {"env_file": ".env", "case_sensitive": False}
|
||||
EVOTOR_APP_ID: str = ""
|
||||
EVOTOR_WEBHOOK_SECRET: str = ""
|
||||
|
||||
VK_CLIENT_ID: str = ""
|
||||
VK_CLIENT_SECRET: str = ""
|
||||
|
||||
JIVOSITE_WIDGET_ID: str = ""
|
||||
VK_DEFAULT_PHOTO_PATH: str = "/app/default_product.png"
|
||||
VK_API_VERSION: str = "5.199"
|
||||
VK_CATEGORY_ID: int = 40932
|
||||
VK_STOCK_AMOUNT: int = 1000
|
||||
|
||||
CATALOG_REFRESH_INTERVAL_SECONDS: int = 3600
|
||||
INVITE_EXPIRE_HOURS: int = 48
|
||||
EMAIL_PROVIDER: str = "console"
|
||||
SMS_PROVIDER: str = "console"
|
||||
SYSTEM_USER_EMAIL: str = ""
|
||||
SYSTEM_USER_PASSWORD: str = ""
|
||||
|
||||
FLOWER_USER: str = "admin"
|
||||
FLOWER_PASSWORD: str = "changeme"
|
||||
|
||||
DB_ROOT_PASSWORD: str = ""
|
||||
DB_NAME: str = ""
|
||||
DB_USER: str = ""
|
||||
DB_PASSWORD: str = ""
|
||||
|
||||
model_config = {"env_file": ".env", "case_sensitive": False, "extra": "ignore"}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||
|
||||
from web.config import settings
|
||||
|
||||
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
|
||||
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
|
||||
engine = create_engine(
|
||||
settings.DATABASE_URL,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=3600,
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
|
||||
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
|
||||
104
web/main.py
104
web/main.py
@@ -1,22 +1,100 @@
|
||||
from fastapi import FastAPI
|
||||
import logging
|
||||
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from web.config import settings
|
||||
from web.database import engine, Base
|
||||
from web.models import User # noqa: F401 — registers model with Base
|
||||
from web.routes import auth, profile, reset
|
||||
try:
|
||||
from pythonjsonlogger import jsonlogger
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(jsonlogger.JsonFormatter("%(asctime)s %(levelname)s %(name)s %(message)s"))
|
||||
logging.root.addHandler(handler)
|
||||
except ImportError:
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logging.root.setLevel(logging.INFO)
|
||||
|
||||
app = FastAPI(title="EvoSync — Личный кабинет")
|
||||
from web.config import settings # noqa: E402 — after logging setup
|
||||
from web.templates_env import templates # noqa: E402
|
||||
|
||||
app = FastAPI(title="ЭвоСинк")
|
||||
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=settings.SECRET_KEY,
|
||||
max_age=86400 * 30,
|
||||
https_only=False,
|
||||
)
|
||||
|
||||
app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY)
|
||||
app.mount("/static", StaticFiles(directory="web/static"), name="static")
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(profile.router)
|
||||
app.include_router(reset.router)
|
||||
# ── Routers ───────────────────────────────────────────────────────────────────
|
||||
from web.routes.auth import router as auth_router # noqa: E402
|
||||
from web.routes.reset import router as reset_router # noqa: E402
|
||||
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.evotor_webhooks import router as evotor_webhooks_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(reset_router)
|
||||
app.include_router(invite_router)
|
||||
app.include_router(profile_router)
|
||||
app.include_router(evotor_webhooks_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)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
# ── Catalog redirect ─────────────────────────────────────────────────────────
|
||||
@app.get("/catalog")
|
||||
async def catalog_redirect():
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse("/catalog/stores", 302)
|
||||
|
||||
|
||||
# ── Health ────────────────────────────────────────────────────────────────────
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ── Root redirect ─────────────────────────────────────────────────────────────
|
||||
@app.get("/")
|
||||
async def root(request: Request, db=Depends(get_db)):
|
||||
from fastapi.responses import RedirectResponse
|
||||
from web.models.user import UserRoleEnum
|
||||
user_id = request.session.get("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("/login", 303)
|
||||
|
||||
|
||||
# ── 403 handler ───────────────────────────────────────────────────────────────
|
||||
from fastapi import HTTPException # noqa: E402
|
||||
from fastapi.exception_handlers import http_exception_handler # noqa: E402
|
||||
|
||||
|
||||
@app.exception_handler(403)
|
||||
async def forbidden_handler(request: Request, exc: HTTPException) -> HTMLResponse:
|
||||
return templates.TemplateResponse(request, "message.html", {
|
||||
"user": None,
|
||||
"title": "Нет доступа",
|
||||
"message": "У вас недостаточно прав для просмотра этой страницы.",
|
||||
"link": "/profile",
|
||||
"link_text": "В личный кабинет",
|
||||
"jivosite_widget_id": settings.JIVOSITE_WIDGET_ID,
|
||||
}, status_code=403)
|
||||
|
||||
50
web/migrations/env.py
Normal file
50
web/migrations/env.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from web.database import Base
|
||||
import web.models # noqa: F401 — ensure all models are imported before autogenerate
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
from web.config import settings
|
||||
|
||||
configuration = config.get_section(config.config_ini_section, {})
|
||||
configuration["sqlalchemy.url"] = settings.DATABASE_URL
|
||||
|
||||
connectable = engine_from_config(
|
||||
configuration,
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
25
web/migrations/script.py.mako
Normal file
25
web/migrations/script.py.mako
Normal file
@@ -0,0 +1,25 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
21
web/migrations/versions/0001_initial.py
Normal file
21
web/migrations/versions/0001_initial.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""initial
|
||||
|
||||
Revision ID: 0001
|
||||
Revises:
|
||||
Create Date: 2026-04-27
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
revision: str = "0001"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
238
web/migrations/versions/0002_users_and_connections.py
Normal file
238
web/migrations/versions/0002_users_and_connections.py
Normal file
@@ -0,0 +1,238 @@
|
||||
"""users and connections schema
|
||||
|
||||
Revision ID: 0002
|
||||
Revises: 0001
|
||||
Create Date: 2026-04-28
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0002"
|
||||
down_revision: Union[str, None] = "0001"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# ── users ────────────────────────────────────────────────────────────────
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
first_name VARCHAR(100) NOT NULL,
|
||||
last_name VARCHAR(100) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
phone VARCHAR(20) NOT NULL,
|
||||
password_hash VARCHAR(255) NULL,
|
||||
is_email_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
email_confirm_token VARCHAR(255) NULL,
|
||||
password_reset_token VARCHAR(255) NULL,
|
||||
password_reset_expires DATETIME NULL,
|
||||
role ENUM('system','admin','user') NOT NULL DEFAULT 'user',
|
||||
status ENUM('pending','active','suspended') NOT NULL DEFAULT 'pending',
|
||||
evotor_user_id VARCHAR(255) NULL,
|
||||
evotor_meta JSON NULL,
|
||||
invite_token VARCHAR(255) NULL,
|
||||
invite_expires DATETIME NULL,
|
||||
phone_otp VARCHAR(10) NULL,
|
||||
phone_otp_expires DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT ix_users_email UNIQUE (email),
|
||||
CONSTRAINT ix_users_phone UNIQUE (phone),
|
||||
CONSTRAINT ix_users_evotor_user_id UNIQUE (evotor_user_id)
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||
"""))
|
||||
|
||||
# Add new columns if migrating from the Node.js schema (which lacked them)
|
||||
for col_sql in [
|
||||
"ALTER TABLE users MODIFY COLUMN password_hash VARCHAR(255) NULL",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS role ENUM('system','admin','user') NOT NULL DEFAULT 'user'",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS status ENUM('pending','active','suspended') NOT NULL DEFAULT 'pending'",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS evotor_user_id VARCHAR(255) NULL",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS evotor_meta JSON NULL",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS invite_token VARCHAR(255) NULL",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS invite_expires DATETIME NULL",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS phone_otp VARCHAR(10) NULL",
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS phone_otp_expires DATETIME NULL",
|
||||
]:
|
||||
try:
|
||||
conn.execute(sa.text(col_sql))
|
||||
except Exception:
|
||||
pass # column/constraint already exists
|
||||
|
||||
# Add unique index on evotor_user_id if missing
|
||||
try:
|
||||
conn.execute(sa.text(
|
||||
"ALTER TABLE users ADD CONSTRAINT ix_users_evotor_user_id UNIQUE (evotor_user_id)"
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Add role/status indexes if missing
|
||||
for idx_sql in [
|
||||
"CREATE INDEX IF NOT EXISTS ix_users_role ON users (role)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_users_status ON users (status)",
|
||||
]:
|
||||
try:
|
||||
conn.execute(sa.text(idx_sql))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── evotor_connections ───────────────────────────────────────────────────
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS evotor_connections (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NULL,
|
||||
evotor_user_id VARCHAR(255) NULL,
|
||||
access_token TEXT NOT NULL,
|
||||
api_token VARCHAR(255) NULL,
|
||||
store_id VARCHAR(255) NULL,
|
||||
store_name VARCHAR(255) NULL,
|
||||
refresh_token TEXT NULL,
|
||||
token_expires_at DATETIME NULL,
|
||||
is_online BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
last_checked_at DATETIME NULL,
|
||||
connected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT ix_evotor_connections_user_id UNIQUE (user_id),
|
||||
CONSTRAINT ix_evotor_connections_evotor_user_id UNIQUE (evotor_user_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||
"""))
|
||||
|
||||
try:
|
||||
conn.execute(sa.text(
|
||||
"ALTER TABLE evotor_connections ADD COLUMN IF NOT EXISTS api_token VARCHAR(255) NULL"
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── vk_connections ───────────────────────────────────────────────────────
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS vk_connections (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
access_token TEXT NOT NULL,
|
||||
vk_user_id VARCHAR(50) NULL,
|
||||
first_name VARCHAR(255) NULL,
|
||||
last_name VARCHAR(255) NULL,
|
||||
is_online BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
last_checked_at DATETIME NULL,
|
||||
connected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT ix_vk_connections_user_id UNIQUE (user_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||
"""))
|
||||
|
||||
# ── sync_configs ─────────────────────────────────────────────────────────
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS sync_configs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
is_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
confirmed_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT ix_sync_configs_user_id UNIQUE (user_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||
"""))
|
||||
|
||||
# ── sync_filters ─────────────────────────────────────────────────────────
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS sync_filters (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
sync_config_id INT NOT NULL,
|
||||
entity_type VARCHAR(20) NOT NULL,
|
||||
entity_id VARCHAR(255) NOT NULL,
|
||||
entity_name VARCHAR(255) NULL,
|
||||
filter_mode VARCHAR(10) NOT NULL,
|
||||
parent_entity_id VARCHAR(255) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT uq_sync_filters_config_type_entity UNIQUE (sync_config_id, entity_type, entity_id),
|
||||
FOREIGN KEY (sync_config_id) REFERENCES sync_configs(id) ON DELETE CASCADE
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||
"""))
|
||||
|
||||
# ── cached_stores ────────────────────────────────────────────────────────
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS cached_stores (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
evotor_id VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
address VARCHAR(500) NULL,
|
||||
fetched_at DATETIME NOT NULL,
|
||||
CONSTRAINT uq_cached_stores_user_evotor UNIQUE (user_id, evotor_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||
"""))
|
||||
try:
|
||||
conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_cached_stores_user_id ON cached_stores (user_id)"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── cached_groups ────────────────────────────────────────────────────────
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS cached_groups (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
evotor_id VARCHAR(255) NOT NULL,
|
||||
store_evotor_id VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
fetched_at DATETIME NOT NULL,
|
||||
CONSTRAINT uq_cached_groups_user_evotor UNIQUE (user_id, evotor_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||
"""))
|
||||
try:
|
||||
conn.execute(sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_cached_groups_user_store ON cached_groups (user_id, store_evotor_id)"
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── cached_products ──────────────────────────────────────────────────────
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS cached_products (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
evotor_id VARCHAR(255) NOT NULL,
|
||||
store_evotor_id VARCHAR(255) NOT NULL,
|
||||
group_evotor_id VARCHAR(255) NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
price DECIMAL(12,2) NULL,
|
||||
quantity DECIMAL(12,3) NULL,
|
||||
measure_name VARCHAR(20) NULL,
|
||||
article_number VARCHAR(100) NULL,
|
||||
allow_to_sell BOOLEAN NULL,
|
||||
fetched_at DATETIME NOT NULL,
|
||||
synced_at DATETIME NULL,
|
||||
CONSTRAINT uq_cached_products_user_evotor UNIQUE (user_id, evotor_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||
"""))
|
||||
try:
|
||||
conn.execute(sa.text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_cached_products_user_store_group "
|
||||
"ON cached_products (user_id, store_evotor_id, group_evotor_id)"
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
for table in [
|
||||
"cached_products", "cached_groups", "cached_stores",
|
||||
"sync_filters", "sync_configs",
|
||||
"vk_connections", "evotor_connections",
|
||||
"users",
|
||||
]:
|
||||
conn.execute(sa.text(f"DROP TABLE IF EXISTS {table}"))
|
||||
105
web/migrations/versions/0003_rbac_tables.py
Normal file
105
web/migrations/versions/0003_rbac_tables.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""RBAC tables with default roles and permissions
|
||||
|
||||
Revision ID: 0003
|
||||
Revises: 0002
|
||||
Create Date: 2026-04-28
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0003"
|
||||
down_revision: Union[str, None] = "0002"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
DEFAULT_ROLES = [
|
||||
("system", "Системный администратор — полный доступ"),
|
||||
("admin", "Администратор — управление пользователями"),
|
||||
("user", "Обычный пользователь"),
|
||||
]
|
||||
|
||||
DEFAULT_PERMISSIONS = [
|
||||
("admin.users.view", "Просмотр списка пользователей"),
|
||||
("admin.users.edit", "Редактирование пользователей"),
|
||||
("admin.users.delete", "Удаление пользователей"),
|
||||
("admin.roles.manage", "Управление ролями и правами"),
|
||||
]
|
||||
|
||||
# system gets all permissions; admin gets view+edit
|
||||
ROLE_PERMISSION_MAP = {
|
||||
"system": ["admin.users.view", "admin.users.edit", "admin.users.delete", "admin.roles.manage"],
|
||||
"admin": ["admin.users.view", "admin.users.edit"],
|
||||
"user": [],
|
||||
}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
description VARCHAR(255) NULL,
|
||||
CONSTRAINT uq_roles_name UNIQUE (name)
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||
"""))
|
||||
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS permissions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description VARCHAR(255) NULL,
|
||||
CONSTRAINT uq_permissions_name UNIQUE (name)
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||
"""))
|
||||
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS role_permissions (
|
||||
role_id INT NOT NULL,
|
||||
permission_id INT NOT NULL,
|
||||
PRIMARY KEY (role_id, permission_id),
|
||||
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||
"""))
|
||||
|
||||
conn.execute(sa.text("""
|
||||
CREATE TABLE IF NOT EXISTS user_roles (
|
||||
user_id INT NOT NULL,
|
||||
role_id INT NOT NULL,
|
||||
PRIMARY KEY (user_id, role_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
|
||||
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
|
||||
"""))
|
||||
|
||||
# Seed default roles
|
||||
for name, description in DEFAULT_ROLES:
|
||||
conn.execute(sa.text(
|
||||
"INSERT IGNORE INTO roles (name, description) VALUES (:name, :desc)"
|
||||
), {"name": name, "desc": description})
|
||||
|
||||
# Seed default permissions
|
||||
for name, description in DEFAULT_PERMISSIONS:
|
||||
conn.execute(sa.text(
|
||||
"INSERT IGNORE INTO permissions (name, description) VALUES (:name, :desc)"
|
||||
), {"name": name, "desc": description})
|
||||
|
||||
# Seed role_permissions
|
||||
for role_name, perm_names in ROLE_PERMISSION_MAP.items():
|
||||
for perm_name in perm_names:
|
||||
conn.execute(sa.text("""
|
||||
INSERT IGNORE INTO role_permissions (role_id, permission_id)
|
||||
SELECT r.id, p.id FROM roles r, permissions p
|
||||
WHERE r.name = :role AND p.name = :perm
|
||||
"""), {"role": role_name, "perm": perm_name})
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
for table in ["user_roles", "role_permissions", "permissions", "roles"]:
|
||||
conn.execute(sa.text(f"DROP TABLE IF EXISTS {table}"))
|
||||
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")
|
||||
16
web/migrations/versions/0012_users_middle_name.py
Normal file
16
web/migrations/versions/0012_users_middle_name.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""Add middle_name to users."""
|
||||
revision = "0012"
|
||||
down_revision = "0011"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column("users", sa.Column("middle_name", sa.String(100), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("users", "middle_name")
|
||||
@@ -1,21 +0,0 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from web.database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
first_name = Column(String(100), nullable=False)
|
||||
last_name = Column(String(100), nullable=False)
|
||||
email = Column(String(255), unique=True, nullable=False, index=True)
|
||||
phone = Column(String(20), unique=True, nullable=False, index=True)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
is_email_confirmed = Column(Boolean, default=False, nullable=False)
|
||||
email_confirm_token = Column(String(255), nullable=True)
|
||||
password_reset_token = Column(String(255), nullable=True)
|
||||
password_reset_expires = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
11
web/models/__init__.py
Normal file
11
web/models/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from web.models.user import User, UserRoleEnum, UserStatusEnum # noqa: F401
|
||||
from web.models.rbac import Role, Permission, role_permissions, UserRole # noqa: F401
|
||||
from web.models.connections import ( # noqa: F401
|
||||
EvotorConnection,
|
||||
VkConnection,
|
||||
SyncConfig,
|
||||
SyncFilter,
|
||||
CachedStore,
|
||||
CachedGroup,
|
||||
CachedProduct,
|
||||
)
|
||||
203
web/models/connections.py
Normal file
203
web/models/connections.py
Normal file
@@ -0,0 +1,203 @@
|
||||
from sqlalchemy import (
|
||||
Boolean, Column, DateTime, ForeignKey, Index, Integer,
|
||||
Numeric, String, Text, UniqueConstraint, func,
|
||||
)
|
||||
|
||||
|
||||
from web.database import Base
|
||||
|
||||
|
||||
class EvotorConnection(Base):
|
||||
__tablename__ = "evotor_connections"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
|
||||
evotor_user_id = Column(String(255), nullable=True)
|
||||
access_token = Column(Text, nullable=False)
|
||||
api_token = Column(String(255), nullable=True) # token we return to Evotor in webhook responses
|
||||
store_id = Column(String(255), nullable=True)
|
||||
store_name = Column(String(255), nullable=True)
|
||||
refresh_token = Column(Text, nullable=True)
|
||||
token_expires_at = Column(DateTime, nullable=True)
|
||||
is_online = Column(Boolean, nullable=False, default=False)
|
||||
last_checked_at = Column(DateTime, nullable=True)
|
||||
connected_at = Column(DateTime, nullable=False, server_default=func.now())
|
||||
updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", name="ix_evotor_connections_user_id"),
|
||||
UniqueConstraint("evotor_user_id", name="ix_evotor_connections_evotor_user_id"),
|
||||
)
|
||||
|
||||
|
||||
class VkConnection(Base):
|
||||
__tablename__ = "vk_connections"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), 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)
|
||||
first_name = Column(String(255), nullable=True)
|
||||
last_name = Column(String(255), nullable=True)
|
||||
is_online = Column(Boolean, nullable=False, default=False)
|
||||
last_checked_at = Column(DateTime, nullable=True)
|
||||
connected_at = Column(DateTime, nullable=False, server_default=func.now())
|
||||
updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", name="ix_vk_connections_user_id"),
|
||||
)
|
||||
|
||||
|
||||
class SyncConfig(Base):
|
||||
__tablename__ = "sync_configs"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
is_enabled = Column(Boolean, nullable=False, default=False)
|
||||
evo_mirror_enabled = Column(Boolean, nullable=False, default=True)
|
||||
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)
|
||||
price_multiplier = Column(Numeric(10, 4), nullable=False, default=1.0)
|
||||
created_at = Column(DateTime, nullable=False, server_default=func.now())
|
||||
updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", name="ix_sync_configs_user_id"),
|
||||
)
|
||||
|
||||
|
||||
class SyncFilter(Base):
|
||||
__tablename__ = "sync_filters"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
sync_config_id = Column(Integer, ForeignKey("sync_configs.id", ondelete="CASCADE"), nullable=False)
|
||||
entity_type = Column(String(20), nullable=False)
|
||||
entity_id = Column(String(255), nullable=False)
|
||||
entity_name = Column(String(255), nullable=True)
|
||||
filter_mode = Column(String(10), nullable=False)
|
||||
parent_entity_id = Column(String(255), nullable=True)
|
||||
created_at = Column(DateTime, nullable=False, server_default=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("sync_config_id", "entity_type", "entity_id",
|
||||
name="uq_sync_filters_config_type_entity"),
|
||||
)
|
||||
|
||||
|
||||
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):
|
||||
__tablename__ = "cached_stores"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
evotor_id = Column(String(255), nullable=False)
|
||||
name = Column(String(255), nullable=False)
|
||||
address = Column(String(500), nullable=True)
|
||||
fetched_at = Column(DateTime, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "evotor_id", name="uq_cached_stores_user_evotor"),
|
||||
Index("ix_cached_stores_user_id", "user_id"),
|
||||
)
|
||||
|
||||
|
||||
class CachedGroup(Base):
|
||||
__tablename__ = "cached_groups"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
evotor_id = Column(String(255), nullable=False)
|
||||
store_evotor_id = Column(String(255), nullable=False)
|
||||
name = Column(String(255), nullable=False)
|
||||
fetched_at = Column(DateTime, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "evotor_id", name="uq_cached_groups_user_evotor"),
|
||||
Index("ix_cached_groups_user_store", "user_id", "store_evotor_id"),
|
||||
)
|
||||
|
||||
|
||||
class CachedProduct(Base):
|
||||
__tablename__ = "cached_products"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
evotor_id = Column(String(255), nullable=False)
|
||||
store_evotor_id = Column(String(255), nullable=False)
|
||||
group_evotor_id = Column(String(255), nullable=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
price = Column(Numeric(12, 2), nullable=True)
|
||||
quantity = Column(Numeric(12, 3), nullable=True)
|
||||
measure_name = Column(String(20), nullable=True)
|
||||
article_number = Column(String(100), nullable=True)
|
||||
allow_to_sell = Column(Boolean, nullable=True)
|
||||
fetched_at = Column(DateTime, nullable=False)
|
||||
synced_at = Column(DateTime, nullable=True)
|
||||
vk_product_id = Column(String(50), nullable=True) # VK market item ID after first push
|
||||
|
||||
__table_args__ = (
|
||||
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"),
|
||||
)
|
||||
|
||||
|
||||
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"),
|
||||
)
|
||||
34
web/models/rbac.py
Normal file
34
web/models/rbac.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from sqlalchemy import Column, ForeignKey, Integer, String, Table
|
||||
|
||||
from web.database import Base
|
||||
|
||||
|
||||
role_permissions = Table(
|
||||
"role_permissions",
|
||||
Base.metadata,
|
||||
Column("role_id", Integer, ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True),
|
||||
Column("permission_id", Integer, ForeignKey("permissions.id", ondelete="CASCADE"), primary_key=True),
|
||||
)
|
||||
|
||||
|
||||
class Role(Base):
|
||||
__tablename__ = "roles"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(50), nullable=False, unique=True)
|
||||
description = Column(String(255), nullable=True)
|
||||
|
||||
|
||||
class Permission(Base):
|
||||
__tablename__ = "permissions"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(100), nullable=False, unique=True)
|
||||
description = Column(String(255), nullable=True)
|
||||
|
||||
|
||||
class UserRole(Base):
|
||||
__tablename__ = "user_roles"
|
||||
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
|
||||
role_id = Column(Integer, ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True)
|
||||
51
web/models/user.py
Normal file
51
web/models/user.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import enum
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, Enum, Index, Integer, JSON, String, UniqueConstraint, func
|
||||
|
||||
from web.database import Base
|
||||
|
||||
|
||||
class UserRoleEnum(str, enum.Enum):
|
||||
system = "system"
|
||||
admin = "admin"
|
||||
user = "user"
|
||||
|
||||
|
||||
class UserStatusEnum(str, enum.Enum):
|
||||
pending = "pending"
|
||||
active = "active"
|
||||
suspended = "suspended"
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
first_name = Column(String(100), nullable=False)
|
||||
last_name = Column(String(100), nullable=False)
|
||||
middle_name = Column(String(100), nullable=True)
|
||||
email = Column(String(255), nullable=False)
|
||||
phone = Column(String(20), nullable=True)
|
||||
password_hash = Column(String(255), nullable=True)
|
||||
is_email_confirmed = Column(Boolean, nullable=False, default=False)
|
||||
email_confirm_token = Column(String(255), nullable=True)
|
||||
password_reset_token = Column(String(255), nullable=True)
|
||||
password_reset_expires = Column(DateTime, nullable=True)
|
||||
role = Column(Enum(UserRoleEnum), nullable=False, default=UserRoleEnum.user)
|
||||
status = Column(Enum(UserStatusEnum), nullable=False, default=UserStatusEnum.pending)
|
||||
evotor_user_id = Column(String(255), nullable=True)
|
||||
evotor_meta = Column(JSON, nullable=True)
|
||||
invite_token = Column(String(255), nullable=True)
|
||||
invite_expires = Column(DateTime, nullable=True)
|
||||
phone_otp = Column(String(10), nullable=True)
|
||||
phone_otp_expires = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, nullable=False, server_default=func.now())
|
||||
updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("email", name="ix_users_email"),
|
||||
UniqueConstraint("phone", name="ix_users_phone"),
|
||||
UniqueConstraint("evotor_user_id", name="ix_users_evotor_user_id"),
|
||||
Index("ix_users_role", "role"),
|
||||
Index("ix_users_status", "status"),
|
||||
)
|
||||
0
web/notifications/__init__.py
Normal file
0
web/notifications/__init__.py
Normal file
11
web/notifications/base.py
Normal file
11
web/notifications/base.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class EmailProvider(ABC):
|
||||
@abstractmethod
|
||||
def send(self, to: str, subject: str, html_body: str) -> None: ...
|
||||
|
||||
|
||||
class SMSProvider(ABC):
|
||||
@abstractmethod
|
||||
def send(self, to: str, text: str) -> None: ...
|
||||
28
web/notifications/console.py
Normal file
28
web/notifications/console.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import logging
|
||||
import re
|
||||
|
||||
from web.notifications.base import EmailProvider, SMSProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConsoleEmailProvider(EmailProvider):
|
||||
def send(self, to: str, subject: str, html_body: str) -> None:
|
||||
# Extract plain URLs from HTML for readability in dev logs
|
||||
urls = re.findall(r'href=["\']([^"\']+)["\']', html_body)
|
||||
logger.info("=" * 50)
|
||||
logger.info("EMAIL")
|
||||
logger.info("Кому: %s", to)
|
||||
logger.info("Тема: %s", subject)
|
||||
for url in urls:
|
||||
logger.info("Ссылка: %s", url)
|
||||
logger.info("=" * 50)
|
||||
|
||||
|
||||
class ConsoleSMSProvider(SMSProvider):
|
||||
def send(self, to: str, text: str) -> None:
|
||||
logger.info("=" * 50)
|
||||
logger.info("SMS")
|
||||
logger.info("Номер: %s", to)
|
||||
logger.info("Текст: %s", text)
|
||||
logger.info("=" * 50)
|
||||
17
web/notifications/registry.py
Normal file
17
web/notifications/registry.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from web.config import settings
|
||||
from web.notifications.base import EmailProvider, SMSProvider
|
||||
from web.notifications.console import ConsoleEmailProvider, ConsoleSMSProvider
|
||||
|
||||
|
||||
def get_email_provider() -> EmailProvider:
|
||||
provider = settings.EMAIL_PROVIDER
|
||||
if provider == "console":
|
||||
return ConsoleEmailProvider()
|
||||
raise ValueError(f"Unknown EMAIL_PROVIDER: {provider!r}")
|
||||
|
||||
|
||||
def get_sms_provider() -> SMSProvider:
|
||||
provider = settings.SMS_PROVIDER
|
||||
if provider == "console":
|
||||
return ConsoleSMSProvider()
|
||||
raise ValueError(f"Unknown SMS_PROVIDER: {provider!r}")
|
||||
12
web/notifications/tasks.py
Normal file
12
web/notifications/tasks.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from web.tasks.celery_app import celery_app
|
||||
from web.notifications.registry import get_email_provider, get_sms_provider
|
||||
|
||||
|
||||
@celery_app.task(name="web.notifications.tasks.send_email_task", queue="notifications")
|
||||
def send_email_task(to: str, subject: str, html_body: str) -> None:
|
||||
get_email_provider().send(to, subject, html_body)
|
||||
|
||||
|
||||
@celery_app.task(name="web.notifications.tasks.send_sms_task", queue="notifications")
|
||||
def send_sms_task(to: str, text: str) -> None:
|
||||
get_sms_provider().send(to, text)
|
||||
364
web/routes/admin.py
Normal file
364
web/routes/admin.py
Normal file
@@ -0,0 +1,364 @@
|
||||
import secrets
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from web.auth.password import hash_password
|
||||
from web.auth.rbac import require_role
|
||||
from web.auth.session import get_current_user
|
||||
from web.config import settings
|
||||
from web.database import get_db
|
||||
from web.models.rbac import Permission, Role, UserRole, role_permissions
|
||||
from web.models.user import User, UserRoleEnum, UserStatusEnum
|
||||
from web.notifications.tasks import send_email_task
|
||||
from web.templates_env import templates
|
||||
|
||||
router = APIRouter(prefix="/admin")
|
||||
|
||||
PAGE_SIZE = 25
|
||||
|
||||
|
||||
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 _admin_user(request: Request, db: Session) -> User:
|
||||
"""Get current user and verify admin/system role."""
|
||||
try:
|
||||
user = get_current_user(request, db)
|
||||
except Exception:
|
||||
raise
|
||||
if user.role not in (UserRoleEnum.admin, UserRoleEnum.system):
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(403, "Недостаточно прав")
|
||||
return user
|
||||
|
||||
|
||||
# ── User list ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/users")
|
||||
async def admin_users(request: Request, db: Session = Depends(get_db)):
|
||||
try:
|
||||
admin = _admin_user(request, db)
|
||||
except Exception:
|
||||
return RedirectResponse("/login", 303)
|
||||
|
||||
q = db.query(User)
|
||||
search = request.query_params.get("search", "").strip()
|
||||
status_filter = request.query_params.get("status", "")
|
||||
role_filter = request.query_params.get("role", "")
|
||||
page = max(1, int(request.query_params.get("page", 1)))
|
||||
|
||||
if search:
|
||||
q = q.filter(
|
||||
(User.first_name.ilike(f"%{search}%")) |
|
||||
(User.last_name.ilike(f"%{search}%")) |
|
||||
(User.email.ilike(f"%{search}%")) |
|
||||
(User.phone.ilike(f"%{search}%"))
|
||||
)
|
||||
if status_filter:
|
||||
try:
|
||||
q = q.filter(User.status == UserStatusEnum(status_filter))
|
||||
except ValueError:
|
||||
pass
|
||||
if role_filter:
|
||||
try:
|
||||
q = q.filter(User.role == UserRoleEnum(role_filter))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
total = q.count()
|
||||
users = q.order_by(User.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/users.html", {
|
||||
"user": admin,
|
||||
"users": users,
|
||||
"search": search,
|
||||
"status_filter": status_filter,
|
||||
"role_filter": role_filter,
|
||||
"page": page,
|
||||
"total_pages": total_pages,
|
||||
"total": total,
|
||||
})
|
||||
|
||||
|
||||
# ── User detail ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/users/{user_id}")
|
||||
async def admin_user_detail(user_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
try:
|
||||
admin = _admin_user(request, db)
|
||||
except Exception:
|
||||
return RedirectResponse("/login", 303)
|
||||
target = db.get(User, user_id)
|
||||
if not target:
|
||||
return RedirectResponse("/admin/users", 303)
|
||||
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", "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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/users/{user_id}/activate")
|
||||
async def admin_activate(user_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
try:
|
||||
_admin_user(request, db)
|
||||
except Exception:
|
||||
return RedirectResponse("/login", 303)
|
||||
user = db.get(User, user_id)
|
||||
if user:
|
||||
user.status = UserStatusEnum.active
|
||||
db.commit()
|
||||
return RedirectResponse(f"/admin/users/{user_id}", 303)
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/suspend")
|
||||
async def admin_suspend(user_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
try:
|
||||
_admin_user(request, db)
|
||||
except Exception:
|
||||
return RedirectResponse("/login", 303)
|
||||
user = db.get(User, user_id)
|
||||
if user:
|
||||
user.status = UserStatusEnum.suspended
|
||||
db.commit()
|
||||
return RedirectResponse(f"/admin/users/{user_id}", 303)
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/reset-password")
|
||||
async def admin_reset_password(user_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
try:
|
||||
_admin_user(request, db)
|
||||
except Exception:
|
||||
return RedirectResponse("/login", 303)
|
||||
user = db.get(User, user_id)
|
||||
if user:
|
||||
token = secrets.token_urlsafe(32)
|
||||
user.password_reset_token = token
|
||||
user.password_reset_expires = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(
|
||||
minutes=settings.PASSWORD_RESET_EXPIRE_MINUTES
|
||||
)
|
||||
db.commit()
|
||||
reset_url = f"{settings.BASE_URL}/reset-password?token={token}"
|
||||
html = f'<p>Сброс пароля (запрошен администратором): <a href="{reset_url}">{reset_url}</a></p>'
|
||||
send_email_task.delay(user.email, "Сброс пароля — ЭВОСИНК", html)
|
||||
return RedirectResponse(f"/admin/users/{user_id}?success=reset_sent", 303)
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/send-invite")
|
||||
async def admin_send_invite(user_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
try:
|
||||
_admin_user(request, db)
|
||||
except Exception:
|
||||
return RedirectResponse("/login", 303)
|
||||
user = db.get(User, user_id)
|
||||
if user:
|
||||
token = secrets.token_urlsafe(32)
|
||||
user.invite_token = token
|
||||
user.invite_expires = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(hours=settings.INVITE_EXPIRE_HOURS)
|
||||
db.commit()
|
||||
invite_url = f"{settings.BASE_URL}/invite?token={token}"
|
||||
html = (
|
||||
f"<p>Вам отправлено приглашение в ЭВОСИНК.</p>"
|
||||
f'<p><a href="{invite_url}">{invite_url}</a></p>'
|
||||
f"<p>Ссылка действительна {settings.INVITE_EXPIRE_HOURS} часов.</p>"
|
||||
)
|
||||
send_email_task.delay(user.email, "Приглашение в ЭВОСИНК", html)
|
||||
return RedirectResponse(f"/admin/users/{user_id}?success=invite_sent", 303)
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/edit")
|
||||
async def admin_edit_user(user_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
try:
|
||||
admin = _admin_user(request, db)
|
||||
except Exception:
|
||||
return RedirectResponse("/login", 303)
|
||||
user = db.get(User, user_id)
|
||||
if not user:
|
||||
return RedirectResponse("/admin/users", 303)
|
||||
|
||||
form = await request.form()
|
||||
data = {k: str(v).strip() for k, v in form.items()}
|
||||
errors = []
|
||||
|
||||
if not data.get("first_name"):
|
||||
errors.append("Имя обязательно")
|
||||
if not data.get("last_name"):
|
||||
errors.append("Фамилия обязательна")
|
||||
|
||||
if errors:
|
||||
return _render(request, "admin/user_detail.html", {
|
||||
"user": admin, "target": user, "errors": errors,
|
||||
})
|
||||
|
||||
user.first_name = data["first_name"]
|
||||
user.last_name = data["last_name"]
|
||||
user.middle_name = data.get("middle_name") or None
|
||||
if data.get("email"):
|
||||
user.email = data["email"]
|
||||
if data.get("phone"):
|
||||
user.phone = data["phone"]
|
||||
if data.get("role"):
|
||||
try:
|
||||
user.role = UserRoleEnum(data["role"])
|
||||
except ValueError:
|
||||
pass
|
||||
db.commit()
|
||||
return RedirectResponse(f"/admin/users/{user_id}?success=saved", 303)
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/delete")
|
||||
async def admin_delete_user(user_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
try:
|
||||
admin = _admin_user(request, db)
|
||||
except Exception:
|
||||
return RedirectResponse("/login", 303)
|
||||
user = db.get(User, user_id)
|
||||
if user:
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
return RedirectResponse("/admin/users", 303)
|
||||
|
||||
|
||||
# ── Roles ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/roles")
|
||||
async def admin_roles(request: Request, db: Session = Depends(get_db)):
|
||||
try:
|
||||
admin = _admin_user(request, db)
|
||||
except Exception:
|
||||
return RedirectResponse("/login", 303)
|
||||
roles = db.query(Role).order_by(Role.id).all()
|
||||
permissions = db.query(Permission).order_by(Permission.name).all()
|
||||
role_perm_ids: dict[int, set[int]] = {}
|
||||
for role in roles:
|
||||
rows = db.execute(
|
||||
role_permissions.select().where(role_permissions.c.role_id == role.id)
|
||||
).fetchall()
|
||||
role_perm_ids[role.id] = {r.permission_id for r in rows}
|
||||
return _render(request, "admin/roles.html", {
|
||||
"user": admin, "roles": roles, "permissions": permissions,
|
||||
"role_perm_ids": role_perm_ids,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/roles/{role_id}/permissions")
|
||||
async def admin_update_role_permissions(
|
||||
role_id: int, request: Request, db: Session = Depends(get_db)
|
||||
):
|
||||
try:
|
||||
admin = _admin_user(request, db)
|
||||
except Exception:
|
||||
return RedirectResponse("/login", 303)
|
||||
form = await request.form()
|
||||
selected_ids = {int(v) for k, v in form.items() if k.startswith("perm_")}
|
||||
|
||||
# Remove all existing, re-insert selected
|
||||
db.execute(role_permissions.delete().where(role_permissions.c.role_id == role_id))
|
||||
for perm_id in selected_ids:
|
||||
db.execute(role_permissions.insert().values(role_id=role_id, permission_id=perm_id))
|
||||
db.commit()
|
||||
return RedirectResponse("/admin/roles", 303)
|
||||
@@ -1,116 +1,110 @@
|
||||
import uuid
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Request, Depends
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from web.auth import hash_password, verify_password, get_current_user
|
||||
from web.auth.password import verify_password
|
||||
from web.auth.session import get_session_user_id
|
||||
from web.config import settings
|
||||
from web.database import get_db
|
||||
from web.models import User
|
||||
from web.schemas import validate_registration, validate_login
|
||||
from web.models.user import User, UserStatusEnum
|
||||
from web.notifications.tasks import send_email_task
|
||||
from web.templates_env import templates
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="web/templates")
|
||||
|
||||
|
||||
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("/register")
|
||||
def register_form(request: Request, user: User | None = Depends(get_current_user)):
|
||||
if user:
|
||||
return RedirectResponse("/profile", 303)
|
||||
return templates.TemplateResponse("register.html", {"request": request, "user": None})
|
||||
async def register_get(request: Request):
|
||||
return Response(status_code=404)
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def register_submit(request: Request, db: Session = Depends(get_db)):
|
||||
form = await request.form()
|
||||
data = dict(form)
|
||||
|
||||
errors = validate_registration(data)
|
||||
|
||||
if not errors:
|
||||
existing = db.query(User).filter(
|
||||
(User.email == data["email"].strip()) | (User.phone == data["phone"].strip())
|
||||
).first()
|
||||
if existing:
|
||||
if existing.email == data["email"].strip():
|
||||
errors.append("Пользователь с таким email уже существует")
|
||||
else:
|
||||
errors.append("Пользователь с таким телефоном уже существует")
|
||||
|
||||
if errors:
|
||||
return templates.TemplateResponse("register.html", {
|
||||
"request": request, "user": None, "errors": errors, "form": data,
|
||||
})
|
||||
|
||||
token = uuid.uuid4().hex
|
||||
user = User(
|
||||
first_name=data["first_name"].strip(),
|
||||
last_name=data["last_name"].strip(),
|
||||
email=data["email"].strip(),
|
||||
phone=data["phone"].strip(),
|
||||
password_hash=hash_password(data["password"]),
|
||||
email_confirm_token=token,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
confirm_url = f"{settings.BASE_URL}/confirm-email?token={token}"
|
||||
print("=" * 40)
|
||||
print("ПОДТВЕРЖДЕНИЕ EMAIL")
|
||||
print(f"Пользователь: {user.email}")
|
||||
print(f"Ссылка: {confirm_url}")
|
||||
print("=" * 40)
|
||||
|
||||
return templates.TemplateResponse("confirm_email.html", {"request": request, "user": None})
|
||||
async def register_post(request: Request):
|
||||
return Response(status_code=404)
|
||||
|
||||
|
||||
@router.get("/confirm-email")
|
||||
def confirm_email(request: Request, token: str, db: Session = Depends(get_db)):
|
||||
async def confirm_email(request: Request, db: Session = Depends(get_db)):
|
||||
token = request.query_params.get("token")
|
||||
if not token:
|
||||
return _render(request, "message.html", {
|
||||
"user": None, "title": "Ошибка", "message": "Неверная или устаревшая ссылка.",
|
||||
"link": "/login", "link_text": "Войти",
|
||||
})
|
||||
user = db.query(User).filter(User.email_confirm_token == token).first()
|
||||
if not user:
|
||||
return templates.TemplateResponse("message.html", {
|
||||
"request": request, "user": None,
|
||||
"title": "Ошибка", "message": "Неверная или устаревшая ссылка.",
|
||||
return _render(request, "message.html", {
|
||||
"user": None, "title": "Ошибка", "message": "Неверная или устаревшая ссылка.",
|
||||
"link": "/login", "link_text": "Войти",
|
||||
})
|
||||
|
||||
user.is_email_confirmed = True
|
||||
user.email_confirm_token = None
|
||||
user.status = UserStatusEnum.active
|
||||
db.commit()
|
||||
return _render(request, "email_confirmed.html", {"user": None})
|
||||
|
||||
return templates.TemplateResponse("email_confirmed.html", {"request": request, "user": None})
|
||||
|
||||
@router.get("/resend-confirm")
|
||||
async def resend_confirm(request: Request, db: Session = Depends(get_db)):
|
||||
user_id = get_session_user_id(request)
|
||||
if not user_id:
|
||||
return RedirectResponse("/login", 303)
|
||||
user = db.get(User, user_id)
|
||||
if not user or user.is_email_confirmed:
|
||||
return RedirectResponse("/profile", 303)
|
||||
token = secrets.token_urlsafe(32)
|
||||
user.email_confirm_token = token
|
||||
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, "message.html", {
|
||||
"user": user,
|
||||
"title": "Письмо отправлено",
|
||||
"message": "Проверьте почту и нажмите на ссылку для подтверждения.",
|
||||
"link": "/profile", "link_text": "Назад",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
def login_form(request: Request, user: User | None = Depends(get_current_user)):
|
||||
if user:
|
||||
async def login_get(request: Request, db: Session = Depends(get_db)):
|
||||
if get_session_user_id(request):
|
||||
return RedirectResponse("/profile", 303)
|
||||
return templates.TemplateResponse("login.html", {"request": request, "user": None})
|
||||
return _render(request, "login.html", {"user": None})
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login_submit(request: Request, db: Session = Depends(get_db)):
|
||||
async def login_post(request: Request, db: Session = Depends(get_db)):
|
||||
form = await request.form()
|
||||
data = dict(form)
|
||||
email = str(form.get("email", "")).strip()
|
||||
password = str(form.get("password", ""))
|
||||
errors = []
|
||||
|
||||
if not email:
|
||||
errors.append("Email обязателен")
|
||||
if not password:
|
||||
errors.append("Пароль обязателен")
|
||||
|
||||
if not errors:
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
if not user or not user.password_hash or not verify_password(password, user.password_hash):
|
||||
errors.append("Неверный email или пароль")
|
||||
elif user.status == UserStatusEnum.suspended:
|
||||
errors.append("Ваш аккаунт заблокирован. Обратитесь к администратору.")
|
||||
elif not user.is_email_confirmed:
|
||||
errors.append("Пожалуйста, подтвердите ваш email")
|
||||
|
||||
errors = validate_login(data)
|
||||
if errors:
|
||||
return templates.TemplateResponse("login.html", {
|
||||
"request": request, "user": None, "errors": errors, "form": data,
|
||||
})
|
||||
|
||||
user = db.query(User).filter(User.email == data["email"].strip()).first()
|
||||
if not user or not verify_password(data["password"], user.password_hash):
|
||||
return templates.TemplateResponse("login.html", {
|
||||
"request": request, "user": None,
|
||||
"errors": ["Неверный email или пароль"], "form": data,
|
||||
})
|
||||
|
||||
if not user.is_email_confirmed:
|
||||
return templates.TemplateResponse("login.html", {
|
||||
"request": request, "user": None,
|
||||
"errors": ["Пожалуйста, подтвердите ваш email"], "form": data,
|
||||
return _render(request, "login.html", {
|
||||
"user": None, "errors": errors, "form": {"email": email},
|
||||
})
|
||||
|
||||
request.session["user_id"] = user.id
|
||||
@@ -118,6 +112,12 @@ async def login_submit(request: Request, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
def logout(request: Request):
|
||||
async def logout(request: Request):
|
||||
request.session.clear()
|
||||
return RedirectResponse("/login", 303)
|
||||
|
||||
|
||||
async def _hash(plain: str) -> str:
|
||||
import asyncio
|
||||
from web.auth.password import hash_password
|
||||
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_viewed_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_viewed_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_viewed_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_viewed_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_viewed_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_viewed_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_viewed_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}"})
|
||||
|
||||
352
web/routes/evotor_webhooks.py
Normal file
352
web/routes/evotor_webhooks.py
Normal file
@@ -0,0 +1,352 @@
|
||||
"""
|
||||
Evotor webhook endpoints.
|
||||
|
||||
POST /user/create — Evotor creates a new subscriber; we create/link a local user and return a token.
|
||||
POST /user/verify — Evotor verifies credentials for a user trying to log in via the Evotor interface.
|
||||
POST /user/token — Evotor sends us its own API token for the user.
|
||||
POST /user/install — Evotor notifies about app install or uninstall for a user.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from web.auth.password import hash_password, verify_password
|
||||
from web.config import settings
|
||||
from web.database import get_db
|
||||
from web.models.connections import EvotorConnection
|
||||
from web.models.user import User, UserRoleEnum, UserStatusEnum
|
||||
from web.notifications.tasks import send_email_task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
EVOTOR_STORES_URL = "https://api.evotor.ru/stores"
|
||||
|
||||
|
||||
def _verify_secret(request: Request) -> bool:
|
||||
secret = settings.EVOTOR_WEBHOOK_SECRET
|
||||
if not secret:
|
||||
return True # dev mode: no secret configured
|
||||
auth = request.headers.get("Authorization", "")
|
||||
return auth == f"Bearer {secret}"
|
||||
|
||||
|
||||
def _parse_custom_fields(raw: Any) -> dict:
|
||||
"""Extract known fields from Evotor customField (may be JSON string or dict)."""
|
||||
if raw is None:
|
||||
return {}
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def _upsert_evotor_connection(
|
||||
db: Session,
|
||||
user_id: int | None,
|
||||
evotor_user_id: str,
|
||||
access_token: str | None = None,
|
||||
) -> str:
|
||||
"""Create or update an evotor_connections row; always regenerates api_token."""
|
||||
api_token = secrets.token_urlsafe(32)
|
||||
conn = db.query(EvotorConnection).filter(
|
||||
or_(
|
||||
EvotorConnection.evotor_user_id == evotor_user_id,
|
||||
EvotorConnection.user_id == user_id,
|
||||
)
|
||||
).first()
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
if conn:
|
||||
conn.api_token = api_token
|
||||
if user_id is not None:
|
||||
conn.user_id = user_id
|
||||
if evotor_user_id:
|
||||
conn.evotor_user_id = evotor_user_id
|
||||
if access_token:
|
||||
conn.access_token = access_token
|
||||
conn.updated_at = now
|
||||
else:
|
||||
conn = EvotorConnection(
|
||||
user_id=user_id,
|
||||
evotor_user_id=evotor_user_id,
|
||||
access_token=access_token or "",
|
||||
api_token=api_token,
|
||||
connected_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(conn)
|
||||
db.flush()
|
||||
return api_token
|
||||
|
||||
|
||||
@router.post("/user/create")
|
||||
async def user_create(request: Request, db: Session = Depends(get_db)):
|
||||
if not _verify_secret(request):
|
||||
return JSONResponse({"error": "Unauthorized"}, status_code=401)
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
|
||||
|
||||
evotor_user_id: str = body.get("userId", "")
|
||||
if not evotor_user_id:
|
||||
return JSONResponse({"error": "userId required"}, status_code=400)
|
||||
|
||||
custom = _parse_custom_fields(body.get("customField"))
|
||||
# Evotor sends fields both at top level and inside customField
|
||||
email = (body.get("email") or custom.get("email") or "").strip().lower() or None
|
||||
phone = (body.get("phone_number") or body.get("phone") or custom.get("phone_number") or custom.get("phone") or "").strip() or None
|
||||
first_name = (body.get("first_name") or body.get("firstName") or custom.get("first_name") or custom.get("firstName") or "").strip() or None
|
||||
last_name = (body.get("second_name") or body.get("last_name") or body.get("lastName") or custom.get("second_name") or custom.get("last_name") or custom.get("lastName") or "").strip() or None
|
||||
middle_name = (body.get("middle_name") or custom.get("middle_name") or "").strip() or None
|
||||
password = (body.get("password") or custom.get("password") or "").strip() or None
|
||||
|
||||
# Try to find existing user
|
||||
user: User | None = None
|
||||
|
||||
# 1. By evotor_user_id
|
||||
user = db.query(User).filter(User.evotor_user_id == evotor_user_id).first()
|
||||
|
||||
# 2. By email
|
||||
if user is None and email:
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
|
||||
# 3. By phone
|
||||
if user is None and phone:
|
||||
user = db.query(User).filter(User.phone == phone).first()
|
||||
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
if user:
|
||||
# Link Evotor to existing user; update name fields if Evotor provided them
|
||||
user.evotor_user_id = evotor_user_id
|
||||
user.evotor_meta = body
|
||||
if first_name:
|
||||
user.first_name = first_name
|
||||
if last_name:
|
||||
user.last_name = last_name
|
||||
if middle_name:
|
||||
user.middle_name = middle_name
|
||||
if password:
|
||||
user.password_hash = hash_password(password)
|
||||
if user.status == UserStatusEnum.pending:
|
||||
user.status = UserStatusEnum.active
|
||||
db.flush()
|
||||
else:
|
||||
# Create new user; active immediately if password provided, else pending
|
||||
user = User(
|
||||
first_name=first_name or "",
|
||||
last_name=last_name or "",
|
||||
middle_name=middle_name,
|
||||
email=email or f"{evotor_user_id}@evotor.invalid",
|
||||
phone=phone or None,
|
||||
password_hash=hash_password(password) if password else None,
|
||||
role=UserRoleEnum.user,
|
||||
status=UserStatusEnum.active if password else UserStatusEnum.pending,
|
||||
evotor_user_id=evotor_user_id,
|
||||
evotor_meta=body,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush() # get user.id
|
||||
|
||||
api_token = _upsert_evotor_connection(db, user.id, evotor_user_id)
|
||||
|
||||
if not password:
|
||||
# No password provided — send invite link so user can set one
|
||||
invite_token = secrets.token_urlsafe(32)
|
||||
user.invite_token = invite_token
|
||||
user.invite_expires = now + timedelta(hours=settings.INVITE_EXPIRE_HOURS)
|
||||
db.commit()
|
||||
if email:
|
||||
invite_url = f"{settings.BASE_URL}/invite?token={invite_token}"
|
||||
html = (
|
||||
f"<p>Здравствуйте!</p>"
|
||||
f"<p>Вам открыт доступ к ЭВОСИНК. Завершите регистрацию по ссылке:</p>"
|
||||
f'<p><a href="{invite_url}">{invite_url}</a></p>'
|
||||
f"<p>Ссылка действительна {settings.INVITE_EXPIRE_HOURS} часов.</p>"
|
||||
)
|
||||
send_email_task.delay(email, "Приглашение в ЭВОСИНК", html)
|
||||
else:
|
||||
logger.info("No email for evotor_user_id=%s, invite URL: %s/invite?token=%s",
|
||||
evotor_user_id, settings.BASE_URL, invite_token)
|
||||
else:
|
||||
db.commit()
|
||||
|
||||
return JSONResponse({"userId": evotor_user_id, "token": api_token})
|
||||
|
||||
|
||||
@router.post("/user/verify")
|
||||
async def user_verify(request: Request, db: Session = Depends(get_db)):
|
||||
if not _verify_secret(request):
|
||||
return JSONResponse({"error": "Unauthorized"}, status_code=401)
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
|
||||
|
||||
logger.info("user/verify body=%s", {k: v for k, v in body.items() if k != "password"})
|
||||
|
||||
evotor_user_id: str = body.get("userId", "")
|
||||
username: str = body.get("username", "").strip()
|
||||
phone: str = body.get("phone", "").strip()
|
||||
password: str = body.get("password", "")
|
||||
|
||||
login = username or phone
|
||||
if not password:
|
||||
return JSONResponse({"error": "password required"}, status_code=400)
|
||||
|
||||
# 1. Match by evotor_user_id (most reliable — Evotor always sends userId)
|
||||
user = db.query(User).filter(User.evotor_user_id == evotor_user_id).first() if evotor_user_id else None
|
||||
|
||||
# 2. Fall back to email or phone
|
||||
if not user and login:
|
||||
user = db.query(User).filter(
|
||||
or_(User.email == login, User.phone == login)
|
||||
).first()
|
||||
|
||||
if not user:
|
||||
return JSONResponse({"error": "Неверные данные"}, status_code=401)
|
||||
|
||||
if user.status == UserStatusEnum.suspended:
|
||||
return JSONResponse({"error": "Аккаунт заблокирован"}, status_code=403)
|
||||
|
||||
if not user.password_hash:
|
||||
# First auth with password — save it and activate the account
|
||||
user.password_hash = hash_password(password)
|
||||
if user.status == UserStatusEnum.pending:
|
||||
user.status = UserStatusEnum.active
|
||||
elif not verify_password(password, user.password_hash):
|
||||
return JSONResponse({"error": "Неверные данные"}, status_code=401)
|
||||
|
||||
# Get or create connection to retrieve api_token
|
||||
conn = db.query(EvotorConnection).filter(
|
||||
EvotorConnection.evotor_user_id == (user.evotor_user_id or evotor_user_id)
|
||||
).first()
|
||||
if not conn:
|
||||
# Auto-link: create connection with Evotor userId from request
|
||||
if evotor_user_id and not user.evotor_user_id:
|
||||
user.evotor_user_id = evotor_user_id
|
||||
db.flush()
|
||||
api_token = _upsert_evotor_connection(db, user.id, evotor_user_id or (user.evotor_user_id or ""))
|
||||
db.commit()
|
||||
else:
|
||||
api_token = conn.api_token or secrets.token_urlsafe(32)
|
||||
if not conn.api_token:
|
||||
conn.api_token = api_token
|
||||
db.commit()
|
||||
|
||||
return JSONResponse({"userId": user.evotor_user_id or evotor_user_id, "token": api_token})
|
||||
|
||||
|
||||
@router.post("/user/token")
|
||||
async def user_token(request: Request, db: Session = Depends(get_db)):
|
||||
if not _verify_secret(request):
|
||||
return JSONResponse({"error": "Unauthorized"}, status_code=401)
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
|
||||
|
||||
evotor_user_id: str = body.get("userId", "")
|
||||
evotor_token: str = body.get("token", "")
|
||||
|
||||
if not evotor_user_id or not evotor_token:
|
||||
return JSONResponse({"error": "userId and token required"}, status_code=400)
|
||||
|
||||
user = db.query(User).filter(User.evotor_user_id == evotor_user_id).first()
|
||||
if not user:
|
||||
return JSONResponse({"error": "User not found"}, status_code=404)
|
||||
|
||||
conn = db.query(EvotorConnection).filter(
|
||||
EvotorConnection.evotor_user_id == evotor_user_id
|
||||
).first()
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
if conn:
|
||||
conn.access_token = evotor_token
|
||||
conn.is_online = True
|
||||
conn.last_checked_at = now
|
||||
conn.updated_at = now
|
||||
else:
|
||||
conn = EvotorConnection(
|
||||
user_id=user.id,
|
||||
evotor_user_id=evotor_user_id,
|
||||
access_token=evotor_token,
|
||||
api_token=secrets.token_urlsafe(32),
|
||||
is_online=True,
|
||||
last_checked_at=now,
|
||||
connected_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(conn)
|
||||
db.commit()
|
||||
|
||||
return JSONResponse({})
|
||||
|
||||
|
||||
@router.post("/user/install")
|
||||
async def user_install(request: Request, db: Session = Depends(get_db)):
|
||||
"""Handle app install / uninstall events from Evotor."""
|
||||
if not _verify_secret(request):
|
||||
return JSONResponse({"error": "Unauthorized"}, status_code=401)
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
|
||||
|
||||
# userId is nested inside "data"; type is e.g. "ApplicationInstalled" / "ApplicationUninstalled"
|
||||
data: dict = body.get("data", {})
|
||||
evotor_user_id: str = data.get("userId", "") or body.get("userId", "")
|
||||
event_type: str = body.get("type", "").lower() # "applicationinstalled" / "applicationuninstalled"
|
||||
|
||||
if not evotor_user_id:
|
||||
logger.warning("user/install missing userId, body=%s", body)
|
||||
return JSONResponse({"error": "userId required"}, status_code=400)
|
||||
|
||||
logger.info("user/install event type=%s userId=%s", event_type, evotor_user_id)
|
||||
|
||||
user = db.query(User).filter(User.evotor_user_id == evotor_user_id).first()
|
||||
if not user:
|
||||
# Unknown user — nothing to act on, but acknowledge the event
|
||||
return JSONResponse({})
|
||||
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
if event_type == "applicationuninstalled":
|
||||
user.status = UserStatusEnum.suspended
|
||||
user.updated_at = now
|
||||
conn = db.query(EvotorConnection).filter(
|
||||
EvotorConnection.evotor_user_id == evotor_user_id
|
||||
).first()
|
||||
if conn:
|
||||
conn.is_online = False
|
||||
conn.updated_at = now
|
||||
db.commit()
|
||||
logger.info("user suspended on uninstall: userId=%s", evotor_user_id)
|
||||
|
||||
elif event_type == "applicationinstalled":
|
||||
if user.status == UserStatusEnum.suspended:
|
||||
user.status = UserStatusEnum.active
|
||||
user.updated_at = now
|
||||
db.commit()
|
||||
logger.info("user reactivated on reinstall: userId=%s", evotor_user_id)
|
||||
|
||||
return JSONResponse({})
|
||||
99
web/routes/invite.py
Normal file
99
web/routes/invite.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from web.auth.password import hash_password
|
||||
from web.config import settings
|
||||
from web.database import get_db
|
||||
from web.models.user import User, UserStatusEnum
|
||||
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)
|
||||
|
||||
|
||||
def _bad_token(request: Request) -> HTMLResponse:
|
||||
return _render(request, "message.html", {
|
||||
"user": None,
|
||||
"title": "Ссылка недействительна",
|
||||
"message": "Ссылка приглашения устарела или недействительна. Обратитесь к администратору.",
|
||||
"link": "/login", "link_text": "Войти",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/invite")
|
||||
async def invite_get(request: Request, db: Session = Depends(get_db)):
|
||||
token = request.query_params.get("token", "")
|
||||
user = db.query(User).filter(User.invite_token == token).first()
|
||||
if not user or not user.invite_expires or user.invite_expires < datetime.now(timezone.utc).replace(tzinfo=None):
|
||||
return _bad_token(request)
|
||||
return _render(request, "invite.html", {"user": None, "invite_user": user, "token": token})
|
||||
|
||||
|
||||
@router.post("/invite")
|
||||
async def invite_post(request: Request, db: Session = Depends(get_db)):
|
||||
token = request.query_params.get("token", "")
|
||||
invite_user = db.query(User).filter(User.invite_token == token).first()
|
||||
if not invite_user or not invite_user.invite_expires or invite_user.invite_expires < datetime.now(timezone.utc).replace(tzinfo=None):
|
||||
return _bad_token(request)
|
||||
|
||||
form = await request.form()
|
||||
data = {k: str(v).strip() for k, v in form.items()}
|
||||
errors = []
|
||||
|
||||
if not data.get("first_name"):
|
||||
errors.append("Имя обязательно")
|
||||
if not data.get("last_name"):
|
||||
errors.append("Фамилия обязательна")
|
||||
if not data.get("email"):
|
||||
errors.append("Email обязателен")
|
||||
if not data.get("phone"):
|
||||
errors.append("Телефон обязателен")
|
||||
if len(data.get("password", "")) < 8:
|
||||
errors.append("Пароль должен содержать минимум 8 символов")
|
||||
if data.get("password") != data.get("password_confirm"):
|
||||
errors.append("Пароли не совпадают")
|
||||
|
||||
if not errors:
|
||||
# Check uniqueness (excluding current invite_user)
|
||||
dup = db.query(User).filter(
|
||||
or_(User.email == data["email"], User.phone == data["phone"]),
|
||||
User.id != invite_user.id,
|
||||
).first()
|
||||
if dup:
|
||||
if dup.email == data["email"]:
|
||||
errors.append("Пользователь с таким email уже существует")
|
||||
else:
|
||||
errors.append("Пользователь с таким телефоном уже существует")
|
||||
|
||||
if errors:
|
||||
return _render(request, "invite.html", {
|
||||
"user": None, "invite_user": invite_user, "token": token,
|
||||
"errors": errors, "form": data,
|
||||
})
|
||||
|
||||
invite_user.first_name = data["first_name"]
|
||||
invite_user.last_name = data["last_name"]
|
||||
invite_user.email = data["email"]
|
||||
invite_user.phone = data["phone"]
|
||||
invite_user.password_hash = hash_password(data["password"])
|
||||
invite_user.is_email_confirmed = True
|
||||
invite_user.status = UserStatusEnum.active
|
||||
invite_user.invite_token = None
|
||||
invite_user.invite_expires = None
|
||||
db.commit()
|
||||
|
||||
return _render(request, "message.html", {
|
||||
"user": None,
|
||||
"title": "Регистрация завершена!",
|
||||
"message": "Ваш аккаунт активирован. Теперь вы можете войти.",
|
||||
"link": "/login", "link_text": "Войти",
|
||||
})
|
||||
84
web/routes/logs.py
Normal file
84
web/routes/logs.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""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.models.user import UserRoleEnum
|
||||
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)
|
||||
if user.role not in (UserRoleEnum.admin, UserRoleEnum.system):
|
||||
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,
|
||||
})
|
||||
@@ -1,145 +1,143 @@
|
||||
from fastapi import APIRouter, Request, Depends
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from web.auth import get_current_user, verify_password, hash_password
|
||||
from web.auth.password import hash_password, verify_password
|
||||
from web.auth.session import get_current_user
|
||||
from web.config import settings
|
||||
from web.database import get_db
|
||||
from web.models import User
|
||||
from web.schemas import validate_profile, validate_reset_password
|
||||
from web.models.user import User
|
||||
from web.templates_env import templates
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="web/templates")
|
||||
|
||||
|
||||
# VIEW PROFILE
|
||||
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("/profile")
|
||||
def profile_view(request: Request, user: User | None = Depends(get_current_user)):
|
||||
if not user:
|
||||
async def profile_view(request: Request, db: Session = Depends(get_db)):
|
||||
try:
|
||||
user = get_current_user(request, db)
|
||||
except Exception:
|
||||
return RedirectResponse("/login", 303)
|
||||
return templates.TemplateResponse("profile_view.html", {"request": request, "user": user})
|
||||
return _render(request, "profile_view.html", {"user": user})
|
||||
|
||||
|
||||
# EDIT PROFILE
|
||||
@router.get("/profile/edit")
|
||||
def profile_edit_form(request: Request, user: User | None = Depends(get_current_user)):
|
||||
if not user:
|
||||
async def profile_edit_get(request: Request, db: Session = Depends(get_db)):
|
||||
try:
|
||||
user = get_current_user(request, db)
|
||||
except Exception:
|
||||
return RedirectResponse("/login", 303)
|
||||
return templates.TemplateResponse("profile_edit.html", {"request": request, "user": user})
|
||||
return _render(request, "profile_edit.html", {"user": user})
|
||||
|
||||
|
||||
@router.post("/profile/edit")
|
||||
async def profile_edit_submit(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User | None = Depends(get_current_user),
|
||||
):
|
||||
if not user:
|
||||
async def profile_edit_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()
|
||||
data = dict(form)
|
||||
data = {k: str(v).strip() for k, v in form.items()}
|
||||
errors = []
|
||||
|
||||
errors = validate_profile(data)
|
||||
if not data.get("first_name"):
|
||||
errors.append("Имя обязательно")
|
||||
if not data.get("last_name"):
|
||||
errors.append("Фамилия обязательна")
|
||||
if not data.get("phone"):
|
||||
errors.append("Телефон обязателен")
|
||||
|
||||
if not errors:
|
||||
existing = db.query(User).filter(
|
||||
User.phone == data["phone"].strip(), User.id != user.id
|
||||
dup = db.query(User).filter(
|
||||
User.phone == data["phone"], User.id != user.id
|
||||
).first()
|
||||
if existing:
|
||||
if dup:
|
||||
errors.append("Пользователь с таким телефоном уже существует")
|
||||
|
||||
if errors:
|
||||
return templates.TemplateResponse("profile_edit.html", {
|
||||
"request": request, "user": user, "errors": errors, "form": data,
|
||||
})
|
||||
return _render(request, "profile_edit.html", {"user": user, "errors": errors, "form": data})
|
||||
|
||||
user.first_name = data["first_name"].strip()
|
||||
user.last_name = data["last_name"].strip()
|
||||
user.phone = data["phone"].strip()
|
||||
user.first_name = data["first_name"]
|
||||
user.last_name = data["last_name"]
|
||||
user.phone = data["phone"]
|
||||
db.commit()
|
||||
|
||||
return templates.TemplateResponse("profile_edit.html", {
|
||||
"request": request, "user": user, "success": "Профиль обновлен",
|
||||
return _render(request, "profile_edit.html", {
|
||||
"user": user, "success": "Профиль обновлён",
|
||||
})
|
||||
|
||||
|
||||
# CHANGE PASSWORD
|
||||
@router.get("/profile/change-password")
|
||||
def change_password_form(request: Request, user: User | None = Depends(get_current_user)):
|
||||
if not user:
|
||||
async def change_pw_get(request: Request, db: Session = Depends(get_db)):
|
||||
try:
|
||||
user = get_current_user(request, db)
|
||||
except Exception:
|
||||
return RedirectResponse("/login", 303)
|
||||
return templates.TemplateResponse("profile_change_password.html", {"request": request, "user": user})
|
||||
return _render(request, "profile_change_password.html", {"user": user})
|
||||
|
||||
|
||||
@router.post("/profile/change-password")
|
||||
async def change_password_submit(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User | None = Depends(get_current_user),
|
||||
):
|
||||
if not user:
|
||||
async def change_pw_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()
|
||||
data = dict(form)
|
||||
|
||||
current = str(form.get("current_password", ""))
|
||||
new_pw = str(form.get("password", ""))
|
||||
confirm = str(form.get("password_confirm", ""))
|
||||
errors = []
|
||||
current_password = data.get("current_password", "")
|
||||
if not current_password:
|
||||
errors.append("Введите текущий пароль")
|
||||
elif not verify_password(current_password, user.password_hash):
|
||||
errors.append("Неверный текущий пароль")
|
||||
|
||||
password_errors = validate_reset_password(data)
|
||||
errors.extend(password_errors)
|
||||
if not user.password_hash or not verify_password(current, user.password_hash):
|
||||
errors.append("Неверный текущий пароль")
|
||||
if len(new_pw) < 8:
|
||||
errors.append("Новый пароль должен содержать минимум 8 символов")
|
||||
if new_pw != confirm:
|
||||
errors.append("Пароли не совпадают")
|
||||
|
||||
if errors:
|
||||
return templates.TemplateResponse("profile_change_password.html", {
|
||||
"request": request, "user": user, "errors": errors,
|
||||
})
|
||||
return _render(request, "profile_change_password.html", {"user": user, "errors": errors})
|
||||
|
||||
user.password_hash = hash_password(data["password"])
|
||||
user.password_hash = hash_password(new_pw)
|
||||
db.commit()
|
||||
|
||||
return templates.TemplateResponse("profile_change_password.html", {
|
||||
"request": request, "user": user, "success": "Пароль изменен",
|
||||
return _render(request, "profile_change_password.html", {
|
||||
"user": user, "success": "Пароль изменён",
|
||||
})
|
||||
|
||||
|
||||
# DELETE ACCOUNT
|
||||
@router.get("/profile/delete")
|
||||
def delete_account_form(request: Request, user: User | None = Depends(get_current_user)):
|
||||
if not user:
|
||||
async def delete_get(request: Request, db: Session = Depends(get_db)):
|
||||
try:
|
||||
user = get_current_user(request, db)
|
||||
except Exception:
|
||||
return RedirectResponse("/login", 303)
|
||||
return templates.TemplateResponse("profile_delete.html", {"request": request, "user": user})
|
||||
return _render(request, "profile_delete.html", {"user": user})
|
||||
|
||||
|
||||
@router.post("/profile/delete")
|
||||
async def delete_account_submit(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: User | None = Depends(get_current_user),
|
||||
):
|
||||
if not user:
|
||||
async def delete_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()
|
||||
data = dict(form)
|
||||
password = str(form.get("password", ""))
|
||||
|
||||
password = data.get("password", "")
|
||||
if not password:
|
||||
return templates.TemplateResponse("profile_delete.html", {
|
||||
"request": request, "user": user, "errors": ["Введите пароль для подтверждения"],
|
||||
})
|
||||
|
||||
if not verify_password(password, user.password_hash):
|
||||
return templates.TemplateResponse("profile_delete.html", {
|
||||
"request": request, "user": user, "errors": ["Неверный пароль"],
|
||||
if not user.password_hash or not verify_password(password, user.password_hash):
|
||||
return _render(request, "profile_delete.html", {
|
||||
"user": user, "errors": ["Неверный пароль"],
|
||||
})
|
||||
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
request.session.clear()
|
||||
|
||||
return RedirectResponse("/", 303)
|
||||
return RedirectResponse("/login", 303)
|
||||
|
||||
@@ -1,108 +1,101 @@
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import secrets
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from fastapi import APIRouter, Request, Depends
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from web.auth import hash_password
|
||||
from web.auth.password import hash_password
|
||||
from web.config import settings
|
||||
from web.database import get_db
|
||||
from web.models import User
|
||||
from web.schemas import validate_reset_password
|
||||
from web.models.user import User
|
||||
from web.notifications.tasks import send_email_task
|
||||
from web.templates_env import templates
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="web/templates")
|
||||
|
||||
|
||||
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("/forgot-password")
|
||||
def forgot_form(request: Request):
|
||||
return templates.TemplateResponse("forgot_password.html", {"request": request, "user": None})
|
||||
async def forgot_get(request: Request):
|
||||
return _render(request, "forgot_password.html", {"user": None})
|
||||
|
||||
|
||||
@router.post("/forgot-password")
|
||||
async def forgot_submit(request: Request, db: Session = Depends(get_db)):
|
||||
async def forgot_post(request: Request, db: Session = Depends(get_db)):
|
||||
form = await request.form()
|
||||
email = form.get("email", "").strip()
|
||||
|
||||
if email:
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
if user:
|
||||
token = uuid.uuid4().hex
|
||||
user.password_reset_token = token
|
||||
user.password_reset_expires = datetime.now(timezone.utc) + timedelta(
|
||||
minutes=settings.PASSWORD_RESET_EXPIRE_MINUTES
|
||||
)
|
||||
db.commit()
|
||||
|
||||
reset_url = f"{settings.BASE_URL}/reset-password?token={token}"
|
||||
print("=" * 40)
|
||||
print("СБРОС ПАРОЛЯ")
|
||||
print(f"Пользователь: {user.email}")
|
||||
print(f"Ссылка: {reset_url}")
|
||||
print(f"Действительна: {settings.PASSWORD_RESET_EXPIRE_MINUTES} мин.")
|
||||
print("=" * 40)
|
||||
|
||||
return templates.TemplateResponse("message.html", {
|
||||
"request": request, "user": None,
|
||||
"title": "Сброс пароля",
|
||||
"message": "Если аккаунт с таким email существует, ссылка для сброса пароля выведена в консоль сервера.",
|
||||
email = str(form.get("email", "")).strip()
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
if user:
|
||||
token = secrets.token_urlsafe(32)
|
||||
user.password_reset_token = token
|
||||
user.password_reset_expires = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(
|
||||
minutes=settings.PASSWORD_RESET_EXPIRE_MINUTES
|
||||
)
|
||||
db.commit()
|
||||
reset_url = f"{settings.BASE_URL}/reset-password?token={token}"
|
||||
html = f'<p>Сброс пароля: <a href="{reset_url}">{reset_url}</a></p>'
|
||||
send_email_task.delay(user.email, "Сброс пароля — ЭВОСИНК", html)
|
||||
# Always show same message to prevent user enumeration
|
||||
return _render(request, "message.html", {
|
||||
"user": None,
|
||||
"title": "Ссылка отправлена",
|
||||
"message": "Если указанный email зарегистрирован, вы получите ссылку для сброса пароля.",
|
||||
"link": "/login", "link_text": "Войти",
|
||||
})
|
||||
|
||||
|
||||
@router.get("/reset-password")
|
||||
def reset_form(request: Request, token: str, db: Session = Depends(get_db)):
|
||||
async def reset_get(request: Request, db: Session = Depends(get_db)):
|
||||
token = request.query_params.get("token", "")
|
||||
user = db.query(User).filter(User.password_reset_token == token).first()
|
||||
if not user or not user.password_reset_expires:
|
||||
return templates.TemplateResponse("message.html", {
|
||||
"request": request, "user": None,
|
||||
"title": "Ошибка", "message": "Неверная или устаревшая ссылка.",
|
||||
if not user or not user.password_reset_expires or user.password_reset_expires < datetime.now(timezone.utc).replace(tzinfo=None):
|
||||
return _render(request, "message.html", {
|
||||
"user": None, "title": "Ссылка недействительна",
|
||||
"message": "Ссылка для сброса пароля устарела или недействительна.",
|
||||
"link": "/forgot-password", "link_text": "Запросить новую ссылку",
|
||||
})
|
||||
|
||||
if datetime.now(timezone.utc) > user.password_reset_expires.replace(tzinfo=timezone.utc):
|
||||
return templates.TemplateResponse("message.html", {
|
||||
"request": request, "user": None,
|
||||
"title": "Ошибка", "message": "Срок действия ссылки истек.",
|
||||
})
|
||||
|
||||
return templates.TemplateResponse("reset_password.html", {
|
||||
"request": request, "user": None, "token": token,
|
||||
})
|
||||
return _render(request, "reset_password.html", {"user": None, "token": token})
|
||||
|
||||
|
||||
@router.post("/reset-password")
|
||||
async def reset_submit(request: Request, token: str, db: Session = Depends(get_db)):
|
||||
user = db.query(User).filter(User.password_reset_token == token).first()
|
||||
if not user or not user.password_reset_expires:
|
||||
return templates.TemplateResponse("message.html", {
|
||||
"request": request, "user": None,
|
||||
"title": "Ошибка", "message": "Неверная или устаревшая ссылка.",
|
||||
})
|
||||
|
||||
if datetime.now(timezone.utc) > user.password_reset_expires.replace(tzinfo=timezone.utc):
|
||||
return templates.TemplateResponse("message.html", {
|
||||
"request": request, "user": None,
|
||||
"title": "Ошибка", "message": "Срок действия ссылки истек.",
|
||||
})
|
||||
|
||||
async def reset_post(request: Request, db: Session = Depends(get_db)):
|
||||
token = request.query_params.get("token", "")
|
||||
form = await request.form()
|
||||
data = dict(form)
|
||||
errors = validate_reset_password(data)
|
||||
password = str(form.get("password", ""))
|
||||
password_confirm = str(form.get("password_confirm", ""))
|
||||
errors = []
|
||||
|
||||
user = db.query(User).filter(User.password_reset_token == token).first()
|
||||
if not user or not user.password_reset_expires or user.password_reset_expires < datetime.now(timezone.utc).replace(tzinfo=None):
|
||||
return _render(request, "message.html", {
|
||||
"user": None, "title": "Ссылка недействительна",
|
||||
"message": "Ссылка для сброса пароля устарела.",
|
||||
"link": "/forgot-password", "link_text": "Запросить новую ссылку",
|
||||
})
|
||||
|
||||
if len(password) < 8:
|
||||
errors.append("Пароль должен содержать минимум 8 символов")
|
||||
if password != password_confirm:
|
||||
errors.append("Пароли не совпадают")
|
||||
|
||||
if errors:
|
||||
return templates.TemplateResponse("reset_password.html", {
|
||||
"request": request, "user": None, "token": token, "errors": errors,
|
||||
return _render(request, "reset_password.html", {
|
||||
"user": None, "token": token, "errors": errors,
|
||||
})
|
||||
|
||||
user.password_hash = hash_password(data["password"])
|
||||
user.password_hash = hash_password(password)
|
||||
user.password_reset_token = None
|
||||
user.password_reset_expires = None
|
||||
db.commit()
|
||||
|
||||
return templates.TemplateResponse("message.html", {
|
||||
"request": request, "user": None,
|
||||
"title": "Пароль изменен",
|
||||
"message": "Ваш пароль успешно изменен. Теперь вы можете войти.",
|
||||
return _render(request, "message.html", {
|
||||
"user": None, "title": "Пароль изменён",
|
||||
"message": "Ваш пароль успешно изменён.",
|
||||
"link": "/login", "link_text": "Войти",
|
||||
})
|
||||
|
||||
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 = "1" in form.getlist("evo_mirror_enabled")
|
||||
vk_mirror_enabled = "1" in form.getlist("vk_mirror_enabled")
|
||||
sync_enabled = "1" in form.getlist("is_enabled")
|
||||
|
||||
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,
|
||||
})
|
||||
@@ -1,52 +0,0 @@
|
||||
import re
|
||||
|
||||
|
||||
def validate_registration(data: dict) -> list[str]:
|
||||
errors = []
|
||||
if not data.get("first_name", "").strip():
|
||||
errors.append("Введите имя")
|
||||
if not data.get("last_name", "").strip():
|
||||
errors.append("Введите фамилию")
|
||||
email = data.get("email", "").strip()
|
||||
if not email or not re.match(r"^[^@]+@[^@]+\.[^@]+$", email):
|
||||
errors.append("Введите корректный email")
|
||||
phone = data.get("phone", "").strip()
|
||||
if not phone or not re.match(r"^\+?[\d\s\-()]{7,20}$", phone):
|
||||
errors.append("Введите корректный телефон")
|
||||
password = data.get("password", "")
|
||||
if len(password) < 8:
|
||||
errors.append("Пароль должен быть не менее 8 символов")
|
||||
if password != data.get("password_confirm", ""):
|
||||
errors.append("Пароли не совпадают")
|
||||
return errors
|
||||
|
||||
|
||||
def validate_login(data: dict) -> list[str]:
|
||||
errors = []
|
||||
if not data.get("email", "").strip():
|
||||
errors.append("Введите email")
|
||||
if not data.get("password", ""):
|
||||
errors.append("Введите пароль")
|
||||
return errors
|
||||
|
||||
|
||||
def validate_reset_password(data: dict) -> list[str]:
|
||||
errors = []
|
||||
password = data.get("password", "")
|
||||
if len(password) < 8:
|
||||
errors.append("Пароль должен быть не менее 8 символов")
|
||||
if password != data.get("password_confirm", ""):
|
||||
errors.append("Пароли не совпадают")
|
||||
return errors
|
||||
|
||||
|
||||
def validate_profile(data: dict) -> list[str]:
|
||||
errors = []
|
||||
if not data.get("first_name", "").strip():
|
||||
errors.append("Введите имя")
|
||||
if not data.get("last_name", "").strip():
|
||||
errors.append("Введите фамилию")
|
||||
phone = data.get("phone", "").strip()
|
||||
if not phone or not re.match(r"^\+?[\d\s\-()]{7,20}$", phone):
|
||||
errors.append("Введите корректный телефон")
|
||||
return errors
|
||||
BIN
web/static/favicon-16.png
Normal file
BIN
web/static/favicon-16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 125 B |
BIN
web/static/favicon-32.png
Normal file
BIN
web/static/favicon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 173 B |
BIN
web/static/favicon.ico
Normal file
BIN
web/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 336 B |
6
web/static/favicon.svg
Normal file
6
web/static/favicon.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="8" fill="#FF5500"/>
|
||||
<path d="M10 13h12l-1.8 10H11.8L10 13Z" fill="white" opacity="0.95"/>
|
||||
<path d="M13 13v-2.5a3 3 0 016 0V13" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
|
||||
<line x1="13.5" y1="17.5" x2="18.5" y2="17.5" stroke="#FF5500" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 437 B |
@@ -1,39 +1,415 @@
|
||||
/* Brand overrides */
|
||||
:root {
|
||||
--bs-primary: #F05023;
|
||||
--bs-primary-rgb: 240, 80, 35;
|
||||
--bs-link-color: #0986E2;
|
||||
--bs-link-hover-color: #0670c0;
|
||||
/* ─── Reset ──────────────────────────────────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; }
|
||||
body { font-family: 'Golos Text', sans-serif; background: #F4F5F7; color: #1C1F2E; font-size: 14px; }
|
||||
a { text-decoration: none; color: inherit; }
|
||||
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #E4E6EE; border-radius: 3px; }
|
||||
|
||||
/* ─── Utility ─────────────────────────────────────────────────────────── */
|
||||
.mono { font-family: 'JetBrains Mono', monospace; }
|
||||
.hr { height: 1px; background: #E4E6EE; margin: 18px 0; }
|
||||
.w-100 { width: 100%; }
|
||||
.text-center { text-align: center; }
|
||||
.d-none { display: none; }
|
||||
|
||||
/* ─── Layout ──────────────────────────────────────────────────────────── */
|
||||
.shell { display: flex; min-height: 100vh; }
|
||||
.sidebar {
|
||||
width: 228px; min-height: 100vh; flex-shrink: 0;
|
||||
background: #1C1F2E;
|
||||
display: flex; flex-direction: column;
|
||||
position: sticky; top: 0; height: 100vh; overflow-y: auto;
|
||||
}
|
||||
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
|
||||
/* ─── Sidebar ─────────────────────────────────────────────────────────── */
|
||||
.sb-logo {
|
||||
padding: 22px 20px 18px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.07);
|
||||
display: flex; align-items: center; gap: 11px;
|
||||
}
|
||||
.sb-logo-icon {
|
||||
width: 36px; height: 36px; border-radius: 9px;
|
||||
background: #FF5500;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sb-logo-name { font-size: 15px; font-weight: 800; color: #fff; line-height: 1.1; letter-spacing: -0.02em; }
|
||||
.sb-logo-sub { font-size: 10px; color: #5C6278; font-weight: 500; margin-top: 1px; letter-spacing: 0.03em; }
|
||||
.sb-nav { flex: 1; padding: 10px 12px; display: flex; flex-direction: column; gap: 1px; }
|
||||
.sb-section {
|
||||
font-size: 9.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em;
|
||||
color: #5C6278; padding: 14px 8px 5px;
|
||||
}
|
||||
.sb-item {
|
||||
display: flex; align-items: center; gap: 9px;
|
||||
padding: 9px 10px; border-radius: 8px; cursor: pointer;
|
||||
font-size: 13.5px; font-weight: 500; color: #A8ADC3;
|
||||
transition: all 0.13s; text-decoration: none; position: relative;
|
||||
}
|
||||
.sb-item:hover { background: #252A3D; color: #fff; }
|
||||
.sb-item.active { background: rgba(255,85,0,0.18); color: #fff; }
|
||||
.sb-item.active .sb-icon { color: #FF5500; }
|
||||
.sb-icon { font-size: 16px; width: 20px; text-align: center; flex-shrink: 0; }
|
||||
.sb-badge {
|
||||
margin-left: auto; font-size: 10px; font-weight: 700;
|
||||
padding: 1px 6px; border-radius: 9px;
|
||||
background: rgba(255,85,0,0.25); color: #FF5500;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
.sb-badge.err { background: rgba(229,57,53,0.2); color: #E53935; }
|
||||
.sb-user {
|
||||
padding: 14px 16px; border-top: 1px solid rgba(255,255,255,0.07);
|
||||
display: flex; align-items: center; gap: 10px; cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.sb-user:hover { background: #252A3D; }
|
||||
.avatar {
|
||||
width: 34px; height: 34px; border-radius: 50%; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 12px; font-weight: 700; color: #fff;
|
||||
background: linear-gradient(135deg, #FF5500 0%, #FF8C42 100%);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.avatar.admin { background: linear-gradient(135deg, #6B48FF 0%, #A78BFA 100%); }
|
||||
.sb-user-name { font-size: 12.5px; font-weight: 600; color: #fff; }
|
||||
.sb-user-role { font-size: 10.5px; color: #5C6278; margin-top: 1px; }
|
||||
.role-chip {
|
||||
display: inline-block; font-size: 9px; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.05em;
|
||||
padding: 1px 5px; border-radius: 4px;
|
||||
}
|
||||
.role-chip.user { background: rgba(255,85,0,0.2); color: #FF5500; }
|
||||
.role-chip.admin { background: rgba(107,72,255,0.2); color: #A78BFA; }
|
||||
|
||||
/* ─── Topbar ──────────────────────────────────────────────────────────── */
|
||||
.topbar {
|
||||
height: 56px; border-bottom: 1px solid #E4E6EE;
|
||||
display: flex; align-items: center; padding: 0 28px; gap: 14px;
|
||||
background: #FFFFFF; position: sticky; top: 0; z-index: 10;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.topbar-title { font-size: 15px; font-weight: 700; flex: 1; color: #1C1F2E; }
|
||||
.conn-pill {
|
||||
display: flex; align-items: center; gap: 6px; padding: 5px 12px;
|
||||
border-radius: 20px; border: 1px solid #E4E6EE;
|
||||
font-size: 12px; font-weight: 500; color: #5C6278; background: #F4F5F7;
|
||||
}
|
||||
.dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
|
||||
.dot.g { background: #17A865; box-shadow: 0 0 5px #17A865; }
|
||||
.dot.r { background: #E53935; box-shadow: 0 0 5px #E53935; }
|
||||
.dot.y { background: #F59E0B; box-shadow: 0 0 5px #F59E0B; }
|
||||
.dot.d { background: #CDD0DC; }
|
||||
.dot.pulse { animation: blink 2s ease-in-out infinite; }
|
||||
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.4} }
|
||||
|
||||
/* ─── Content ─────────────────────────────────────────────────────────── */
|
||||
.content { flex: 1; padding: 28px 32px; overflow-y: auto; }
|
||||
.pg-title { font-size: 22px; font-weight: 800; color: #1C1F2E; letter-spacing: -0.02em; }
|
||||
.pg-sub { font-size: 13px; color: #5C6278; margin-top: 3px; margin-bottom: 22px; }
|
||||
|
||||
/* ─── Cards ───────────────────────────────────────────────────────────── */
|
||||
.card {
|
||||
background: #FFFFFF; border: 1px solid #E4E6EE;
|
||||
border-radius: 12px; padding: 22px 24px;
|
||||
}
|
||||
.card-title { font-size: 14px; font-weight: 700; color: #1C1F2E; }
|
||||
.card-sub { font-size: 12px; color: #5C6278; margin-top: 2px; }
|
||||
.card-hd { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 16px; }
|
||||
|
||||
/* ─── Stat cards ──────────────────────────────────────────────────────── */
|
||||
.stat-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 14px; margin-bottom: 20px; }
|
||||
.stat-card {
|
||||
background: #FFFFFF; border: 1px solid #E4E6EE; border-radius: 12px;
|
||||
padding: 18px 20px; position: relative; overflow: hidden;
|
||||
}
|
||||
.stat-card::before {
|
||||
content:''; position: absolute; top: 0; left: 0; right: 0; height: 3px; border-radius: 12px 12px 0 0;
|
||||
}
|
||||
.stat-card.or::before { background: #FF5500; }
|
||||
.stat-card.gr::before { background: #17A865; }
|
||||
.stat-card.yl::before { background: #F59E0B; }
|
||||
.stat-card.rd::before { background: #E53935; }
|
||||
.stat-card.bl::before { background: #3B6FFF; }
|
||||
.stat-lbl { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.07em; color: #9EA8BE; }
|
||||
.stat-val { font-size: 30px; font-weight: 800; color: #1C1F2E; margin: 5px 0 2px; font-family: 'JetBrains Mono', monospace; letter-spacing: -0.03em; }
|
||||
.stat-delta { font-size: 12px; color: #5C6278; }
|
||||
.stat-icon { position: absolute; right: 16px; top: 50%; transform: translateY(-50%); font-size: 28px; opacity: 0.1; }
|
||||
|
||||
/* ─── Grids ───────────────────────────────────────────────────────────── */
|
||||
.g2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.g3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 14px; }
|
||||
.g4 { display: grid; grid-template-columns: repeat(4,1fr); gap: 14px; }
|
||||
|
||||
/* ─── Buttons ─────────────────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
padding: 8px 18px; border-radius: 8px; font-size: 13px; font-weight: 600;
|
||||
cursor: pointer; border: none; font-family: 'Golos Text', sans-serif;
|
||||
transition: all 0.14s; white-space: nowrap; text-decoration: none;
|
||||
}
|
||||
.btn-primary { background: #FF5500; color: #fff; }
|
||||
.btn-primary:hover { background: #E64D00; box-shadow: 0 3px 12px rgba(255,85,0,0.3); }
|
||||
.btn-outline { background: transparent; border: 1.5px solid #E4E6EE; color: #5C6278; }
|
||||
.btn-outline:hover { border-color: #CDD0DC; color: #1C1F2E; background: #F9FAFB; }
|
||||
.btn-ghost { background: transparent; color: #5C6278; padding: 6px 10px; border: none; }
|
||||
.btn-ghost:hover { color: #1C1F2E; background: #F4F5F7; }
|
||||
.btn-danger { background: #FEF1F1; color: #E53935; border: 1.5px solid #F4AEAE; }
|
||||
.btn-danger:hover { background: #fce4e4; }
|
||||
.btn-sm { padding: 6px 13px; font-size: 12px; border-radius: 7px; }
|
||||
.btn-xs { padding: 4px 9px; font-size: 11.5px; border-radius: 6px; }
|
||||
|
||||
/* ─── Tags ────────────────────────────────────────────────────────────── */
|
||||
.tag {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
padding: 3px 8px; border-radius: 5px; font-size: 11.5px; font-weight: 600;
|
||||
}
|
||||
.tag-gr { background: #EDFAF4; color: #17A865; border: 1px solid #A7E8CC; }
|
||||
.tag-rd { background: #FEF1F1; color: #E53935; border: 1px solid #F4AEAE; }
|
||||
.tag-yl { background: #FFFBEB; color: #F59E0B; border: 1px solid #FCD678; }
|
||||
.tag-or { background: #FFF2EC; color: #FF5500; border: 1px solid #FFD9C7; }
|
||||
.tag-dim { background: #F4F5F7; color: #5C6278; border: 1px solid #E4E6EE; }
|
||||
.tag-bl { background: #EEF3FF; color: #3B6FFF; border: 1px solid #C7D7FF; }
|
||||
|
||||
/* ─── Table ───────────────────────────────────────────────────────────── */
|
||||
.tbl { width: 100%; border-collapse: collapse; }
|
||||
.tbl th {
|
||||
text-align: left; font-size: 10.5px; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.08em; color: #9EA8BE;
|
||||
padding: 10px 16px; border-bottom: 1.5px solid #E4E6EE;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tbl td { padding: 12px 16px; font-size: 13px; border-bottom: 1px solid #E4E6EE; vertical-align: middle; }
|
||||
.tbl tr:last-child td { border-bottom: none; }
|
||||
.tbl tbody tr:hover td { background: #F9FAFB; }
|
||||
.tbl-name { font-weight: 600; color: #1C1F2E; }
|
||||
.tbl-sub { font-size: 11px; color: #5C6278; margin-top: 1px; }
|
||||
|
||||
/* ─── Toggle ──────────────────────────────────────────────────────────── */
|
||||
.tog {
|
||||
width: 38px; height: 22px; border-radius: 11px;
|
||||
background: #E4E6EE; position: relative; cursor: pointer; transition: background 0.18s; flex-shrink: 0;
|
||||
display: inline-block;
|
||||
}
|
||||
.tog.on { background: #FF5500; }
|
||||
.tog::after {
|
||||
content:''; position: absolute; top: 3px; left: 3px;
|
||||
width: 16px; height: 16px; background: #fff; border-radius: 50%;
|
||||
transition: transform 0.18s; box-shadow: 0 1px 4px rgba(0,0,0,0.15);
|
||||
}
|
||||
.tog.on::after { transform: translateX(16px); }
|
||||
|
||||
/* ─── Progress ────────────────────────────────────────────────────────── */
|
||||
.prog { height: 5px; background: #E4E6EE; border-radius: 3px; overflow: hidden; }
|
||||
.prog-fill { height: 100%; border-radius: 3px; transition: width 0.4s; }
|
||||
.prog-fill.or { background: #FF5500; }
|
||||
.prog-fill.gr { background: #17A865; }
|
||||
|
||||
/* ─── Inputs ──────────────────────────────────────────────────────────── */
|
||||
.inp {
|
||||
background: #FFFFFF; border: 1.5px solid #E4E6EE; border-radius: 8px;
|
||||
padding: 8px 12px; font-size: 13px; color: #1C1F2E;
|
||||
font-family: 'Golos Text', sans-serif; outline: none;
|
||||
transition: border-color 0.14s; width: 100%;
|
||||
display: block;
|
||||
}
|
||||
.inp:focus { border-color: #FF5500; box-shadow: 0 0 0 3px #FFF2EC; }
|
||||
.inp::placeholder { color: #9EA8BE; }
|
||||
select.inp { cursor: pointer; }
|
||||
.form-row { margin-bottom: 14px; }
|
||||
.form-lbl { font-size: 11.5px; font-weight: 600; color: #5C6278; margin-bottom: 5px; text-transform: uppercase; letter-spacing: 0.04em; display: block; }
|
||||
|
||||
/* ─── Alerts ──────────────────────────────────────────────────────────── */
|
||||
.alert { border-radius: 9px; padding: 11px 14px; font-size: 12.5px; display: flex; gap: 9px; margin-bottom: 16px; }
|
||||
.alert-gr { background: #EDFAF4; border: 1px solid #A7E8CC; color: #136B41; }
|
||||
.alert-yl { background: #FFFBEB; border: 1px solid #FCD678; color: #92680A; }
|
||||
.alert-bl { background: #EEF3FF; border: 1px solid #C7D7FF; color: #3B6FFF; }
|
||||
.alert-rd { background: #FEF1F1; border: 1px solid #F4AEAE; color: #E53935; }
|
||||
|
||||
/* ─── Tabs ────────────────────────────────────────────────────────────── */
|
||||
.tabs { display: flex; gap: 0; border-bottom: 1.5px solid #E4E6EE; margin-bottom: 20px; }
|
||||
.tab {
|
||||
padding: 9px 18px; font-size: 13px; font-weight: 600; color: #5C6278;
|
||||
cursor: pointer; border-bottom: 2.5px solid transparent; margin-bottom: -1.5px; transition: all 0.13s;
|
||||
text-decoration: none; display: inline-block;
|
||||
}
|
||||
.tab:hover { color: #1C1F2E; }
|
||||
.tab.active { color: #FF5500; border-bottom-color: #FF5500; }
|
||||
|
||||
/* ─── Conn detail ─────────────────────────────────────────────────────── */
|
||||
.conn-detail {
|
||||
background: #F4F5F7; border: 1px solid #E4E6EE; border-radius: 9px;
|
||||
padding: 12px 16px; display: flex; flex-direction: column; gap: 9px;
|
||||
}
|
||||
.conn-row { display: flex; align-items: center; justify-content: space-between; }
|
||||
.conn-k { font-size: 12px; color: #9EA8BE; }
|
||||
.conn-v { font-size: 12px; font-weight: 600; color: #1C1F2E; font-family: 'JetBrains Mono', monospace; }
|
||||
|
||||
/* ─── Activity ────────────────────────────────────────────────────────── */
|
||||
.act-item { display: flex; gap: 10px; padding: 10px 0; border-bottom: 1px solid #E4E6EE; }
|
||||
.act-item:last-child { border-bottom: none; }
|
||||
.act-icon { width: 32px; height: 32px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 14px; flex-shrink: 0; }
|
||||
.act-text { font-size: 12.5px; color: #1C1F2E; }
|
||||
.act-time { font-size: 11px; color: #9EA8BE; margin-top: 2px; font-family: 'JetBrains Mono', monospace; }
|
||||
|
||||
/* ─── Section sep ─────────────────────────────────────────────────────── */
|
||||
.section-sep { display: flex; align-items: center; gap: 10px; margin: 20px 0; }
|
||||
.section-sep-line { flex: 1; height: 1px; background: #E4E6EE; }
|
||||
.section-sep-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: #9EA8BE; }
|
||||
|
||||
/* ─── Pipeline ────────────────────────────────────────────────────────── */
|
||||
.pipeline { display: flex; align-items: center; padding: 18px 0; }
|
||||
.pipe-step { display: flex; flex-direction: column; align-items: center; gap: 7px; flex: 1; }
|
||||
.pipe-node { width: 46px; height: 46px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 18px; border: 2px solid; }
|
||||
.pipe-node.done { border-color: #17A865; background: #EDFAF4; }
|
||||
.pipe-node.active { border-color: #FF5500; background: #FFF2EC; }
|
||||
.pipe-node.idle { border-color: #E4E6EE; background: #F4F5F7; }
|
||||
.pipe-lbl { font-size: 11px; font-weight: 600; color: #5C6278; text-align: center; }
|
||||
.pipe-line { flex: 1; height: 2px; max-width: 52px; align-self: flex-start; margin-top: 22px; }
|
||||
.pipe-line.done { background: #17A865; }
|
||||
.pipe-line.idle { background: #E4E6EE; }
|
||||
|
||||
/* ─── Sync log ────────────────────────────────────────────────────────── */
|
||||
.log-row { display: flex; align-items: flex-start; gap: 10px; padding: 10px 0; border-bottom: 1px solid #E4E6EE; }
|
||||
.log-row:last-child { border-bottom: none; }
|
||||
.log-bullet { width: 8px; height: 8px; border-radius: 50%; margin-top: 4px; flex-shrink: 0; }
|
||||
.log-msg { font-size: 12.5px; color: #1C1F2E; }
|
||||
.log-meta { font-size: 11px; color: #9EA8BE; font-family: 'JetBrains Mono', monospace; margin-top: 2px; }
|
||||
|
||||
/* ─── User row actions ────────────────────────────────────────────────── */
|
||||
.action-row { display: flex; gap: 6px; }
|
||||
|
||||
/* ─── Inline status ───────────────────────────────────────────────────── */
|
||||
.inline-st { display: flex; align-items: center; gap: 5px; font-size: 12.5px; font-weight: 500; }
|
||||
|
||||
/* ─── List group (legacy compat) ──────────────────────────────────────── */
|
||||
.list-group { list-style: none; padding: 0; margin: 0; }
|
||||
.list-group-item {
|
||||
padding: 10px 0; border-bottom: 1px solid #E4E6EE;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
.list-group-item:last-child { border-bottom: none; }
|
||||
.list-group-item .lbl { font-size: 12px; color: #9EA8BE; }
|
||||
.list-group-item .val { font-size: 12px; font-weight: 600; color: #1C1F2E; font-family: 'JetBrains Mono', monospace; }
|
||||
|
||||
/* ─── Breadcrumb ──────────────────────────────────────────────────────── */
|
||||
.breadcrumb { display: flex; align-items: center; gap: 6px; margin-bottom: 16px; font-size: 12px; color: #9EA8BE; list-style: none; padding: 0; }
|
||||
.breadcrumb a { color: #5C6278; text-decoration: none; }
|
||||
.breadcrumb a:hover { color: #FF5500; }
|
||||
.breadcrumb li + li::before { content: "/"; margin-right: 6px; color: #CDD0DC; }
|
||||
|
||||
/* ─── Empty state ─────────────────────────────────────────────────────── */
|
||||
.empty-state {
|
||||
text-align: center; padding: 48px 24px; color: #9EA8BE;
|
||||
}
|
||||
.empty-state i { font-size: 2.5rem; display: block; margin-bottom: 12px; }
|
||||
.empty-state p { font-size: 13px; }
|
||||
|
||||
/* ─── Table scroll wrapper ────────────────────────────────────────────── */
|
||||
.table-wrap { overflow-x: auto; width: 100%; }
|
||||
|
||||
/* ─── Pagination ──────────────────────────────────────────────────────── */
|
||||
.pagination { display: flex; align-items: center; justify-content: center; gap: 8px; padding: 16px; }
|
||||
|
||||
/* ─── Spinner ─────────────────────────────────────────────────────────── */
|
||||
.spinner {
|
||||
display: inline-block; width: 20px; height: 20px;
|
||||
border: 2px solid #E4E6EE; border-top-color: #FF5500;
|
||||
border-radius: 50%; animation: spin 0.75s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ─── Code ────────────────────────────────────────────────────────────── */
|
||||
pre, code { font-family: 'JetBrains Mono', monospace; font-size: 12px; }
|
||||
pre {
|
||||
background: #F4F5F7; border: 1px solid #E4E6EE; border-radius: 8px;
|
||||
padding: 12px 14px; overflow-x: auto; white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #F05023 !important;
|
||||
/* ─── Dialog ──────────────────────────────────────────────────────────── */
|
||||
dialog {
|
||||
border: none; border-radius: 14px; padding: 0;
|
||||
box-shadow: 0 8px 40px rgba(0,0,0,0.16);
|
||||
max-width: 520px; width: 100%;
|
||||
}
|
||||
dialog::backdrop { background: rgba(0,0,0,0.45); }
|
||||
.dialog-hd {
|
||||
padding: 20px 24px 0; display: flex; align-items: center;
|
||||
justify-content: space-between; margin-bottom: 16px;
|
||||
}
|
||||
.dialog-title { font-size: 16px; font-weight: 800; color: #1C1F2E; }
|
||||
.dialog-body { padding: 0 24px 24px; }
|
||||
.dialog-close {
|
||||
background: none; border: none; cursor: pointer; font-size: 18px;
|
||||
color: #9EA8BE; padding: 4px; border-radius: 6px; transition: color 0.13s;
|
||||
}
|
||||
.dialog-close:hover { color: #1C1F2E; }
|
||||
|
||||
/* ─── Filter bar ──────────────────────────────────────────────────────── */
|
||||
.filter-bar {
|
||||
display: flex; gap: 8px; flex-wrap: wrap; align-items: center;
|
||||
padding: 14px 20px; border-bottom: 1px solid #E4E6EE;
|
||||
}
|
||||
|
||||
.brand-border {
|
||||
border-color: #F05023 !important;
|
||||
/* ─── Viewed-as banner ────────────────────────────────────────────────── */
|
||||
.view-as-bar {
|
||||
background: #E64D00; color: #fff; text-align: center;
|
||||
padding: 6px 16px; font-size: 13px;
|
||||
}
|
||||
.view-as-bar strong { font-weight: 700; }
|
||||
|
||||
.btn-primary {
|
||||
--bs-btn-bg: #F05023;
|
||||
--bs-btn-border-color: #F05023;
|
||||
--bs-btn-hover-bg: #d44420;
|
||||
--bs-btn-hover-border-color: #d44420;
|
||||
--bs-btn-active-bg: #c03d1c;
|
||||
--bs-btn-active-border-color: #c03d1c;
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* LOGIN PAGE */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
.login-wrap { min-height: 100vh; display: flex; background: #F4F5F7; }
|
||||
.login-left {
|
||||
flex: 1; background: #1C1F2E; display: flex; flex-direction: column;
|
||||
justify-content: space-between; padding: 48px;
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
--bs-btn-bg: #0986E2;
|
||||
--bs-btn-border-color: #0986E2;
|
||||
--bs-btn-hover-bg: #0770c0;
|
||||
--bs-btn-hover-border-color: #0770c0;
|
||||
--bs-btn-active-bg: #065fa3;
|
||||
--bs-btn-active-border-color: #065fa3;
|
||||
.login-left-bg {
|
||||
position: absolute; inset: 0; pointer-events: none;
|
||||
background:
|
||||
radial-gradient(ellipse 70% 55% at 0% 110%, rgba(255,85,0,0.18) 0%, transparent 65%),
|
||||
radial-gradient(ellipse 50% 40% at 100% -10%, rgba(255,85,0,0.10) 0%, transparent 60%);
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: #F05023 !important;
|
||||
.login-left-pattern {
|
||||
position: absolute; inset: 0; pointer-events: none;
|
||||
background-image: radial-gradient(circle, rgba(255,255,255,0.04) 1px, transparent 1px);
|
||||
background-size: 28px 28px;
|
||||
}
|
||||
.login-brand { display: flex; align-items: center; gap: 12px; position: relative; z-index: 1; }
|
||||
.login-brand-icon { width: 44px; height: 44px; border-radius: 11px; background: #FF5500; display: flex; align-items: center; justify-content: center; }
|
||||
.login-brand-name { font-size: 20px; font-weight: 800; color: #fff; letter-spacing: -0.02em; }
|
||||
.login-brand-sub { font-size: 11px; color: #5C6278; }
|
||||
.login-hero { position: relative; z-index: 1; }
|
||||
.login-hero-title { font-size: 32px; font-weight: 800; color: #fff; line-height: 1.25; letter-spacing: -0.03em; margin-bottom: 14px; }
|
||||
.login-hero-title em { color: #FF5500; font-style: normal; }
|
||||
.login-hero-body { font-size: 14px; color: #A8ADC3; line-height: 1.6; max-width: 340px; }
|
||||
.login-chips { display: flex; gap: 10px; margin-top: 22px; flex-wrap: wrap; }
|
||||
.login-chip {
|
||||
display: flex; align-items: center; gap: 7px; padding: 7px 13px;
|
||||
border-radius: 20px; background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.1);
|
||||
font-size: 12px; color: rgba(255,255,255,0.65); font-weight: 500;
|
||||
}
|
||||
.login-footer { font-size: 11px; color: #5C6278; position: relative; z-index: 1; }
|
||||
.login-right { width: 460px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; padding: 40px; }
|
||||
.login-box { width: 100%; max-width: 360px; }
|
||||
.login-box-title { font-size: 24px; font-weight: 800; color: #1C1F2E; letter-spacing: -0.02em; margin-bottom: 4px; }
|
||||
.login-box-sub { font-size: 13px; color: #5C6278; margin-bottom: 28px; }
|
||||
.login-btn {
|
||||
width: 100%; padding: 11px; border-radius: 9px; border: none; cursor: pointer;
|
||||
background: #FF5500; color: #fff; font-size: 14px; font-weight: 700;
|
||||
font-family: 'Golos Text', sans-serif; transition: all 0.15s; margin-top: 6px; display: block;
|
||||
}
|
||||
.login-btn:hover { background: #E64D00; box-shadow: 0 6px 20px rgba(255,85,0,0.3); }
|
||||
.login-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.login-divider { display: flex; align-items: center; gap: 10px; margin: 18px 0; }
|
||||
.login-divider span { font-size: 11px; color: #9EA8BE; }
|
||||
.login-divider::before, .login-divider::after { content:''; flex: 1; height: 1px; background: #E4E6EE; }
|
||||
.login-hint { font-size: 11.5px; color: #9EA8BE; text-align: center; margin-top: 14px; line-height: 1.5; }
|
||||
.login-hint a { color: #FF5500; text-decoration: none; }
|
||||
.login-hint a:hover { text-decoration: underline; }
|
||||
|
||||
0
web/tasks/__init__.py
Normal file
0
web/tasks/__init__.py
Normal file
233
web/tasks/catalog.py
Normal file
233
web/tasks/catalog.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""
|
||||
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
|
||||
from web.models.user import User, UserRoleEnum
|
||||
|
||||
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)
|
||||
.join(User, User.id == EvotorConnection.user_id)
|
||||
.filter(
|
||||
EvotorConnection.user_id.isnot(None),
|
||||
EvotorConnection.access_token.isnot(None),
|
||||
EvotorConnection.access_token != "",
|
||||
User.role == UserRoleEnum.user,
|
||||
)
|
||||
.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
|
||||
61
web/tasks/celery_app.py
Normal file
61
web/tasks/celery_app.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from celery import Celery
|
||||
from celery.schedules import timedelta
|
||||
|
||||
from web.config import settings
|
||||
|
||||
celery_app = Celery("evosync", broker=settings.REDIS_URL, backend=settings.REDIS_URL)
|
||||
|
||||
celery_app.conf.update(
|
||||
task_serializer="json",
|
||||
result_serializer="json",
|
||||
accept_content=["json"],
|
||||
timezone="Europe/Moscow",
|
||||
enable_utc=True,
|
||||
task_track_started=True,
|
||||
task_acks_late=True,
|
||||
worker_prefetch_multiplier=1,
|
||||
broker_connection_retry_on_startup=True,
|
||||
task_routes={
|
||||
"web.tasks.sync.*": {"queue": "sync"},
|
||||
"web.tasks.vk_sync.*": {"queue": "sync"},
|
||||
"web.tasks.health.*": {"queue": "health"},
|
||||
"web.tasks.catalog.*": {"queue": "default"},
|
||||
"web.tasks.vk_catalog.*": {"queue": "default"},
|
||||
"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"
|
||||
190
web/tasks/vk_catalog.py
Normal file
190
web/tasks/vk_catalog.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
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
|
||||
from web.models.user import User, UserRoleEnum
|
||||
|
||||
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)
|
||||
.join(User, User.id == VkConnection.user_id)
|
||||
.filter(
|
||||
VkConnection.user_id.isnot(None),
|
||||
VkConnection.access_token.isnot(None),
|
||||
VkConnection.access_token != "",
|
||||
VkConnection.vk_user_id.isnot(None),
|
||||
VkConnection.vk_user_id != "",
|
||||
User.role == UserRoleEnum.user,
|
||||
)
|
||||
.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
|
||||
441
web/tasks/vk_sync.py
Normal file
441
web/tasks/vk_sync.py
Normal file
@@ -0,0 +1,441 @@
|
||||
"""
|
||||
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,
|
||||
)
|
||||
from web.models.user import User, UserRoleEnum
|
||||
|
||||
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)
|
||||
.join(User, User.id == VkConnection.user_id)
|
||||
.filter(
|
||||
VkConnection.user_id.isnot(None),
|
||||
VkConnection.access_token.isnot(None),
|
||||
VkConnection.access_token != "",
|
||||
VkConnection.vk_user_id.isnot(None),
|
||||
VkConnection.vk_user_id != "",
|
||||
User.role == UserRoleEnum.user,
|
||||
)
|
||||
.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 page_title %}API Логи{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="pg-title">API Логи</div>
|
||||
<div class="pg-sub">Журнал всех исходящих запросов · Найдено: {{ total }}</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card" style="margin-bottom:14px;padding:14px 20px;">
|
||||
<form method="get" action="/admin/logs" style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;">
|
||||
<select class="inp" 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 class="inp" 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 class="inp" 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 class="inp" 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 class="inp" type="search" name="q" value="{{ filter_q }}"
|
||||
placeholder="URL или тело ответа…" style="flex:1;min-width:160px;">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Применить</button>
|
||||
{% if filter_service or filter_method or filter_status or filter_q or filter_hours != 24 %}
|
||||
<a href="/admin/logs" class="btn btn-outline btn-sm">Сбросить</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:0;">
|
||||
{% if logs %}
|
||||
<div class="table-wrap">
|
||||
<table class="tbl" style="font-size:12px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:150px;">Время</th>
|
||||
<th style="width:80px;">Сервис</th>
|
||||
<th style="width:50px;">Метод</th>
|
||||
<th style="width:60px;">Статус</th>
|
||||
<th style="width:70px;">Мс</th>
|
||||
<th>URL</th>
|
||||
<th style="width:32px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in logs %}
|
||||
{% set is_error = log.response_status and log.response_status >= 400 %}
|
||||
<tr style="cursor:pointer;" onclick="toggleDetail({{ log.id }})">
|
||||
<td><span class="mono" style="font-size:11px;color:#9EA8BE;">{{ log.created_at | datefmt }}</span></td>
|
||||
<td>
|
||||
{% if log.service == 'evotor' %}
|
||||
<span class="tag tag-bl" style="font-size:10px;padding:2px 6px;">{{ log.service }}</span>
|
||||
{% elif log.service == 'vk' %}
|
||||
<span class="tag tag-or" style="font-size:10px;padding:2px 6px;">{{ log.service }}</span>
|
||||
{% else %}
|
||||
<span class="tag tag-dim" style="font-size:10px;padding:2px 6px;">{{ log.service }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><span class="mono" style="font-size:11px;">{{ log.method }}</span></td>
|
||||
<td>
|
||||
{% if log.response_status %}
|
||||
<span class="mono" style="font-size:11px;color:{% if is_error %}#E53935{% else %}#17A865{% endif %};">{{ log.response_status }}</span>
|
||||
{% else %}
|
||||
<span style="color:#9EA8BE;">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><span class="mono" style="font-size:11px;color:#9EA8BE;">{{ log.duration_ms if log.duration_ms is not none else '—' }}</span></td>
|
||||
<td style="max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
|
||||
<span class="mono" style="font-size:11px;{% if is_error %}color:#E53935;{% endif %}" title="{{ log.url }}">{{ log.url }}</span>
|
||||
</td>
|
||||
<td style="color:#9EA8BE;"><i class="bi bi-chevron-down"></i></td>
|
||||
</tr>
|
||||
<tr id="detail-{{ log.id }}" style="display:none;background:#F9FAFB;">
|
||||
<td colspan="7" style="padding:14px 20px;">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
|
||||
<div>
|
||||
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.06em;color:#9EA8BE;margin-bottom:6px;">URL</div>
|
||||
<code style="word-break:break-all;font-size:11px;">{{ log.url }}</code>
|
||||
{% if log.request_body %}
|
||||
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.06em;color:#9EA8BE;margin:10px 0 6px;">Request body</div>
|
||||
<pre style="font-size:11px;max-height:180px;overflow:auto;">{{ log.request_body }}</pre>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.06em;color:#9EA8BE;margin-bottom:6px;">Response ({{ log.response_status }})</div>
|
||||
{% if log.response_body %}
|
||||
<pre style="font-size:11px;max-height:180px;overflow:auto;">{{ log.response_body }}</pre>
|
||||
{% else %}
|
||||
<span style="color:#9EA8BE;font-size:12px;">—</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if total_pages > 1 %}
|
||||
<div class="pagination">
|
||||
{% if page > 1 %}
|
||||
<a href="?service={{ filter_service }}&method={{ filter_method }}&status={{ filter_status }}&q={{ filter_q }}&hours={{ filter_hours }}&page={{ page - 1 }}" class="btn btn-outline btn-sm">← Назад</a>
|
||||
{% endif %}
|
||||
<span style="font-size:12px;color:#9EA8BE;">Стр. {{ 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 }}" class="btn btn-outline btn-sm">Вперёд →</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-journal-x"></i>
|
||||
<p>Записей не найдено за выбранный период.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function toggleDetail(id) {
|
||||
const row = document.getElementById('detail-' + id);
|
||||
row.style.display = row.style.display === 'none' ? 'table-row' : 'none';
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
44
web/templates/admin/roles.html
Normal file
44
web/templates/admin/roles.html
Normal file
@@ -0,0 +1,44 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Роли и права — Мои Товары{% endblock %}
|
||||
{% block page_title %}Роли и права{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="/admin/users">Пользователи</a></li>
|
||||
<li>Роли и права</li>
|
||||
</ol>
|
||||
|
||||
<div class="pg-title">Роли и права</div>
|
||||
<div class="pg-sub">Управление разрешениями для каждой роли</div>
|
||||
|
||||
{% for role in roles %}
|
||||
<div class="card" style="margin-bottom:14px;">
|
||||
<div class="card-hd">
|
||||
<div>
|
||||
<div class="card-title"><i class="bi bi-shield-lock" style="margin-right:6px;"></i>{{ role.name }}</div>
|
||||
{% if role.description %}
|
||||
<div class="card-sub">{{ role.description }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="/admin/roles/{{ role.id }}/permissions">
|
||||
<div style="display:flex;flex-wrap:wrap;gap:12px;margin-bottom:16px;">
|
||||
{% for perm in permissions %}
|
||||
<label style="display:flex;align-items:center;gap:7px;cursor:pointer;font-size:13px;padding:6px 10px;border:1px solid #E4E6EE;border-radius:7px;background:#F9FAFB;">
|
||||
<input type="checkbox" name="perm_{{ perm.id }}" value="{{ perm.id }}"
|
||||
{% if perm.id in role_perm_ids[role.id] %}checked{% endif %}
|
||||
style="accent-color:#FF5500;">
|
||||
{{ perm.name }}
|
||||
{% if perm.description %}
|
||||
<span style="font-size:11px;color:#9EA8BE;">({{ perm.description }})</span>
|
||||
{% endif %}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-save"></i> Сохранить права для «{{ role.name }}»
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
180
web/templates/admin/user_detail.html
Normal file
180
web/templates/admin/user_detail.html
Normal file
@@ -0,0 +1,180 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ target.first_name }} {{ target.last_name }} — Админ — Мои Товары{% endblock %}
|
||||
{% block page_title %}Пользователь{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="/admin/users">Пользователи</a></li>
|
||||
<li>{{ target.first_name }} {{ target.last_name }}</li>
|
||||
</ol>
|
||||
|
||||
{% if request.query_params.get('success') == 'reset_sent' %}
|
||||
<div class="alert alert-gr"><span><i class="bi bi-check-circle"></i></span><div>Ссылка для сброса пароля отправлена.</div></div>
|
||||
{% elif request.query_params.get('success') == 'invite_sent' %}
|
||||
<div class="alert alert-gr"><span><i class="bi bi-check-circle"></i></span><div>Приглашение отправлено.</div></div>
|
||||
{% elif request.query_params.get('success') == 'saved' %}
|
||||
<div class="alert alert-gr"><span><i class="bi bi-check-circle"></i></span><div>Данные сохранены.</div></div>
|
||||
{% endif %}
|
||||
|
||||
<!-- User header -->
|
||||
<div style="display:flex;align-items:center;gap:14px;margin-bottom:24px;">
|
||||
<div class="avatar" style="width:48px;height:48px;font-size:16px;">
|
||||
{{ target.first_name[0] if target.first_name else '?' }}{{ target.last_name[0] if target.last_name else '' }}
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:18px;font-weight:800;letter-spacing:-0.02em;">{{ target.first_name }} {{ target.last_name }}</div>
|
||||
<div class="mono" style="font-size:12px;color:#9EA8BE;">{{ target.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="g2" style="align-items:start;">
|
||||
|
||||
<!-- Left column -->
|
||||
<div style="display:flex;flex-direction:column;gap:14px;">
|
||||
|
||||
<!-- Profile -->
|
||||
<div class="card">
|
||||
<div class="card-hd"><div><div class="card-title">Профиль</div></div></div>
|
||||
<div class="conn-detail">
|
||||
<div class="conn-row"><span class="conn-k">ID</span><span class="conn-v">{{ target.id }}</span></div>
|
||||
<div class="conn-row">
|
||||
<span class="conn-k">Email</span>
|
||||
<span class="conn-v" style="display:flex;align-items:center;gap:6px;">
|
||||
{{ target.email }}
|
||||
{% if target.is_email_confirmed %}
|
||||
<span class="tag tag-gr" style="font-size:10px;padding:1px 6px;">подтверждён</span>
|
||||
{% else %}
|
||||
<span class="tag tag-yl" style="font-size:10px;padding:1px 6px;">не подтверждён</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="conn-row"><span class="conn-k">Телефон</span><span class="conn-v">{{ target.phone or '—' }}</span></div>
|
||||
<div class="conn-row">
|
||||
<span class="conn-k">Роль</span>
|
||||
<span class="conn-v" style="font-family:inherit;">
|
||||
{% if target.role == 'system' %}<span class="tag tag-rd" style="font-size:10.5px;">Системный</span>
|
||||
{% elif target.role == 'admin' %}<span class="tag tag-or" style="font-size:10.5px;">Администратор</span>
|
||||
{% else %}<span class="tag tag-dim" style="font-size:10.5px;">Пользователь</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="conn-row">
|
||||
<span class="conn-k">Статус</span>
|
||||
<span class="conn-v" style="font-family:inherit;">
|
||||
{% if target.status == 'active' %}<span class="tag tag-gr"><span class="dot g"></span>Активен</span>
|
||||
{% elif target.status == 'pending' %}<span class="tag tag-yl"><span class="dot y pulse"></span>Ожидает</span>
|
||||
{% else %}<span class="tag tag-rd"><span class="dot r"></span>Заблокирован</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="conn-row"><span class="conn-k">Регистрация</span><span class="conn-v">{{ target.created_at | datefmt }}</span></div>
|
||||
{% if target.evotor_user_id %}
|
||||
<div class="conn-row"><span class="conn-k">Эвотор ID</span><span class="conn-v">{{ target.evotor_user_id }}</span></div>
|
||||
{% endif %}
|
||||
{% if target.invite_token %}
|
||||
<div class="conn-row"><span class="conn-k">Приглашение до</span><span class="conn-v">{{ target.invite_expires | datefmt }}</span></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if target.evotor_meta %}
|
||||
<div class="card">
|
||||
<div class="card-hd"><div><div class="card-title">Данные Эвотор</div></div></div>
|
||||
<pre>{{ target.evotor_meta | tojson(indent=2) }}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Right column -->
|
||||
<div style="display:flex;flex-direction:column;gap:14px;">
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="card">
|
||||
<div class="card-title" style="margin-bottom:14px;">Действия</div>
|
||||
<div style="display:flex;flex-direction:column;gap:8px;">
|
||||
{% if target.status != 'active' %}
|
||||
<form method="post" action="/admin/users/{{ target.id }}/activate">
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="bi bi-check-circle"></i> Активировать
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if target.status != 'suspended' %}
|
||||
<form method="post" action="/admin/users/{{ target.id }}/suspend">
|
||||
<button type="submit" class="btn btn-danger w-100">
|
||||
<i class="bi bi-slash-circle"></i> Заблокировать
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="/admin/users/{{ target.id }}/reset-password">
|
||||
<button type="submit" class="btn btn-outline w-100">
|
||||
<i class="bi bi-key"></i> Сбросить пароль
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/users/{{ target.id }}/send-invite">
|
||||
<button type="submit" class="btn btn-outline w-100">
|
||||
<i class="bi bi-envelope"></i> Отправить приглашение
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/users/{{ target.id }}/view-as">
|
||||
<button type="submit" class="btn btn-outline w-100">
|
||||
<i class="bi bi-eye"></i> Просмотр от имени пользователя
|
||||
</button>
|
||||
</form>
|
||||
{% if user.role == 'system' and target.id != user.id %}
|
||||
<form method="post" action="/admin/users/{{ target.id }}/delete"
|
||||
onsubmit="return confirm('Удалить пользователя {{ target.email }}? Это действие необратимо.')">
|
||||
<button type="submit" class="btn btn-danger btn-sm w-100">
|
||||
<i class="bi bi-trash"></i> Удалить
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit -->
|
||||
<div class="card">
|
||||
<div class="card-title" style="margin-bottom:14px;">Редактировать</div>
|
||||
<form method="post" action="/admin/users/{{ target.id }}/edit">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;">
|
||||
<div class="form-row">
|
||||
<label class="form-lbl" for="first_name">Имя</label>
|
||||
<input class="inp" type="text" id="first_name" name="first_name" value="{{ target.first_name }}" required>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="form-lbl" for="last_name">Фамилия</label>
|
||||
<input class="inp" type="text" id="last_name" name="last_name" value="{{ target.last_name }}" required>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="form-lbl" for="middle_name">Отчество</label>
|
||||
<input class="inp" type="text" id="middle_name" name="middle_name" value="{{ target.middle_name or '' }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="form-lbl" for="email">Email</label>
|
||||
<input class="inp" type="email" id="email" name="email" value="{{ target.email }}">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="form-lbl" for="phone">Телефон</label>
|
||||
<input class="inp" type="tel" id="phone" name="phone" value="{{ target.phone }}">
|
||||
</div>
|
||||
{% if user.role in ('system', 'admin') %}
|
||||
<div class="form-row">
|
||||
<label class="form-lbl" for="role">Роль</label>
|
||||
<select class="inp" id="role" name="role">
|
||||
<option value="user" {% if target.role == 'user' %}selected{% endif %}>Пользователь</option>
|
||||
<option value="admin" {% if target.role == 'admin' %}selected{% endif %}>Администратор</option>
|
||||
<option value="system" {% if target.role == 'system' %}selected{% endif %}>Системный</option>
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-save"></i> Сохранить
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
206
web/templates/admin/users.html
Normal file
206
web/templates/admin/users.html
Normal file
@@ -0,0 +1,206 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Пользователи — Администрирование — Мои Товары{% endblock %}
|
||||
{% block page_title %}Пользователи{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="pg-title">Пользователи</div>
|
||||
<div class="pg-sub">Управление аккаунтами и подключениями пользователей</div>
|
||||
|
||||
<!-- Topbar action -->
|
||||
<div style="display:flex;justify-content:flex-end;margin-bottom:16px;">
|
||||
<button class="btn btn-primary btn-sm" onclick="document.getElementById('create-user-dialog').showModal()">
|
||||
<i class="bi bi-person-plus"></i> Создать пользователя
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Create user dialog -->
|
||||
<dialog id="create-user-dialog">
|
||||
<div class="dialog-hd">
|
||||
<div class="dialog-title">Создать пользователя</div>
|
||||
<button class="dialog-close" onclick="document.getElementById('create-user-dialog').close()">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
{% if create_errors %}
|
||||
<div class="alert alert-rd" style="margin-bottom:14px;">
|
||||
<span><i class="bi bi-x-circle"></i></span>
|
||||
<div>{% for e in create_errors %}<div>{{ e }}</div>{% endfor %}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="post" action="/admin/users/create" novalidate>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
||||
<div class="form-row">
|
||||
<label class="form-lbl" for="cu_first_name">Имя</label>
|
||||
<input class="inp" type="text" id="cu_first_name" name="first_name"
|
||||
value="{{ create_form.first_name if create_form else '' }}" required>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="form-lbl" for="cu_last_name">Фамилия</label>
|
||||
<input class="inp" type="text" id="cu_last_name" name="last_name"
|
||||
value="{{ create_form.last_name if create_form else '' }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="form-lbl" for="cu_email">Email</label>
|
||||
<input class="inp" type="text" id="cu_email" name="email"
|
||||
value="{{ create_form.email if create_form else '' }}" required>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="form-lbl" for="cu_phone">Телефон</label>
|
||||
<input class="inp" type="tel" id="cu_phone" name="phone"
|
||||
value="{{ create_form.phone if create_form else '' }}" placeholder="+7 (999) 999-99-99">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="form-lbl" for="cu_password">Пароль</label>
|
||||
<input class="inp" type="password" id="cu_password" name="password" required>
|
||||
</div>
|
||||
{% if user.role in ('system', 'admin') %}
|
||||
<div class="form-row">
|
||||
<label class="form-lbl" for="cu_role">Роль</label>
|
||||
<select class="inp" 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>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px;">
|
||||
<button type="button" class="btn btn-outline" onclick="document.getElementById('create-user-dialog').close()">Отмена</button>
|
||||
<button type="submit" class="btn btn-primary">Создать</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
{% if create_errors %}
|
||||
<script>document.addEventListener('DOMContentLoaded', () => document.getElementById('create-user-dialog').showModal());</script>
|
||||
{% endif %}
|
||||
|
||||
<!-- Search / filter bar -->
|
||||
<div class="card" style="margin-bottom:14px;padding:14px 20px;">
|
||||
<form method="get" action="/admin/users" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||
<input class="inp" type="text" name="search" value="{{ search }}"
|
||||
placeholder="Поиск по имени, email, телефону" style="flex:1;min-width:200px;">
|
||||
<select class="inp" name="status" style="width:auto;">
|
||||
<option value="">Все статусы</option>
|
||||
<option value="pending" {% if status_filter == 'pending' %}selected{% endif %}>Ожидает</option>
|
||||
<option value="active" {% if status_filter == 'active' %}selected{% endif %}>Активен</option>
|
||||
<option value="suspended" {% if status_filter == 'suspended' %}selected{% endif %}>Заблокирован</option>
|
||||
</select>
|
||||
<select class="inp" name="role" style="width:auto;">
|
||||
<option value="">Все роли</option>
|
||||
<option value="user" {% if role_filter == 'user' %}selected{% endif %}>Пользователь</option>
|
||||
<option value="admin" {% if role_filter == 'admin' %}selected{% endif %}>Администратор</option>
|
||||
<option value="system" {% if role_filter == 'system' %}selected{% endif %}>Системный</option>
|
||||
</select>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Найти</button>
|
||||
{% if search or status_filter or role_filter %}
|
||||
<a href="/admin/users" class="btn btn-outline btn-sm">Сбросить</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Users table -->
|
||||
<div class="card" style="padding:0;">
|
||||
<div class="table-wrap">
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Пользователь</th>
|
||||
<th>Телефон</th>
|
||||
<th>Роль</th>
|
||||
<th>Статус</th>
|
||||
<th>Эвотор</th>
|
||||
<th>Дата</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td><span class="mono" style="font-size:11px;color:#9EA8BE;">{{ u.id }}</span></td>
|
||||
<td>
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<div class="avatar" style="width:30px;height:30px;font-size:10px;">
|
||||
{{ u.first_name[0] if u.first_name else '?' }}{{ u.last_name[0] if u.last_name else '' }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="tbl-name">{{ u.first_name }} {{ u.last_name }}</div>
|
||||
<div class="tbl-sub">
|
||||
{{ u.email }}
|
||||
{% if not u.is_email_confirmed %}
|
||||
<span class="tag tag-yl" style="font-size:9.5px;padding:0 5px;margin-left:4px;"><i class="bi bi-exclamation-circle"></i></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td style="font-size:12px;color:#9EA8BE;">{{ u.phone or '—' }}</td>
|
||||
<td>
|
||||
{% if u.role == 'system' %}
|
||||
<span class="tag tag-rd" style="font-size:10.5px;">Системный</span>
|
||||
{% elif u.role == 'admin' %}
|
||||
<span class="tag tag-or" style="font-size:10.5px;">Админ</span>
|
||||
{% else %}
|
||||
<span class="tag tag-dim" style="font-size:10.5px;">Польз.</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if u.status == 'active' %}
|
||||
<span class="tag tag-gr"><span class="dot g"></span>Активен</span>
|
||||
{% elif u.status == 'pending' %}
|
||||
<span class="tag tag-yl"><span class="dot y pulse"></span>Ожидает</span>
|
||||
{% else %}
|
||||
<span class="tag tag-rd"><span class="dot r"></span>Заблок.</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if u.evotor_user_id %}
|
||||
<span class="tag tag-gr" style="font-size:10.5px;"><i class="bi bi-check-circle"></i></span>
|
||||
{% else %}
|
||||
<span style="color:#9EA8BE;font-size:12px;">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><span class="mono" style="font-size:11px;color:#9EA8BE;">{{ u.created_at | datefmt }}</span></td>
|
||||
<td style="white-space:nowrap;">
|
||||
<a href="/admin/users/{{ u.id }}" class="btn btn-outline btn-xs">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline btn-xs" style="color:#E53935;border-color:#F4AEAE;margin-left:4px;"
|
||||
onclick="if(confirm('Удалить пользователя {{ u.first_name }} {{ u.last_name }} ({{ u.email }})?')) { document.getElementById('del-{{ u.id }}').submit(); }">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
<form id="del-{{ u.id }}" method="post" action="/admin/users/{{ u.id }}/delete" style="display:none;"></form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="8" class="text-center" style="padding:32px;color:#9EA8BE;">Пользователи не найдены</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if total_pages > 1 %}
|
||||
<div class="pagination">
|
||||
{% if page > 1 %}
|
||||
<a href="?page={{ page - 1 }}&search={{ search }}&status={{ status_filter }}&role={{ role_filter }}" class="btn btn-outline btn-sm">« Назад</a>
|
||||
{% endif %}
|
||||
<span style="font-size:12px;color:#9EA8BE;">Стр. {{ page }} из {{ total_pages }}</span>
|
||||
{% if page < total_pages %}
|
||||
<a href="?page={{ page + 1 }}&search={{ search }}&status={{ status_filter }}&role={{ role_filter }}" class="btn btn-outline btn-sm">Вперёд »</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if user.role in ('system', 'admin') %}
|
||||
<div style="margin-top:14px;text-align:right;">
|
||||
<a href="/admin/roles" class="btn btn-outline btn-sm">
|
||||
<i class="bi bi-shield-lock"></i> Управление ролями
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,60 +1,158 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}EvoSync{% endblock %}</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Мои Товары{% endblock %}</title>
|
||||
<link rel="icon" href="/static/favicon.ico" sizes="16x16 32x32" type="image/x-icon">
|
||||
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/static/favicon-32.png">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Golos+Text:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg bg-white border-bottom border-2 brand-border">
|
||||
<div class="container">
|
||||
<a href="/" class="navbar-brand brand-logo">EvoSync</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
{% if user %}
|
||||
<li class="nav-item">
|
||||
<a href="/profile" class="nav-link"><i class="bi bi-person-circle me-1"></i>Личный кабинет</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="/logout" class="nav-link text-muted">Выход</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a href="/login" class="nav-link">Вход</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="/register" class="nav-link">Регистрация</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% block body %}
|
||||
<div class="shell">
|
||||
|
||||
<!-- ── Sidebar ── -->
|
||||
<aside class="sidebar">
|
||||
<div class="sb-logo">
|
||||
<div class="sb-logo-icon">
|
||||
<svg width="22" height="22" viewBox="0 0 28 28" fill="none">
|
||||
<path d="M9 11h10l-1.5 9H10.5L9 11Z" fill="white" opacity="0.95"/>
|
||||
<path d="M11.5 11V9a2.5 2.5 0 015 0v2" stroke="white" stroke-width="1.6" stroke-linecap="round" fill="none"/>
|
||||
<line x1="12" y1="15" x2="16" y2="15" stroke="#FF5500" stroke-width="1.3" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="sb-logo-name">Мои Товары</div>
|
||||
<div class="sb-logo-sub">мои-товары.рф</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="sb-nav">
|
||||
{% if user %}
|
||||
{% if user.role in ('admin', 'system') and not viewed_user %}
|
||||
<div class="sb-section">Управление</div>
|
||||
<a href="/admin/users" class="sb-item {% if request.url.path.startswith('/admin/users') %}active{% endif %}">
|
||||
<span class="sb-icon"><i class="bi bi-people"></i></span>
|
||||
<span>Пользователи</span>
|
||||
</a>
|
||||
{% if user.role in ('admin', 'system') %}
|
||||
<a href="/admin/logs" class="sb-item {% if request.url.path.startswith('/admin/logs') %}active{% endif %}">
|
||||
<span class="sb-icon"><i class="bi bi-journal-text"></i></span>
|
||||
<span>Логи</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="sb-section">Главное</div>
|
||||
<a href="/connections" class="sb-item {% if request.url.path == '/connections' %}active{% endif %}">
|
||||
<span class="sb-icon"><i class="bi bi-plug"></i></span>
|
||||
<span>Подключения</span>
|
||||
</a>
|
||||
<a href="/catalog/stores" class="sb-item {% if request.url.path.startswith('/catalog') %}active{% endif %}">
|
||||
<span class="sb-icon"><i class="bi bi-shop"></i></span>
|
||||
<span>Каталог Эвотор</span>
|
||||
</a>
|
||||
<a href="/vk-catalog/albums" class="sb-item {% if request.url.path.startswith('/vk-catalog') %}active{% endif %}">
|
||||
<span class="sb-icon"><i class="bi bi-bag"></i></span>
|
||||
<span>Каталог ВК</span>
|
||||
</a>
|
||||
<a href="/sync" class="sb-item {% if request.url.path == '/sync' %}active{% endif %}">
|
||||
<span class="sb-icon"><i class="bi bi-arrow-repeat"></i></span>
|
||||
<span>Синхронизация</span>
|
||||
</a>
|
||||
<div class="sb-section" style="margin-top:6px;">Аккаунт</div>
|
||||
<a href="/profile" class="sb-item {% if request.url.path.startswith('/profile') %}active{% endif %}">
|
||||
<span class="sb-icon"><i class="bi bi-person"></i></span>
|
||||
<span>Профиль</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</nav>
|
||||
|
||||
<main class="container py-4">
|
||||
{% if errors %}
|
||||
<div class="alert alert-danger">
|
||||
{% for error in errors %}
|
||||
<p class="mb-1">{{ error }}</p>
|
||||
{% endfor %}
|
||||
{% if user %}
|
||||
<a href="/profile" class="sb-user">
|
||||
<div class="avatar {% if user.role in ('admin','system') %}admin{% endif %}">
|
||||
{{ user.first_name[0] if user.first_name else '?' }}{{ user.last_name[0] if user.last_name else '' }}
|
||||
</div>
|
||||
<div style="flex:1; min-width:0;">
|
||||
<div class="sb-user-name">{{ user.first_name }} {{ user.last_name }}</div>
|
||||
<div class="sb-user-role">
|
||||
<span class="role-chip {% if user.role in ('admin','system') %}admin{% else %}user{% endif %}">
|
||||
{% if user.role == 'system' %}SYSTEM{% elif user.role == 'admin' %}ADMIN{% else %}USER{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
</aside>
|
||||
|
||||
{% if success %}
|
||||
<div class="alert alert-success">
|
||||
<p class="mb-0">{{ success }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- ── Main ── -->
|
||||
<div class="main">
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<!-- Topbar -->
|
||||
<div class="topbar">
|
||||
<div class="topbar-title">{% block page_title %}{% endblock %}</div>
|
||||
{% block topbar_extras %}{% endblock %}
|
||||
{% if user %}
|
||||
<a href="/logout" class="btn btn-ghost btn-sm" style="margin-left:auto;" title="Выйти">
|
||||
<i class="bi bi-box-arrow-right"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{% if viewed_user %}
|
||||
<div class="view-as-bar">
|
||||
<i class="bi bi-eye"></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 rgba(255,255,255,0.6);color:#fff;padding:2px 10px;font-size:12px;cursor:pointer;border-radius:4px;font-family:inherit;">Выйти</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="content">
|
||||
{% if errors %}
|
||||
<div class="alert alert-rd" style="margin-bottom:16px;">
|
||||
<span><i class="bi bi-x-circle"></i></span>
|
||||
<div>{% for e in errors %}<div>{{ e }}</div>{% endfor %}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if success %}
|
||||
<div class="alert alert-gr" style="margin-bottom:16px;">
|
||||
<span><i class="bi bi-check-circle"></i></span>
|
||||
<div>{{ success }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% if jivosite_widget_id %}
|
||||
<script src="//code.jivosite.com/widget/{{ jivosite_widget_id }}" async></script>
|
||||
{% endif %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/inputmask@5.0.9/dist/inputmask.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var phoneInputs = document.querySelectorAll('input[name="phone"]');
|
||||
if (phoneInputs.length) {
|
||||
Inputmask('+7 (999) 999-99-99', { placeholder: '_', showMaskOnHover: false, clearMaskOnLostFocus: false }).mask(phoneInputs);
|
||||
}
|
||||
});
|
||||
document.addEventListener('invalid', function(e) {
|
||||
if (e.target.validity.valueMissing) e.target.setCustomValidity('Пожалуйста, заполните это поле');
|
||||
else if (e.target.validity.typeMismatch) e.target.setCustomValidity('Пожалуйста, введите корректное значение');
|
||||
}, true);
|
||||
document.addEventListener('input', function(e) {
|
||||
if (e.target.required) e.target.setCustomValidity('');
|
||||
}, true);
|
||||
</script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
63
web/templates/catalog/groups.html
Normal file
63
web/templates/catalog/groups.html
Normal file
@@ -0,0 +1,63 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Группы — {{ store.name }} — Мои Товары{% endblock %}
|
||||
{% block page_title %}Каталог Эвотор{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="/catalog/stores">Магазины</a></li>
|
||||
<li>{{ store.name }}</li>
|
||||
<li>Группы</li>
|
||||
</ol>
|
||||
|
||||
<div class="pg-title">Группы товаров — {{ store.name }}</div>
|
||||
<div class="pg-sub">Управление категориями · Всего: {{ groups | length }}</div>
|
||||
|
||||
<div class="card" style="padding:0;">
|
||||
{% if groups %}
|
||||
<div class="table-wrap">
|
||||
<table class="tbl">
|
||||
<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>
|
||||
<td>
|
||||
<form method="post" action="/catalog/stores/{{ store.evotor_id }}/groups/{{ g.evotor_id }}/toggle" style="margin:0;">
|
||||
<button type="submit" class="tog {% if is_enabled %}on{% endif %}"
|
||||
title="{% if is_enabled %}Отключить синхронизацию{% else %}Включить синхронизацию{% endif %}"
|
||||
style="border:none;background:none;padding:0;cursor:pointer;"></button>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<div class="tbl-name"><i class="bi bi-folder2" style="color:#9EA8BE;margin-right:6px;"></i>{{ g.name }}</div>
|
||||
</td>
|
||||
<td><span class="mono" style="font-size:12px;">{{ product_counts.get(g.evotor_id, 0) }}</span></td>
|
||||
<td><span class="mono" style="font-size:11px;color:#9EA8BE;">{{ g.evotor_id }}</span></td>
|
||||
<td><span class="mono" style="font-size:11px;color:#9EA8BE;">{{ g.fetched_at | datefmt }}</span></td>
|
||||
<td>
|
||||
<a href="/catalog/stores/{{ store.evotor_id }}/products?group={{ g.evotor_id }}" class="btn btn-outline btn-xs">
|
||||
<i class="bi bi-box-seam"></i> Товары
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-folder"></i>
|
||||
<p>Группы для этого магазина ещё не загружены.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
78
web/templates/catalog/products.html
Normal file
78
web/templates/catalog/products.html
Normal file
@@ -0,0 +1,78 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Товары — {{ store.name }} — Мои Товары{% endblock %}
|
||||
{% block page_title %}Каталог Эвотор{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="/catalog/stores">Магазины</a></li>
|
||||
<li>{{ store.name }}</li>
|
||||
<li>Товары</li>
|
||||
</ol>
|
||||
|
||||
<div class="pg-title">Товары — {{ store.name }}</div>
|
||||
<div class="pg-sub">Всего: {{ products | length }}</div>
|
||||
|
||||
{% if groups %}
|
||||
<div class="card" style="margin-bottom:16px;padding:14px 20px;">
|
||||
<form method="get" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||
<select class="inp" name="group" style="width:auto;" 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" class="btn btn-outline btn-sm">Сбросить</a>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card" style="padding:0;">
|
||||
{% if products %}
|
||||
<div class="table-wrap">
|
||||
<table class="tbl">
|
||||
<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><div class="tbl-name">{{ p.name }}</div></td>
|
||||
<td style="font-size:12px;color:#9EA8BE;">{{ group_map.get(p.group_evotor_id) or '—' }}</td>
|
||||
<td><span class="mono" style="font-size:11px;color:#9EA8BE;">{{ p.article_number or '—' }}</span></td>
|
||||
<td><span class="mono">{% if p.price is not none %}{{ p.price | price }}{% else %}—{% endif %}</span></td>
|
||||
<td><span class="mono" style="color:{% if p.quantity is not none and p.quantity == 0 %}#E53935{% else %}#1C1F2E{% endif %};">{% if p.quantity is not none %}{{ p.quantity }}{% else %}—{% endif %}</span></td>
|
||||
<td style="font-size:12px;color:#9EA8BE;">{{ p.measure_name or '—' }}</td>
|
||||
<td>
|
||||
{% if p.allow_to_sell %}
|
||||
<span class="tag tag-gr" style="font-size:10.5px;"><i class="bi bi-check-circle"></i></span>
|
||||
{% elif p.allow_to_sell == false %}
|
||||
<span class="tag tag-rd" style="font-size:10.5px;"><i class="bi bi-x-circle"></i></span>
|
||||
{% else %}
|
||||
<span style="color:#9EA8BE;">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><span class="mono" style="font-size:11px;color:#9EA8BE;">{{ p.fetched_at | datefmt }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-box-seam"></i>
|
||||
<p>Товары не найдены.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
62
web/templates/catalog/stores.html
Normal file
62
web/templates/catalog/stores.html
Normal file
@@ -0,0 +1,62 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Магазины — Мои Товары{% endblock %}
|
||||
{% block page_title %}Каталог Эвотор{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="pg-title">Магазины Эвотор</div>
|
||||
<div class="pg-sub">Выберите магазины для синхронизации · Всего: {{ stores | length }}</div>
|
||||
|
||||
<div class="card" style="padding:0;">
|
||||
{% if stores %}
|
||||
<div class="table-wrap">
|
||||
<table class="tbl">
|
||||
<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>
|
||||
<td>
|
||||
<form method="post" action="/catalog/stores/{{ s.evotor_id }}/toggle" style="margin:0;">
|
||||
<button type="submit" class="tog {% if is_enabled %}on{% endif %}"
|
||||
title="{% if is_enabled %}Отключить синхронизацию{% else %}Включить синхронизацию{% endif %}"
|
||||
style="border:none;background:none;padding:0;cursor:pointer;"></button>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<div class="tbl-name">{{ s.name }}</div>
|
||||
</td>
|
||||
<td style="color:#9EA8BE;font-size:12px;">{{ s.address or '—' }}</td>
|
||||
<td><span class="mono" style="font-size:11px;color:#9EA8BE;">{{ s.evotor_id }}</span></td>
|
||||
<td><span class="mono" style="font-size:11px;color:#9EA8BE;">{{ s.fetched_at | datefmt }}</span></td>
|
||||
<td>
|
||||
<div style="display:flex;gap:6px;">
|
||||
<a href="/catalog/stores/{{ s.evotor_id }}/products" class="btn btn-outline btn-xs">
|
||||
<i class="bi bi-box-seam"></i> Товары
|
||||
</a>
|
||||
<a href="/catalog/stores/{{ s.evotor_id }}/groups" class="btn btn-outline btn-xs">
|
||||
<i class="bi bi-folder"></i> Группы
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-shop"></i>
|
||||
<p>Магазины ещё не загружены.<br>Синхронизация выполняется каждые {{ refresh_interval }} сек. автоматически.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,17 +1,22 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Подтверждение email — EvoSync{% endblock %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Подтверждение email — Мои Товары</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Golos+Text:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body style="display:flex;align-items:center;justify-content:center;min-height:100vh;background:#F4F5F7;">
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-sm-10 col-md-6 col-lg-5">
|
||||
<div class="card shadow-sm mt-5 text-center">
|
||||
<div class="card-body p-5">
|
||||
<i class="bi bi-envelope-check display-4 text-primary mb-3"></i>
|
||||
<h1 class="h4 mb-3">Подтвердите ваш email</h1>
|
||||
<p class="text-muted">Ссылка для подтверждения email выведена в консоль сервера.</p>
|
||||
<p class="text-muted">Скопируйте её и откройте в браузере.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="max-width:400px;width:100%;padding:40px 32px;text-align:center;">
|
||||
<i class="bi bi-envelope-check" style="font-size:48px;color:#FF5500;display:block;margin-bottom:16px;"></i>
|
||||
<div class="pg-title" style="margin-bottom:8px;">Подтвердите ваш email</div>
|
||||
<div style="font-size:13px;color:#5C6278;">Проверьте почту и нажмите на ссылку для подтверждения.</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
212
web/templates/connections.html
Normal file
212
web/templates/connections.html
Normal file
@@ -0,0 +1,212 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Подключения — Мои Товары{% endblock %}
|
||||
{% block page_title %}Подключения{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="pg-title">Подключения</div>
|
||||
<div class="pg-sub">Управление интеграциями с Эвотор и VK Market</div>
|
||||
|
||||
{% if request.query_params.get('success') %}
|
||||
<div class="alert alert-gr">
|
||||
<span><i class="bi bi-check-circle"></i></span>
|
||||
<div>Подключение сохранено.</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="g2" style="align-items:start;">
|
||||
|
||||
{# ── Evotor ── #}
|
||||
<div class="card">
|
||||
<div class="card-hd">
|
||||
<div>
|
||||
<div class="card-title"><i class="bi bi-cpu" style="margin-right:6px;"></i>Эвотор</div>
|
||||
<div class="card-sub">Платформа кассовых решений и товарного учёта</div>
|
||||
</div>
|
||||
{% if evotor %}
|
||||
<span class="tag tag-gr"><span class="dot g"></span>Подключено</span>
|
||||
{% else %}
|
||||
<span class="tag tag-dim"><span class="dot d"></span>Не подключено</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if evotor %}
|
||||
<div class="conn-detail" style="margin-bottom:16px;">
|
||||
<div class="conn-row">
|
||||
<span class="conn-k">Токен</span>
|
||||
<span class="conn-v">{{ evotor.access_token[:8] }}••••••••</span>
|
||||
</div>
|
||||
{% if evotor.evotor_user_id %}
|
||||
<div class="conn-row">
|
||||
<span class="conn-k">Evotor User ID</span>
|
||||
<span class="conn-v">{{ evotor.evotor_user_id }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="conn-row">
|
||||
<span class="conn-k">Подключено</span>
|
||||
<span class="conn-v">{{ evotor.connected_at | datefmt }}</span>
|
||||
</div>
|
||||
<div class="conn-row">
|
||||
<span class="conn-k">Обновлено</span>
|
||||
<span class="conn-v">{{ evotor.updated_at | datefmt }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<details {% if not evotor %}open{% endif %} style="margin-bottom:14px;">
|
||||
<summary class="btn btn-outline btn-sm" style="cursor:pointer;list-style:none;display:inline-flex;align-items:center;gap:6px;">
|
||||
<i class="bi bi-pencil"></i>
|
||||
{% if evotor %}Обновить токен{% else %}Ввести API-токен{% endif %}
|
||||
</summary>
|
||||
<form method="post" action="/connections/evotor" style="margin-top:12px;">
|
||||
<div class="form-row">
|
||||
<label class="form-lbl">API-токен Эвотор</label>
|
||||
<input class="inp" type="text" name="access_token"
|
||||
placeholder="Вставьте токен из личного кабинета Эвотор"
|
||||
value="{{ evotor.access_token if evotor else '' }}"
|
||||
required autocomplete="off">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="form-lbl">Evotor User ID <span style="font-weight:400;text-transform:none;letter-spacing:0;">(необязательно)</span></label>
|
||||
<input class="inp" 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">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-save"></i> Сохранить
|
||||
</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
{% if evotor %}
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||
<button type="button" class="btn btn-outline btn-sm" onclick="testConnection('evotor', this)">
|
||||
<i class="bi bi-wifi"></i> Проверить соединение
|
||||
</button>
|
||||
<span id="evotor-test-result" style="font-size:12px;"></span>
|
||||
</div>
|
||||
<form method="post" action="/connections/evotor/disconnect" style="margin-top:10px;"
|
||||
onsubmit="return confirm('Отключить Эвотор? Кешированные данные каталога останутся.')">
|
||||
<button type="submit" class="btn btn-danger btn-sm">
|
||||
<i class="bi bi-plug"></i> Отключить
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── VK ── #}
|
||||
<div class="card">
|
||||
<div class="card-hd">
|
||||
<div>
|
||||
<div class="card-title"><i class="bi bi-badge-vr" style="margin-right:6px;"></i>ВКонтакте (Маркет)</div>
|
||||
<div class="card-sub">market.* API, версия 5.199</div>
|
||||
</div>
|
||||
{% if vk %}
|
||||
<span class="tag tag-gr"><span class="dot g"></span>Подключено</span>
|
||||
{% else %}
|
||||
<span class="tag tag-dim"><span class="dot d"></span>Не подключено</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if vk %}
|
||||
<div class="conn-detail" style="margin-bottom:16px;">
|
||||
<div class="conn-row">
|
||||
<span class="conn-k">Токен</span>
|
||||
<span class="conn-v">{{ vk.access_token[:8] }}••••••••</span>
|
||||
</div>
|
||||
{% if vk.vk_user_id %}
|
||||
<div class="conn-row">
|
||||
<span class="conn-k">ID сообщества</span>
|
||||
<span class="conn-v">{{ vk.vk_user_id }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if vk.first_name or vk.last_name %}
|
||||
<div class="conn-row">
|
||||
<span class="conn-k">Аккаунт</span>
|
||||
<span class="conn-v">{{ vk.first_name }} {{ vk.last_name }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="conn-row">
|
||||
<span class="conn-k">Подключено</span>
|
||||
<span class="conn-v">{{ vk.connected_at | datefmt }}</span>
|
||||
</div>
|
||||
<div class="conn-row">
|
||||
<span class="conn-k">Обновлено</span>
|
||||
<span class="conn-v">{{ vk.updated_at | datefmt }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="margin-bottom:14px;">
|
||||
<a href="/vk-auth" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-box-arrow-in-right"></i>
|
||||
{% if vk %}Переподключить ВКонтакте{% else %}Войти через ВКонтакте{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<details style="margin-bottom:14px;">
|
||||
<summary class="btn btn-outline btn-sm" style="cursor:pointer;list-style:none;display:inline-flex;align-items:center;gap:6px;">
|
||||
<i class="bi bi-key"></i> Ввести токен вручную
|
||||
</summary>
|
||||
<form method="post" action="/connections/vk" style="margin-top:12px;">
|
||||
<div class="form-row">
|
||||
<label class="form-lbl">Токен доступа VK</label>
|
||||
<input class="inp" type="text" name="access_token"
|
||||
placeholder="vk1.a.xxxxxxxxxxxxxxxx…"
|
||||
value="{{ vk.access_token if vk else '' }}"
|
||||
required autocomplete="off">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="form-lbl">ID сообщества ВКонтакте</label>
|
||||
<input class="inp" type="text" name="vk_group_id"
|
||||
placeholder="Например: 229744980"
|
||||
value="{{ vk.vk_user_id if vk and vk.vk_user_id else '' }}"
|
||||
autocomplete="off">
|
||||
<div style="font-size:11px;color:#9EA8BE;margin-top:4px;">Числовой ID группы/паблика с включённым Маркетом (без минуса)</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-save"></i> Сохранить
|
||||
</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
{% if vk %}
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||
<button type="button" class="btn btn-outline btn-sm" onclick="testConnection('vk', this)">
|
||||
<i class="bi bi-wifi"></i> Проверить соединение
|
||||
</button>
|
||||
<span id="vk-test-result" style="font-size:12px;"></span>
|
||||
</div>
|
||||
<form method="post" action="/connections/vk/disconnect" style="margin-top:10px;"
|
||||
onsubmit="return confirm('Отключить ВКонтакте?')">
|
||||
<button type="submit" class="btn btn-danger btn-sm">
|
||||
<i class="bi bi-plug"></i> Отключить
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</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 ? '#17A865' : '#E53935';
|
||||
} catch (e) {
|
||||
resultEl.textContent = 'Ошибка сети';
|
||||
resultEl.style.color = '#E53935';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,17 +1,23 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Email подтвержден — EvoSync{% endblock %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Email подтверждён — Мои Товары</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Golos+Text:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body style="display:flex;align-items:center;justify-content:center;min-height:100vh;background:#F4F5F7;">
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-sm-10 col-md-6 col-lg-5">
|
||||
<div class="card shadow-sm mt-5 text-center">
|
||||
<div class="card-body p-5">
|
||||
<i class="bi bi-check-circle display-4 text-success mb-3"></i>
|
||||
<h1 class="h4 mb-3">Email подтвержден!</h1>
|
||||
<p class="text-muted">Ваш email успешно подтвержден. Теперь вы можете войти в систему.</p>
|
||||
<a href="/login" class="btn btn-primary mt-2">Войти</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="max-width:400px;width:100%;padding:40px 32px;text-align:center;">
|
||||
<i class="bi bi-check-circle" style="font-size:48px;color:#17A865;display:block;margin-bottom:16px;"></i>
|
||||
<div class="pg-title" style="margin-bottom:8px;">Email подтверждён!</div>
|
||||
<div style="font-size:13px;color:#5C6278;margin-bottom:20px;">Ваш email успешно подтверждён. Теперь вы можете войти в систему.</div>
|
||||
<a href="/login" class="btn btn-primary">Войти</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,27 +1,48 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Забыли пароль — EvoSync{% endblock %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Забыли пароль — Мои Товары</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Golos+Text:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body style="display:flex;align-items:center;justify-content:center;min-height:100vh;background:#F4F5F7;">
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-sm-10 col-md-6 col-lg-5">
|
||||
<div class="card shadow-sm mt-4">
|
||||
<div class="card-body p-4">
|
||||
<h1 class="card-title h4 mb-2">Забыли пароль?</h1>
|
||||
<p class="text-muted small mb-4">Введите email, указанный при регистрации.</p>
|
||||
<form method="post" action="/forgot-password">
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input type="email" id="email" name="email" class="form-control" required>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary">Отправить ссылку для сброса</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="mt-3 text-center small">
|
||||
<a href="/login">Вернуться ко входу</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="max-width:400px;width:100%;padding:32px;">
|
||||
<div style="text-align:center;margin-bottom:24px;">
|
||||
<div style="width:44px;height:44px;border-radius:11px;background:#FF5500;display:flex;align-items:center;justify-content:center;margin:0 auto 16px;">
|
||||
<svg width="24" height="24" viewBox="0 0 28 28" fill="none">
|
||||
<path d="M9 11h10l-1.5 9H10.5L9 11Z" fill="white" opacity="0.95"/>
|
||||
<path d="M11.5 11V9a2.5 2.5 0 015 0v2" stroke="white" stroke-width="1.6" stroke-linecap="round" fill="none"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="login-box-title">Забыли пароль?</div>
|
||||
<div style="font-size:13px;color:#5C6278;margin-top:4px;">Введите email, указанный при регистрации</div>
|
||||
</div>
|
||||
|
||||
{% if errors %}
|
||||
<div class="alert alert-rd" style="margin-bottom:14px;">
|
||||
<span><i class="bi bi-x-circle"></i></span>
|
||||
<div>{% for e in errors %}<div>{{ e }}</div>{% endfor %}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/forgot-password">
|
||||
<div class="form-row">
|
||||
<label class="form-lbl" for="email">Email</label>
|
||||
<input class="inp" type="email" id="email" name="email" placeholder="you@store.ru" required>
|
||||
</div>
|
||||
<button type="submit" class="login-btn">Отправить ссылку для сброса</button>
|
||||
</form>
|
||||
|
||||
<div class="login-hint" style="margin-top:16px;">
|
||||
<a href="/login">← Вернуться ко входу</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
79
web/templates/invite.html
Normal file
79
web/templates/invite.html
Normal file
@@ -0,0 +1,79 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Завершение регистрации — Мои Товары</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Golos+Text:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body style="display:flex;align-items:center;justify-content:center;min-height:100vh;background:#F4F5F7;padding:24px;">
|
||||
|
||||
<div class="card" style="max-width:480px;width:100%;padding:32px;">
|
||||
<div style="text-align:center;margin-bottom:24px;">
|
||||
<div style="width:44px;height:44px;border-radius:11px;background:#FF5500;display:flex;align-items:center;justify-content:center;margin:0 auto 16px;">
|
||||
<svg width="24" height="24" viewBox="0 0 28 28" fill="none">
|
||||
<path d="M9 11h10l-1.5 9H10.5L9 11Z" fill="white" opacity="0.95"/>
|
||||
<path d="M11.5 11V9a2.5 2.5 0 015 0v2" stroke="white" stroke-width="1.6" stroke-linecap="round" fill="none"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="login-box-title">Добро пожаловать!</div>
|
||||
<div style="font-size:13px;color:#5C6278;margin-top:4px;">Ваш аккаунт создан через Эвотор. Заполните профиль и задайте пароль для входа.</div>
|
||||
</div>
|
||||
|
||||
{% if errors %}
|
||||
<div class="alert alert-rd" style="margin-bottom:14px;">
|
||||
<span><i class="bi bi-x-circle"></i></span>
|
||||
<div>{% for e in errors %}<div>{{ e }}</div>{% endfor %}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/invite?token={{ token }}">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
|
||||
<div class="form-row">
|
||||
<label class="form-lbl" for="first_name">Имя <span style="color:#E53935;">*</span></label>
|
||||
<input class="inp" type="text" id="first_name" name="first_name"
|
||||
value="{{ form.first_name if form else (invite_user.first_name or '') }}" required>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="form-lbl" for="last_name">Фамилия <span style="color:#E53935;">*</span></label>
|
||||
<input class="inp" type="text" id="last_name" name="last_name"
|
||||
value="{{ form.last_name if form else (invite_user.last_name or '') }}" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="form-lbl" for="email">Email <span style="color:#E53935;">*</span></label>
|
||||
<input class="inp" type="email" id="email" name="email"
|
||||
value="{{ form.email if form else (invite_user.email or '') }}" required>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="form-lbl" for="phone">Телефон <span style="color:#E53935;">*</span></label>
|
||||
<input class="inp" type="tel" id="phone" name="phone"
|
||||
value="{{ form.phone if form else (invite_user.phone or '') }}" required>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="form-lbl" for="password">Пароль <span style="color:#E53935;">*</span></label>
|
||||
<input class="inp" type="password" id="password" name="password" required minlength="8">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="form-lbl" for="password_confirm">Подтверждение пароля <span style="color:#E53935;">*</span></label>
|
||||
<input class="inp" type="password" id="password_confirm" name="password_confirm" required>
|
||||
</div>
|
||||
<button type="submit" class="login-btn">Завершить регистрацию</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/inputmask@5.0.9/dist/inputmask.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var phoneInputs = document.querySelectorAll('input[name="phone"]');
|
||||
if (phoneInputs.length) {
|
||||
Inputmask('+7 (999) 999-99-99', { placeholder: '_', showMaskOnHover: false, clearMaskOnLostFocus: false }).mask(phoneInputs);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user