Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
796cf49ff9 | ||
|
|
7a06045bef | ||
|
|
fc65e591b3 | ||
|
|
5ead89e0cf | ||
| ba34adbbcf | |||
|
|
2df4898098 | ||
|
|
15a362ca42 | ||
|
|
049e82654d | ||
|
|
854c912a88 | ||
|
|
db0c1cbed3 | ||
|
|
fd7d0022ea | ||
|
|
1bf82adbfc | ||
|
|
9a68c083e3 |
22
.env.example
22
.env.example
@@ -1,11 +1,17 @@
|
|||||||
DATABASE_URL=mysql+pymysql://evosync:evosync@db:3306/evosync
|
# Database
|
||||||
SECRET_KEY=your-random-secret-key-here
|
DB_ROOT_PASSWORD=rootpassword
|
||||||
BASE_URL=http://localhost:8000
|
|
||||||
|
|
||||||
EVOTOR_APP_ID=your-evotor-app-id
|
|
||||||
EVOTOR_WEBHOOK_SECRET=your-webhook-secret
|
|
||||||
|
|
||||||
DB_ROOT_PASSWORD=rootpass
|
|
||||||
DB_NAME=evosync
|
DB_NAME=evosync
|
||||||
DB_USER=evosync
|
DB_USER=evosync
|
||||||
DB_PASSWORD=evosync
|
DB_PASSWORD=evosync
|
||||||
|
|
||||||
|
# App
|
||||||
|
SECRET_KEY=change-me-in-production
|
||||||
|
BASE_URL=https://evosync.ru
|
||||||
|
|
||||||
|
# Evotor
|
||||||
|
EVOTOR_APP_ID=
|
||||||
|
EVOTOR_WEBHOOK_SECRET=
|
||||||
|
|
||||||
|
# Celery Flower
|
||||||
|
FLOWER_USER=admin
|
||||||
|
FLOWER_PASSWORD=changeme
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -17,3 +17,6 @@ passwords.txt
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
certbot
|
certbot
|
||||||
|
web-resources
|
||||||
|
.coverage
|
||||||
|
password|*
|
||||||
|
|||||||
64
CHANGELOG.md
64
CHANGELOG.md
@@ -1,64 +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.8.2] - 2026-03-06
|
|
||||||
|
|
||||||
### Miscellaneous
|
|
||||||
|
|
||||||
- Replace EvoSync with ЭВОСИНК throughout UI
|
|
||||||
|
|
||||||
## [1.8.1] - 2026-03-06
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- Update changelog for v1.7.3
|
|
||||||
- Fix changelog order — 1.8.0 before 1.7.3
|
|
||||||
|
|
||||||
### Other
|
|
||||||
|
|
||||||
- V1.8.1
|
|
||||||
|
|
||||||
## [1.7.3] - 2026-03-06
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Add nginx reverse proxy and Let's Encrypt TLS setup
|
|
||||||
|
|
||||||
### Other
|
|
||||||
|
|
||||||
- V1.7.3
|
|
||||||
|
|
||||||
## [1.8.0] - 2026-03-06
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Add Evotor OAuth connection feature with formatted phone input
|
|
||||||
- Add Alembic database migrations
|
|
||||||
- Add connections dashboard with background health checks
|
|
||||||
- Add VK OAuth connection with health checks
|
|
||||||
- Release v1.8.0 — connections dashboard, VK OAuth, sync config, catalog browser
|
|
||||||
|
|
||||||
### Miscellaneous
|
|
||||||
|
|
||||||
- Add semantic versioning and automatic changelog generation
|
|
||||||
|
|
||||||
## [1.7.2] - 2026-03-05
|
|
||||||
|
|
||||||
### Other
|
|
||||||
|
|
||||||
- Add user registration and auth web app
|
|
||||||
- Update docker-compose.yml: remove database service, adjust ports and host
|
|
||||||
- Integrate Bootstrap 5 and Bootstrap Icons into UI
|
|
||||||
|
|
||||||
## [1.0.0] - 2026-02-02
|
|
||||||
|
|
||||||
### Other
|
|
||||||
|
|
||||||
- Initial commit
|
|
||||||
- V1.
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --upgrade pip && pip install -r requirements.txt
|
||||||
|
|
||||||
COPY web/ ./web/
|
COPY . .
|
||||||
COPY alembic.ini .
|
|
||||||
COPY docker-entrypoint.sh .
|
|
||||||
RUN chmod +x docker-entrypoint.sh
|
|
||||||
|
|
||||||
CMD ["./docker-entrypoint.sh"]
|
CMD ["uvicorn", "web.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
227
README.md
227
README.md
@@ -1,3 +1,226 @@
|
|||||||
# 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 product catalog |
|
||||||
|
| `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.
|
||||||
|
|
||||||
|
| Task | Schedule | Description |
|
||||||
|
|------|----------|-------------|
|
||||||
|
| `web.tasks.catalog.refresh_catalog` | Every `CATALOG_REFRESH_INTERVAL_SECONDS` | Fetches stores, product groups, and products from the Evotor API for every connected user; upserts into `cached_stores`, `cached_groups`, `cached_products` |
|
||||||
|
| `web.tasks.vk_catalog.refresh_vk_catalog` | Every `CATALOG_REFRESH_INTERVAL_SECONDS` | Fetches Market albums and products from VK API for every connected user; upserts into `vk_cached_albums`, `vk_cached_products` |
|
||||||
|
|
||||||
|
**Evotor sync 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 sync sequence per user:**
|
||||||
|
1. `market.getAlbums` → upsert `vk_cached_albums`
|
||||||
|
2. `market.get` (extended=1, paginated) → upsert `vk_cached_products` with album membership
|
||||||
|
|
||||||
|
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 |
|
||||||
|
|
||||||
|
### 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 |
|
||||||
|
|
||||||
|
### 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 |
|
||||||
|
| `BASE_URL` | `http://localhost:8000` | 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` | Evotor + VK catalog sync interval (s) |
|
||||||
|
| `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 # fill in your values
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
App is available at `http://localhost:8080`.
|
||||||
|
Flower (queue monitor) at `http://localhost:5555`.
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
alembic upgrade head
|
||||||
|
uvicorn web.main:app --reload --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest --cov=web
|
||||||
|
```
|
||||||
|
|||||||
@@ -3,11 +3,6 @@ script_location = web/migrations
|
|||||||
prepend_sys_path = .
|
prepend_sys_path = .
|
||||||
version_path_separator = os
|
version_path_separator = os
|
||||||
|
|
||||||
# URL is set dynamically in env.py from DATABASE_URL env var
|
|
||||||
sqlalchemy.url =
|
|
||||||
|
|
||||||
[post_write_hooks]
|
|
||||||
|
|
||||||
[loggers]
|
[loggers]
|
||||||
keys = root,sqlalchemy,alembic
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
|||||||
45
cliff.toml
45
cliff.toml
@@ -1,45 +0,0 @@
|
|||||||
[changelog]
|
|
||||||
header = """# Changelog
|
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
|
||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n
|
|
||||||
"""
|
|
||||||
body = """
|
|
||||||
{% if version %}\
|
|
||||||
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
|
|
||||||
{% else %}\
|
|
||||||
## [Unreleased]
|
|
||||||
{% endif %}\
|
|
||||||
{% for group, commits in commits | group_by(attribute="group") %}
|
|
||||||
### {{ group | striptags | trim | upper_first }}
|
|
||||||
{% for commit in commits %}
|
|
||||||
- {% if commit.scope %}**{{ commit.scope }}**: {% endif %}\
|
|
||||||
{{ commit.message | split(pat="\n") | first | upper_first }}\
|
|
||||||
{% endfor %}
|
|
||||||
{% endfor %}\n
|
|
||||||
"""
|
|
||||||
trim = true
|
|
||||||
footer = ""
|
|
||||||
|
|
||||||
[git]
|
|
||||||
conventional_commits = true
|
|
||||||
filter_unconventional = false
|
|
||||||
split_commits = false
|
|
||||||
commit_parsers = [
|
|
||||||
{ message = "^feat", group = "Added" },
|
|
||||||
{ message = "^fix", group = "Fixed" },
|
|
||||||
{ message = "^doc", group = "Documentation" },
|
|
||||||
{ message = "^perf", group = "Performance" },
|
|
||||||
{ message = "^refactor", group = "Changed" },
|
|
||||||
{ message = "^style", group = "Styling" },
|
|
||||||
{ message = "^test", group = "Testing" },
|
|
||||||
{ message = "^chore\\(release\\)", skip = true },
|
|
||||||
{ message = "^chore", group = "Miscellaneous" },
|
|
||||||
{ message = "^ci", group = "CI/CD" },
|
|
||||||
{ body = ".*security", group = "Security" },
|
|
||||||
{ message = ".*", group = "Other" },
|
|
||||||
]
|
|
||||||
filter_commits = false
|
|
||||||
tag_pattern = "v[0-9].*"
|
|
||||||
@@ -1,34 +1,111 @@
|
|||||||
version: "3.8"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
db:
|
||||||
|
image: mariadb:11.4
|
||||||
|
restart: unless-stopped
|
||||||
|
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:
|
web:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.web
|
dockerfile: Dockerfile.web
|
||||||
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "8080:8000"
|
- "8080:8000"
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=mysql+pymysql://${DB_USER}:${DB_PASSWORD}@host.docker.internal:3306/${DB_NAME}
|
DATABASE_URL: mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db:3306/${DB_NAME}
|
||||||
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}
|
REDIS_URL: redis://redis:6379/0
|
||||||
- BASE_URL=${BASE_URL:-https://evosync.ru}
|
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
|
||||||
- EVOTOR_APP_ID=${EVOTOR_APP_ID}
|
BASE_URL: ${BASE_URL:-https://evosync.ru}
|
||||||
- EVOTOR_WEBHOOK_SECRET=${EVOTOR_WEBHOOK_SECRET}
|
EVOTOR_APP_ID: ${EVOTOR_APP_ID:-}
|
||||||
- VK_CLIENT_ID=${VK_CLIENT_ID}
|
EVOTOR_WEBHOOK_SECRET: ${EVOTOR_WEBHOOK_SECRET:-}
|
||||||
- VK_CLIENT_SECRET=${VK_CLIENT_SECRET}
|
JIVOSITE_WIDGET_ID: ${JIVOSITE_WIDGET_ID:-}
|
||||||
|
VK_DEFAULT_PHOTO_PATH: /app/default_product.png
|
||||||
volumes:
|
volumes:
|
||||||
- ./web:/app/web
|
- ./5393364294319597854.png:/app/default_product.png:ro
|
||||||
- ./alembic.ini:/app/alembic.ini
|
depends_on:
|
||||||
- ./docker-entrypoint.sh:/app/docker-entrypoint.sh
|
db:
|
||||||
restart: unless-stopped
|
condition: service_healthy
|
||||||
extra_hosts:
|
redis:
|
||||||
- "host.docker.internal:host-gateway"
|
condition: service_healthy
|
||||||
|
command: >
|
||||||
|
sh -c "alembic upgrade head && uvicorn web.main:app --host 0.0.0.0 --port 8000"
|
||||||
|
|
||||||
# sync:
|
worker:
|
||||||
# build:
|
build:
|
||||||
# context: .
|
context: .
|
||||||
# dockerfile: Dockerfile
|
dockerfile: Dockerfile.web
|
||||||
# volumes:
|
restart: unless-stopped
|
||||||
# - ./evo:/var/www/evo
|
environment:
|
||||||
# - ./vk:/var/www/vk
|
DATABASE_URL: mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db:3306/${DB_NAME}
|
||||||
# - ./run:/var/www/run
|
REDIS_URL: redis://redis:6379/0
|
||||||
# - ./logs:/var/www/logs
|
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:
|
||||||
|
- ./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=2 --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
|
||||||
|
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:
|
||||||
|
|||||||
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
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
# Catalog Browser with Filter Management & CSV Export
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
Users need to browse their Evotor catalog (stores, groups, products) in a table view, manage sync whitelist/blacklist rules inline, and export data to CSV.
|
|
||||||
|
|
||||||
This feature **replaces** the separate `/sync/stores`, `/sync/groups`, `/sync/products` pages from the sync-configuration plan. The catalog browser becomes the unified place for both viewing data and managing filter rules.
|
|
||||||
|
|
||||||
Data is cached in DB with a refresh mechanism — not fetched live on every page load.
|
|
||||||
|
|
||||||
## Data Model
|
|
||||||
|
|
||||||
### Catalog Cache Tables
|
|
||||||
|
|
||||||
```
|
|
||||||
tablename: "cached_stores"
|
|
||||||
- id (Integer, PK)
|
|
||||||
- user_id (Integer, FK users.id CASCADE)
|
|
||||||
- evotor_id (String 255) # Evotor UUID
|
|
||||||
- name (String 255)
|
|
||||||
- address (String 500, nullable)
|
|
||||||
- fetched_at (DateTime) # when this snapshot was taken
|
|
||||||
|
|
||||||
UniqueConstraint: (user_id, evotor_id)
|
|
||||||
Index: user_id
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
tablename: "cached_groups"
|
|
||||||
- id (Integer, PK)
|
|
||||||
- user_id (Integer, FK users.id CASCADE)
|
|
||||||
- evotor_id (String 255) # Evotor UUID
|
|
||||||
- store_evotor_id (String 255) # parent store UUID
|
|
||||||
- name (String 255)
|
|
||||||
- fetched_at (DateTime)
|
|
||||||
|
|
||||||
UniqueConstraint: (user_id, evotor_id)
|
|
||||||
Index: (user_id, store_evotor_id)
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
tablename: "cached_products"
|
|
||||||
- id (Integer, PK)
|
|
||||||
- user_id (Integer, FK users.id CASCADE)
|
|
||||||
- evotor_id (String 255) # Evotor UUID
|
|
||||||
- store_evotor_id (String 255) # parent store UUID
|
|
||||||
- group_evotor_id (String 255, nullable) # parent group UUID
|
|
||||||
- name (String 255)
|
|
||||||
- price (Numeric(12,2), nullable)
|
|
||||||
- quantity (Numeric(12,3), nullable)
|
|
||||||
- measure_name (String 20, nullable)
|
|
||||||
- article_number (String 100, nullable)
|
|
||||||
- allow_to_sell (Boolean, nullable)
|
|
||||||
- fetched_at (DateTime)
|
|
||||||
|
|
||||||
UniqueConstraint: (user_id, evotor_id)
|
|
||||||
Index: (user_id, store_evotor_id, group_evotor_id)
|
|
||||||
```
|
|
||||||
|
|
||||||
### `SyncFilter` (from sync-configuration plan, unchanged)
|
|
||||||
|
|
||||||
```
|
|
||||||
tablename: "sync_filters"
|
|
||||||
- sync_config_id, entity_type, entity_id, entity_name, filter_mode, parent_entity_id
|
|
||||||
```
|
|
||||||
|
|
||||||
The catalog browser reads from cache tables for display and from `sync_filters` for the current filter state of each entity.
|
|
||||||
|
|
||||||
### Cache Refresh
|
|
||||||
|
|
||||||
`web/evotor_api.py` gets a new function:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def refresh_catalog_cache(user_id: int, access_token: str, db: Session):
|
|
||||||
"""Fetch all stores, groups, products from Evotor API and upsert into cache tables."""
|
|
||||||
```
|
|
||||||
|
|
||||||
Triggered by:
|
|
||||||
- Manual "Обновить" button on the catalog page
|
|
||||||
- Background job (optional, can reuse health_checker interval or separate setting)
|
|
||||||
- First visit to catalog if cache is empty
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
### 1. New Models — `web/models.py`
|
|
||||||
|
|
||||||
Add `CachedStore`, `CachedGroup`, `CachedProduct` models as described above.
|
|
||||||
|
|
||||||
### 2. Alembic Migration
|
|
||||||
|
|
||||||
Create `cached_stores`, `cached_groups`, `cached_products` tables.
|
|
||||||
|
|
||||||
### 3. Evotor API Helper — `web/evotor_api.py`
|
|
||||||
|
|
||||||
Extend with:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def fetch_stores(access_token: str) -> list[dict]
|
|
||||||
async def fetch_groups(access_token: str, store_id: str) -> list[dict]
|
|
||||||
async def fetch_products(access_token: str, store_id: str) -> list[dict]
|
|
||||||
async def refresh_catalog_cache(user_id: int, access_token: str, db: Session)
|
|
||||||
```
|
|
||||||
|
|
||||||
`refresh_catalog_cache` does:
|
|
||||||
1. Fetch all stores
|
|
||||||
2. For each store, fetch groups and products
|
|
||||||
3. Upsert into cache tables (delete old rows for user, insert fresh)
|
|
||||||
4. Update `fetched_at` timestamps
|
|
||||||
|
|
||||||
### 4. Catalog Route — `web/routes/catalog.py` (new)
|
|
||||||
|
|
||||||
**`GET /catalog`** — Stores table. Requires auth + Evotor connection.
|
|
||||||
- Reads `cached_stores` for user
|
|
||||||
- If cache is empty, triggers refresh
|
|
||||||
- Shows table with columns: Название, Адрес, Статус фильтра, Действия
|
|
||||||
- Each row shows the store's current `SyncFilter` state (included/excluded/no rule)
|
|
||||||
- Link to drill into groups for each store
|
|
||||||
- "Обновить каталог" button, "Экспорт CSV" button, back link
|
|
||||||
|
|
||||||
**`GET /catalog/groups?store_id=UUID`** — Groups table for a store.
|
|
||||||
- Reads `cached_groups` filtered by `store_evotor_id`
|
|
||||||
- Table columns: Название, Статус фильтра, Кол-во товаров, Действия
|
|
||||||
- Each row shows group's `SyncFilter` state
|
|
||||||
- Link to drill into products for each group
|
|
||||||
- "Экспорт CSV" button, back to stores
|
|
||||||
|
|
||||||
**`GET /catalog/products?store_id=UUID&group_id=UUID`** — Products table for a group.
|
|
||||||
- Reads `cached_products` filtered by `store_evotor_id` and `group_evotor_id`
|
|
||||||
- Table columns: Название, Артикул, Цена, Кол-во, Ед. изм., В продаже, Статус фильтра, Действия
|
|
||||||
- Each row shows product's `SyncFilter` state
|
|
||||||
- "Экспорт CSV" button, back to groups
|
|
||||||
|
|
||||||
**`GET /catalog/products?store_id=UUID`** — All products for a store (no group filter).
|
|
||||||
- Same table, but shows all products in the store with a "Группа" column added
|
|
||||||
|
|
||||||
**`POST /catalog/filter`** — Toggle filter for an entity.
|
|
||||||
- Body: `entity_type`, `entity_id`, `entity_name`, `filter_mode` (include/exclude/none), `parent_entity_id`
|
|
||||||
- Creates, updates, or deletes the `SyncFilter` row
|
|
||||||
- Redirects back to the referring page
|
|
||||||
|
|
||||||
**`POST /catalog/refresh`** — Manual cache refresh.
|
|
||||||
- Calls `refresh_catalog_cache()`
|
|
||||||
- Redirects back to `/catalog`
|
|
||||||
|
|
||||||
**`GET /catalog/export?type=stores|groups|products&store_id=UUID&group_id=UUID`** — CSV export.
|
|
||||||
- Reads from cache tables
|
|
||||||
- Returns `StreamingResponse` with `text/csv` content type and `Content-Disposition: attachment`
|
|
||||||
- Filename: `{type}_{date}.csv`
|
|
||||||
|
|
||||||
### 5. Templates
|
|
||||||
|
|
||||||
**`web/templates/catalog_stores.html`** — Stores table:
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────────────────────────────────────────┐
|
|
||||||
│ Каталог [Обновить] [Экспорт CSV] │
|
|
||||||
│ Последнее обновление: 06.03.2026 14:30 │
|
|
||||||
├──────────────────────────────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ Магазины │
|
|
||||||
│ ┌──────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ Название │ Адрес │ Фильтр │ │ │
|
|
||||||
│ ├──────────────────────────────────────────────────────┤ │
|
|
||||||
│ │ Чайная │ ул. Мира, 1 │ ✓ Вкл │ [→][▼] │ │
|
|
||||||
│ │ Склад │ — │ ✗ Выкл │ [→][▼] │ │
|
|
||||||
│ │ Точка 2 │ ул. Мира, 5 │ — Нет │ [→][▼] │ │
|
|
||||||
│ └──────────────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ [→] = перейти к группам │
|
|
||||||
│ [▼] = dropdown: Включить / Исключить / Убрать правило │
|
|
||||||
│ │
|
|
||||||
└──────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
**Filter status column** shows:
|
|
||||||
- `✓ Включено` (green badge) — entity has an "include" rule
|
|
||||||
- `✗ Исключено` (red badge) — entity has an "exclude" rule
|
|
||||||
- `— Нет правила` (grey badge) — no filter rule (follows default behavior)
|
|
||||||
|
|
||||||
**Actions column** per row:
|
|
||||||
- Link icon → drill into children (groups for stores, products for groups)
|
|
||||||
- Dropdown button with filter actions: "Включить в синхронизацию" / "Исключить из синхронизации" / "Убрать правило". Each is a small POST form to `/catalog/filter`.
|
|
||||||
|
|
||||||
**`web/templates/catalog_groups.html`** — Groups table:
|
|
||||||
- Breadcrumb: Каталог > {Store name} > Группы
|
|
||||||
- Same table pattern, columns: Название, Кол-во товаров, Фильтр, Действия
|
|
||||||
- Drill-down link to products per group
|
|
||||||
|
|
||||||
**`web/templates/catalog_products.html`** — Products table:
|
|
||||||
- Breadcrumb: Каталог > {Store name} > {Group name} > Товары
|
|
||||||
- Columns: Название, Артикул, Цена, Кол-во, Ед. изм., В продаже, Фильтр, Действия
|
|
||||||
- "В продаже" column: green check / red cross based on `allow_to_sell`
|
|
||||||
|
|
||||||
All tables use Bootstrap table styling (`table table-striped table-hover`) with responsive wrapper.
|
|
||||||
|
|
||||||
### 6. CSV Export Format
|
|
||||||
|
|
||||||
**Stores CSV:**
|
|
||||||
```
|
|
||||||
Название,Адрес,ID,Фильтр
|
|
||||||
Чайная,"ул. Мира, 1",uuid-123,Включено
|
|
||||||
```
|
|
||||||
|
|
||||||
**Groups CSV:**
|
|
||||||
```
|
|
||||||
Магазин,Название,ID,Фильтр
|
|
||||||
Чайная,Белый чай,uuid-456,Включено
|
|
||||||
```
|
|
||||||
|
|
||||||
**Products CSV:**
|
|
||||||
```
|
|
||||||
Магазин,Группа,Название,Артикул,Цена,Количество,Ед. измерения,В продаже,ID,Фильтр
|
|
||||||
Чайная,Белый чай,Бай Му Дань,1005,350.00,180.0,г,Да,uuid-789,Включено
|
|
||||||
```
|
|
||||||
|
|
||||||
UTF-8 with BOM (`\ufeff`) for Excel compatibility. Delimiter: comma.
|
|
||||||
|
|
||||||
### 7. Update Sync Configuration Plan
|
|
||||||
|
|
||||||
The `/sync` page links to `/catalog` instead of separate filter pages:
|
|
||||||
- "Настроить фильтры" button → `/catalog`
|
|
||||||
- Filter summary on `/sync` reads from `SyncFilter` table (unchanged)
|
|
||||||
- Remove `/sync/stores`, `/sync/groups`, `/sync/products` routes from sync-configuration plan — replaced by catalog browser
|
|
||||||
|
|
||||||
### 8. Navbar / Navigation
|
|
||||||
|
|
||||||
Add "Каталог" link to navbar for logged-in users. Order: Подключения → Каталог → Синхронизация → Личный кабинет → Выход.
|
|
||||||
|
|
||||||
### 9. Register Route — `web/main.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
from web.routes import catalog
|
|
||||||
app.include_router(catalog.router)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Files Summary
|
|
||||||
|
|
||||||
| File | Action |
|
|
||||||
|------|--------|
|
|
||||||
| `web/models.py` | Modify — add `CachedStore`, `CachedGroup`, `CachedProduct` |
|
|
||||||
| `web/evotor_api.py` | Create — API fetch + cache refresh functions |
|
|
||||||
| `web/routes/catalog.py` | Create — catalog routes (tables, filter toggle, refresh, CSV export) |
|
|
||||||
| `web/templates/catalog_stores.html` | Create — stores table |
|
|
||||||
| `web/templates/catalog_groups.html` | Create — groups table |
|
|
||||||
| `web/templates/catalog_products.html` | Create — products table |
|
|
||||||
| `web/templates/base.html` | Modify — add "Каталог" nav link |
|
|
||||||
| `web/main.py` | Modify — register catalog router |
|
|
||||||
| `docs/plans/sync-configuration.md` | Update — remove /sync/stores,groups,products; link to /catalog |
|
|
||||||
| Alembic migration | Create — cache tables |
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
1. Run `alembic upgrade head`
|
|
||||||
2. Visit `/catalog` without Evotor connection → warning to connect first
|
|
||||||
3. Connect Evotor, visit `/catalog` → triggers first cache refresh, shows stores table
|
|
||||||
4. Click store → shows groups table with group names from Evotor
|
|
||||||
5. Click group → shows products table with full product details
|
|
||||||
6. Toggle filter on a product → badge changes, `SyncFilter` row created in DB
|
|
||||||
7. Go to `/sync` → filter summary reflects the change
|
|
||||||
8. Click "Экспорт CSV" on products page → downloads CSV, opens correctly in Excel
|
|
||||||
9. Click "Обновить каталог" → re-fetches from Evotor API, updates cache
|
|
||||||
10. Verify breadcrumb navigation works correctly through the hierarchy
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
# Connections Dashboard with Background Health Checks
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
Users currently access Evotor connection via a dedicated `/evotor` page linked from the navbar. As more integrations are planned, we need a unified **Connections** page where users can manage all their connections: add new ones, view status, edit, and delete. The dashboard starts empty — users explicitly add each connection they need.
|
|
||||||
|
|
||||||
Supported connection types: **Evotor**, **VK** (one per type per user).
|
|
||||||
|
|
||||||
## Data Design
|
|
||||||
|
|
||||||
### Current state (separate models)
|
|
||||||
|
|
||||||
`EvotorConnection` and `VkConnection` remain as-is — they hold service-specific fields (store_id/store_name for Evotor, vk_user_id/first_name/last_name for VK). The connections dashboard reads from both tables.
|
|
||||||
|
|
||||||
No new unified "connection" table needed. The dashboard builds a virtual list by querying both tables. The "add" flow is just a gateway to the existing per-service OAuth pages.
|
|
||||||
|
|
||||||
### Model additions (both `EvotorConnection` and `VkConnection`)
|
|
||||||
|
|
||||||
Already planned:
|
|
||||||
- `is_online` (Boolean, default=False, server_default="0")
|
|
||||||
- `last_checked_at` (DateTime, nullable)
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
### 1. Model Changes — `web/models.py`
|
|
||||||
|
|
||||||
Add `is_online` and `last_checked_at` to both `EvotorConnection` and `VkConnection`.
|
|
||||||
|
|
||||||
### 2. Alembic Migration
|
|
||||||
|
|
||||||
Add health check fields to both connection tables.
|
|
||||||
|
|
||||||
### 3. Config Addition — `web/config.py`
|
|
||||||
|
|
||||||
Add `HEALTH_CHECK_INTERVAL_SECONDS: int = 600` (10 minutes default).
|
|
||||||
|
|
||||||
### 4. Background Health Checker — `web/health_checker.py` (new)
|
|
||||||
|
|
||||||
- `check_evotor_connection(access_token) -> bool` — async, `GET https://api.evotor.ru/stores` with Bearer token
|
|
||||||
- `check_vk_connection(access_token) -> bool` — async, `GET https://api.vk.com/method/users.get` with token
|
|
||||||
- `run_health_checks()` — queries all connection rows, checks each, updates `is_online` and `last_checked_at`
|
|
||||||
- `health_check_loop(interval)` — infinite loop with `asyncio.sleep`
|
|
||||||
|
|
||||||
### 5. Wire Background Task — `web/main.py`
|
|
||||||
|
|
||||||
Add FastAPI lifespan context manager:
|
|
||||||
- On startup: `asyncio.create_task(health_check_loop(...))`
|
|
||||||
- On shutdown: cancel the task
|
|
||||||
- Register connections router
|
|
||||||
|
|
||||||
### 6. Connections Route — `web/routes/connections.py` (new)
|
|
||||||
|
|
||||||
**`GET /connections`** — Main dashboard. Requires auth.
|
|
||||||
|
|
||||||
Queries both `EvotorConnection` and `VkConnection` for the current user. Builds a list of available service types and their connection state:
|
|
||||||
|
|
||||||
```python
|
|
||||||
SERVICE_TYPES = [
|
|
||||||
{"type": "evotor", "name": "Эвотор", "icon": "bi-shop", "connect_url": "/evotor", "disconnect_url": "/evotor/disconnect"},
|
|
||||||
{"type": "vk", "name": "ВКонтакте", "icon": "bi-chat-dots", "connect_url": "/vk", "disconnect_url": "/vk/disconnect"},
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
For each type, attach the connection record (or None). Template renders based on state.
|
|
||||||
|
|
||||||
**`GET /connections/add`** — "Add connection" page.
|
|
||||||
|
|
||||||
Shows only service types the user has NOT yet connected:
|
|
||||||
- Card per available type with service name, icon, short description
|
|
||||||
- "Подключить" button linking to the service's OAuth page (`/evotor` or `/vk`)
|
|
||||||
- If all types already connected — message "Все доступные сервисы подключены"
|
|
||||||
- Back link to `/connections`
|
|
||||||
|
|
||||||
**`POST /connections/delete?type=evotor|vk`** — Delete a connection.
|
|
||||||
|
|
||||||
Same as existing disconnect endpoints but accessed from the dashboard. Deletes the connection record, redirects to `/connections`.
|
|
||||||
|
|
||||||
(The existing `/evotor/disconnect` and `/vk/disconnect` routes remain as aliases.)
|
|
||||||
|
|
||||||
### 7. Templates
|
|
||||||
|
|
||||||
**`web/templates/connections.html`** — Dashboard:
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ Подключения [+ Добавить] │
|
|
||||||
├─────────────────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ ┌─ Card ─────────────────────────────────────┐ │
|
|
||||||
│ │ 🏪 Эвотор ● (green) │ │
|
|
||||||
│ │ Магазин "Чайная" │ │
|
|
||||||
│ │ Последняя проверка: 06.03.2026 14:30 │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ [Настроить] [Отключить] │ │
|
|
||||||
│ └────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌─ Card ─────────────────────────────────────┐ │
|
|
||||||
│ │ 💬 ВКонтакте ● (green) │ │
|
|
||||||
│ │ Иван Иванов │ │
|
|
||||||
│ │ Последняя проверка: 06.03.2026 14:30 │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ [Настроить] [Отключить] │ │
|
|
||||||
│ └────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ (Нет подключений — нажмите «Добавить») │
|
|
||||||
│ │
|
|
||||||
└─────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
Each connection card:
|
|
||||||
- Icon + service name + status indicator (green/red/grey)
|
|
||||||
- Details line (store name for Evotor, profile name for VK)
|
|
||||||
- Last checked timestamp in card footer
|
|
||||||
- "Настроить" button → links to service page (`/evotor` or `/vk`) for reconnect/details
|
|
||||||
- "Отключить" button → POST to `/connections/delete?type=...` with confirmation
|
|
||||||
|
|
||||||
Empty state: message prompting user to add their first connection.
|
|
||||||
|
|
||||||
**`web/templates/connections_add.html`** — Add connection page:
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────┐
|
|
||||||
│ Добавить подключение │
|
|
||||||
├─────────────────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ ┌─ Card ─────────────────────────────────────┐ │
|
|
||||||
│ │ 🏪 Эвотор │ │
|
|
||||||
│ │ Подключите кассу Эвотор для синхронизации │ │
|
|
||||||
│ │ каталога товаров. │ │
|
|
||||||
│ │ [Подключить →] │ │
|
|
||||||
│ └────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌─ Card ─────────────────────────────────────┐ │
|
|
||||||
│ │ 💬 ВКонтакте │ │
|
|
||||||
│ │ Подключите аккаунт ВКонтакте для │ │
|
|
||||||
│ │ публикации товаров в вашу группу. │ │
|
|
||||||
│ │ [Подключить →] │ │
|
|
||||||
│ └────────────────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ← Вернуться к подключениям │
|
|
||||||
│ │
|
|
||||||
└─────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8. Navbar Update — `web/templates/base.html`
|
|
||||||
|
|
||||||
Replace "Эвотор" link with "Подключения" → `/connections`.
|
|
||||||
|
|
||||||
### 9. Evotor/VK Callback Updates
|
|
||||||
|
|
||||||
On successful OAuth callback in both `/evotor/callback` and `/vk/callback`:
|
|
||||||
- Set `is_online=True` and `last_checked_at=now()`
|
|
||||||
- Redirect to `/connections` (already done for Evotor)
|
|
||||||
|
|
||||||
### 10. Evotor/VK Template Back Links
|
|
||||||
|
|
||||||
Change back links on `/evotor` and `/vk` pages: "Вернуться к подключениям" → `/connections`.
|
|
||||||
|
|
||||||
### 11. Delete Confirmation
|
|
||||||
|
|
||||||
The "Отключить" button on the dashboard uses a simple JS `confirm()` dialog: "Вы уверены, что хотите отключить {service name}?" before submitting the POST form.
|
|
||||||
|
|
||||||
## Files Summary
|
|
||||||
|
|
||||||
| File | Action |
|
|
||||||
|------|--------|
|
|
||||||
| `web/models.py` | Modify — add `is_online`, `last_checked_at` to both connection models |
|
|
||||||
| `web/config.py` | Modify — add `HEALTH_CHECK_INTERVAL_SECONDS` |
|
|
||||||
| `web/main.py` | Modify — lifespan + register connections router |
|
|
||||||
| `web/routes/evotor.py` | Modify — set online on callback, redirect to /connections |
|
|
||||||
| `web/routes/vk.py` | Modify — set online on callback, redirect to /connections |
|
|
||||||
| `web/routes/connections.py` | Create — dashboard, add page, delete endpoint |
|
|
||||||
| `web/health_checker.py` | Create — background checks for both Evotor and VK |
|
|
||||||
| `web/templates/connections.html` | Create — dashboard with cards |
|
|
||||||
| `web/templates/connections_add.html` | Create — add connection page |
|
|
||||||
| `web/templates/base.html` | Modify — navbar link |
|
|
||||||
| `web/templates/evotor.html` | Modify — back link to /connections |
|
|
||||||
| `web/templates/vk.html` | Modify — back link to /connections |
|
|
||||||
| Alembic migration | Create |
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
1. Run `alembic upgrade head`
|
|
||||||
2. Start the app, verify background task logs appear
|
|
||||||
3. Visit `/connections` — empty state, "Добавить" button visible
|
|
||||||
4. Click "Добавить" → shows Evotor and VK as available services
|
|
||||||
5. Add Evotor → goes through OAuth → returns to `/connections` with green status card
|
|
||||||
6. Add VK → same flow → both connections visible
|
|
||||||
7. Click "Добавить" again → shows "Все доступные сервисы подключены"
|
|
||||||
8. Click "Отключить" on Evotor → confirmation dialog → connection removed → card disappears
|
|
||||||
9. Click "Добавить" → Evotor is available again
|
|
||||||
10. Wait for health check cycle → verify `is_online` and `last_checked_at` update on remaining connections
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
# Sync Configuration Feature
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
EvoSync syncs product catalogs from Evotor → VK. Currently sync runs as a shell-based cron service with a hardcoded store ID and a flat-file whitelist of group names (`vk/whitelist`). This doesn't support multi-user or per-user configuration.
|
|
||||||
|
|
||||||
Users need a web UI to:
|
|
||||||
- Enable/disable the whole sync process
|
|
||||||
- Configure which stores, groups, and products to sync (whitelist/blacklist)
|
|
||||||
- Explicitly confirm before sync starts
|
|
||||||
|
|
||||||
The web app will store config in DB; the shell sync service will read from DB instead of flat files.
|
|
||||||
|
|
||||||
## Data Model
|
|
||||||
|
|
||||||
### `SyncConfig` — per-user master switch
|
|
||||||
|
|
||||||
```
|
|
||||||
tablename: "sync_configs"
|
|
||||||
- id (Integer, PK)
|
|
||||||
- user_id (Integer, FK users.id CASCADE, unique)
|
|
||||||
- is_enabled (Boolean, default=False) # master on/off
|
|
||||||
- confirmed_at (DateTime, nullable) # NULL = never confirmed/started
|
|
||||||
- created_at (DateTime, server_default=now)
|
|
||||||
- updated_at (DateTime, server_default=now, onupdate=now)
|
|
||||||
|
|
||||||
Relationship: User.sync_config (one-to-one)
|
|
||||||
```
|
|
||||||
|
|
||||||
### `SyncFilter` — stores, groups, products filter rules
|
|
||||||
|
|
||||||
```
|
|
||||||
tablename: "sync_filters"
|
|
||||||
- id (Integer, PK)
|
|
||||||
- sync_config_id (Integer, FK sync_configs.id CASCADE)
|
|
||||||
- entity_type (String, enum: "store", "group", "product")
|
|
||||||
- entity_id (String 255) # Evotor UUID
|
|
||||||
- entity_name (String 255) # human-readable, cached
|
|
||||||
- filter_mode (String, enum: "include", "exclude")
|
|
||||||
- parent_entity_id (String 255, nullable) # store_id for groups, group_id for products
|
|
||||||
- created_at (DateTime, server_default=now)
|
|
||||||
|
|
||||||
UniqueConstraint: (sync_config_id, entity_type, entity_id)
|
|
||||||
Relationship: SyncConfig.filters (one-to-many)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Filter Logic
|
|
||||||
|
|
||||||
The filter model uses **explicit include/exclude rules** with these semantics:
|
|
||||||
- **No rules for an entity type** = sync everything of that type (default permissive)
|
|
||||||
- **Any "include" rule exists for a type** = ONLY sync included entities (whitelist mode)
|
|
||||||
- **Only "exclude" rules for a type** = sync everything EXCEPT excluded (blacklist mode)
|
|
||||||
- Hierarchy: store filters → group filters → product filters. If a store is excluded, all its groups/products are excluded regardless of their individual rules.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
### 1. New Models — `web/models.py`
|
|
||||||
|
|
||||||
Add `SyncConfig` and `SyncFilter` as described above. Add `sync_config` relationship to `User`.
|
|
||||||
|
|
||||||
### 2. Alembic Migration
|
|
||||||
|
|
||||||
Create `sync_configs` and `sync_filters` tables.
|
|
||||||
|
|
||||||
### 3. Evotor API Helper — `web/evotor_api.py` (new)
|
|
||||||
|
|
||||||
Async functions to fetch data from Evotor API using a user's stored access token:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def fetch_stores(access_token: str) -> list[dict]:
|
|
||||||
"""GET https://api.evotor.ru/stores → [{"id": "uuid", "name": "..."}]"""
|
|
||||||
|
|
||||||
async def fetch_groups(access_token: str, store_id: str) -> list[dict]:
|
|
||||||
"""GET https://api.evotor.ru/stores/{store_id}/product-groups → [{"id": "uuid", "name": "..."}]"""
|
|
||||||
|
|
||||||
async def fetch_products(access_token: str, store_id: str) -> list[dict]:
|
|
||||||
"""GET https://api.evotor.ru/stores/{store_id}/products → [{"id": "uuid", "name": "...", "parent_id": "..."}]"""
|
|
||||||
```
|
|
||||||
|
|
||||||
Uses `httpx.AsyncClient`. Returns simplified dicts. Raises on auth failure.
|
|
||||||
|
|
||||||
### 4. Sync Config Route — `web/routes/sync.py` (new)
|
|
||||||
|
|
||||||
**`GET /sync`** — Main sync configuration page.
|
|
||||||
- Requires auth + active Evotor connection
|
|
||||||
- Loads `SyncConfig` (creates default if missing)
|
|
||||||
- Shows: master enable/disable toggle, confirm button, link to filter config
|
|
||||||
|
|
||||||
**`POST /sync/toggle`** — Enable/disable sync.
|
|
||||||
- Toggles `is_enabled`. If enabling for the first time and no filters configured, stays on page with message to configure filters first.
|
|
||||||
|
|
||||||
**`POST /sync/confirm`** — Confirm and start sync.
|
|
||||||
- Sets `confirmed_at = now()`. Only works if `is_enabled=True` and at least one store is configured.
|
|
||||||
|
|
||||||
**Filter management is handled by the Catalog Browser** (see `docs/plans/catalog-browser.md`).
|
|
||||||
The `/catalog` page provides table views of stores, groups, and products with inline filter toggle actions. No separate `/sync/stores`, `/sync/groups`, `/sync/products` routes needed.
|
|
||||||
|
|
||||||
### 5. Templates
|
|
||||||
|
|
||||||
**`web/templates/sync.html`** — Main sync page:
|
|
||||||
- Card with master toggle (on/off switch)
|
|
||||||
- Status: "Не настроено" / "Настроено, ожидает подтверждения" / "Активна"
|
|
||||||
- Warning if Evotor not connected (link to /evotor)
|
|
||||||
- Warning if VK not connected (link to /vk)
|
|
||||||
- "Настроить фильтры" button → `/catalog` (catalog browser)
|
|
||||||
- "Подтвердить и запустить" button (disabled until filters configured)
|
|
||||||
- Summary of current filter rules (X stores, Y groups, Z products)
|
|
||||||
|
|
||||||
### 6. Navbar / Navigation
|
|
||||||
|
|
||||||
Add "Синхронизация" link to navbar (for logged-in users), or add it as a card on the `/connections` page since sync depends on connections.
|
|
||||||
|
|
||||||
### 7. Register Route — `web/main.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
from web.routes import sync
|
|
||||||
app.include_router(sync.router)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8. Shell Script DB Integration
|
|
||||||
|
|
||||||
Modify the sync service to read configuration from DB instead of flat files:
|
|
||||||
|
|
||||||
- Add a Python helper script `run/read_config.py` that queries `sync_configs` + `sync_filters` for a given user and outputs JSON config
|
|
||||||
- Shell scripts call this helper to get: enabled flag, store IDs, whitelisted/blacklisted group names, product exclusions
|
|
||||||
- The sync service only runs for users where `is_enabled=True` AND `confirmed_at IS NOT NULL`
|
|
||||||
- Replaces the flat `vk/whitelist` file
|
|
||||||
|
|
||||||
## Files Summary
|
|
||||||
|
|
||||||
| File | Action |
|
|
||||||
|------|--------|
|
|
||||||
| `web/models.py` | Modify — add `SyncConfig`, `SyncFilter` + User relationship |
|
|
||||||
| `web/routes/sync.py` | Create — sync config routes (toggle, confirm) |
|
|
||||||
| `web/templates/sync.html` | Create — main sync config page |
|
|
||||||
| `web/templates/base.html` | Modify — add sync nav link |
|
|
||||||
| `web/main.py` | Modify — register sync router |
|
|
||||||
| `run/read_config.py` | Create — DB config reader for shell scripts |
|
|
||||||
| Alembic migration | Create — sync_configs + sync_filters tables |
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
1. Run `alembic upgrade head`
|
|
||||||
2. Visit `/sync` without Evotor connection → shows warning to connect first
|
|
||||||
3. Connect Evotor, visit `/sync` → shows disabled state, "Настроить фильтры" button
|
|
||||||
4. Go to `/sync/stores` → fetches live stores from Evotor API, shows checkboxes
|
|
||||||
5. Select stores, save → drill into groups, select groups, save → drill into products
|
|
||||||
6. Back to `/sync` → shows summary of configured filters
|
|
||||||
7. Enable sync toggle → confirm → `confirmed_at` set
|
|
||||||
8. Verify `run/read_config.py` outputs correct JSON for the user's config
|
|
||||||
9. Disable sync → `is_enabled=False`, sync service stops processing this user
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
# VK OAuth Connection Feature
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
EvoSync syncs product catalogs from Evotor to VK. Users already connect their Evotor account via OAuth. Now we need the same for VK — users authorize via VK OAuth, we store the access token, and show connection status on the connections dashboard.
|
|
||||||
|
|
||||||
## VK OAuth Flow (Web)
|
|
||||||
|
|
||||||
- **Authorize URL**: `https://oauth.vk.com/authorize`
|
|
||||||
- **Token URL**: `https://oauth.vk.com/access_token`
|
|
||||||
- **Verify endpoint**: `GET https://api.vk.com/method/users.get?access_token={token}&v=5.131`
|
|
||||||
- Error code 5 = token invalid/expired
|
|
||||||
- **Scopes**: `market groups offline` (offline = permanent token, no expiry)
|
|
||||||
- **Token response fields**: `access_token`, `user_id`, `expires_in` (0 if offline scope used)
|
|
||||||
|
|
||||||
With `offline` scope, tokens don't expire — no refresh logic needed. If a user revokes access on VK's side, the health checker will detect it.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
### 1. New Model — `VkConnection` in `web/models.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
class VkConnection(Base):
|
|
||||||
__tablename__ = "vk_connections"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
||||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False)
|
|
||||||
access_token = Column(Text, nullable=False)
|
|
||||||
vk_user_id = Column(String(50), nullable=True) # VK user ID from token response
|
|
||||||
first_name = Column(String(255), nullable=True) # VK profile first name
|
|
||||||
last_name = Column(String(255), nullable=True) # VK profile last name
|
|
||||||
is_online = Column(Boolean, default=False, server_default="0", nullable=False)
|
|
||||||
last_checked_at = Column(DateTime, nullable=True)
|
|
||||||
connected_at = Column(DateTime, server_default=func.now(), nullable=False)
|
|
||||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
|
||||||
|
|
||||||
user = relationship("User", back_populates="vk_connection")
|
|
||||||
```
|
|
||||||
|
|
||||||
Add to `User` model:
|
|
||||||
```python
|
|
||||||
vk_connection = relationship("VkConnection", back_populates="user", uselist=False)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Alembic Migration
|
|
||||||
|
|
||||||
Generate migration for the new `vk_connections` table and the relationship.
|
|
||||||
|
|
||||||
### 3. Config — `web/config.py`
|
|
||||||
|
|
||||||
Add:
|
|
||||||
```python
|
|
||||||
VK_CLIENT_ID: str = ""
|
|
||||||
VK_CLIENT_SECRET: str = ""
|
|
||||||
VK_SCOPES: str = "market groups offline"
|
|
||||||
VK_API_VERSION: str = "5.131"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. VK Route — `web/routes/vk.py` (new)
|
|
||||||
|
|
||||||
Follow the same pattern as `web/routes/evotor.py`:
|
|
||||||
|
|
||||||
**Constants:**
|
|
||||||
```python
|
|
||||||
VK_AUTHORIZE_URL = "https://oauth.vk.com/authorize"
|
|
||||||
VK_TOKEN_URL = "https://oauth.vk.com/access_token"
|
|
||||||
VK_API_URL = "https://api.vk.com/method"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Endpoints:**
|
|
||||||
|
|
||||||
- `GET /vk` — Connection page. Shows connected state (VK profile name, user_id) or disconnected state with explanation and connect button.
|
|
||||||
|
|
||||||
- `GET /vk/connect` — Generate state token, save in session, redirect to:
|
|
||||||
```
|
|
||||||
https://oauth.vk.com/authorize?client_id={id}&response_type=code
|
|
||||||
&redirect_uri={BASE_URL}/vk/callback&scope={scopes}&state={state}
|
|
||||||
&display=page&v=5.131
|
|
||||||
```
|
|
||||||
|
|
||||||
- `GET /vk/callback` — OAuth callback:
|
|
||||||
1. Validate state from session
|
|
||||||
2. Exchange code for token via GET to `https://oauth.vk.com/access_token` with params: `client_id`, `client_secret`, `code`, `redirect_uri` (NOTE: VK uses GET, not POST, and params in query string, not body)
|
|
||||||
3. Response contains: `access_token`, `user_id`, `expires_in`
|
|
||||||
4. Fetch user profile via `users.get` to get first_name, last_name
|
|
||||||
5. Save/update `VkConnection` record with `is_online=True`, `last_checked_at=now()`
|
|
||||||
6. Redirect to `/connections`
|
|
||||||
|
|
||||||
- `POST /vk/disconnect` — Delete VkConnection record, redirect to `/vk`
|
|
||||||
|
|
||||||
### 5. VK Template — `web/templates/vk.html` (new)
|
|
||||||
|
|
||||||
Same structure as `evotor.html`:
|
|
||||||
|
|
||||||
**Connected state:**
|
|
||||||
- Status badge: "Подключено" (green)
|
|
||||||
- VK profile: first_name + last_name
|
|
||||||
- VK user ID (monospace)
|
|
||||||
- Connected timestamp
|
|
||||||
- Buttons: "Переподключить", "Отключить аккаунт ВКонтакте"
|
|
||||||
|
|
||||||
**Disconnected state:**
|
|
||||||
- Explanation text: "Подключите ваш аккаунт ВКонтакте, чтобы система могла автоматически синхронизировать каталог товаров из Эвотор в вашу группу ВКонтакте."
|
|
||||||
- Bullet points: redirect to VK for auth, auto-setup after confirmation, can disconnect anytime
|
|
||||||
- Button: "Подключить ВКонтакте"
|
|
||||||
|
|
||||||
**Error display:** same pattern as evotor.html (invalid_state, token_exchange, no_token)
|
|
||||||
|
|
||||||
**Back link:** "Вернуться к подключениям" → `/connections`
|
|
||||||
|
|
||||||
### 6. Register Route — `web/main.py`
|
|
||||||
|
|
||||||
```python
|
|
||||||
from web.routes import vk
|
|
||||||
app.include_router(vk.router)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7. Add to Connections Dashboard — `web/routes/connections.py`
|
|
||||||
|
|
||||||
Add VK entry to the connections list:
|
|
||||||
```python
|
|
||||||
vk_conn = db.query(VkConnection).filter(VkConnection.user_id == user.id).first()
|
|
||||||
|
|
||||||
connections.append({
|
|
||||||
"name": "ВКонтакте",
|
|
||||||
"icon": "bi-chat-dots", # or another suitable Bootstrap icon
|
|
||||||
"connected": vk_conn is not None,
|
|
||||||
"is_online": vk_conn.is_online if vk_conn else False,
|
|
||||||
"last_checked_at": vk_conn.last_checked_at if vk_conn else None,
|
|
||||||
"details": f"{vk_conn.first_name} {vk_conn.last_name}" if vk_conn and vk_conn.first_name else None,
|
|
||||||
"connect_url": "/vk",
|
|
||||||
"disconnect_url": "/vk/disconnect",
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8. Background Health Check — `web/health_checker.py`
|
|
||||||
|
|
||||||
Add VK check alongside existing Evotor check:
|
|
||||||
|
|
||||||
```python
|
|
||||||
async def check_vk_connection(access_token: str) -> bool:
|
|
||||||
"""Call users.get to verify VK token is valid."""
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.get(
|
|
||||||
"https://api.vk.com/method/users.get",
|
|
||||||
params={"access_token": access_token, "v": "5.131"},
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
return False
|
|
||||||
data = resp.json()
|
|
||||||
# Error code 5 = invalid token
|
|
||||||
if "error" in data:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
```
|
|
||||||
|
|
||||||
In `run_health_checks()`, add a loop over `VkConnection` rows with the same pattern as Evotor checks.
|
|
||||||
|
|
||||||
## Files Summary
|
|
||||||
|
|
||||||
| File | Action |
|
|
||||||
|------|--------|
|
|
||||||
| `web/models.py` | Modify — add `VkConnection` model + User relationship |
|
|
||||||
| `web/config.py` | Modify — add `VK_*` settings |
|
|
||||||
| `web/main.py` | Modify — register vk router |
|
|
||||||
| `web/routes/vk.py` | Create — OAuth flow (connect/callback/disconnect/page) |
|
|
||||||
| `web/routes/connections.py` | Modify — add VK to connections list |
|
|
||||||
| `web/health_checker.py` | Modify — add VK health check |
|
|
||||||
| `web/templates/vk.html` | Create — VK connection page |
|
|
||||||
| Alembic migration | Create — `vk_connections` table |
|
|
||||||
|
|
||||||
## Env Config Needed
|
|
||||||
|
|
||||||
```
|
|
||||||
VK_CLIENT_ID=your_vk_app_id
|
|
||||||
VK_CLIENT_SECRET=your_vk_app_secret
|
|
||||||
VK_SCOPES=market groups offline
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
1. Run `alembic upgrade head`
|
|
||||||
2. Visit `/connections` — should show VK as disconnected (grey)
|
|
||||||
3. Click VK → "Подключить ВКонтакте" → redirects to VK auth
|
|
||||||
4. After VK auth → callback saves token → redirects to `/connections` → VK shows green
|
|
||||||
5. Visit `/vk` — shows connected state with VK profile info
|
|
||||||
6. Disconnect → VK returns to grey on connections page
|
|
||||||
7. Wait for health check cycle — verify `is_online` and `last_checked_at` update
|
|
||||||
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,13 +1,20 @@
|
|||||||
fastapi==0.115.0
|
fastapi==0.115.5
|
||||||
uvicorn[standard]==0.30.0
|
uvicorn[standard]==0.32.1
|
||||||
sqlalchemy==2.0.35
|
|
||||||
pymysql==1.1.1
|
|
||||||
cryptography>=41.0.0
|
|
||||||
jinja2==3.1.4
|
|
||||||
python-multipart==0.0.12
|
python-multipart==0.0.12
|
||||||
passlib[bcrypt]==1.7.4
|
jinja2==3.1.4
|
||||||
bcrypt==4.2.0
|
sqlalchemy==2.0.36
|
||||||
pydantic-settings==2.5.2
|
alembic==1.14.0
|
||||||
itsdangerous==2.1.2
|
pymysql==1.1.1
|
||||||
httpx==0.27.2
|
itsdangerous>=2.1.0
|
||||||
alembic==1.13.3
|
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
|
||||||
|
|||||||
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,
|
||||||
|
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()
|
||||||
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"
|
||||||
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
|
||||||
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)
|
||||||
25
web/auth/session.py
Normal file
25
web/auth/session.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
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, 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 login_redirect() -> RedirectResponse:
|
||||||
|
return RedirectResponse("/login", status_code=303)
|
||||||
@@ -2,28 +2,35 @@ from pydantic_settings import BaseSettings
|
|||||||
|
|
||||||
|
|
||||||
class Settings(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"
|
SECRET_KEY: str = "change-me-in-production"
|
||||||
BASE_URL: str = "http://localhost:8080"
|
BASE_URL: str = "http://localhost:8000"
|
||||||
PASSWORD_RESET_EXPIRE_MINUTES: int = 60
|
PASSWORD_RESET_EXPIRE_MINUTES: int = 60
|
||||||
|
|
||||||
EVOTOR_APP_ID: str = ""
|
EVOTOR_APP_ID: str = ""
|
||||||
EVOTOR_WEBHOOK_SECRET: str = ""
|
EVOTOR_WEBHOOK_SECRET: str = ""
|
||||||
|
|
||||||
JIVOSITE_WIDGET_ID: str = ""
|
JIVOSITE_WIDGET_ID: str = ""
|
||||||
|
VK_DEFAULT_PHOTO_PATH: str = "/app/default_product.png"
|
||||||
|
VK_API_VERSION: str = "5.199"
|
||||||
|
|
||||||
HEALTH_CHECK_INTERVAL_SECONDS: int = 600
|
|
||||||
CATALOG_REFRESH_INTERVAL_SECONDS: int = 3600
|
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 = ""
|
||||||
|
|
||||||
VK_API_VERSION: str = "5.131"
|
FLOWER_USER: str = "admin"
|
||||||
|
FLOWER_PASSWORD: str = "changeme"
|
||||||
|
|
||||||
# Docker compose vars (ignored in app, kept for env compatibility)
|
|
||||||
DB_ROOT_PASSWORD: str = ""
|
DB_ROOT_PASSWORD: str = ""
|
||||||
DB_NAME: str = ""
|
DB_NAME: str = ""
|
||||||
DB_USER: str = ""
|
DB_USER: str = ""
|
||||||
DB_PASSWORD: str = ""
|
DB_PASSWORD: str = ""
|
||||||
|
|
||||||
model_config = {"env_file": ".env", "case_sensitive": False}
|
model_config = {"env_file": ".env", "case_sensitive": False, "extra": "ignore"}
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||||
|
|
||||||
from web.config import settings
|
from web.config import settings
|
||||||
|
|
||||||
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
|
engine = create_engine(
|
||||||
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
|
settings.DATABASE_URL,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_recycle=3600,
|
||||||
|
)
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
class Base(DeclarativeBase):
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
EVOTOR_API_BASE = "https://api.evotor.ru"
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_stores(access_token: str) -> list[dict]:
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.get(
|
|
||||||
f"{EVOTOR_API_BASE}/stores",
|
|
||||||
headers={"Authorization": f"Bearer {access_token}"},
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
items = data.get("items", data) if isinstance(data, dict) else data
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"id": s.get("uuid") or s.get("id"),
|
|
||||||
"name": s.get("name"),
|
|
||||||
"address": s.get("address"),
|
|
||||||
}
|
|
||||||
for s in items
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_groups(access_token: str, store_id: str) -> list[dict]:
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.get(
|
|
||||||
f"{EVOTOR_API_BASE}/stores/{store_id}/product-groups",
|
|
||||||
headers={"Authorization": f"Bearer {access_token}"},
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
if resp.status_code == 402:
|
|
||||||
return []
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
items = data.get("items", data) if isinstance(data, dict) else data
|
|
||||||
return [{"id": g.get("uuid") or g.get("id"), "name": g.get("name")} for g in items]
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_products(access_token: str, store_id: str) -> list[dict]:
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.get(
|
|
||||||
f"{EVOTOR_API_BASE}/stores/{store_id}/products",
|
|
||||||
headers={"Authorization": f"Bearer {access_token}"},
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
if resp.status_code == 402:
|
|
||||||
return []
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
items = data.get("items", data) if isinstance(data, dict) else data
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"id": p.get("uuid") or p.get("id"),
|
|
||||||
"name": p.get("name"),
|
|
||||||
"parent_id": p.get("parentUuid") or p.get("parent_id"),
|
|
||||||
"price": p.get("price"),
|
|
||||||
"quantity": p.get("quantity"),
|
|
||||||
"measure_name": p.get("measureName") or p.get("measure_name"),
|
|
||||||
"article_number": p.get("code") or p.get("article_number"),
|
|
||||||
"allow_to_sell": p.get("allowToSell") if p.get("allowToSell") is not None else p.get("allow_to_sell"),
|
|
||||||
}
|
|
||||||
for p in items
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def refresh_catalog_cache(user_id: int, access_token: str, db: Session) -> None:
|
|
||||||
from web.models import CachedStore, CachedGroup, CachedProduct
|
|
||||||
|
|
||||||
now = datetime.utcnow()
|
|
||||||
|
|
||||||
# Delete old cache for user
|
|
||||||
db.query(CachedProduct).filter(CachedProduct.user_id == user_id).delete()
|
|
||||||
db.query(CachedGroup).filter(CachedGroup.user_id == user_id).delete()
|
|
||||||
db.query(CachedStore).filter(CachedStore.user_id == user_id).delete()
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
stores = await fetch_stores(access_token)
|
|
||||||
for store in stores:
|
|
||||||
db.add(CachedStore(
|
|
||||||
user_id=user_id,
|
|
||||||
evotor_id=store["id"],
|
|
||||||
name=store["name"] or "",
|
|
||||||
address=store.get("address"),
|
|
||||||
fetched_at=now,
|
|
||||||
))
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
for store in stores:
|
|
||||||
groups = await fetch_groups(access_token, store["id"])
|
|
||||||
for group in groups:
|
|
||||||
db.add(CachedGroup(
|
|
||||||
user_id=user_id,
|
|
||||||
evotor_id=group["id"],
|
|
||||||
store_evotor_id=store["id"],
|
|
||||||
name=group["name"] or "",
|
|
||||||
fetched_at=now,
|
|
||||||
))
|
|
||||||
|
|
||||||
products = await fetch_products(access_token, store["id"])
|
|
||||||
for product in products:
|
|
||||||
db.add(CachedProduct(
|
|
||||||
user_id=user_id,
|
|
||||||
evotor_id=product["id"],
|
|
||||||
store_evotor_id=store["id"],
|
|
||||||
group_evotor_id=product.get("parent_id"),
|
|
||||||
name=product["name"] or "",
|
|
||||||
price=product.get("price"),
|
|
||||||
quantity=product.get("quantity"),
|
|
||||||
measure_name=product.get("measure_name"),
|
|
||||||
article_number=product.get("article_number"),
|
|
||||||
allow_to_sell=product.get("allow_to_sell"),
|
|
||||||
fetched_at=now,
|
|
||||||
))
|
|
||||||
db.commit()
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from web.database import SessionLocal
|
|
||||||
from web.models import EvotorConnection, VkConnection, CachedStore
|
|
||||||
|
|
||||||
logger = logging.getLogger("uvicorn.error")
|
|
||||||
|
|
||||||
EVOTOR_STORES_URL = "https://api.evotor.ru/stores"
|
|
||||||
EVOTOR_TOKEN_URL = "https://oauth.evotor.ru/oauth/token"
|
|
||||||
VK_GROUPS_GET_URL = "https://api.vk.com/method/groups.getById"
|
|
||||||
VK_API_VERSION = "5.131"
|
|
||||||
|
|
||||||
# Refresh Evotor token if it expires within this window
|
|
||||||
REFRESH_BEFORE_EXPIRY = timedelta(hours=2)
|
|
||||||
|
|
||||||
|
|
||||||
async def _refresh_evotor_token(conn: EvotorConnection) -> str | None:
|
|
||||||
"""Attempt to refresh the Evotor access token. Returns new access token or None."""
|
|
||||||
from web.config import settings
|
|
||||||
if not conn.refresh_token:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.post(
|
|
||||||
EVOTOR_TOKEN_URL,
|
|
||||||
data={
|
|
||||||
"grant_type": "refresh_token",
|
|
||||||
"refresh_token": conn.refresh_token,
|
|
||||||
},
|
|
||||||
auth=(settings.EVOTOR_CLIENT_ID, settings.EVOTOR_CLIENT_SECRET),
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
return None
|
|
||||||
data = resp.json()
|
|
||||||
return data if data.get("access_token") else None
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def check_evotor_connection(access_token: str) -> bool:
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
response = await client.get(
|
|
||||||
EVOTOR_STORES_URL,
|
|
||||||
headers={"Authorization": f"Bearer {access_token}"},
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
return response.status_code == 200
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def check_vk_connection(access_token: str) -> bool:
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.get(
|
|
||||||
VK_GROUPS_GET_URL,
|
|
||||||
params={"access_token": access_token, "v": VK_API_VERSION},
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
return False
|
|
||||||
data = resp.json()
|
|
||||||
return "error" not in data
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def run_health_checks() -> None:
|
|
||||||
db = SessionLocal()
|
|
||||||
try:
|
|
||||||
now = datetime.utcnow()
|
|
||||||
|
|
||||||
evotor_connections = db.query(EvotorConnection).all()
|
|
||||||
for conn in evotor_connections:
|
|
||||||
# Proactively refresh if token expires soon
|
|
||||||
needs_refresh = (
|
|
||||||
conn.refresh_token and
|
|
||||||
conn.token_expires_at and
|
|
||||||
conn.token_expires_at - now < REFRESH_BEFORE_EXPIRY
|
|
||||||
)
|
|
||||||
if needs_refresh:
|
|
||||||
token_data = await _refresh_evotor_token(conn)
|
|
||||||
if token_data:
|
|
||||||
conn.access_token = token_data["access_token"]
|
|
||||||
conn.refresh_token = token_data.get("refresh_token", conn.refresh_token)
|
|
||||||
expires_in = token_data.get("expires_in")
|
|
||||||
conn.token_expires_at = now + timedelta(seconds=expires_in) if expires_in else None
|
|
||||||
logger.info("Refreshed Evotor token for user_id=%d", conn.user_id)
|
|
||||||
|
|
||||||
is_online = await check_evotor_connection(conn.access_token)
|
|
||||||
|
|
||||||
# If offline and not yet tried refresh, attempt it now
|
|
||||||
if not is_online and conn.refresh_token and not needs_refresh:
|
|
||||||
token_data = await _refresh_evotor_token(conn)
|
|
||||||
if token_data:
|
|
||||||
conn.access_token = token_data["access_token"]
|
|
||||||
conn.refresh_token = token_data.get("refresh_token", conn.refresh_token)
|
|
||||||
expires_in = token_data.get("expires_in")
|
|
||||||
conn.token_expires_at = now + timedelta(seconds=expires_in) if expires_in else None
|
|
||||||
is_online = await check_evotor_connection(conn.access_token)
|
|
||||||
if is_online:
|
|
||||||
logger.info("Evotor token refreshed after failed check for user_id=%d", conn.user_id)
|
|
||||||
|
|
||||||
conn.is_online = is_online
|
|
||||||
conn.last_checked_at = now
|
|
||||||
|
|
||||||
vk_connections = db.query(VkConnection).all()
|
|
||||||
for conn in vk_connections:
|
|
||||||
conn.is_online = await check_vk_connection(conn.access_token)
|
|
||||||
conn.last_checked_at = now
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
# Refresh catalog cache for online Evotor connections
|
|
||||||
from web.config import settings
|
|
||||||
refreshed_catalog = 0
|
|
||||||
for conn in evotor_connections:
|
|
||||||
if not conn.is_online:
|
|
||||||
continue
|
|
||||||
cached = db.query(CachedStore).filter(CachedStore.user_id == conn.user_id).first()
|
|
||||||
cache_age = (now - cached.fetched_at).total_seconds() if cached else None
|
|
||||||
if cached is None or cache_age >= settings.CATALOG_REFRESH_INTERVAL_SECONDS:
|
|
||||||
try:
|
|
||||||
from web.evotor_api import refresh_catalog_cache
|
|
||||||
await refresh_catalog_cache(conn.user_id, conn.access_token, db)
|
|
||||||
refreshed_catalog += 1
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to refresh catalog cache for user_id=%d", conn.user_id)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Health checks completed: %d Evotor, %d VK, %d catalogs refreshed",
|
|
||||||
len(evotor_connections),
|
|
||||||
len(vk_connections),
|
|
||||||
refreshed_catalog,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Error during health checks")
|
|
||||||
db.rollback()
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
async def health_check_loop(interval: int) -> None:
|
|
||||||
while True:
|
|
||||||
await run_health_checks()
|
|
||||||
await asyncio.sleep(interval)
|
|
||||||
111
web/main.py
111
web/main.py
@@ -1,47 +1,90 @@
|
|||||||
import asyncio
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
|
|
||||||
from fastapi import FastAPI, Depends, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
from web.auth import get_current_user
|
try:
|
||||||
from web.config import settings
|
from pythonjsonlogger import jsonlogger
|
||||||
from web.health_checker import health_check_loop
|
handler = logging.StreamHandler()
|
||||||
from web.models import User
|
handler.setFormatter(jsonlogger.JsonFormatter("%(asctime)s %(levelname)s %(name)s %(message)s"))
|
||||||
from web.routes import auth, profile, reset, evotor, vk, sync, catalog
|
logging.root.addHandler(handler)
|
||||||
from web.routes import connections
|
except ImportError:
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logging.root.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
from web.config import settings # noqa: E402 — after logging setup
|
||||||
|
from web.templates_env import templates # noqa: E402
|
||||||
|
|
||||||
@asynccontextmanager
|
app = FastAPI(title="ЭвоСинк")
|
||||||
async def lifespan(app: FastAPI):
|
|
||||||
task = asyncio.create_task(health_check_loop(settings.HEALTH_CHECK_INTERVAL_SECONDS))
|
|
||||||
yield
|
|
||||||
task.cancel()
|
|
||||||
try:
|
|
||||||
await task
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
SessionMiddleware,
|
||||||
|
secret_key=settings.SECRET_KEY,
|
||||||
|
max_age=86400 * 30,
|
||||||
|
https_only=False,
|
||||||
|
)
|
||||||
|
|
||||||
app = FastAPI(title="ЭВОСИНК — Личный кабинет", lifespan=lifespan)
|
|
||||||
|
|
||||||
app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY)
|
|
||||||
app.mount("/static", StaticFiles(directory="web/static"), name="static")
|
app.mount("/static", StaticFiles(directory="web/static"), name="static")
|
||||||
|
|
||||||
app.include_router(auth.router)
|
# ── Routers ───────────────────────────────────────────────────────────────────
|
||||||
app.include_router(profile.router)
|
from web.routes.auth import router as auth_router # noqa: E402
|
||||||
app.include_router(reset.router)
|
from web.routes.reset import router as reset_router # noqa: E402
|
||||||
app.include_router(evotor.router)
|
from web.routes.invite import router as invite_router # noqa: E402
|
||||||
app.include_router(connections.router)
|
from web.routes.profile import router as profile_router # noqa: E402
|
||||||
app.include_router(vk.router)
|
from web.routes.evotor_webhooks import router as evotor_webhooks_router # noqa: E402
|
||||||
app.include_router(sync.router)
|
from web.routes.admin import router as admin_router # noqa: E402
|
||||||
app.include_router(catalog.router)
|
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
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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("/")
|
@app.get("/")
|
||||||
def home(request: Request, user: User | None = Depends(get_current_user)):
|
async def root(request: Request):
|
||||||
if user:
|
from fastapi.responses import RedirectResponse
|
||||||
return RedirectResponse("/profile", 302)
|
user_id = request.session.get("user_id")
|
||||||
return RedirectResponse("/login", 302)
|
if user_id:
|
||||||
|
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)
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
Generic single-database configuration.
|
|
||||||
@@ -1,21 +1,16 @@
|
|||||||
from logging.config import fileConfig
|
from logging.config import fileConfig
|
||||||
|
|
||||||
from sqlalchemy import engine_from_config
|
|
||||||
from sqlalchemy import pool
|
|
||||||
|
|
||||||
from alembic import context
|
from alembic import context
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
from web.config import settings
|
|
||||||
from web.database import Base
|
from web.database import Base
|
||||||
from web.models import User, EvotorConnection # noqa: F401 — register models with Base
|
import web.models # noqa: F401 — ensure all models are imported before autogenerate
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
|
|
||||||
if config.config_file_name is not None:
|
if config.config_file_name is not None:
|
||||||
fileConfig(config.config_file_name)
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
|
||||||
|
|
||||||
target_metadata = Base.metadata
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
@@ -27,23 +22,24 @@ def run_migrations_offline() -> None:
|
|||||||
literal_binds=True,
|
literal_binds=True,
|
||||||
dialect_opts={"paramstyle": "named"},
|
dialect_opts={"paramstyle": "named"},
|
||||||
)
|
)
|
||||||
|
|
||||||
with context.begin_transaction():
|
with context.begin_transaction():
|
||||||
context.run_migrations()
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
def run_migrations_online() -> None:
|
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(
|
connectable = engine_from_config(
|
||||||
config.get_section(config.config_ini_section, {}),
|
configuration,
|
||||||
prefix="sqlalchemy.",
|
prefix="sqlalchemy.",
|
||||||
poolclass=pool.NullPool,
|
poolclass=pool.NullPool,
|
||||||
)
|
)
|
||||||
|
|
||||||
with connectable.connect() as connection:
|
with connectable.connect() as connection:
|
||||||
context.configure(
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
connection=connection, target_metadata=target_metadata
|
|
||||||
)
|
|
||||||
|
|
||||||
with context.begin_transaction():
|
with context.begin_transaction():
|
||||||
context.run_migrations()
|
context.run_migrations()
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from alembic import op
|
|||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
${imports if imports else ""}
|
${imports if imports else ""}
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = ${repr(up_revision)}
|
revision: str = ${repr(up_revision)}
|
||||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
|||||||
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")
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
"""initial
|
|
||||||
|
|
||||||
Revision ID: 2c15000e752b
|
|
||||||
Revises:
|
|
||||||
Create Date: 2026-03-06 09:07:16.180639
|
|
||||||
|
|
||||||
"""
|
|
||||||
from typing import Sequence, Union
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
|
||||||
revision: str = '2c15000e752b'
|
|
||||||
down_revision: Union[str, None] = None
|
|
||||||
branch_labels: Union[str, Sequence[str], None] = None
|
|
||||||
depends_on: Union[str, Sequence[str], None] = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.create_table(
|
|
||||||
"users",
|
|
||||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
|
||||||
sa.Column("first_name", sa.String(length=100), nullable=False),
|
|
||||||
sa.Column("last_name", sa.String(length=100), nullable=False),
|
|
||||||
sa.Column("email", sa.String(length=255), nullable=False),
|
|
||||||
sa.Column("phone", sa.String(length=20), nullable=False),
|
|
||||||
sa.Column("password_hash", sa.String(length=255), nullable=False),
|
|
||||||
sa.Column("is_email_confirmed", sa.Boolean(), nullable=False),
|
|
||||||
sa.Column("email_confirm_token", sa.String(length=255), nullable=True),
|
|
||||||
sa.Column("password_reset_token", sa.String(length=255), nullable=True),
|
|
||||||
sa.Column("password_reset_expires", sa.DateTime(), nullable=True),
|
|
||||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
|
||||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
|
||||||
sa.PrimaryKeyConstraint("id"),
|
|
||||||
)
|
|
||||||
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
|
|
||||||
op.create_index(op.f("ix_users_phone"), "users", ["phone"], unique=True)
|
|
||||||
|
|
||||||
op.create_table(
|
|
||||||
"evotor_connections",
|
|
||||||
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
|
||||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
|
||||||
sa.Column("access_token", sa.Text(), nullable=False),
|
|
||||||
sa.Column("store_id", sa.String(length=255), nullable=True),
|
|
||||||
sa.Column("store_name", sa.String(length=255), nullable=True),
|
|
||||||
sa.Column("connected_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
|
||||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
|
||||||
sa.PrimaryKeyConstraint("id"),
|
|
||||||
sa.UniqueConstraint("user_id"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_table("evotor_connections")
|
|
||||||
op.drop_index(op.f("ix_users_phone"), table_name="users")
|
|
||||||
op.drop_index(op.f("ix_users_email"), table_name="users")
|
|
||||||
op.drop_table("users")
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
"""add is_online and last_checked_at to evotor_connections
|
|
||||||
|
|
||||||
Revision ID: a1b2c3d4e5f6
|
|
||||||
Revises: 2c15000e752b
|
|
||||||
Create Date: 2026-03-06 00:00:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
revision = 'a1b2c3d4e5f6'
|
|
||||||
down_revision = '2c15000e752b'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.add_column('evotor_connections',
|
|
||||||
sa.Column('is_online', sa.Boolean(), nullable=False, server_default='0'))
|
|
||||||
op.add_column('evotor_connections',
|
|
||||||
sa.Column('last_checked_at', sa.DateTime(), nullable=True))
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_column('evotor_connections', 'last_checked_at')
|
|
||||||
op.drop_column('evotor_connections', 'is_online')
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
"""add vk_connections table
|
|
||||||
|
|
||||||
Revision ID: b2c3d4e5f6a7
|
|
||||||
Revises: a1b2c3d4e5f6
|
|
||||||
Create Date: 2026-03-06 00:01:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
revision = 'b2c3d4e5f6a7'
|
|
||||||
down_revision = 'a1b2c3d4e5f6'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.create_table(
|
|
||||||
'vk_connections',
|
|
||||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
|
||||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('access_token', sa.Text(), nullable=False),
|
|
||||||
sa.Column('vk_user_id', sa.String(50), nullable=True),
|
|
||||||
sa.Column('first_name', sa.String(255), nullable=True),
|
|
||||||
sa.Column('last_name', sa.String(255), nullable=True),
|
|
||||||
sa.Column('is_online', sa.Boolean(), nullable=False, server_default='0'),
|
|
||||||
sa.Column('last_checked_at', sa.DateTime(), nullable=True),
|
|
||||||
sa.Column('connected_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
|
||||||
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
|
|
||||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
sa.UniqueConstraint('user_id'),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_table('vk_connections')
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
"""add sync_configs and sync_filters tables
|
|
||||||
|
|
||||||
Revision ID: c3d4e5f6a7b8
|
|
||||||
Revises: b2c3d4e5f6a7
|
|
||||||
Create Date: 2026-03-06 00:02:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
revision = 'c3d4e5f6a7b8'
|
|
||||||
down_revision = 'b2c3d4e5f6a7'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.create_table(
|
|
||||||
'sync_configs',
|
|
||||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
|
||||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('is_enabled', sa.Boolean(), nullable=False, server_default='0'),
|
|
||||||
sa.Column('confirmed_at', sa.DateTime(), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
|
||||||
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
|
||||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
sa.UniqueConstraint('user_id'),
|
|
||||||
)
|
|
||||||
op.create_table(
|
|
||||||
'sync_filters',
|
|
||||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
|
||||||
sa.Column('sync_config_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('entity_type', sa.String(20), nullable=False),
|
|
||||||
sa.Column('entity_id', sa.String(255), nullable=False),
|
|
||||||
sa.Column('entity_name', sa.String(255), nullable=True),
|
|
||||||
sa.Column('filter_mode', sa.String(10), nullable=False),
|
|
||||||
sa.Column('parent_entity_id', sa.String(255), nullable=True),
|
|
||||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
|
||||||
sa.ForeignKeyConstraint(['sync_config_id'], ['sync_configs.id'], ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
sa.UniqueConstraint('sync_config_id', 'entity_type', 'entity_id', name='uq_sync_filter'),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_table('sync_filters')
|
|
||||||
op.drop_table('sync_configs')
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
"""add catalog cache tables
|
|
||||||
|
|
||||||
Revision ID: d4e5f6a7b8c9
|
|
||||||
Revises: c3d4e5f6a7b8
|
|
||||||
Create Date: 2026-03-06 00:03:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
revision = 'd4e5f6a7b8c9'
|
|
||||||
down_revision = 'c3d4e5f6a7b8'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.create_table(
|
|
||||||
'cached_stores',
|
|
||||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
|
||||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('evotor_id', sa.String(255), nullable=False),
|
|
||||||
sa.Column('name', sa.String(255), nullable=False),
|
|
||||||
sa.Column('address', sa.String(500), nullable=True),
|
|
||||||
sa.Column('fetched_at', sa.DateTime(), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
sa.UniqueConstraint('user_id', 'evotor_id', name='uq_cached_stores'),
|
|
||||||
)
|
|
||||||
op.create_index('ix_cached_stores_user_id', 'cached_stores', ['user_id'])
|
|
||||||
|
|
||||||
op.create_table(
|
|
||||||
'cached_groups',
|
|
||||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
|
||||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('evotor_id', sa.String(255), nullable=False),
|
|
||||||
sa.Column('store_evotor_id', sa.String(255), nullable=False),
|
|
||||||
sa.Column('name', sa.String(255), nullable=False),
|
|
||||||
sa.Column('fetched_at', sa.DateTime(), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
sa.UniqueConstraint('user_id', 'evotor_id', name='uq_cached_groups'),
|
|
||||||
)
|
|
||||||
op.create_index('ix_cached_groups_user_store', 'cached_groups', ['user_id', 'store_evotor_id'])
|
|
||||||
|
|
||||||
op.create_table(
|
|
||||||
'cached_products',
|
|
||||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
|
||||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
|
||||||
sa.Column('evotor_id', sa.String(255), nullable=False),
|
|
||||||
sa.Column('store_evotor_id', sa.String(255), nullable=False),
|
|
||||||
sa.Column('group_evotor_id', sa.String(255), nullable=True),
|
|
||||||
sa.Column('name', sa.String(255), nullable=False),
|
|
||||||
sa.Column('price', sa.Numeric(12, 2), nullable=True),
|
|
||||||
sa.Column('quantity', sa.Numeric(12, 3), nullable=True),
|
|
||||||
sa.Column('measure_name', sa.String(20), nullable=True),
|
|
||||||
sa.Column('article_number', sa.String(100), nullable=True),
|
|
||||||
sa.Column('allow_to_sell', sa.Boolean(), nullable=True),
|
|
||||||
sa.Column('fetched_at', sa.DateTime(), nullable=False),
|
|
||||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
|
||||||
sa.PrimaryKeyConstraint('id'),
|
|
||||||
sa.UniqueConstraint('user_id', 'evotor_id', name='uq_cached_products'),
|
|
||||||
)
|
|
||||||
op.create_index('ix_cached_products_user_store_group', 'cached_products', ['user_id', 'store_evotor_id', 'group_evotor_id'])
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_table('cached_products')
|
|
||||||
op.drop_table('cached_groups')
|
|
||||||
op.drop_table('cached_stores')
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
"""add refresh_token and token_expires_at to evotor_connections
|
|
||||||
|
|
||||||
Revision ID: e5f6a7b8c9d0
|
|
||||||
Revises: d4e5f6a7b8c9
|
|
||||||
Create Date: 2026-03-06 00:04:00.000000
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
revision = 'e5f6a7b8c9d0'
|
|
||||||
down_revision = 'd4e5f6a7b8c9'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
op.add_column('evotor_connections', sa.Column('refresh_token', sa.Text(), nullable=True))
|
|
||||||
op.add_column('evotor_connections', sa.Column('token_expires_at', sa.DateTime(), nullable=True))
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_column('evotor_connections', 'token_expires_at')
|
|
||||||
op.drop_column('evotor_connections', 'refresh_token')
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
"""evotor webhook token flow: add evotor_user_id, make user_id nullable
|
|
||||||
|
|
||||||
Revision ID: f6a7b8c9d0e1
|
|
||||||
Revises: e5f6a7b8c9d0
|
|
||||||
Branch Labels: None
|
|
||||||
Depends On: None
|
|
||||||
|
|
||||||
"""
|
|
||||||
from alembic import op
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
revision = 'f6a7b8c9d0e1'
|
|
||||||
down_revision = 'e5f6a7b8c9d0'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
|
|
||||||
# Check existing columns
|
|
||||||
columns = [row[0] for row in conn.execute(sa.text(
|
|
||||||
"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS "
|
|
||||||
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'evotor_connections'"
|
|
||||||
))]
|
|
||||||
|
|
||||||
if 'evotor_user_id' not in columns:
|
|
||||||
op.add_column('evotor_connections',
|
|
||||||
sa.Column('evotor_user_id', sa.String(255), nullable=True))
|
|
||||||
|
|
||||||
# Check existing indexes
|
|
||||||
indexes = [row[2] for row in conn.execute(sa.text(
|
|
||||||
"SHOW INDEX FROM evotor_connections"
|
|
||||||
))]
|
|
||||||
|
|
||||||
if 'uq_evotor_connections_evotor_user_id' not in indexes:
|
|
||||||
op.create_unique_constraint('uq_evotor_connections_evotor_user_id',
|
|
||||||
'evotor_connections', ['evotor_user_id'])
|
|
||||||
|
|
||||||
if 'ix_evotor_connections_evotor_user_id' not in indexes:
|
|
||||||
op.create_index('ix_evotor_connections_evotor_user_id',
|
|
||||||
'evotor_connections', ['evotor_user_id'])
|
|
||||||
|
|
||||||
op.alter_column('evotor_connections', 'user_id',
|
|
||||||
existing_type=sa.Integer(), nullable=True)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.alter_column('evotor_connections', 'user_id',
|
|
||||||
existing_type=sa.Integer(), nullable=False)
|
|
||||||
op.drop_index('ix_evotor_connections_evotor_user_id', 'evotor_connections')
|
|
||||||
op.drop_constraint('uq_evotor_connections_evotor_user_id', 'evotor_connections')
|
|
||||||
op.drop_column('evotor_connections', 'evotor_user_id')
|
|
||||||
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,
|
||||||
|
)
|
||||||
@@ -1,82 +1,66 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, UniqueConstraint, Numeric, Index
|
from sqlalchemy import (
|
||||||
from sqlalchemy.orm import relationship
|
Boolean, Column, DateTime, ForeignKey, Index, Integer,
|
||||||
from sqlalchemy.sql import func
|
Numeric, String, Text, UniqueConstraint, func,
|
||||||
|
)
|
||||||
|
|
||||||
from web.database import Base
|
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)
|
|
||||||
|
|
||||||
evotor_connection = relationship("EvotorConnection", back_populates="user", uselist=False)
|
|
||||||
vk_connection = relationship("VkConnection", back_populates="user", uselist=False)
|
|
||||||
sync_config = relationship("SyncConfig", back_populates="user", uselist=False)
|
|
||||||
cached_stores = relationship("CachedStore", back_populates="user", cascade="all, delete-orphan")
|
|
||||||
cached_groups = relationship("CachedGroup", back_populates="user", cascade="all, delete-orphan")
|
|
||||||
cached_products = relationship("CachedProduct", back_populates="user", cascade="all, delete-orphan")
|
|
||||||
|
|
||||||
|
|
||||||
class EvotorConnection(Base):
|
class EvotorConnection(Base):
|
||||||
__tablename__ = "evotor_connections"
|
__tablename__ = "evotor_connections"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=True)
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
|
||||||
evotor_user_id = Column(String(255), unique=True, nullable=True, index=True)
|
evotor_user_id = Column(String(255), nullable=True)
|
||||||
access_token = Column(Text, nullable=False)
|
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_id = Column(String(255), nullable=True)
|
||||||
store_name = Column(String(255), nullable=True)
|
store_name = Column(String(255), nullable=True)
|
||||||
refresh_token = Column(Text, nullable=True)
|
refresh_token = Column(Text, nullable=True)
|
||||||
token_expires_at = Column(DateTime, nullable=True)
|
token_expires_at = Column(DateTime, nullable=True)
|
||||||
is_online = Column(Boolean, default=False, server_default="0", nullable=False)
|
is_online = Column(Boolean, nullable=False, default=False)
|
||||||
last_checked_at = Column(DateTime, nullable=True)
|
last_checked_at = Column(DateTime, nullable=True)
|
||||||
connected_at = Column(DateTime, server_default=func.now(), nullable=False)
|
connected_at = Column(DateTime, nullable=False, server_default=func.now())
|
||||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
user = relationship("User", back_populates="evotor_connection")
|
__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):
|
class VkConnection(Base):
|
||||||
__tablename__ = "vk_connections"
|
__tablename__ = "vk_connections"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False)
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
access_token = Column(Text, nullable=False)
|
access_token = Column(Text, nullable=False)
|
||||||
vk_user_id = Column(String(50), nullable=True)
|
vk_user_id = Column(String(50), nullable=True)
|
||||||
first_name = Column(String(255), nullable=True)
|
first_name = Column(String(255), nullable=True)
|
||||||
last_name = Column(String(255), nullable=True)
|
last_name = Column(String(255), nullable=True)
|
||||||
is_online = Column(Boolean, default=False, server_default="0", nullable=False)
|
is_online = Column(Boolean, nullable=False, default=False)
|
||||||
last_checked_at = Column(DateTime, nullable=True)
|
last_checked_at = Column(DateTime, nullable=True)
|
||||||
connected_at = Column(DateTime, server_default=func.now(), nullable=False)
|
connected_at = Column(DateTime, nullable=False, server_default=func.now())
|
||||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
user = relationship("User", back_populates="vk_connection")
|
__table_args__ = (
|
||||||
|
UniqueConstraint("user_id", name="ix_vk_connections_user_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SyncConfig(Base):
|
class SyncConfig(Base):
|
||||||
__tablename__ = "sync_configs"
|
__tablename__ = "sync_configs"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False)
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
is_enabled = Column(Boolean, default=False, nullable=False)
|
is_enabled = Column(Boolean, nullable=False, default=False)
|
||||||
confirmed_at = Column(DateTime, nullable=True)
|
confirmed_at = Column(DateTime, nullable=True)
|
||||||
created_at = Column(DateTime, server_default=func.now(), nullable=False)
|
created_at = Column(DateTime, nullable=False, server_default=func.now())
|
||||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
user = relationship("User", back_populates="sync_config")
|
__table_args__ = (
|
||||||
filters = relationship("SyncFilter", back_populates="sync_config", cascade="all, delete-orphan")
|
UniqueConstraint("user_id", name="ix_sync_configs_user_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SyncFilter(Base):
|
class SyncFilter(Base):
|
||||||
@@ -84,18 +68,55 @@ class SyncFilter(Base):
|
|||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
sync_config_id = Column(Integer, ForeignKey("sync_configs.id", ondelete="CASCADE"), nullable=False)
|
sync_config_id = Column(Integer, ForeignKey("sync_configs.id", ondelete="CASCADE"), nullable=False)
|
||||||
entity_type = Column(String(20), nullable=False) # "store", "group", "product"
|
entity_type = Column(String(20), nullable=False)
|
||||||
entity_id = Column(String(255), nullable=False)
|
entity_id = Column(String(255), nullable=False)
|
||||||
entity_name = Column(String(255), nullable=True)
|
entity_name = Column(String(255), nullable=True)
|
||||||
filter_mode = Column(String(10), nullable=False) # "include", "exclude"
|
filter_mode = Column(String(10), nullable=False)
|
||||||
parent_entity_id = Column(String(255), nullable=True)
|
parent_entity_id = Column(String(255), nullable=True)
|
||||||
created_at = Column(DateTime, server_default=func.now(), nullable=False)
|
created_at = Column(DateTime, nullable=False, server_default=func.now())
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint("sync_config_id", "entity_type", "entity_id"),
|
UniqueConstraint("sync_config_id", "entity_type", "entity_id",
|
||||||
|
name="uq_sync_filters_config_type_entity"),
|
||||||
)
|
)
|
||||||
|
|
||||||
sync_config = relationship("SyncConfig", back_populates="filters")
|
|
||||||
|
class VkCachedAlbum(Base):
|
||||||
|
__tablename__ = "vk_cached_albums"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
vk_group_id = Column(String(50), nullable=False)
|
||||||
|
album_id = Column(String(50), nullable=False)
|
||||||
|
title = Column(String(255), nullable=False)
|
||||||
|
count = Column(Integer, nullable=True)
|
||||||
|
fetched_at = Column(DateTime, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("user_id", "vk_group_id", "album_id", name="uq_vk_cached_albums"),
|
||||||
|
Index("ix_vk_cached_albums_user_group", "user_id", "vk_group_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class VkCachedProduct(Base):
|
||||||
|
__tablename__ = "vk_cached_products"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
vk_group_id = Column(String(50), nullable=False)
|
||||||
|
vk_product_id = Column(String(50), nullable=False)
|
||||||
|
album_id = Column(String(50), nullable=True)
|
||||||
|
name = Column(String(255), nullable=False)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
price = Column(Numeric(12, 2), nullable=True)
|
||||||
|
availability = Column(Integer, nullable=True)
|
||||||
|
thumb_url = Column(String(1024), nullable=True)
|
||||||
|
fetched_at = Column(DateTime, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("user_id", "vk_group_id", "vk_product_id", name="uq_vk_cached_products"),
|
||||||
|
Index("ix_vk_cached_products_user_group_album", "user_id", "vk_group_id", "album_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CachedStore(Base):
|
class CachedStore(Base):
|
||||||
@@ -109,12 +130,10 @@ class CachedStore(Base):
|
|||||||
fetched_at = Column(DateTime, nullable=False)
|
fetched_at = Column(DateTime, nullable=False)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint("user_id", "evotor_id"),
|
UniqueConstraint("user_id", "evotor_id", name="uq_cached_stores_user_evotor"),
|
||||||
Index("ix_cached_stores_user_id", "user_id"),
|
Index("ix_cached_stores_user_id", "user_id"),
|
||||||
)
|
)
|
||||||
|
|
||||||
user = relationship("User", back_populates="cached_stores")
|
|
||||||
|
|
||||||
|
|
||||||
class CachedGroup(Base):
|
class CachedGroup(Base):
|
||||||
__tablename__ = "cached_groups"
|
__tablename__ = "cached_groups"
|
||||||
@@ -127,12 +146,10 @@ class CachedGroup(Base):
|
|||||||
fetched_at = Column(DateTime, nullable=False)
|
fetched_at = Column(DateTime, nullable=False)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint("user_id", "evotor_id"),
|
UniqueConstraint("user_id", "evotor_id", name="uq_cached_groups_user_evotor"),
|
||||||
Index("ix_cached_groups_user_store", "user_id", "store_evotor_id"),
|
Index("ix_cached_groups_user_store", "user_id", "store_evotor_id"),
|
||||||
)
|
)
|
||||||
|
|
||||||
user = relationship("User", back_populates="cached_groups")
|
|
||||||
|
|
||||||
|
|
||||||
class CachedProduct(Base):
|
class CachedProduct(Base):
|
||||||
__tablename__ = "cached_products"
|
__tablename__ = "cached_products"
|
||||||
@@ -149,10 +166,9 @@ class CachedProduct(Base):
|
|||||||
article_number = Column(String(100), nullable=True)
|
article_number = Column(String(100), nullable=True)
|
||||||
allow_to_sell = Column(Boolean, nullable=True)
|
allow_to_sell = Column(Boolean, nullable=True)
|
||||||
fetched_at = Column(DateTime, nullable=False)
|
fetched_at = Column(DateTime, nullable=False)
|
||||||
|
synced_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint("user_id", "evotor_id"),
|
UniqueConstraint("user_id", "evotor_id", name="uq_cached_products_user_evotor"),
|
||||||
Index("ix_cached_products_user_store_group", "user_id", "store_evotor_id", "group_evotor_id"),
|
Index("ix_cached_products_user_store_group", "user_id", "store_evotor_id", "group_evotor_id"),
|
||||||
)
|
)
|
||||||
|
|
||||||
user = relationship("User", back_populates="cached_products")
|
|
||||||
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)
|
||||||
50
web/models/user.py
Normal file
50
web/models/user.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
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)
|
||||||
|
email = Column(String(255), nullable=False)
|
||||||
|
phone = Column(String(20), nullable=False)
|
||||||
|
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)
|
||||||
271
web/routes/admin.py
Normal file
271
web/routes/admin.py
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
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})
|
||||||
|
|
||||||
|
|
||||||
|
# ── 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"]
|
||||||
|
if data.get("email"):
|
||||||
|
user.email = data["email"]
|
||||||
|
if data.get("phone"):
|
||||||
|
user.phone = data["phone"]
|
||||||
|
if data.get("role") and admin.role == UserRoleEnum.system:
|
||||||
|
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)
|
||||||
|
if admin.role != UserRoleEnum.system:
|
||||||
|
return RedirectResponse(f"/admin/users/{user_id}", 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)
|
||||||
|
if admin.role != UserRoleEnum.system:
|
||||||
|
return RedirectResponse("/admin/users", 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)
|
||||||
|
if admin.role != UserRoleEnum.system:
|
||||||
|
return RedirectResponse("/admin/roles", 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,115 +1,158 @@
|
|||||||
import uuid
|
import secrets
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, Depends
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from web.templates_env import templates
|
from sqlalchemy import or_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from web.auth import hash_password, verify_password, get_current_user
|
from web.auth.password import hash_password, verify_password
|
||||||
|
from web.auth.session import get_session_user_id
|
||||||
from web.config import settings
|
from web.config import settings
|
||||||
from web.database import get_db
|
from web.database import get_db
|
||||||
from web.models import User
|
from web.models.user import User, UserStatusEnum
|
||||||
from web.schemas import validate_registration, validate_login
|
from web.notifications.tasks import send_email_task
|
||||||
|
from web.templates_env import templates
|
||||||
|
|
||||||
router = APIRouter()
|
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("/register")
|
@router.get("/register")
|
||||||
def register_form(request: Request, user: User | None = Depends(get_current_user)):
|
async def register_get(request: Request, db: Session = Depends(get_db)):
|
||||||
if user:
|
if get_session_user_id(request):
|
||||||
return RedirectResponse("/profile", 303)
|
return RedirectResponse("/profile", 303)
|
||||||
return templates.TemplateResponse("register.html", {"request": request, "user": None})
|
return _render(request, "register.html", {"user": None})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register")
|
@router.post("/register")
|
||||||
async def register_submit(request: Request, db: Session = Depends(get_db)):
|
async def register_post(request: Request, db: Session = Depends(get_db)):
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
data = dict(form)
|
data = {k: str(v).strip() for k, v in form.items()}
|
||||||
|
errors = []
|
||||||
|
|
||||||
errors = validate_registration(data)
|
if not data.get("email"):
|
||||||
|
errors.append("Email обязателен")
|
||||||
|
if not data.get("phone"):
|
||||||
|
errors.append("Телефон обязателен")
|
||||||
|
if not data.get("password"):
|
||||||
|
errors.append("Пароль обязателен")
|
||||||
|
if len(data.get("password", "")) < 8:
|
||||||
|
errors.append("Пароль должен содержать минимум 8 символов")
|
||||||
|
if data.get("password") != data.get("password_confirm"):
|
||||||
|
errors.append("Пароли не совпадают")
|
||||||
|
|
||||||
if not errors:
|
if not errors:
|
||||||
existing = db.query(User).filter(
|
existing = db.query(User).filter(
|
||||||
(User.email == data["email"].strip()) | (User.phone == data["phone"].strip())
|
or_(User.email == data["email"], User.phone == data["phone"])
|
||||||
).first()
|
).first()
|
||||||
if existing:
|
if existing:
|
||||||
if existing.email == data["email"].strip():
|
if existing.email == data["email"]:
|
||||||
errors.append("Пользователь с таким email уже существует")
|
errors.append("Пользователь с таким email уже существует")
|
||||||
else:
|
else:
|
||||||
errors.append("Пользователь с таким телефоном уже существует")
|
errors.append("Пользователь с таким телефоном уже существует")
|
||||||
|
|
||||||
if errors:
|
if errors:
|
||||||
return templates.TemplateResponse("register.html", {
|
return _render(request, "register.html", {"user": None, "errors": errors, "form": data})
|
||||||
"request": request, "user": None, "errors": errors, "form": data,
|
|
||||||
})
|
|
||||||
|
|
||||||
token = uuid.uuid4().hex
|
token = secrets.token_urlsafe(32)
|
||||||
user = User(
|
user = User(
|
||||||
first_name=data["first_name"].strip(),
|
first_name=data.get("first_name", ""),
|
||||||
last_name=data["last_name"].strip(),
|
last_name=data.get("last_name", ""),
|
||||||
email=data["email"].strip(),
|
email=data["email"],
|
||||||
phone=data["phone"].strip(),
|
phone=data["phone"],
|
||||||
password_hash=hash_password(data["password"]),
|
password_hash=await _hash(data["password"]),
|
||||||
email_confirm_token=token,
|
email_confirm_token=token,
|
||||||
|
status=UserStatusEnum.pending,
|
||||||
)
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
confirm_url = f"{settings.BASE_URL}/confirm-email?token={token}"
|
confirm_url = f"{settings.BASE_URL}/confirm-email?token={token}"
|
||||||
print("=" * 40)
|
html = f'<p>Подтвердите email: <a href="{confirm_url}">{confirm_url}</a></p>'
|
||||||
print("ПОДТВЕРЖДЕНИЕ EMAIL")
|
send_email_task.delay(user.email, "Подтвердите ваш email — ЭВОСИНК", html)
|
||||||
print(f"Пользователь: {user.email}")
|
|
||||||
print(f"Ссылка: {confirm_url}")
|
|
||||||
print("=" * 40)
|
|
||||||
|
|
||||||
return templates.TemplateResponse("confirm_email.html", {"request": request, "user": None})
|
return _render(request, "confirm_email.html", {"user": None})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/confirm-email")
|
@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()
|
user = db.query(User).filter(User.email_confirm_token == token).first()
|
||||||
if not user:
|
if not user:
|
||||||
return templates.TemplateResponse("message.html", {
|
return _render(request, "message.html", {
|
||||||
"request": request, "user": None,
|
"user": None, "title": "Ошибка", "message": "Неверная или устаревшая ссылка.",
|
||||||
"title": "Ошибка", "message": "Неверная или устаревшая ссылка.",
|
"link": "/login", "link_text": "Войти",
|
||||||
})
|
})
|
||||||
|
|
||||||
user.is_email_confirmed = True
|
user.is_email_confirmed = True
|
||||||
user.email_confirm_token = None
|
user.email_confirm_token = None
|
||||||
|
user.status = UserStatusEnum.active
|
||||||
db.commit()
|
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")
|
@router.get("/login")
|
||||||
def login_form(request: Request, user: User | None = Depends(get_current_user)):
|
async def login_get(request: Request, db: Session = Depends(get_db)):
|
||||||
if user:
|
if get_session_user_id(request):
|
||||||
return RedirectResponse("/profile", 303)
|
return RedirectResponse("/profile", 303)
|
||||||
return templates.TemplateResponse("login.html", {"request": request, "user": None})
|
return _render(request, "login.html", {"user": None})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login")
|
@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()
|
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:
|
if errors:
|
||||||
return templates.TemplateResponse("login.html", {
|
return _render(request, "login.html", {
|
||||||
"request": request, "user": None, "errors": errors, "form": data,
|
"user": None, "errors": errors, "form": {"email": email},
|
||||||
})
|
|
||||||
|
|
||||||
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,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
request.session["user_id"] = user.id
|
request.session["user_id"] = user.id
|
||||||
@@ -117,6 +160,11 @@ async def login_submit(request: Request, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/logout")
|
@router.get("/logout")
|
||||||
def logout(request: Request):
|
async def logout(request: Request):
|
||||||
request.session.clear()
|
request.session.clear()
|
||||||
return RedirectResponse("/login", 303)
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
|
|
||||||
|
async def _hash(plain: str) -> str:
|
||||||
|
import asyncio
|
||||||
|
return await asyncio.get_event_loop().run_in_executor(None, hash_password, plain)
|
||||||
|
|||||||
@@ -1,308 +1,252 @@
|
|||||||
import csv
|
from datetime import datetime, timezone
|
||||||
import io
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, Depends
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.responses import RedirectResponse, StreamingResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from web.templates_env import templates
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from web.auth import get_current_user
|
from web.auth.session import get_current_user
|
||||||
|
from web.config import settings
|
||||||
from web.database import get_db
|
from web.database import get_db
|
||||||
from web.evotor_api import refresh_catalog_cache
|
from web.models.connections import CachedGroup, CachedProduct, CachedStore, SyncConfig, SyncFilter
|
||||||
from web.models import User, EvotorConnection, SyncConfig, SyncFilter, CachedStore, CachedGroup, CachedProduct
|
from web.templates_env import templates
|
||||||
|
|
||||||
router = APIRouter(prefix="/catalog")
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
def _get_or_create_sync_config(db: Session, user_id: int) -> SyncConfig:
|
def _get_or_create_sync_config(db: Session, user_id: int) -> SyncConfig:
|
||||||
config = db.query(SyncConfig).filter(SyncConfig.user_id == user_id).first()
|
cfg = db.query(SyncConfig).filter_by(user_id=user_id).first()
|
||||||
if not config:
|
if not cfg:
|
||||||
config = SyncConfig(user_id=user_id, is_enabled=False)
|
cfg = SyncConfig(user_id=user_id, is_enabled=True)
|
||||||
db.add(config)
|
db.add(cfg)
|
||||||
db.commit()
|
db.flush()
|
||||||
db.refresh(config)
|
return cfg
|
||||||
return config
|
|
||||||
|
|
||||||
|
|
||||||
def _filter_map(config: SyncConfig) -> dict:
|
def _enabled_store_ids(db: Session, user_id: int) -> set[str] | None:
|
||||||
"""Returns {entity_id: filter_mode} for quick lookup."""
|
"""Return set of enabled store evotor_ids, or None if no filters set (all enabled)."""
|
||||||
return {f.entity_id: f.filter_mode for f in config.filters}
|
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()
|
||||||
|
if not filters:
|
||||||
|
return None
|
||||||
|
return {f.entity_id for f in filters}
|
||||||
|
|
||||||
|
|
||||||
def _filter_label(mode: str | None) -> str:
|
def _enabled_group_ids(db: Session, user_id: int, store_evotor_id: str) -> set[str] | None:
|
||||||
if mode == "include":
|
"""Return set of enabled group evotor_ids for a store, or None if all enabled."""
|
||||||
return "include"
|
cfg = db.query(SyncConfig).filter_by(user_id=user_id).first()
|
||||||
if mode == "exclude":
|
if not cfg:
|
||||||
return "exclude"
|
return None
|
||||||
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()
|
||||||
|
if not filters:
|
||||||
|
return None
|
||||||
|
return {f.entity_id for f in filters}
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
def _render(request: Request, template: str, ctx: dict) -> HTMLResponse:
|
||||||
async def catalog_stores(
|
ctx["request"] = request
|
||||||
request: Request,
|
ctx.setdefault("jivosite_widget_id", settings.JIVOSITE_WIDGET_ID)
|
||||||
db: Session = Depends(get_db),
|
return templates.TemplateResponse(ctx.pop("request"), template, ctx)
|
||||||
user: User | None = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
if not user:
|
@router.get("/catalog/stores")
|
||||||
|
async def catalog_stores(request: Request, db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
user = get_current_user(request, db)
|
||||||
|
except Exception:
|
||||||
return RedirectResponse("/login", 303)
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
evotor = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
|
stores = (
|
||||||
if not evotor:
|
db.query(CachedStore)
|
||||||
return templates.TemplateResponse("catalog_stores.html", {
|
.filter(CachedStore.user_id == user.id)
|
||||||
"request": request, "user": user,
|
.order_by(CachedStore.name)
|
||||||
"evotor": None, "stores": [], "filter_map": {}, "fetched_at": None,
|
.all()
|
||||||
})
|
)
|
||||||
|
enabled_ids = _enabled_store_ids(db, user.id)
|
||||||
stores = db.query(CachedStore).filter(CachedStore.user_id == user.id).order_by(CachedStore.name).all()
|
return _render(request, "catalog/stores.html", {
|
||||||
|
|
||||||
# Auto-refresh if cache is empty
|
|
||||||
if not stores:
|
|
||||||
await refresh_catalog_cache(user.id, evotor.access_token, db)
|
|
||||||
stores = db.query(CachedStore).filter(CachedStore.user_id == user.id).order_by(CachedStore.name).all()
|
|
||||||
|
|
||||||
config = _get_or_create_sync_config(db, user.id)
|
|
||||||
fmap = _filter_map(config)
|
|
||||||
fetched_at = stores[0].fetched_at if stores else None
|
|
||||||
|
|
||||||
return templates.TemplateResponse("catalog_stores.html", {
|
|
||||||
"request": request,
|
|
||||||
"user": user,
|
"user": user,
|
||||||
"evotor": evotor,
|
|
||||||
"stores": stores,
|
"stores": stores,
|
||||||
"filter_map": fmap,
|
"enabled_ids": enabled_ids, # None = all enabled, set = explicit list
|
||||||
"fetched_at": fetched_at,
|
"refresh_interval": settings.CATALOG_REFRESH_INTERVAL_SECONDS,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/groups")
|
@router.get("/catalog/stores/{store_evotor_id}/groups")
|
||||||
def catalog_groups(
|
async def catalog_groups(store_evotor_id: str, request: Request, db: Session = Depends(get_db)):
|
||||||
request: Request,
|
try:
|
||||||
store_id: str,
|
user = get_current_user(request, db)
|
||||||
db: Session = Depends(get_db),
|
except Exception:
|
||||||
user: User | None = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
if not user:
|
|
||||||
return RedirectResponse("/login", 303)
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
store = db.query(CachedStore).filter(
|
store = (
|
||||||
CachedStore.user_id == user.id,
|
db.query(CachedStore)
|
||||||
CachedStore.evotor_id == store_id,
|
.filter(CachedStore.user_id == user.id, CachedStore.evotor_id == store_evotor_id)
|
||||||
).first()
|
.first()
|
||||||
|
)
|
||||||
if not store:
|
if not store:
|
||||||
return RedirectResponse("/catalog", 303)
|
return RedirectResponse("/catalog/stores", 303)
|
||||||
|
|
||||||
groups = db.query(CachedGroup).filter(
|
groups = (
|
||||||
CachedGroup.user_id == user.id,
|
db.query(CachedGroup)
|
||||||
CachedGroup.store_evotor_id == store_id,
|
.filter(CachedGroup.user_id == user.id, CachedGroup.store_evotor_id == store_evotor_id)
|
||||||
).order_by(CachedGroup.name).all()
|
.order_by(CachedGroup.name)
|
||||||
|
.all()
|
||||||
# Count products per group
|
)
|
||||||
product_counts = {}
|
enabled_ids = _enabled_group_ids(db, user.id, store_evotor_id)
|
||||||
for g in groups:
|
return _render(request, "catalog/groups.html", {
|
||||||
product_counts[g.evotor_id] = db.query(CachedProduct).filter(
|
"user": user, "store": store, "groups": groups,
|
||||||
CachedProduct.user_id == user.id,
|
"enabled_ids": enabled_ids,
|
||||||
CachedProduct.group_evotor_id == g.evotor_id,
|
|
||||||
).count()
|
|
||||||
|
|
||||||
config = _get_or_create_sync_config(db, user.id)
|
|
||||||
fmap = _filter_map(config)
|
|
||||||
|
|
||||||
return templates.TemplateResponse("catalog_groups.html", {
|
|
||||||
"request": request,
|
|
||||||
"user": user,
|
|
||||||
"store": store,
|
|
||||||
"groups": groups,
|
|
||||||
"product_counts": product_counts,
|
|
||||||
"filter_map": fmap,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/products")
|
@router.get("/catalog/stores/{store_evotor_id}/products")
|
||||||
def catalog_products(
|
async def catalog_products(store_evotor_id: str, request: Request, db: Session = Depends(get_db)):
|
||||||
request: Request,
|
try:
|
||||||
store_id: str,
|
user = get_current_user(request, db)
|
||||||
group_id: str | None = None,
|
except Exception:
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user: User | None = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
if not user:
|
|
||||||
return RedirectResponse("/login", 303)
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
store = db.query(CachedStore).filter(
|
store = (
|
||||||
CachedStore.user_id == user.id,
|
db.query(CachedStore)
|
||||||
CachedStore.evotor_id == store_id,
|
.filter(CachedStore.user_id == user.id, CachedStore.evotor_id == store_evotor_id)
|
||||||
).first()
|
.first()
|
||||||
|
)
|
||||||
if not store:
|
if not store:
|
||||||
return RedirectResponse("/catalog", 303)
|
return RedirectResponse("/catalog/stores", 303)
|
||||||
|
|
||||||
group = None
|
group_id = request.query_params.get("group")
|
||||||
query = db.query(CachedProduct).filter(
|
q = db.query(CachedProduct).filter(
|
||||||
CachedProduct.user_id == user.id,
|
CachedProduct.user_id == user.id,
|
||||||
CachedProduct.store_evotor_id == store_id,
|
CachedProduct.store_evotor_id == store_evotor_id,
|
||||||
)
|
)
|
||||||
if group_id:
|
if group_id:
|
||||||
group = db.query(CachedGroup).filter(
|
q = q.filter(CachedProduct.group_evotor_id == group_id)
|
||||||
CachedGroup.user_id == user.id,
|
|
||||||
CachedGroup.evotor_id == group_id,
|
|
||||||
).first()
|
|
||||||
query = query.filter(CachedProduct.group_evotor_id == group_id)
|
|
||||||
|
|
||||||
products = query.order_by(CachedProduct.name).all()
|
products = q.order_by(CachedProduct.name).all()
|
||||||
|
groups = (
|
||||||
config = _get_or_create_sync_config(db, user.id)
|
db.query(CachedGroup)
|
||||||
fmap = _filter_map(config)
|
.filter(CachedGroup.user_id == user.id, CachedGroup.store_evotor_id == store_evotor_id)
|
||||||
|
.order_by(CachedGroup.name)
|
||||||
return templates.TemplateResponse("catalog_products.html", {
|
.all()
|
||||||
"request": request,
|
)
|
||||||
|
return _render(request, "catalog/products.html", {
|
||||||
"user": user,
|
"user": user,
|
||||||
"store": store,
|
"store": store,
|
||||||
"group": group,
|
|
||||||
"products": products,
|
"products": products,
|
||||||
"filter_map": fmap,
|
"groups": groups,
|
||||||
|
"group_id": group_id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/filter")
|
@router.post("/catalog/stores/{store_evotor_id}/toggle")
|
||||||
async def catalog_filter(
|
async def catalog_store_toggle(store_evotor_id: str, request: Request, db: Session = Depends(get_db)):
|
||||||
request: Request,
|
try:
|
||||||
db: Session = Depends(get_db),
|
user = get_current_user(request, db)
|
||||||
user: User | None = Depends(get_current_user),
|
except Exception:
|
||||||
):
|
|
||||||
if not user:
|
|
||||||
return RedirectResponse("/login", 303)
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
form = await request.form()
|
cfg = _get_or_create_sync_config(db, user.id)
|
||||||
entity_type = form.get("entity_type")
|
|
||||||
entity_id = form.get("entity_id")
|
|
||||||
entity_name = form.get("entity_name")
|
|
||||||
filter_mode = form.get("filter_mode") # "include", "exclude", "none"
|
|
||||||
parent_entity_id = form.get("parent_entity_id") or None
|
|
||||||
redirect_to = form.get("redirect_to", "/catalog")
|
|
||||||
|
|
||||||
config = _get_or_create_sync_config(db, user.id)
|
# If no filters exist yet, that means all stores are implicitly enabled.
|
||||||
|
# Toggling one store OFF means we create include-filters for all OTHER stores.
|
||||||
|
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}
|
||||||
|
|
||||||
existing = db.query(SyncFilter).filter(
|
if store_evotor_id in existing_ids:
|
||||||
SyncFilter.sync_config_id == config.id,
|
# Currently enabled → disable: remove its filter
|
||||||
SyncFilter.entity_type == entity_type,
|
db.query(SyncFilter).filter_by(
|
||||||
SyncFilter.entity_id == entity_id,
|
sync_config_id=cfg.id, entity_type="store",
|
||||||
).first()
|
entity_id=store_evotor_id, filter_mode="include",
|
||||||
|
).delete()
|
||||||
if filter_mode == "none":
|
|
||||||
if existing:
|
|
||||||
db.delete(existing)
|
|
||||||
elif existing:
|
|
||||||
existing.filter_mode = filter_mode
|
|
||||||
existing.entity_name = entity_name
|
|
||||||
else:
|
else:
|
||||||
db.add(SyncFilter(
|
if not existing_ids:
|
||||||
sync_config_id=config.id,
|
# First toggle: seed include-filters for all OTHER stores
|
||||||
entity_type=entity_type,
|
all_stores = db.query(CachedStore).filter_by(user_id=user.id).all()
|
||||||
entity_id=entity_id,
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
entity_name=entity_name,
|
for s in all_stores:
|
||||||
filter_mode=filter_mode,
|
if s.evotor_id == store_evotor_id:
|
||||||
parent_entity_id=parent_entity_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,
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
# 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),
|
||||||
|
))
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
return RedirectResponse("/catalog/stores", 303)
|
||||||
return RedirectResponse(redirect_to, 303)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/refresh")
|
@router.post("/catalog/stores/{store_evotor_id}/groups/{group_evotor_id}/toggle")
|
||||||
async def catalog_refresh(
|
async def catalog_group_toggle(
|
||||||
request: Request,
|
store_evotor_id: str, group_evotor_id: str,
|
||||||
db: Session = Depends(get_db),
|
request: Request, db: Session = Depends(get_db),
|
||||||
user: User | None = Depends(get_current_user),
|
|
||||||
):
|
):
|
||||||
if not user:
|
try:
|
||||||
|
user = get_current_user(request, db)
|
||||||
|
except Exception:
|
||||||
return RedirectResponse("/login", 303)
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
evotor = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
|
cfg = _get_or_create_sync_config(db, user.id)
|
||||||
if evotor:
|
|
||||||
await refresh_catalog_cache(user.id, evotor.access_token, db)
|
|
||||||
|
|
||||||
return RedirectResponse("/catalog", 303)
|
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 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:
|
||||||
|
if not existing_ids:
|
||||||
|
# First toggle: seed include-filters for all OTHER groups in this store
|
||||||
|
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,
|
||||||
|
))
|
||||||
|
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),
|
||||||
|
))
|
||||||
|
|
||||||
@router.get("/export")
|
db.commit()
|
||||||
def catalog_export(
|
return RedirectResponse(f"/catalog/stores/{store_evotor_id}/groups", 303)
|
||||||
request: Request,
|
|
||||||
type: str,
|
|
||||||
store_id: str | None = None,
|
|
||||||
group_id: str | None = None,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user: User | None = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
if not user:
|
|
||||||
return RedirectResponse("/login", 303)
|
|
||||||
|
|
||||||
config = _get_or_create_sync_config(db, user.id)
|
|
||||||
fmap = _filter_map(config)
|
|
||||||
|
|
||||||
def filter_label(eid):
|
|
||||||
m = fmap.get(eid)
|
|
||||||
if m == "include":
|
|
||||||
return "Включено"
|
|
||||||
if m == "exclude":
|
|
||||||
return "Исключено"
|
|
||||||
return "Нет правила"
|
|
||||||
|
|
||||||
output = io.StringIO()
|
|
||||||
output.write("\ufeff") # UTF-8 BOM for Excel
|
|
||||||
writer = csv.writer(output)
|
|
||||||
|
|
||||||
from datetime import date
|
|
||||||
today = date.today().strftime("%Y%m%d")
|
|
||||||
|
|
||||||
if type == "stores":
|
|
||||||
writer.writerow(["Название", "Адрес", "ID", "Фильтр"])
|
|
||||||
stores = db.query(CachedStore).filter(CachedStore.user_id == user.id).order_by(CachedStore.name).all()
|
|
||||||
for s in stores:
|
|
||||||
writer.writerow([s.name, s.address or "", s.evotor_id, filter_label(s.evotor_id)])
|
|
||||||
filename = f"stores_{today}.csv"
|
|
||||||
|
|
||||||
elif type == "groups":
|
|
||||||
writer.writerow(["Магазин", "Название", "ID", "Фильтр"])
|
|
||||||
q = db.query(CachedGroup, CachedStore).join(
|
|
||||||
CachedStore,
|
|
||||||
(CachedStore.evotor_id == CachedGroup.store_evotor_id) & (CachedStore.user_id == user.id)
|
|
||||||
).filter(CachedGroup.user_id == user.id)
|
|
||||||
if store_id:
|
|
||||||
q = q.filter(CachedGroup.store_evotor_id == store_id)
|
|
||||||
for g, s in q.order_by(CachedGroup.name).all():
|
|
||||||
writer.writerow([s.name, g.name, g.evotor_id, filter_label(g.evotor_id)])
|
|
||||||
filename = f"groups_{today}.csv"
|
|
||||||
|
|
||||||
else: # products
|
|
||||||
writer.writerow(["Магазин", "Группа", "Название", "Артикул", "Цена", "Количество", "Ед. измерения", "В продаже", "ID", "Фильтр"])
|
|
||||||
q = db.query(CachedProduct, CachedStore, CachedGroup).join(
|
|
||||||
CachedStore,
|
|
||||||
(CachedStore.evotor_id == CachedProduct.store_evotor_id) & (CachedStore.user_id == user.id)
|
|
||||||
).outerjoin(
|
|
||||||
CachedGroup,
|
|
||||||
(CachedGroup.evotor_id == CachedProduct.group_evotor_id) & (CachedGroup.user_id == user.id)
|
|
||||||
).filter(CachedProduct.user_id == user.id)
|
|
||||||
if store_id:
|
|
||||||
q = q.filter(CachedProduct.store_evotor_id == store_id)
|
|
||||||
if group_id:
|
|
||||||
q = q.filter(CachedProduct.group_evotor_id == group_id)
|
|
||||||
for p, s, g in q.order_by(CachedProduct.name).all():
|
|
||||||
writer.writerow([
|
|
||||||
s.name,
|
|
||||||
g.name if g else "",
|
|
||||||
p.name,
|
|
||||||
p.article_number or "",
|
|
||||||
p.price or "",
|
|
||||||
p.quantity or "",
|
|
||||||
p.measure_name or "",
|
|
||||||
"Да" if p.allow_to_sell else ("Нет" if p.allow_to_sell is not None else ""),
|
|
||||||
p.evotor_id,
|
|
||||||
filter_label(p.evotor_id),
|
|
||||||
])
|
|
||||||
filename = f"products_{today}.csv"
|
|
||||||
|
|
||||||
output.seek(0)
|
|
||||||
return StreamingResponse(
|
|
||||||
iter([output.getvalue()]),
|
|
||||||
media_type="text/csv; charset=utf-8",
|
|
||||||
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,125 +1,229 @@
|
|||||||
from fastapi import APIRouter, Request, Depends
|
import secrets
|
||||||
from fastapi.responses import RedirectResponse
|
from datetime import datetime, timezone
|
||||||
from web.templates_env import templates
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from web.auth import get_current_user
|
from web.auth.session import get_current_user
|
||||||
|
from web.config import settings
|
||||||
from web.database import get_db
|
from web.database import get_db
|
||||||
from web.models import User, EvotorConnection, VkConnection
|
from web.models.connections import EvotorConnection, VkConnection
|
||||||
|
from web.templates_env import templates
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
SERVICE_TYPES = [
|
|
||||||
{
|
def _render(request: Request, template: str, ctx: dict) -> HTMLResponse:
|
||||||
"type": "evotor",
|
ctx["request"] = request
|
||||||
"name": "Эвотор",
|
ctx.setdefault("jivosite_widget_id", settings.JIVOSITE_WIDGET_ID)
|
||||||
"icon": "bi-shop",
|
return templates.TemplateResponse(ctx.pop("request"), template, ctx)
|
||||||
"description": "Подключите кассу Эвотор для синхронизации каталога товаров.",
|
|
||||||
"configure_url": "/evotor",
|
|
||||||
"connect_url": "/evotor",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "vk",
|
|
||||||
"name": "ВКонтакте",
|
|
||||||
"icon": "bi-bag",
|
|
||||||
"description": "Подключите аккаунт ВКонтакте для публикации товаров в вашу группу.",
|
|
||||||
"configure_url": "/vk",
|
|
||||||
"connect_url": "/vk",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _get_connection(svc_type: str, evotor, vk):
|
def _now() -> datetime:
|
||||||
if svc_type == "evotor":
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
return evotor
|
|
||||||
if svc_type == "vk":
|
|
||||||
return vk
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _get_details(svc_type: str, conn):
|
|
||||||
if conn is None:
|
|
||||||
return None
|
|
||||||
if svc_type == "evotor":
|
|
||||||
return conn.store_name
|
|
||||||
if svc_type == "vk":
|
|
||||||
return f"{conn.first_name} {conn.last_name}".strip() if conn.first_name else None
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/connections")
|
@router.get("/connections")
|
||||||
def connections_page(
|
async def connections_get(request: Request, db: Session = Depends(get_db)):
|
||||||
request: Request,
|
try:
|
||||||
db: Session = Depends(get_db),
|
user = get_current_user(request, db)
|
||||||
user: User | None = Depends(get_current_user),
|
except Exception:
|
||||||
):
|
|
||||||
if not user:
|
|
||||||
return RedirectResponse("/login", 303)
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
evotor = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
|
evotor = db.query(EvotorConnection).filter_by(user_id=user.id).first()
|
||||||
vk = db.query(VkConnection).filter(VkConnection.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})
|
||||||
connected = []
|
|
||||||
for svc in SERVICE_TYPES:
|
|
||||||
conn = _get_connection(svc["type"], evotor, vk)
|
|
||||||
if conn is not None:
|
|
||||||
connected.append({
|
|
||||||
**svc,
|
|
||||||
"is_online": conn.is_online,
|
|
||||||
"last_checked_at": conn.last_checked_at,
|
|
||||||
"details": _get_details(svc["type"], conn),
|
|
||||||
})
|
|
||||||
|
|
||||||
return templates.TemplateResponse("connections.html", {
|
|
||||||
"request": request,
|
|
||||||
"user": user,
|
|
||||||
"connections": connected,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/connections/add")
|
@router.post("/connections/evotor")
|
||||||
def connections_add_page(
|
async def connections_evotor_post(request: Request, db: Session = Depends(get_db)):
|
||||||
request: Request,
|
try:
|
||||||
db: Session = Depends(get_db),
|
user = get_current_user(request, db)
|
||||||
user: User | None = Depends(get_current_user),
|
except Exception:
|
||||||
):
|
|
||||||
if not user:
|
|
||||||
return RedirectResponse("/login", 303)
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
evotor = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
|
form = await request.form()
|
||||||
vk = db.query(VkConnection).filter(VkConnection.user_id == user.id).first()
|
access_token = str(form.get("access_token", "")).strip()
|
||||||
|
evotor_user_id = str(form.get("evotor_user_id", "")).strip() or None
|
||||||
|
|
||||||
available = [
|
if not access_token:
|
||||||
svc for svc in SERVICE_TYPES
|
evotor = db.query(EvotorConnection).filter_by(user_id=user.id).first()
|
||||||
if _get_connection(svc["type"], evotor, vk) is None
|
return _render(request, "connections.html", {
|
||||||
]
|
"user": user,
|
||||||
|
"evotor": evotor,
|
||||||
|
"errors": ["API-токен обязателен"],
|
||||||
|
})
|
||||||
|
|
||||||
return templates.TemplateResponse("connections_add.html", {
|
now = _now()
|
||||||
"request": request,
|
conn = db.query(EvotorConnection).filter_by(user_id=user.id).first()
|
||||||
"user": user,
|
if conn:
|
||||||
"available": available,
|
conn.access_token = access_token
|
||||||
})
|
if evotor_user_id:
|
||||||
|
conn.evotor_user_id = evotor_user_id
|
||||||
|
conn.updated_at = now
|
||||||
@router.post("/connections/delete")
|
|
||||||
async def connections_delete(
|
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user: User | None = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
if not user:
|
|
||||||
return RedirectResponse("/login", 303)
|
|
||||||
|
|
||||||
svc_type = request.query_params.get("type")
|
|
||||||
if svc_type == "evotor":
|
|
||||||
conn = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
|
|
||||||
elif svc_type == "vk":
|
|
||||||
conn = db.query(VkConnection).filter(VkConnection.user_id == user.id).first()
|
|
||||||
else:
|
else:
|
||||||
conn = None
|
conn = EvotorConnection(
|
||||||
|
user_id=user.id,
|
||||||
|
evotor_user_id=evotor_user_id,
|
||||||
|
access_token=access_token,
|
||||||
|
api_token=secrets.token_urlsafe(32),
|
||||||
|
connected_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
db.add(conn)
|
||||||
|
|
||||||
|
if evotor_user_id and not user.evotor_user_id:
|
||||||
|
user.evotor_user_id = evotor_user_id
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse("/connections?success=1", 303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/connections/evotor/disconnect")
|
||||||
|
async def connections_evotor_disconnect(request: Request, db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
user = get_current_user(request, db)
|
||||||
|
except Exception:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
|
conn = db.query(EvotorConnection).filter_by(user_id=user.id).first()
|
||||||
if conn:
|
if conn:
|
||||||
db.delete(conn)
|
db.delete(conn)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return RedirectResponse("/connections", 303)
|
return RedirectResponse("/connections", 303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/connections/vk")
|
||||||
|
async def connections_vk_post(request: Request, db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
user = get_current_user(request, db)
|
||||||
|
except Exception:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
|
form = await request.form()
|
||||||
|
access_token = str(form.get("access_token", "")).strip()
|
||||||
|
vk_group_id = str(form.get("vk_group_id", "")).strip() or None
|
||||||
|
|
||||||
|
if not access_token:
|
||||||
|
evotor = db.query(EvotorConnection).filter_by(user_id=user.id).first()
|
||||||
|
vk = db.query(VkConnection).filter_by(user_id=user.id).first()
|
||||||
|
return _render(request, "connections.html", {
|
||||||
|
"user": user,
|
||||||
|
"evotor": evotor,
|
||||||
|
"vk": vk,
|
||||||
|
"errors": ["Токен VK обязателен"],
|
||||||
|
})
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
conn = db.query(VkConnection).filter_by(user_id=user.id).first()
|
||||||
|
if conn:
|
||||||
|
conn.access_token = access_token
|
||||||
|
if vk_group_id:
|
||||||
|
conn.vk_user_id = vk_group_id
|
||||||
|
conn.updated_at = now
|
||||||
|
else:
|
||||||
|
conn = VkConnection(
|
||||||
|
user_id=user.id,
|
||||||
|
access_token=access_token,
|
||||||
|
vk_user_id=vk_group_id,
|
||||||
|
connected_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
db.add(conn)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse("/connections?success=1", 303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/connections/vk/disconnect")
|
||||||
|
async def connections_vk_disconnect(request: Request, db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
user = get_current_user(request, db)
|
||||||
|
except Exception:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
|
conn = db.query(VkConnection).filter_by(user_id=user.id).first()
|
||||||
|
if conn:
|
||||||
|
db.delete(conn)
|
||||||
|
db.commit()
|
||||||
|
return RedirectResponse("/connections", 303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/connections/evotor/test")
|
||||||
|
async def connections_evotor_test(request: Request, db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
user = get_current_user(request, db)
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse({"ok": False, "message": "Не авторизован"}, status_code=401)
|
||||||
|
|
||||||
|
conn = db.query(EvotorConnection).filter_by(user_id=user.id).first()
|
||||||
|
if not conn:
|
||||||
|
return JSONResponse({"ok": False, "message": "Подключение не настроено"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = httpx.get(
|
||||||
|
"https://api.evotor.ru/stores",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {conn.access_token}",
|
||||||
|
"Accept": "application/vnd.evotor.v2+json",
|
||||||
|
},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if r.status_code == 200:
|
||||||
|
data = r.json()
|
||||||
|
items = data.get("items", data) if isinstance(data, dict) else data
|
||||||
|
count = len(items) if isinstance(items, list) else "?"
|
||||||
|
return JSONResponse({"ok": True, "message": f"Успешно. Найдено магазинов: {count}"})
|
||||||
|
elif r.status_code == 401:
|
||||||
|
return JSONResponse({"ok": False, "message": "Токен недействителен (401)"})
|
||||||
|
else:
|
||||||
|
return JSONResponse({"ok": False, "message": f"Ошибка API: HTTP {r.status_code}"})
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
return JSONResponse({"ok": False, "message": "Таймаут запроса к Эвотор"})
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse({"ok": False, "message": f"Ошибка: {e}"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/connections/vk/test")
|
||||||
|
async def connections_vk_test(request: Request, db: Session = Depends(get_db)):
|
||||||
|
try:
|
||||||
|
user = get_current_user(request, db)
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse({"ok": False, "message": "Не авторизован"}, status_code=401)
|
||||||
|
|
||||||
|
conn = db.query(VkConnection).filter_by(user_id=user.id).first()
|
||||||
|
if not conn:
|
||||||
|
return JSONResponse({"ok": False, "message": "Подключение не настроено"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
params = {
|
||||||
|
"access_token": conn.access_token,
|
||||||
|
"v": settings.VK_API_VERSION,
|
||||||
|
}
|
||||||
|
if conn.vk_user_id:
|
||||||
|
params["group_ids"] = conn.vk_user_id
|
||||||
|
|
||||||
|
r = httpx.get(
|
||||||
|
"https://api.vk.com/method/groups.getById",
|
||||||
|
params=params,
|
||||||
|
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 groups:
|
||||||
|
name = groups[0].get("name", "—")
|
||||||
|
return JSONResponse({"ok": True, "message": f"Успешно. Сообщество: «{name}»"})
|
||||||
|
else:
|
||||||
|
return JSONResponse({"ok": True, "message": "Токен действителен. Укажите ID сообщества для полной проверки."})
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
return JSONResponse({"ok": False, "message": "Таймаут запроса к VK"})
|
||||||
|
except Exception as e:
|
||||||
|
return JSONResponse({"ok": False, "message": f"Ошибка: {e}"})
|
||||||
|
|
||||||
|
|||||||
@@ -1,193 +0,0 @@
|
|||||||
import logging
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from fastapi import APIRouter, Request, Depends, HTTPException
|
|
||||||
from fastapi.responses import RedirectResponse, JSONResponse
|
|
||||||
from web.templates_env import templates
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from web.auth import get_current_user
|
|
||||||
from web.config import settings
|
|
||||||
from web.database import get_db
|
|
||||||
from web.models import User, EvotorConnection
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/evotor")
|
|
||||||
|
|
||||||
EVOTOR_APP_URL = "https://market.evotor.ru/store/apps/{app_id}"
|
|
||||||
EVOTOR_STORES_URL = "https://api.evotor.ru/stores"
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
def evotor_page(
|
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user: User | None = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
if not user:
|
|
||||||
return RedirectResponse("/login", 303)
|
|
||||||
|
|
||||||
connection = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
|
|
||||||
error = request.query_params.get("error")
|
|
||||||
app_url = EVOTOR_APP_URL.format(app_id=settings.EVOTOR_APP_ID) if settings.EVOTOR_APP_ID else None
|
|
||||||
return templates.TemplateResponse("evotor.html", {
|
|
||||||
"request": request,
|
|
||||||
"user": user,
|
|
||||||
"connection": connection,
|
|
||||||
"error": error,
|
|
||||||
"app_url": app_url,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
class EvotorTokenPayload(BaseModel):
|
|
||||||
userId: str
|
|
||||||
token: str
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/callback")
|
|
||||||
async def evotor_callback(
|
|
||||||
request: Request,
|
|
||||||
payload: EvotorTokenPayload,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Webhook endpoint: Evotor POSTs {"userId": "...", "token": "..."} here
|
|
||||||
after the user authorizes the app in their Evotor account.
|
|
||||||
"""
|
|
||||||
# Verify the Authorization header matches our configured webhook secret
|
|
||||||
if settings.EVOTOR_WEBHOOK_SECRET:
|
|
||||||
auth_header = request.headers.get("Authorization", "")
|
|
||||||
expected = f"Bearer {settings.EVOTOR_WEBHOOK_SECRET}"
|
|
||||||
if auth_header != expected:
|
|
||||||
logger.warning("Evotor webhook: invalid Authorization header")
|
|
||||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
|
||||||
|
|
||||||
now = datetime.utcnow()
|
|
||||||
|
|
||||||
# Fetch store info using the received token
|
|
||||||
store_id = None
|
|
||||||
store_name = None
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
stores_response = await client.get(
|
|
||||||
EVOTOR_STORES_URL,
|
|
||||||
headers={"Authorization": f"Bearer {payload.token}"},
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
if stores_response.status_code == 200:
|
|
||||||
stores = stores_response.json()
|
|
||||||
items = stores.get("items", stores) if isinstance(stores, dict) else stores
|
|
||||||
if items:
|
|
||||||
store_id = items[0].get("uuid") or items[0].get("id")
|
|
||||||
store_name = items[0].get("name")
|
|
||||||
except Exception:
|
|
||||||
pass # Store info is optional
|
|
||||||
|
|
||||||
# Upsert by evotor_user_id (user_id stays NULL until /evotor/link is called)
|
|
||||||
connection = db.query(EvotorConnection).filter(
|
|
||||||
EvotorConnection.evotor_user_id == payload.userId
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if connection:
|
|
||||||
connection.access_token = payload.token
|
|
||||||
connection.store_id = store_id
|
|
||||||
connection.store_name = store_name
|
|
||||||
connection.is_online = True
|
|
||||||
connection.last_checked_at = now
|
|
||||||
connection.updated_at = now
|
|
||||||
else:
|
|
||||||
connection = EvotorConnection(
|
|
||||||
evotor_user_id=payload.userId,
|
|
||||||
access_token=payload.token,
|
|
||||||
store_id=store_id,
|
|
||||||
store_name=store_name,
|
|
||||||
is_online=True,
|
|
||||||
last_checked_at=now,
|
|
||||||
)
|
|
||||||
db.add(connection)
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
logger.info("Evotor webhook: saved token for evotor_user_id=%s", payload.userId)
|
|
||||||
|
|
||||||
return JSONResponse({"status": "ok"})
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/token")
|
|
||||||
async def evotor_token_manual(
|
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user: User | None = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Allow user to manually paste their Evotor token."""
|
|
||||||
if not user:
|
|
||||||
return RedirectResponse("/login", 303)
|
|
||||||
|
|
||||||
form = await request.form()
|
|
||||||
token = (form.get("token") or "").strip()
|
|
||||||
if not token:
|
|
||||||
return RedirectResponse("/evotor?error=empty_token", 303)
|
|
||||||
|
|
||||||
now = datetime.utcnow()
|
|
||||||
|
|
||||||
# Fetch store info
|
|
||||||
store_id = None
|
|
||||||
store_name = None
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
stores_response = await client.get(
|
|
||||||
EVOTOR_STORES_URL,
|
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
if stores_response.status_code == 200:
|
|
||||||
stores = stores_response.json()
|
|
||||||
items = stores.get("items", stores) if isinstance(stores, dict) else stores
|
|
||||||
if items:
|
|
||||||
store_id = items[0].get("uuid") or items[0].get("id")
|
|
||||||
store_name = items[0].get("name")
|
|
||||||
elif stores_response.status_code == 401:
|
|
||||||
return RedirectResponse("/evotor?error=invalid_token", 303)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
connection = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
|
|
||||||
if connection:
|
|
||||||
connection.access_token = token
|
|
||||||
connection.store_id = store_id
|
|
||||||
connection.store_name = store_name
|
|
||||||
connection.is_online = True
|
|
||||||
connection.last_checked_at = now
|
|
||||||
connection.updated_at = now
|
|
||||||
else:
|
|
||||||
connection = EvotorConnection(
|
|
||||||
user_id=user.id,
|
|
||||||
access_token=token,
|
|
||||||
store_id=store_id,
|
|
||||||
store_name=store_name,
|
|
||||||
is_online=True,
|
|
||||||
last_checked_at=now,
|
|
||||||
)
|
|
||||||
db.add(connection)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return RedirectResponse("/connections", 303)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/disconnect")
|
|
||||||
async def evotor_disconnect(
|
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user: User | None = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
if not user:
|
|
||||||
return RedirectResponse("/login", 303)
|
|
||||||
|
|
||||||
connection = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
|
|
||||||
if connection:
|
|
||||||
db.delete(connection)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return RedirectResponse("/connections", 303)
|
|
||||||
269
web/routes/evotor_webhooks.py
Normal file
269
web/routes/evotor_webhooks.py
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
"""
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
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 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(
|
||||||
|
EvotorConnection.evotor_user_id == evotor_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 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"))
|
||||||
|
email = (custom.get("email") or "").strip().lower() or None
|
||||||
|
phone = (custom.get("phone") or "").strip() or None
|
||||||
|
first_name = (custom.get("first_name") or custom.get("firstName") or "").strip() or None
|
||||||
|
last_name = (custom.get("last_name") or custom.get("lastName") 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
|
||||||
|
user.evotor_user_id = evotor_user_id
|
||||||
|
user.evotor_meta = custom or body
|
||||||
|
if user.status == UserStatusEnum.pending:
|
||||||
|
user.status = UserStatusEnum.active
|
||||||
|
db.flush()
|
||||||
|
else:
|
||||||
|
# Create new pending user
|
||||||
|
user = User(
|
||||||
|
first_name=first_name or "",
|
||||||
|
last_name=last_name or "",
|
||||||
|
email=email or f"{evotor_user_id}@evotor.placeholder",
|
||||||
|
phone=phone or "",
|
||||||
|
password_hash=None,
|
||||||
|
role=UserRoleEnum.user,
|
||||||
|
status=UserStatusEnum.pending,
|
||||||
|
evotor_user_id=evotor_user_id,
|
||||||
|
evotor_meta=custom or body,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.flush() # get user.id
|
||||||
|
|
||||||
|
# Generate invite
|
||||||
|
invite_token = secrets.token_urlsafe(32)
|
||||||
|
user.invite_token = invite_token
|
||||||
|
user.invite_expires = now + timedelta(hours=settings.INVITE_EXPIRE_HOURS)
|
||||||
|
|
||||||
|
api_token = _upsert_evotor_connection(db, user.id, evotor_user_id)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Send invite email if we have a real email address
|
||||||
|
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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
evotor_user_id: str = body.get("userId", "")
|
||||||
|
username: str = body.get("username", "").strip()
|
||||||
|
password: str = body.get("password", "")
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
return JSONResponse({"error": "username and password required"}, status_code=400)
|
||||||
|
|
||||||
|
# username is email or phone
|
||||||
|
user = db.query(User).filter(
|
||||||
|
or_(User.email == username, User.phone == username)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not user or not user.password_hash:
|
||||||
|
return JSONResponse({"error": "Неверные данные"}, status_code=401)
|
||||||
|
|
||||||
|
if user.status == UserStatusEnum.suspended:
|
||||||
|
return JSONResponse({"error": "Аккаунт заблокирован"}, status_code=403)
|
||||||
|
|
||||||
|
if 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({})
|
||||||
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": "Войти",
|
||||||
|
})
|
||||||
@@ -1,144 +1,143 @@
|
|||||||
from fastapi import APIRouter, Request, Depends
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from web.templates_env import templates
|
from sqlalchemy import or_
|
||||||
from sqlalchemy.orm import Session
|
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.database import get_db
|
||||||
from web.models import User
|
from web.models.user import User
|
||||||
from web.schemas import validate_profile, validate_reset_password
|
from web.templates_env import templates
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
# 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")
|
@router.get("/profile")
|
||||||
def profile_view(request: Request, user: User | None = Depends(get_current_user)):
|
async def profile_view(request: Request, db: Session = Depends(get_db)):
|
||||||
if not user:
|
try:
|
||||||
|
user = get_current_user(request, db)
|
||||||
|
except Exception:
|
||||||
return RedirectResponse("/login", 303)
|
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")
|
@router.get("/profile/edit")
|
||||||
def profile_edit_form(request: Request, user: User | None = Depends(get_current_user)):
|
async def profile_edit_get(request: Request, db: Session = Depends(get_db)):
|
||||||
if not user:
|
try:
|
||||||
|
user = get_current_user(request, db)
|
||||||
|
except Exception:
|
||||||
return RedirectResponse("/login", 303)
|
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")
|
@router.post("/profile/edit")
|
||||||
async def profile_edit_submit(
|
async def profile_edit_post(request: Request, db: Session = Depends(get_db)):
|
||||||
request: Request,
|
try:
|
||||||
db: Session = Depends(get_db),
|
user = get_current_user(request, db)
|
||||||
user: User | None = Depends(get_current_user),
|
except Exception:
|
||||||
):
|
|
||||||
if not user:
|
|
||||||
return RedirectResponse("/login", 303)
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
form = await request.form()
|
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:
|
if not errors:
|
||||||
existing = db.query(User).filter(
|
dup = db.query(User).filter(
|
||||||
User.phone == data["phone"].strip(), User.id != user.id
|
User.phone == data["phone"], User.id != user.id
|
||||||
).first()
|
).first()
|
||||||
if existing:
|
if dup:
|
||||||
errors.append("Пользователь с таким телефоном уже существует")
|
errors.append("Пользователь с таким телефоном уже существует")
|
||||||
|
|
||||||
if errors:
|
if errors:
|
||||||
return templates.TemplateResponse("profile_edit.html", {
|
return _render(request, "profile_edit.html", {"user": user, "errors": errors, "form": data})
|
||||||
"request": request, "user": user, "errors": errors, "form": data,
|
|
||||||
})
|
|
||||||
|
|
||||||
user.first_name = data["first_name"].strip()
|
user.first_name = data["first_name"]
|
||||||
user.last_name = data["last_name"].strip()
|
user.last_name = data["last_name"]
|
||||||
user.phone = data["phone"].strip()
|
user.phone = data["phone"]
|
||||||
db.commit()
|
db.commit()
|
||||||
|
return _render(request, "profile_edit.html", {
|
||||||
return templates.TemplateResponse("profile_edit.html", {
|
"user": user, "success": "Профиль обновлён",
|
||||||
"request": request, "user": user, "success": "Профиль обновлен",
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
# CHANGE PASSWORD
|
|
||||||
@router.get("/profile/change-password")
|
@router.get("/profile/change-password")
|
||||||
def change_password_form(request: Request, user: User | None = Depends(get_current_user)):
|
async def change_pw_get(request: Request, db: Session = Depends(get_db)):
|
||||||
if not user:
|
try:
|
||||||
|
user = get_current_user(request, db)
|
||||||
|
except Exception:
|
||||||
return RedirectResponse("/login", 303)
|
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")
|
@router.post("/profile/change-password")
|
||||||
async def change_password_submit(
|
async def change_pw_post(request: Request, db: Session = Depends(get_db)):
|
||||||
request: Request,
|
try:
|
||||||
db: Session = Depends(get_db),
|
user = get_current_user(request, db)
|
||||||
user: User | None = Depends(get_current_user),
|
except Exception:
|
||||||
):
|
|
||||||
if not user:
|
|
||||||
return RedirectResponse("/login", 303)
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
form = await request.form()
|
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 = []
|
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)
|
if not user.password_hash or not verify_password(current, user.password_hash):
|
||||||
errors.extend(password_errors)
|
errors.append("Неверный текущий пароль")
|
||||||
|
if len(new_pw) < 8:
|
||||||
|
errors.append("Новый пароль должен содержать минимум 8 символов")
|
||||||
|
if new_pw != confirm:
|
||||||
|
errors.append("Пароли не совпадают")
|
||||||
|
|
||||||
if errors:
|
if errors:
|
||||||
return templates.TemplateResponse("profile_change_password.html", {
|
return _render(request, "profile_change_password.html", {"user": user, "errors": errors})
|
||||||
"request": request, "user": user, "errors": errors,
|
|
||||||
})
|
|
||||||
|
|
||||||
user.password_hash = hash_password(data["password"])
|
user.password_hash = hash_password(new_pw)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
return _render(request, "profile_change_password.html", {
|
||||||
return templates.TemplateResponse("profile_change_password.html", {
|
"user": user, "success": "Пароль изменён",
|
||||||
"request": request, "user": user, "success": "Пароль изменен",
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
# DELETE ACCOUNT
|
|
||||||
@router.get("/profile/delete")
|
@router.get("/profile/delete")
|
||||||
def delete_account_form(request: Request, user: User | None = Depends(get_current_user)):
|
async def delete_get(request: Request, db: Session = Depends(get_db)):
|
||||||
if not user:
|
try:
|
||||||
|
user = get_current_user(request, db)
|
||||||
|
except Exception:
|
||||||
return RedirectResponse("/login", 303)
|
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")
|
@router.post("/profile/delete")
|
||||||
async def delete_account_submit(
|
async def delete_post(request: Request, db: Session = Depends(get_db)):
|
||||||
request: Request,
|
try:
|
||||||
db: Session = Depends(get_db),
|
user = get_current_user(request, db)
|
||||||
user: User | None = Depends(get_current_user),
|
except Exception:
|
||||||
):
|
|
||||||
if not user:
|
|
||||||
return RedirectResponse("/login", 303)
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
data = dict(form)
|
password = str(form.get("password", ""))
|
||||||
|
|
||||||
password = data.get("password", "")
|
if not user.password_hash or not verify_password(password, user.password_hash):
|
||||||
if not password:
|
return _render(request, "profile_delete.html", {
|
||||||
return templates.TemplateResponse("profile_delete.html", {
|
"user": user, "errors": ["Неверный пароль"],
|
||||||
"request": request, "user": user, "errors": ["Введите пароль для подтверждения"],
|
|
||||||
})
|
|
||||||
|
|
||||||
if not verify_password(password, user.password_hash):
|
|
||||||
return templates.TemplateResponse("profile_delete.html", {
|
|
||||||
"request": request, "user": user, "errors": ["Неверный пароль"],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
db.delete(user)
|
db.delete(user)
|
||||||
db.commit()
|
db.commit()
|
||||||
request.session.clear()
|
request.session.clear()
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
return RedirectResponse("/", 303)
|
|
||||||
|
|||||||
@@ -1,107 +1,101 @@
|
|||||||
import uuid
|
import secrets
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, Depends
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from web.templates_env import templates
|
|
||||||
from sqlalchemy.orm import Session
|
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.config import settings
|
||||||
from web.database import get_db
|
from web.database import get_db
|
||||||
from web.models import User
|
from web.models.user import User
|
||||||
from web.schemas import validate_reset_password
|
from web.notifications.tasks import send_email_task
|
||||||
|
from web.templates_env import templates
|
||||||
|
|
||||||
router = APIRouter()
|
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("/forgot-password")
|
@router.get("/forgot-password")
|
||||||
def forgot_form(request: Request):
|
async def forgot_get(request: Request):
|
||||||
return templates.TemplateResponse("forgot_password.html", {"request": request, "user": None})
|
return _render(request, "forgot_password.html", {"user": None})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/forgot-password")
|
@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()
|
form = await request.form()
|
||||||
email = form.get("email", "").strip()
|
email = str(form.get("email", "")).strip()
|
||||||
|
user = db.query(User).filter(User.email == email).first()
|
||||||
if email:
|
if user:
|
||||||
user = db.query(User).filter(User.email == email).first()
|
token = secrets.token_urlsafe(32)
|
||||||
if user:
|
user.password_reset_token = token
|
||||||
token = uuid.uuid4().hex
|
user.password_reset_expires = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(
|
||||||
user.password_reset_token = token
|
minutes=settings.PASSWORD_RESET_EXPIRE_MINUTES
|
||||||
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}"
|
||||||
db.commit()
|
html = f'<p>Сброс пароля: <a href="{reset_url}">{reset_url}</a></p>'
|
||||||
|
send_email_task.delay(user.email, "Сброс пароля — ЭВОСИНК", html)
|
||||||
reset_url = f"{settings.BASE_URL}/reset-password?token={token}"
|
# Always show same message to prevent user enumeration
|
||||||
print("=" * 40)
|
return _render(request, "message.html", {
|
||||||
print("СБРОС ПАРОЛЯ")
|
"user": None,
|
||||||
print(f"Пользователь: {user.email}")
|
"title": "Ссылка отправлена",
|
||||||
print(f"Ссылка: {reset_url}")
|
"message": "Если указанный email зарегистрирован, вы получите ссылку для сброса пароля.",
|
||||||
print(f"Действительна: {settings.PASSWORD_RESET_EXPIRE_MINUTES} мин.")
|
"link": "/login", "link_text": "Войти",
|
||||||
print("=" * 40)
|
|
||||||
|
|
||||||
return templates.TemplateResponse("message.html", {
|
|
||||||
"request": request, "user": None,
|
|
||||||
"title": "Сброс пароля",
|
|
||||||
"message": "Если аккаунт с таким email существует, мы отправили письмо со ссылкой для сброса пароля.",
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/reset-password")
|
@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()
|
user = db.query(User).filter(User.password_reset_token == token).first()
|
||||||
if not user or not user.password_reset_expires:
|
if not user or not user.password_reset_expires or user.password_reset_expires < datetime.now(timezone.utc).replace(tzinfo=None):
|
||||||
return templates.TemplateResponse("message.html", {
|
return _render(request, "message.html", {
|
||||||
"request": request, "user": None,
|
"user": None, "title": "Ссылка недействительна",
|
||||||
"title": "Ошибка", "message": "Неверная или устаревшая ссылка.",
|
"message": "Ссылка для сброса пароля устарела или недействительна.",
|
||||||
|
"link": "/forgot-password", "link_text": "Запросить новую ссылку",
|
||||||
})
|
})
|
||||||
|
return _render(request, "reset_password.html", {"user": None, "token": token})
|
||||||
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,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/reset-password")
|
@router.post("/reset-password")
|
||||||
async def reset_submit(request: Request, token: str, db: Session = Depends(get_db)):
|
async def reset_post(request: Request, db: Session = Depends(get_db)):
|
||||||
user = db.query(User).filter(User.password_reset_token == token).first()
|
token = request.query_params.get("token", "")
|
||||||
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": "Срок действия ссылки истек.",
|
|
||||||
})
|
|
||||||
|
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
data = dict(form)
|
password = str(form.get("password", ""))
|
||||||
errors = validate_reset_password(data)
|
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:
|
if errors:
|
||||||
return templates.TemplateResponse("reset_password.html", {
|
return _render(request, "reset_password.html", {
|
||||||
"request": request, "user": None, "token": token, "errors": errors,
|
"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_token = None
|
||||||
user.password_reset_expires = None
|
user.password_reset_expires = None
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return templates.TemplateResponse("message.html", {
|
return _render(request, "message.html", {
|
||||||
"request": request, "user": None,
|
"user": None, "title": "Пароль изменён",
|
||||||
"title": "Пароль изменен",
|
"message": "Ваш пароль успешно изменён.",
|
||||||
"message": "Ваш пароль успешно изменен. Теперь вы можете войти.",
|
|
||||||
"link": "/login", "link_text": "Войти",
|
"link": "/login", "link_text": "Войти",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, Depends
|
|
||||||
from fastapi.responses import RedirectResponse
|
|
||||||
from web.templates_env import templates
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from web.auth import get_current_user
|
|
||||||
from web.database import get_db
|
|
||||||
from web.models import User, EvotorConnection, VkConnection, SyncConfig, SyncFilter
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/sync")
|
|
||||||
|
|
||||||
|
|
||||||
def _get_or_create_sync_config(db: Session, user_id: int) -> SyncConfig:
|
|
||||||
config = db.query(SyncConfig).filter(SyncConfig.user_id == user_id).first()
|
|
||||||
if not config:
|
|
||||||
config = SyncConfig(user_id=user_id, is_enabled=False)
|
|
||||||
db.add(config)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(config)
|
|
||||||
return config
|
|
||||||
|
|
||||||
|
|
||||||
def _filter_summary(config: SyncConfig) -> dict:
|
|
||||||
stores = [f for f in config.filters if f.entity_type == "store"]
|
|
||||||
groups = [f for f in config.filters if f.entity_type == "group"]
|
|
||||||
products = [f for f in config.filters if f.entity_type == "product"]
|
|
||||||
return {
|
|
||||||
"stores": len(stores),
|
|
||||||
"groups": len(groups),
|
|
||||||
"products": len(products),
|
|
||||||
"total": len(config.filters),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
def sync_page(
|
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user: User | None = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
if not user:
|
|
||||||
return RedirectResponse("/login", 303)
|
|
||||||
|
|
||||||
evotor = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
|
|
||||||
vk = db.query(VkConnection).filter(VkConnection.user_id == user.id).first()
|
|
||||||
config = _get_or_create_sync_config(db, user.id)
|
|
||||||
summary = _filter_summary(config)
|
|
||||||
|
|
||||||
if config.confirmed_at and config.is_enabled:
|
|
||||||
status = "active"
|
|
||||||
elif config.confirmed_at and not config.is_enabled:
|
|
||||||
status = "paused"
|
|
||||||
elif summary["total"] > 0:
|
|
||||||
status = "pending"
|
|
||||||
else:
|
|
||||||
status = "unconfigured"
|
|
||||||
|
|
||||||
return templates.TemplateResponse("sync.html", {
|
|
||||||
"request": request,
|
|
||||||
"user": user,
|
|
||||||
"evotor": evotor,
|
|
||||||
"vk": vk,
|
|
||||||
"config": config,
|
|
||||||
"summary": summary,
|
|
||||||
"status": status,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/toggle")
|
|
||||||
def sync_toggle(
|
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user: User | None = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
if not user:
|
|
||||||
return RedirectResponse("/login", 303)
|
|
||||||
|
|
||||||
config = _get_or_create_sync_config(db, user.id)
|
|
||||||
config.is_enabled = not config.is_enabled
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return RedirectResponse("/sync", 303)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/confirm")
|
|
||||||
def sync_confirm(
|
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user: User | None = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
if not user:
|
|
||||||
return RedirectResponse("/login", 303)
|
|
||||||
|
|
||||||
config = _get_or_create_sync_config(db, user.id)
|
|
||||||
if config.is_enabled and len(config.filters) > 0:
|
|
||||||
config.confirmed_at = datetime.utcnow()
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return RedirectResponse("/sync", 303)
|
|
||||||
116
web/routes/vk.py
116
web/routes/vk.py
@@ -1,116 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, Depends
|
|
||||||
from fastapi.responses import RedirectResponse
|
|
||||||
from web.templates_env import templates
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from web.auth import get_current_user
|
|
||||||
from web.config import settings
|
|
||||||
from web.database import get_db
|
|
||||||
from web.models import User, VkConnection
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/vk")
|
|
||||||
|
|
||||||
VK_API_URL = "https://api.vk.com/method"
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
|
||||||
def vk_page(
|
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user: User | None = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
if not user:
|
|
||||||
return RedirectResponse("/login", 303)
|
|
||||||
|
|
||||||
connection = db.query(VkConnection).filter(VkConnection.user_id == user.id).first()
|
|
||||||
error = request.query_params.get("error")
|
|
||||||
return templates.TemplateResponse("vk.html", {
|
|
||||||
"request": request,
|
|
||||||
"user": user,
|
|
||||||
"connection": connection,
|
|
||||||
"error": error,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/token")
|
|
||||||
async def vk_token(
|
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user: User | None = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""Save a manually entered VK community access token."""
|
|
||||||
if not user:
|
|
||||||
return RedirectResponse("/login", 303)
|
|
||||||
|
|
||||||
form = await request.form()
|
|
||||||
token = (form.get("token") or "").strip()
|
|
||||||
if not token:
|
|
||||||
return RedirectResponse("/vk?error=empty_token", 303)
|
|
||||||
|
|
||||||
# Fetch community info to validate the token and get group name/id
|
|
||||||
group_id = None
|
|
||||||
group_name = None
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.get(
|
|
||||||
f"{VK_API_URL}/groups.getById",
|
|
||||||
params={"access_token": token, "v": settings.VK_API_VERSION},
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
if resp.status_code == 200:
|
|
||||||
data = resp.json()
|
|
||||||
if "error" in data:
|
|
||||||
return RedirectResponse("/vk?error=invalid_token", 303)
|
|
||||||
groups = data.get("response", {}).get("groups", [])
|
|
||||||
if groups:
|
|
||||||
group_id = str(groups[0].get("id", ""))
|
|
||||||
group_name = groups[0].get("name")
|
|
||||||
elif resp.status_code == 401:
|
|
||||||
return RedirectResponse("/vk?error=invalid_token", 303)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
connection = db.query(VkConnection).filter(VkConnection.user_id == user.id).first()
|
|
||||||
now = datetime.utcnow()
|
|
||||||
if connection:
|
|
||||||
connection.access_token = token
|
|
||||||
connection.vk_user_id = group_id
|
|
||||||
connection.first_name = group_name
|
|
||||||
connection.last_name = None
|
|
||||||
connection.is_online = True
|
|
||||||
connection.last_checked_at = now
|
|
||||||
else:
|
|
||||||
connection = VkConnection(
|
|
||||||
user_id=user.id,
|
|
||||||
access_token=token,
|
|
||||||
vk_user_id=group_id,
|
|
||||||
first_name=group_name,
|
|
||||||
last_name=None,
|
|
||||||
is_online=True,
|
|
||||||
last_checked_at=now,
|
|
||||||
)
|
|
||||||
db.add(connection)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return RedirectResponse("/connections", 303)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/disconnect")
|
|
||||||
async def vk_disconnect(
|
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user: User | None = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
if not user:
|
|
||||||
return RedirectResponse("/login", 303)
|
|
||||||
|
|
||||||
connection = db.query(VkConnection).filter(VkConnection.user_id == user.id).first()
|
|
||||||
if connection:
|
|
||||||
db.delete(connection)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return RedirectResponse("/connections", 303)
|
|
||||||
63
web/routes/vk_catalog.py
Normal file
63
web/routes/vk_catalog.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from web.auth.session import get_current_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:
|
||||||
|
user = get_current_user(request, db)
|
||||||
|
except Exception:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
|
vk_conn = db.query(VkConnection).filter_by(user_id=user.id).first()
|
||||||
|
albums = (
|
||||||
|
db.query(VkCachedAlbum)
|
||||||
|
.filter(VkCachedAlbum.user_id == user.id)
|
||||||
|
.order_by(VkCachedAlbum.title)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return _render(request, "vk_catalog/albums.html", {
|
||||||
|
"user": user,
|
||||||
|
"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:
|
||||||
|
user = get_current_user(request, db)
|
||||||
|
except Exception:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
|
album = db.query(VkCachedAlbum).filter_by(user_id=user.id, album_id=album_id).first()
|
||||||
|
if not album:
|
||||||
|
return RedirectResponse("/vk-catalog/albums", 303)
|
||||||
|
|
||||||
|
products = (
|
||||||
|
db.query(VkCachedProduct)
|
||||||
|
.filter(VkCachedProduct.user_id == user.id, VkCachedProduct.album_id == album_id)
|
||||||
|
.order_by(VkCachedProduct.name)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return _render(request, "vk_catalog/products.html", {
|
||||||
|
"user": user,
|
||||||
|
"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"^\+7 \(\d{3}\) \d{3}-\d{2}-\d{2}$", phone):
|
|
||||||
errors.append("Введите телефон в формате +7 (XXX) XXX-XX-XX")
|
|
||||||
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"^\+7 \(\d{3}\) \d{3}-\d{2}-\d{2}$", phone):
|
|
||||||
errors.append("Введите телефон в формате +7 (XXX) XXX-XX-XX")
|
|
||||||
return errors
|
|
||||||
@@ -1,39 +1,431 @@
|
|||||||
/* Brand overrides */
|
/* Brand colors */
|
||||||
:root {
|
:root {
|
||||||
--bs-primary: #F05023;
|
--pico-primary: #F05023;
|
||||||
--bs-primary-rgb: 240, 80, 35;
|
--pico-primary-hover: #d44420;
|
||||||
--bs-link-color: #0986E2;
|
--pico-primary-focus: rgba(240, 80, 35, 0.25);
|
||||||
--bs-link-hover-color: #0670c0;
|
--pico-primary-inverse: #fff;
|
||||||
|
--brand-primary: #F05023;
|
||||||
|
--brand-secondary: #0986E2;
|
||||||
|
--brand-secondary-hover: #0770c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header / nav */
|
||||||
|
.site-header {
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 2px solid var(--brand-primary);
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header nav > ul {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-logo {
|
.brand-logo {
|
||||||
font-size: 22px;
|
font-size: 1.3rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #F05023 !important;
|
color: var(--brand-primary) !important;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-border {
|
.nav-links {
|
||||||
border-color: #F05023 !important;
|
flex: 1;
|
||||||
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.nav-links a {
|
||||||
--bs-btn-bg: #F05023;
|
color: var(--pico-color);
|
||||||
--bs-btn-border-color: #F05023;
|
text-decoration: none;
|
||||||
--bs-btn-hover-bg: #d44420;
|
padding: 0.25rem 0.5rem;
|
||||||
--bs-btn-hover-border-color: #d44420;
|
border-radius: 4px;
|
||||||
--bs-btn-active-bg: #c03d1c;
|
|
||||||
--bs-btn-active-border-color: #c03d1c;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.nav-links a:hover {
|
||||||
--bs-btn-bg: #0986E2;
|
color: var(--brand-primary);
|
||||||
--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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link:hover {
|
.nav-links a.secondary {
|
||||||
color: #F05023 !important;
|
color: var(--pico-muted-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu summary {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu > ul {
|
||||||
|
position: absolute;
|
||||||
|
right: 1rem;
|
||||||
|
background: var(--pico-background-color);
|
||||||
|
border: 1px solid var(--pico-border-color);
|
||||||
|
border-radius: var(--pico-border-radius);
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
z-index: 100;
|
||||||
|
min-width: 180px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu > ul li a {
|
||||||
|
display: block;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--pico-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu > ul li a:hover {
|
||||||
|
background: var(--pico-muted-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.nav-links { display: none; }
|
||||||
|
.mobile-menu { display: block; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page spacing */
|
||||||
|
.py-4 {
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alerts */
|
||||||
|
.alert {
|
||||||
|
border-radius: var(--pico-border-radius);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert p { margin: 0; }
|
||||||
|
.alert p + p { margin-top: 0.25rem; }
|
||||||
|
|
||||||
|
.alert-danger {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-success {
|
||||||
|
background: #f0fdf4;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-warning {
|
||||||
|
background: #fffbeb;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards (using <article>) */
|
||||||
|
article.card {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
article.card > header {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--pico-muted-background-color);
|
||||||
|
border-bottom: 1px solid var(--pico-border-color);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
article.card > header h1,
|
||||||
|
article.card > header h2,
|
||||||
|
article.card > header h5 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
article.card > .card-body {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
article.card > footer {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--pico-muted-background-color);
|
||||||
|
border-top: 1px solid var(--pico-border-color);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* List groups */
|
||||||
|
.list-group {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-group-item {
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--pico-border-color);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-group-item:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success { background: #dcfce7; color: #15803d; }
|
||||||
|
.badge-danger { background: #fee2e2; color: #b91c1c; }
|
||||||
|
.badge-warning { background: #fef3c7; color: #b45309; }
|
||||||
|
.badge-secondary { background: var(--pico-muted-background-color); color: var(--pico-muted-color); }
|
||||||
|
.badge-light { background: var(--pico-muted-background-color); color: var(--pico-muted-color); border: 1px solid var(--pico-border-color); }
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
button.secondary, a[role="button"].secondary {
|
||||||
|
--pico-background-color: var(--brand-secondary);
|
||||||
|
--pico-border-color: var(--brand-secondary);
|
||||||
|
--pico-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.outline.danger, a[role="button"].outline.danger {
|
||||||
|
--pico-color: #dc2626;
|
||||||
|
--pico-border-color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger, a[role="button"].danger {
|
||||||
|
--pico-background-color: #dc2626;
|
||||||
|
--pico-border-color: #dc2626;
|
||||||
|
--pico-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.sm, a[role="button"].sm {
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout helpers */
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col { flex: 1 1 0; }
|
||||||
|
.col-auto { flex: 0 0 auto; }
|
||||||
|
|
||||||
|
.col-sm-6 { flex: 0 0 calc(50% - 0.5rem); min-width: 200px; }
|
||||||
|
.col-sm-10 { flex: 0 0 calc(83.33% - 0.5rem); }
|
||||||
|
.col-md-6 { flex: 0 0 calc(50% - 0.5rem); }
|
||||||
|
.col-md-7 { flex: 0 0 calc(58.33% - 0.5rem); }
|
||||||
|
.col-lg-4 { flex: 0 0 calc(33.33% - 0.67rem); }
|
||||||
|
.col-lg-5 { flex: 0 0 calc(41.67% - 0.5rem); }
|
||||||
|
.col-lg-6 { flex: 0 0 calc(50% - 0.5rem); }
|
||||||
|
.col-12 { flex: 0 0 100%; }
|
||||||
|
.col-md-6-auto { flex: 0 0 calc(50% - 0.5rem); }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.col-sm-6, .col-md-6, .col-md-7, .col-lg-4, .col-lg-5, .col-lg-6, .col-md-6-auto { flex: 0 0 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.justify-center { justify-content: center; }
|
||||||
|
.justify-between { justify-content: space-between; }
|
||||||
|
.justify-end { justify-content: flex-end; }
|
||||||
|
.align-center { align-items: center; }
|
||||||
|
.flex-wrap { flex-wrap: wrap; }
|
||||||
|
.flex-col { flex-direction: column; }
|
||||||
|
.flex-1 { flex: 1; }
|
||||||
|
.flex-fill { flex: 1 1 0; }
|
||||||
|
|
||||||
|
.gap-1 { gap: 0.25rem; }
|
||||||
|
.gap-2 { gap: 0.5rem; }
|
||||||
|
.gap-3 { gap: 0.75rem; }
|
||||||
|
|
||||||
|
.mt-2 { margin-top: 0.5rem; }
|
||||||
|
.mt-3 { margin-top: 0.75rem; }
|
||||||
|
.mt-4 { margin-top: 1.5rem; }
|
||||||
|
.mt-5 { margin-top: 3rem; }
|
||||||
|
.mb-0 { margin-bottom: 0; }
|
||||||
|
.mb-1 { margin-bottom: 0.25rem; }
|
||||||
|
.mb-2 { margin-bottom: 0.5rem; }
|
||||||
|
.mb-3 { margin-bottom: 0.75rem; }
|
||||||
|
.mb-4 { margin-bottom: 1.5rem; }
|
||||||
|
.ms-auto { margin-left: auto; }
|
||||||
|
.me-1 { margin-right: 0.25rem; }
|
||||||
|
.me-2 { margin-right: 0.5rem; }
|
||||||
|
.me-3 { margin-right: 0.75rem; }
|
||||||
|
|
||||||
|
.d-flex { display: flex; }
|
||||||
|
.d-grid { display: grid; }
|
||||||
|
.d-none { display: none; }
|
||||||
|
.d-block { display: block; }
|
||||||
|
|
||||||
|
.text-center { text-align: center; }
|
||||||
|
.text-end { text-align: right; }
|
||||||
|
.text-muted { color: var(--pico-muted-color); }
|
||||||
|
.small { font-size: 0.875rem; }
|
||||||
|
.fs-1 { font-size: 2rem; }
|
||||||
|
.fs-2 { font-size: 1.5rem; }
|
||||||
|
.fs-5 { font-size: 1.15rem; }
|
||||||
|
.fs-6 { font-size: 0.875rem; }
|
||||||
|
|
||||||
|
.text-success { color: #15803d; }
|
||||||
|
.text-danger { color: #dc2626; }
|
||||||
|
.text-warning { color: #b45309; }
|
||||||
|
.text-primary { color: var(--brand-primary); }
|
||||||
|
.text-secondary { color: var(--brand-secondary); }
|
||||||
|
.text-white { color: #fff; }
|
||||||
|
|
||||||
|
.bg-danger-header {
|
||||||
|
background: #dc2626;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-monospace { font-family: monospace; }
|
||||||
|
.w-100 { width: 100%; }
|
||||||
|
.h-100 { height: 100%; }
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.table-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.align-middle td,
|
||||||
|
table.align-middle th {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Breadcrumb */
|
||||||
|
.breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-item + .breadcrumb-item::before {
|
||||||
|
content: "/";
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-item.active { color: var(--pico-color); }
|
||||||
|
|
||||||
|
/* Dropdown */
|
||||||
|
.dropdown {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
background: var(--pico-background-color);
|
||||||
|
border: 1px solid var(--pico-border-color);
|
||||||
|
border-radius: var(--pico-border-radius);
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.12);
|
||||||
|
z-index: 200;
|
||||||
|
min-width: 220px;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown.open .dropdown-menu { display: block; }
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.45rem 1rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--pico-color);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item:hover {
|
||||||
|
background: var(--pico-muted-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item.muted { color: var(--pico-muted-color); }
|
||||||
|
|
||||||
|
.dropdown-divider {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--pico-border-color);
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spinner */
|
||||||
|
.spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border: 3px solid var(--pico-muted-background-color);
|
||||||
|
border-top-color: var(--brand-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.75s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* Input group */
|
||||||
|
.input-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group input {
|
||||||
|
border-radius: var(--pico-border-radius) 0 0 var(--pico-border-radius);
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group button {
|
||||||
|
border-radius: 0 var(--pico-border-radius) var(--pico-border-radius) 0;
|
||||||
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
color: var(--pico-muted-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state .empty-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|||||||
0
web/tasks/__init__.py
Normal file
0
web/tasks/__init__.py
Normal file
227
web/tasks/catalog.py
Normal file
227
web/tasks/catalog.py
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from celery import shared_task
|
||||||
|
|
||||||
|
from web.config import settings
|
||||||
|
from web.database import SessionLocal
|
||||||
|
from web.models.connections import CachedGroup, CachedProduct, CachedStore, EvotorConnection, SyncConfig, SyncFilter
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
EVO_API = "https://api.evotor.ru"
|
||||||
|
|
||||||
|
|
||||||
|
def _headers(token: str) -> dict:
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Accept": "application/vnd.evotor.v2+json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
# ── per-user helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _fetch_stores(token: str) -> list[dict]:
|
||||||
|
r = httpx.get(f"{EVO_API}/stores", 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) -> list[dict] | None:
|
||||||
|
"""Returns None if the store is not accessible (402/403), list otherwise."""
|
||||||
|
r = httpx.get(
|
||||||
|
f"{EVO_API}/stores/{store_id}/product-groups",
|
||||||
|
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) -> list[dict] | None:
|
||||||
|
"""Returns None if the store is not accessible (402/403), list otherwise."""
|
||||||
|
r = httpx.get(
|
||||||
|
f"{EVO_API}/stores/{store_id}/products",
|
||||||
|
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")
|
||||||
|
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"),
|
||||||
|
name=p.get("name", ""),
|
||||||
|
price=float(price) if price is not None else None,
|
||||||
|
quantity=float(quantity) if quantity is not None else None,
|
||||||
|
measure_name=p.get("measureName") or p.get("measure_name"),
|
||||||
|
article_number=p.get("code") or p.get("article_number"),
|
||||||
|
allow_to_sell=p.get("allowToSell") if p.get("allowToSell") is not None else p.get("allow_to_sell"),
|
||||||
|
fetched_at=now,
|
||||||
|
))
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
logger.info(
|
||||||
|
"user=%s catalog synced: %d stores, %d groups, %d products",
|
||||||
|
user_id,
|
||||||
|
len(stores),
|
||||||
|
sum(1 for _ in db.query(CachedGroup).filter_by(user_id=user_id)),
|
||||||
|
sum(1 for _ in db.query(CachedProduct).filter_by(user_id=user_id)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Celery task ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@shared_task(
|
||||||
|
name="web.tasks.catalog.refresh_catalog",
|
||||||
|
queue="default",
|
||||||
|
bind=True,
|
||||||
|
max_retries=2,
|
||||||
|
default_retry_delay=60,
|
||||||
|
)
|
||||||
|
def refresh_catalog(self) -> dict:
|
||||||
|
"""Fetch and cache stores/groups/products for all connected Evotor users."""
|
||||||
|
db = SessionLocal()
|
||||||
|
results = {"ok": 0, "failed": 0}
|
||||||
|
try:
|
||||||
|
connections = (
|
||||||
|
db.query(EvotorConnection)
|
||||||
|
.filter(
|
||||||
|
EvotorConnection.user_id.isnot(None),
|
||||||
|
EvotorConnection.access_token.isnot(None),
|
||||||
|
EvotorConnection.access_token != "",
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for conn in connections:
|
||||||
|
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
|
||||||
37
web/tasks/celery_app.py
Normal file
37
web/tasks/celery_app.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
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.health.*": {"queue": "health"},
|
||||||
|
"web.tasks.catalog.*": {"queue": "default"},
|
||||||
|
"web.notifications.tasks.*": {"queue": "notifications"},
|
||||||
|
},
|
||||||
|
beat_schedule={
|
||||||
|
"refresh-catalog": {
|
||||||
|
"task": "web.tasks.catalog.refresh_catalog",
|
||||||
|
"schedule": timedelta(seconds=settings.CATALOG_REFRESH_INTERVAL_SECONDS),
|
||||||
|
},
|
||||||
|
"refresh-vk-catalog": {
|
||||||
|
"task": "web.tasks.vk_catalog.refresh_vk_catalog",
|
||||||
|
"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"])
|
||||||
167
web/tasks/vk_catalog.py
Normal file
167
web/tasks/vk_catalog.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from celery import shared_task
|
||||||
|
|
||||||
|
from web.config import settings
|
||||||
|
from web.database import SessionLocal
|
||||||
|
from web.models.connections import VkCachedAlbum, VkCachedProduct, VkConnection
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
VK_API = "https://api.vk.com/method"
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _vk_get(method: str, params: dict, token: str) -> dict:
|
||||||
|
params = {**params, "access_token": token, "v": settings.VK_API_VERSION}
|
||||||
|
r = httpx.get(f"{VK_API}/{method}", 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)
|
||||||
|
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,
|
||||||
|
))
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
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_raw = p.get("price", {}).get("amount")
|
||||||
|
price = float(price_raw) / 100 if price_raw is not None else None
|
||||||
|
thumb = None
|
||||||
|
if p.get("thumb_photo"):
|
||||||
|
sizes = p["thumb_photo"].get("sizes", [])
|
||||||
|
if sizes:
|
||||||
|
thumb = sizes[-1].get("url")
|
||||||
|
|
||||||
|
row = db.query(VkCachedProduct).filter_by(
|
||||||
|
user_id=user_id, vk_group_id=group_id, vk_product_id=pid,
|
||||||
|
).first()
|
||||||
|
if row:
|
||||||
|
row.album_id = album_id
|
||||||
|
row.name = p.get("title", "")
|
||||||
|
row.description = p.get("description")
|
||||||
|
row.price = price
|
||||||
|
row.availability = p.get("availability")
|
||||||
|
row.thumb_url = thumb
|
||||||
|
row.fetched_at = now
|
||||||
|
else:
|
||||||
|
db.add(VkCachedProduct(
|
||||||
|
user_id=user_id,
|
||||||
|
vk_group_id=group_id,
|
||||||
|
vk_product_id=pid,
|
||||||
|
album_id=album_id,
|
||||||
|
name=p.get("title", ""),
|
||||||
|
description=p.get("description"),
|
||||||
|
price=price,
|
||||||
|
availability=p.get("availability"),
|
||||||
|
thumb_url=thumb,
|
||||||
|
fetched_at=now,
|
||||||
|
))
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
logger.info(
|
||||||
|
"user=%s vk catalog synced: group=%s albums=%d products=%d",
|
||||||
|
user_id, group_id, len(albums), len(all_products),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(
|
||||||
|
name="web.tasks.vk_catalog.refresh_vk_catalog",
|
||||||
|
queue="default",
|
||||||
|
bind=True,
|
||||||
|
max_retries=2,
|
||||||
|
default_retry_delay=60,
|
||||||
|
)
|
||||||
|
def refresh_vk_catalog(self) -> dict:
|
||||||
|
"""Fetch and cache VK Market albums and products for all connected users."""
|
||||||
|
db = SessionLocal()
|
||||||
|
results = {"ok": 0, "failed": 0}
|
||||||
|
try:
|
||||||
|
connections = (
|
||||||
|
db.query(VkConnection)
|
||||||
|
.filter(
|
||||||
|
VkConnection.user_id.isnot(None),
|
||||||
|
VkConnection.access_token.isnot(None),
|
||||||
|
VkConnection.access_token != "",
|
||||||
|
VkConnection.vk_user_id.isnot(None),
|
||||||
|
VkConnection.vk_user_id != "",
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for conn in connections:
|
||||||
|
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
|
||||||
40
web/templates/admin/roles.html
Normal file
40
web/templates/admin/roles.html
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Роли и права — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<nav class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="/admin/users">Пользователи</a></li>
|
||||||
|
<li class="breadcrumb-item active">Роли и права</li>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<h1 style="font-size:1.3rem;" class="mb-3"><i class="bi bi-shield-lock me-2"></i>Роли и права</h1>
|
||||||
|
|
||||||
|
{% for role in roles %}
|
||||||
|
<article class="card mb-3">
|
||||||
|
<header>
|
||||||
|
<h2 style="font-size:1rem;">{{ role.name }}
|
||||||
|
<span class="text-muted small fw-normal">— {{ role.description or '' }}</span>
|
||||||
|
</h2>
|
||||||
|
</header>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="/admin/roles/{{ role.id }}/permissions">
|
||||||
|
<div class="row gap-2 flex-wrap">
|
||||||
|
{% for perm in permissions %}
|
||||||
|
<div class="col-auto">
|
||||||
|
<label style="display:flex; align-items:center; gap:0.4rem; margin:0;">
|
||||||
|
<input type="checkbox" name="perm_{{ perm.id }}" value="{{ perm.id }}"
|
||||||
|
{% if perm.id in role_perm_ids[role.id] %}checked{% endif %}>
|
||||||
|
{{ perm.name }}
|
||||||
|
{% if perm.description %}
|
||||||
|
<span class="text-muted small">({{ perm.description }})</span>
|
||||||
|
{% endif %}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="sm mt-3">Сохранить права для «{{ role.name }}»</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock %}
|
||||||
147
web/templates/admin/user_detail.html
Normal file
147
web/templates/admin/user_detail.html
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ target.first_name }} {{ target.last_name }} — Админ — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<nav class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="/admin/users">Пользователи</a></li>
|
||||||
|
<li class="breadcrumb-item active">{{ target.first_name }} {{ target.last_name }}</li>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{% if request.query_params.get('success') == 'reset_sent' %}
|
||||||
|
<div class="alert alert-success mb-3"><p>Ссылка для сброса пароля отправлена.</p></div>
|
||||||
|
{% elif request.query_params.get('success') == 'invite_sent' %}
|
||||||
|
<div class="alert alert-success mb-3"><p>Приглашение отправлено.</p></div>
|
||||||
|
{% elif request.query_params.get('success') == 'saved' %}
|
||||||
|
<div class="alert alert-success mb-3"><p>Данные сохранены.</p></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="row gap-3 align-start">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<article class="card">
|
||||||
|
<header><h2>Профиль</h2></header>
|
||||||
|
<ul class="list-group">
|
||||||
|
<li class="list-group-item"><span class="text-muted small">ID</span><span>{{ target.id }}</span></li>
|
||||||
|
<li class="list-group-item"><span class="text-muted small">Имя</span><span>{{ target.first_name }} {{ target.last_name }}</span></li>
|
||||||
|
<li class="list-group-item"><span class="text-muted small">Email</span>
|
||||||
|
<span>{{ target.email }}
|
||||||
|
{% if target.is_email_confirmed %}
|
||||||
|
<span class="badge badge-success ms-1">подтверждён</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-warning ms-1">не подтверждён</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item"><span class="text-muted small">Телефон</span><span>{{ target.phone }}</span></li>
|
||||||
|
<li class="list-group-item"><span class="text-muted small">Роль</span>
|
||||||
|
<span>
|
||||||
|
{% if target.role == 'system' %}<span class="badge badge-danger">Системный</span>
|
||||||
|
{% elif target.role == 'admin' %}<span class="badge badge-warning">Администратор</span>
|
||||||
|
{% else %}<span class="badge badge-secondary">Пользователь</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item"><span class="text-muted small">Статус</span>
|
||||||
|
<span>
|
||||||
|
{% if target.status == 'active' %}<span class="badge badge-success">Активен</span>
|
||||||
|
{% elif target.status == 'pending' %}<span class="badge badge-warning">Ожидает</span>
|
||||||
|
{% else %}<span class="badge badge-danger">Заблокирован</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item"><span class="text-muted small">Регистрация</span><span>{{ target.created_at | datefmt }}</span></li>
|
||||||
|
{% if target.evotor_user_id %}
|
||||||
|
<li class="list-group-item"><span class="text-muted small">Эвотор ID</span><span class="font-monospace small">{{ target.evotor_user_id }}</span></li>
|
||||||
|
{% endif %}
|
||||||
|
{% if target.invite_token %}
|
||||||
|
<li class="list-group-item"><span class="text-muted small">Приглашение до</span><span>{{ target.invite_expires | datefmt }}</span></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{% if target.evotor_meta %}
|
||||||
|
<article class="card mt-3">
|
||||||
|
<header><h2>Данные Эвотор</h2></header>
|
||||||
|
<div class="card-body">
|
||||||
|
<pre class="font-monospace small" style="overflow-x:auto; white-space:pre-wrap; margin:0;">{{ target.evotor_meta | tojson(indent=2) }}</pre>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<article class="card">
|
||||||
|
<header><h2>Действия</h2></header>
|
||||||
|
<div class="card-body d-grid gap-2">
|
||||||
|
{% if target.status != 'active' %}
|
||||||
|
<form method="post" action="/admin/users/{{ target.id }}/activate">
|
||||||
|
<button type="submit" class="w-100">
|
||||||
|
<i class="bi bi-check-circle me-1"></i>Активировать
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if target.status != 'suspended' %}
|
||||||
|
<form method="post" action="/admin/users/{{ target.id }}/suspend">
|
||||||
|
<button type="submit" class="w-100 outline danger">
|
||||||
|
<i class="bi bi-slash-circle me-1"></i>Заблокировать
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="/admin/users/{{ target.id }}/reset-password">
|
||||||
|
<button type="submit" class="w-100 outline secondary">
|
||||||
|
<i class="bi bi-key me-1"></i>Сбросить пароль
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/admin/users/{{ target.id }}/send-invite">
|
||||||
|
<button type="submit" class="w-100 outline secondary">
|
||||||
|
<i class="bi bi-envelope me-1"></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="w-100 danger sm">
|
||||||
|
<i class="bi bi-trash me-1"></i>Удалить
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card mt-3">
|
||||||
|
<header><h2>Редактировать</h2></header>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="/admin/users/{{ target.id }}/edit">
|
||||||
|
<div class="row gap-2 mb-2">
|
||||||
|
<div class="col">
|
||||||
|
<label for="first_name">Имя
|
||||||
|
<input type="text" id="first_name" name="first_name" value="{{ target.first_name }}" required>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label for="last_name">Фамилия
|
||||||
|
<input type="text" id="last_name" name="last_name" value="{{ target.last_name }}" required>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label for="email">Email
|
||||||
|
<input type="email" id="email" name="email" value="{{ target.email }}">
|
||||||
|
</label>
|
||||||
|
<label for="phone">Телефон
|
||||||
|
<input type="tel" id="phone" name="phone" value="{{ target.phone }}">
|
||||||
|
</label>
|
||||||
|
{% if user.role == 'system' %}
|
||||||
|
<label for="role">Роль
|
||||||
|
<select 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>
|
||||||
|
</label>
|
||||||
|
{% endif %}
|
||||||
|
<button type="submit">Сохранить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
118
web/templates/admin/users.html
Normal file
118
web/templates/admin/users.html
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Пользователи — Администрирование — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-between align-center mb-3">
|
||||||
|
<h1 style="font-size:1.3rem; margin:0;"><i class="bi bi-people me-2"></i>Пользователи</h1>
|
||||||
|
<span class="text-muted small">Всего: {{ total }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article class="card mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="get" action="/admin/users" class="d-flex gap-2 flex-wrap align-center">
|
||||||
|
<input type="text" name="search" value="{{ search }}" placeholder="Поиск по имени, email, телефону" style="flex:1; min-width:200px; margin:0;">
|
||||||
|
<select name="status" style="width:auto; margin:0;">
|
||||||
|
<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 name="role" style="width:auto; margin:0;">
|
||||||
|
<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="sm">Найти</button>
|
||||||
|
{% if search or status_filter or role_filter %}
|
||||||
|
<a href="/admin/users" role="button" class="outline secondary sm">Сбросить</a>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="card">
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table class="align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Имя</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Телефон</th>
|
||||||
|
<th>Роль</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Эвотор</th>
|
||||||
|
<th>Регистрация</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for u in users %}
|
||||||
|
<tr>
|
||||||
|
<td class="text-muted small">{{ u.id }}</td>
|
||||||
|
<td>{{ u.first_name }} {{ u.last_name }}</td>
|
||||||
|
<td>
|
||||||
|
{{ u.email }}
|
||||||
|
{% if not u.is_email_confirmed %}
|
||||||
|
<span class="badge badge-warning ms-1" title="Email не подтверждён"><i class="bi bi-exclamation-circle"></i></span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ u.phone }}</td>
|
||||||
|
<td>
|
||||||
|
{% if u.role == 'system' %}<span class="badge badge-danger">Системный</span>
|
||||||
|
{% elif u.role == 'admin' %}<span class="badge badge-warning">Админ</span>
|
||||||
|
{% else %}<span class="badge badge-secondary">Польз.</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if u.status == 'active' %}<span class="badge badge-success">Активен</span>
|
||||||
|
{% elif u.status == 'pending' %}<span class="badge badge-warning">Ожидает</span>
|
||||||
|
{% else %}<span class="badge badge-danger">Заблок.</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if u.evotor_user_id %}
|
||||||
|
<i class="bi bi-check-circle text-success" title="{{ u.evotor_user_id }}"></i>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-muted small">{{ u.created_at | datefmt }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="/admin/users/{{ u.id }}" role="button" class="outline sm">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="9" class="text-center text-muted py-4">Пользователи не найдены</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% if total_pages > 1 %}
|
||||||
|
<footer>
|
||||||
|
<div class="d-flex gap-2 justify-center align-center">
|
||||||
|
{% if page > 1 %}
|
||||||
|
<a href="?page={{ page - 1 }}&search={{ search }}&status={{ status_filter }}&role={{ role_filter }}" role="button" class="outline sm">«</a>
|
||||||
|
{% endif %}
|
||||||
|
<span class="text-muted small">Стр. {{ page }} из {{ total_pages }}</span>
|
||||||
|
{% if page < total_pages %}
|
||||||
|
<a href="?page={{ page + 1 }}&search={{ search }}&status={{ status_filter }}&role={{ role_filter }}" role="button" class="outline sm">»</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{% if user.role == 'system' %}
|
||||||
|
<div class="mt-3 text-end">
|
||||||
|
<a href="/admin/roles" role="button" class="outline secondary sm">
|
||||||
|
<i class="bi bi-shield-lock me-1"></i>Управление ролями
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -1,63 +1,74 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ru">
|
<html lang="ru" data-theme="light">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% block title %}ЭВОСИНК{% endblock %}</title>
|
<title>{% block title %}ЭВОСИНК{% 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/@picocss/pico@2/css/pico.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="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="navbar navbar-expand-lg bg-white border-bottom border-2 brand-border">
|
<header class="site-header">
|
||||||
<div class="container">
|
<nav class="container">
|
||||||
<a href="/" class="navbar-brand brand-logo">ЭВОСИНК</a>
|
<ul>
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
<li><a href="/" class="brand-logo">ЭВОСИНК</a></li>
|
||||||
<span class="navbar-toggler-icon"></span>
|
</ul>
|
||||||
</button>
|
<ul class="nav-links">
|
||||||
<div class="collapse navbar-collapse" id="navbarNav">
|
{% if user %}
|
||||||
<ul class="navbar-nav ms-auto">
|
<li><a href="/connections">Подключения</a></li>
|
||||||
{% if user %}
|
<li><a href="/catalog">Каталог Эвотор</a></li>
|
||||||
<li class="nav-item">
|
<li><a href="/vk-catalog/albums">Каталог ВК</a></li>
|
||||||
<a href="/connections" class="nav-link">Подключения</a>
|
<li><a href="/sync">Синхронизация</a></li>
|
||||||
</li>
|
{% if user.role in ('admin', 'system') %}
|
||||||
<li class="nav-item">
|
<li><a href="/admin/users"><i class="bi bi-shield-lock"></i> Админ</a></li>
|
||||||
<a href="/catalog" class="nav-link">Каталог</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a href="/sync" class="nav-link">Синхронизация</a>
|
|
||||||
</li>
|
|
||||||
<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 %}
|
{% endif %}
|
||||||
|
<li><a href="/profile"><i class="bi bi-person-circle"></i> Личный кабинет</a></li>
|
||||||
|
<li><a href="/logout" class="secondary">Выход</a></li>
|
||||||
|
{% else %}
|
||||||
|
<li><a href="/login">Вход</a></li>
|
||||||
|
<li><a href="/register">Регистрация</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
{% if user %}
|
||||||
|
<details class="mobile-menu">
|
||||||
|
<summary role="button" class="outline secondary icon-btn"><i class="bi bi-list"></i></summary>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/connections">Подключения</a></li>
|
||||||
|
<li><a href="/catalog">Каталог Эвотор</a></li>
|
||||||
|
<li><a href="/vk-catalog/albums">Каталог ВК</a></li>
|
||||||
|
<li><a href="/sync">Синхронизация</a></li>
|
||||||
|
{% if user.role in ('admin', 'system') %}
|
||||||
|
<li><a href="/admin/users">Админ</a></li>
|
||||||
|
{% endif %}
|
||||||
|
<li><a href="/profile">Личный кабинет</a></li>
|
||||||
|
<li><a href="/logout">Выход</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</details>
|
||||||
</div>
|
{% else %}
|
||||||
</nav>
|
<details class="mobile-menu">
|
||||||
|
<summary role="button" class="outline secondary icon-btn"><i class="bi bi-list"></i></summary>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/login">Вход</a></li>
|
||||||
|
<li><a href="/register">Регистрация</a></li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
<main class="container py-4">
|
<main class="container py-4">
|
||||||
{% if errors %}
|
{% if errors %}
|
||||||
<div class="alert alert-danger">
|
<div role="alert" class="alert alert-danger">
|
||||||
{% for error in errors %}
|
{% for error in errors %}
|
||||||
<p class="mb-1">{{ error }}</p>
|
<p>{{ error }}</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if success %}
|
{% if success %}
|
||||||
<div class="alert alert-success">
|
<div role="alert" class="alert alert-success">
|
||||||
<p class="mb-0">{{ success }}</p>
|
<p>{{ success }}</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -67,7 +78,6 @@
|
|||||||
{% if jivosite_widget_id %}
|
{% if jivosite_widget_id %}
|
||||||
<script src="//code.jivosite.com/widget/{{ jivosite_widget_id }}" async></script>
|
<script src="//code.jivosite.com/widget/{{ jivosite_widget_id }}" async></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/inputmask@5.0.9/dist/inputmask.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/inputmask@5.0.9/dist/inputmask.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
@@ -93,5 +103,6 @@
|
|||||||
if (e.target.required) e.target.setCustomValidity('');
|
if (e.target.required) e.target.setCustomValidity('');
|
||||||
}, true);
|
}, true);
|
||||||
</script>
|
</script>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
69
web/templates/catalog/groups.html
Normal file
69
web/templates/catalog/groups.html
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Группы — {{ store.name }} — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<nav aria-label="breadcrumb" class="mb-3">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li><a href="/catalog/stores">Магазины</a></li>
|
||||||
|
<li>{{ store.name }}</li>
|
||||||
|
<li>Группы</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="d-flex justify-between align-center mb-3">
|
||||||
|
<h1 style="font-size:1.3rem; margin:0;"><i class="bi bi-folder me-2"></i>Группы товаров — {{ store.name }}</h1>
|
||||||
|
<span class="text-muted small">Всего: {{ groups | length }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article class="card">
|
||||||
|
{% if groups %}
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table class="align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Синхронизация</th>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Обновлено</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for g in groups %}
|
||||||
|
{% set is_enabled = (enabled_ids is none) or (g.evotor_id in enabled_ids) %}
|
||||||
|
<tr class="{% if not is_enabled %}text-muted{% endif %}">
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/catalog/stores/{{ store.evotor_id }}/groups/{{ g.evotor_id }}/toggle" style="margin:0;">
|
||||||
|
<button type="submit"
|
||||||
|
class="outline sm {% if is_enabled %}success{% else %}secondary{% endif %}"
|
||||||
|
title="{% if is_enabled %}Отключить синхронизацию{% else %}Включить синхронизацию{% endif %}"
|
||||||
|
style="padding:0.2rem 0.6rem;">
|
||||||
|
{% if is_enabled %}
|
||||||
|
<i class="bi bi-toggle-on"></i>
|
||||||
|
{% else %}
|
||||||
|
<i class="bi bi-toggle-off"></i>
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td><i class="bi bi-folder2 me-1 text-muted"></i> <strong>{{ g.name }}</strong></td>
|
||||||
|
<td class="text-muted small">{{ g.evotor_id }}</td>
|
||||||
|
<td class="text-muted small">{{ g.fetched_at | datefmt }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="/catalog/stores/{{ store.evotor_id }}/products?group={{ g.evotor_id }}" role="button" class="outline sm">
|
||||||
|
<i class="bi bi-box-seam"></i> Товары
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<i class="bi bi-folder" style="font-size:2rem;"></i>
|
||||||
|
<p class="mt-2">Группы для этого магазина ещё не загружены.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
81
web/templates/catalog/products.html
Normal file
81
web/templates/catalog/products.html
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Товары — {{ store.name }} — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<nav aria-label="breadcrumb" class="mb-3">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li><a href="/catalog/stores">Магазины</a></li>
|
||||||
|
<li>{{ store.name }}</li>
|
||||||
|
<li>Товары</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="d-flex justify-between align-center mb-3">
|
||||||
|
<h1 style="font-size:1.3rem; margin:0;"><i class="bi bi-box-seam me-2"></i>Товары — {{ store.name }}</h1>
|
||||||
|
<span class="text-muted small">Всего: {{ products | length }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if groups %}
|
||||||
|
<article class="card mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="get" class="d-flex gap-2 align-center flex-wrap">
|
||||||
|
<select name="group" style="width:auto; margin:0;" onchange="this.form.submit()">
|
||||||
|
<option value="">Все группы</option>
|
||||||
|
{% for g in groups %}
|
||||||
|
<option value="{{ g.evotor_id }}" {% if group_id == g.evotor_id %}selected{% endif %}>{{ g.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% if group_id %}
|
||||||
|
<a href="/catalog/stores/{{ store.evotor_id }}/products" role="button" class="outline secondary sm">Сбросить</a>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<article class="card">
|
||||||
|
{% if products %}
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table class="align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Артикул</th>
|
||||||
|
<th>Цена</th>
|
||||||
|
<th>Остаток</th>
|
||||||
|
<th>Ед.</th>
|
||||||
|
<th>Продаётся</th>
|
||||||
|
<th>Обновлено</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for p in products %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ p.name }}</td>
|
||||||
|
<td class="text-muted small">{{ p.article_number or '—' }}</td>
|
||||||
|
<td>{% if p.price is not none %}{{ p.price | price }}{% else %}—{% endif %}</td>
|
||||||
|
<td>{% if p.quantity is not none %}{{ p.quantity }}{% else %}—{% endif %}</td>
|
||||||
|
<td class="text-muted small">{{ p.measure_name or '—' }}</td>
|
||||||
|
<td>
|
||||||
|
{% if p.allow_to_sell %}
|
||||||
|
<i class="bi bi-check-circle text-success"></i>
|
||||||
|
{% elif p.allow_to_sell == false %}
|
||||||
|
<i class="bi bi-x-circle text-danger"></i>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="text-muted small">{{ p.fetched_at | datefmt }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<i class="bi bi-box-seam" style="font-size:2rem;"></i>
|
||||||
|
<p class="mt-2">Товары не найдены.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
66
web/templates/catalog/stores.html
Normal file
66
web/templates/catalog/stores.html
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Магазины — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="d-flex justify-between align-center mb-3">
|
||||||
|
<h1 style="font-size:1.3rem; margin:0;"><i class="bi bi-shop me-2"></i>Магазины Эвотор</h1>
|
||||||
|
<span class="text-muted small">Всего: {{ stores | length }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<article class="card">
|
||||||
|
{% if stores %}
|
||||||
|
<div class="table-scroll">
|
||||||
|
<table class="align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Синхронизация</th>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Адрес</th>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Обновлено</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in stores %}
|
||||||
|
{% set is_enabled = (enabled_ids is none) or (s.evotor_id in enabled_ids) %}
|
||||||
|
<tr class="{% if not is_enabled %}text-muted{% endif %}">
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/catalog/stores/{{ s.evotor_id }}/toggle" style="margin:0;">
|
||||||
|
<button type="submit"
|
||||||
|
class="outline sm {% if is_enabled %}success{% else %}secondary{% endif %}"
|
||||||
|
title="{% if is_enabled %}Отключить синхронизацию{% else %}Включить синхронизацию{% endif %}"
|
||||||
|
style="padding:0.2rem 0.6rem;">
|
||||||
|
{% if is_enabled %}
|
||||||
|
<i class="bi bi-toggle-on"></i>
|
||||||
|
{% else %}
|
||||||
|
<i class="bi bi-toggle-off"></i>
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td><strong>{{ s.name }}</strong></td>
|
||||||
|
<td class="text-muted">{{ s.address or '—' }}</td>
|
||||||
|
<td class="text-muted small">{{ s.evotor_id }}</td>
|
||||||
|
<td class="text-muted small">{{ s.fetched_at | datefmt }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="/catalog/stores/{{ s.evotor_id }}/products" role="button" class="outline sm" title="Товары">
|
||||||
|
<i class="bi bi-box-seam"></i> Товары
|
||||||
|
</a>
|
||||||
|
<a href="/catalog/stores/{{ s.evotor_id }}/groups" role="button" class="outline secondary sm" title="Группы">
|
||||||
|
<i class="bi bi-folder"></i> Группы
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<i class="bi bi-shop" style="font-size:2rem;"></i>
|
||||||
|
<p class="mt-2">Магазины ещё не загружены.<br>Синхронизация выполняется каждые {{ refresh_interval }} сек. автоматически.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}Группы — {{ store.name }} — ЭВОСИНК{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<nav aria-label="breadcrumb" class="mb-3">
|
|
||||||
<ol class="breadcrumb">
|
|
||||||
<li class="breadcrumb-item"><a href="/catalog">Каталог</a></li>
|
|
||||||
<li class="breadcrumb-item active">{{ store.name }}</li>
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
|
||||||
<h1 class="h4 mb-0">Группы товаров</h1>
|
|
||||||
<a href="/catalog/export?type=groups&store_id={{ store.evotor_id }}" class="btn btn-outline-secondary btn-sm">
|
|
||||||
<i class="bi bi-download me-1"></i>Экспорт CSV
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if not groups %}
|
|
||||||
<div class="text-center py-5 text-muted">
|
|
||||||
<i class="bi bi-folder fs-1 mb-3 d-block"></i>
|
|
||||||
<p>Группы не найдены в этом магазине.</p>
|
|
||||||
<a href="/catalog/products?store_id={{ store.evotor_id }}" class="btn btn-outline-primary btn-sm">
|
|
||||||
Посмотреть все товары магазина
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-striped table-hover align-middle">
|
|
||||||
<thead class="table-light">
|
|
||||||
<tr>
|
|
||||||
<th>Название</th>
|
|
||||||
<th>Кол-во товаров</th>
|
|
||||||
<th>Фильтр</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for group in groups %}
|
|
||||||
{% set mode = filter_map.get(group.evotor_id) %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ group.name }}</td>
|
|
||||||
<td class="text-muted">{{ product_counts.get(group.evotor_id, 0) }}</td>
|
|
||||||
<td>
|
|
||||||
{% if mode == "include" %}
|
|
||||||
<span class="badge bg-success">✓ Включено</span>
|
|
||||||
{% elif mode == "exclude" %}
|
|
||||||
<span class="badge bg-danger">✗ Исключено</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge bg-light text-muted border">— Нет правила</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<div class="d-flex gap-1 justify-content-end">
|
|
||||||
<a href="/catalog/products?store_id={{ store.evotor_id }}&group_id={{ group.evotor_id }}"
|
|
||||||
class="btn btn-outline-secondary btn-sm" title="Товары">
|
|
||||||
<i class="bi bi-arrow-right"></i>
|
|
||||||
</a>
|
|
||||||
<div class="dropdown">
|
|
||||||
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown">
|
|
||||||
<i class="bi bi-funnel"></i>
|
|
||||||
</button>
|
|
||||||
<ul class="dropdown-menu dropdown-menu-end">
|
|
||||||
<li>
|
|
||||||
<form method="post" action="/catalog/filter">
|
|
||||||
<input type="hidden" name="entity_type" value="group">
|
|
||||||
<input type="hidden" name="entity_id" value="{{ group.evotor_id }}">
|
|
||||||
<input type="hidden" name="entity_name" value="{{ group.name }}">
|
|
||||||
<input type="hidden" name="filter_mode" value="include">
|
|
||||||
<input type="hidden" name="parent_entity_id" value="{{ store.evotor_id }}">
|
|
||||||
<input type="hidden" name="redirect_to" value="/catalog/groups?store_id={{ store.evotor_id }}">
|
|
||||||
<button type="submit" class="dropdown-item">✓ Включить в синхронизацию</button>
|
|
||||||
</form>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<form method="post" action="/catalog/filter">
|
|
||||||
<input type="hidden" name="entity_type" value="group">
|
|
||||||
<input type="hidden" name="entity_id" value="{{ group.evotor_id }}">
|
|
||||||
<input type="hidden" name="entity_name" value="{{ group.name }}">
|
|
||||||
<input type="hidden" name="filter_mode" value="exclude">
|
|
||||||
<input type="hidden" name="parent_entity_id" value="{{ store.evotor_id }}">
|
|
||||||
<input type="hidden" name="redirect_to" value="/catalog/groups?store_id={{ store.evotor_id }}">
|
|
||||||
<button type="submit" class="dropdown-item">✗ Исключить из синхронизации</button>
|
|
||||||
</form>
|
|
||||||
</li>
|
|
||||||
<li><hr class="dropdown-divider"></li>
|
|
||||||
<li>
|
|
||||||
<form method="post" action="/catalog/filter">
|
|
||||||
<input type="hidden" name="entity_type" value="group">
|
|
||||||
<input type="hidden" name="entity_id" value="{{ group.evotor_id }}">
|
|
||||||
<input type="hidden" name="entity_name" value="{{ group.name }}">
|
|
||||||
<input type="hidden" name="filter_mode" value="none">
|
|
||||||
<input type="hidden" name="parent_entity_id" value="{{ store.evotor_id }}">
|
|
||||||
<input type="hidden" name="redirect_to" value="/catalog/groups?store_id={{ store.evotor_id }}">
|
|
||||||
<button type="submit" class="dropdown-item text-muted">— Убрать правило</button>
|
|
||||||
</form>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}Товары — ЭВОСИНК{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<nav aria-label="breadcrumb" class="mb-3">
|
|
||||||
<ol class="breadcrumb">
|
|
||||||
<li class="breadcrumb-item"><a href="/catalog">Каталог</a></li>
|
|
||||||
<li class="breadcrumb-item"><a href="/catalog/groups?store_id={{ store.evotor_id }}">{{ store.name }}</a></li>
|
|
||||||
{% if group %}
|
|
||||||
<li class="breadcrumb-item active">{{ group.name }}</li>
|
|
||||||
{% else %}
|
|
||||||
<li class="breadcrumb-item active">Все товары</li>
|
|
||||||
{% endif %}
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
|
||||||
<h1 class="h4 mb-0">Товары{% if group %}: {{ group.name }}{% endif %}</h1>
|
|
||||||
<a href="/catalog/export?type=products&store_id={{ store.evotor_id }}{% if group %}&group_id={{ group.evotor_id }}{% endif %}"
|
|
||||||
class="btn btn-outline-secondary btn-sm">
|
|
||||||
<i class="bi bi-download me-1"></i>Экспорт CSV
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% set redirect_back %}/catalog/products?store_id={{ store.evotor_id }}{% if group %}&group_id={{ group.evotor_id }}{% endif %}{% endset %}
|
|
||||||
|
|
||||||
{% if not products %}
|
|
||||||
<div class="text-center py-5 text-muted">
|
|
||||||
<i class="bi bi-box fs-1 mb-3 d-block"></i>
|
|
||||||
<p>Товары не найдены.</p>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="table-responsive" style="overflow: visible;">
|
|
||||||
<table class="table table-striped table-hover align-middle small">
|
|
||||||
<thead class="table-light">
|
|
||||||
<tr>
|
|
||||||
<th>Название</th>
|
|
||||||
<th>Артикул</th>
|
|
||||||
<th>Цена</th>
|
|
||||||
<th>Кол-во</th>
|
|
||||||
<th>Ед. изм.</th>
|
|
||||||
<th>В продаже</th>
|
|
||||||
<th>Фильтр</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for product in products %}
|
|
||||||
{% set mode = filter_map.get(product.evotor_id) %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ product.name }}</td>
|
|
||||||
<td class="text-muted font-monospace">{{ product.article_number or "—" }}</td>
|
|
||||||
<td>{% if product.price %}{{ "%.2f"|format(product.price) }} ₽{% else %}—{% endif %}</td>
|
|
||||||
<td>{% if product.quantity is not none %}{{ product.quantity }}{% else %}—{% endif %}</td>
|
|
||||||
<td class="text-muted">{{ product.measure_name or "—" }}</td>
|
|
||||||
<td>
|
|
||||||
{% if product.allow_to_sell is none %}
|
|
||||||
<span class="text-muted">—</span>
|
|
||||||
{% elif product.allow_to_sell %}
|
|
||||||
<i class="bi bi-check-circle-fill text-success"></i>
|
|
||||||
{% else %}
|
|
||||||
<i class="bi bi-x-circle-fill text-danger"></i>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{% if mode == "include" %}
|
|
||||||
<span class="badge bg-success">✓ Включено</span>
|
|
||||||
{% elif mode == "exclude" %}
|
|
||||||
<span class="badge bg-danger">✗ Исключено</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge bg-light text-muted border">— Нет правила</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="dropdown">
|
|
||||||
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown" data-bs-strategy="fixed">
|
|
||||||
<i class="bi bi-funnel"></i>
|
|
||||||
</button>
|
|
||||||
<ul class="dropdown-menu dropdown-menu-end">
|
|
||||||
<li>
|
|
||||||
<form method="post" action="/catalog/filter">
|
|
||||||
<input type="hidden" name="entity_type" value="product">
|
|
||||||
<input type="hidden" name="entity_id" value="{{ product.evotor_id }}">
|
|
||||||
<input type="hidden" name="entity_name" value="{{ product.name }}">
|
|
||||||
<input type="hidden" name="filter_mode" value="include">
|
|
||||||
<input type="hidden" name="parent_entity_id" value="{{ product.group_evotor_id or '' }}">
|
|
||||||
<input type="hidden" name="redirect_to" value="{{ redirect_back }}">
|
|
||||||
<button type="submit" class="dropdown-item">✓ Включить в синхронизацию</button>
|
|
||||||
</form>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<form method="post" action="/catalog/filter">
|
|
||||||
<input type="hidden" name="entity_type" value="product">
|
|
||||||
<input type="hidden" name="entity_id" value="{{ product.evotor_id }}">
|
|
||||||
<input type="hidden" name="entity_name" value="{{ product.name }}">
|
|
||||||
<input type="hidden" name="filter_mode" value="exclude">
|
|
||||||
<input type="hidden" name="parent_entity_id" value="{{ product.group_evotor_id or '' }}">
|
|
||||||
<input type="hidden" name="redirect_to" value="{{ redirect_back }}">
|
|
||||||
<button type="submit" class="dropdown-item">✗ Исключить из синхронизации</button>
|
|
||||||
</form>
|
|
||||||
</li>
|
|
||||||
<li><hr class="dropdown-divider"></li>
|
|
||||||
<li>
|
|
||||||
<form method="post" action="/catalog/filter">
|
|
||||||
<input type="hidden" name="entity_type" value="product">
|
|
||||||
<input type="hidden" name="entity_id" value="{{ product.evotor_id }}">
|
|
||||||
<input type="hidden" name="entity_name" value="{{ product.name }}">
|
|
||||||
<input type="hidden" name="filter_mode" value="none">
|
|
||||||
<input type="hidden" name="parent_entity_id" value="{{ product.group_evotor_id or '' }}">
|
|
||||||
<input type="hidden" name="redirect_to" value="{{ redirect_back }}">
|
|
||||||
<button type="submit" class="dropdown-item text-muted">— Убрать правило</button>
|
|
||||||
</form>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}Каталог — ЭВОСИНК{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
|
||||||
<h1 class="h4 mb-0">Каталог</h1>
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
{% if evotor %}
|
|
||||||
<form method="post" action="/catalog/refresh">
|
|
||||||
<button type="submit" class="btn btn-outline-secondary btn-sm">
|
|
||||||
<i class="bi bi-arrow-clockwise me-1"></i>Обновить
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<a href="/catalog/export?type=stores" class="btn btn-outline-secondary btn-sm">
|
|
||||||
<i class="bi bi-download me-1"></i>Экспорт CSV
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if not evotor %}
|
|
||||||
<div class="alert alert-warning d-flex align-items-center gap-2">
|
|
||||||
<i class="bi bi-exclamation-triangle-fill"></i>
|
|
||||||
<span>Эвотор не подключён. <a href="/evotor">Подключить Эвотор</a></span>
|
|
||||||
</div>
|
|
||||||
{% elif not stores %}
|
|
||||||
<div class="text-center py-5 text-muted">
|
|
||||||
<i class="bi bi-shop fs-1 mb-3 d-block"></i>
|
|
||||||
<p>Магазины не найдены в вашем аккаунте Эвотор.</p>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
{% if fetched_at %}
|
|
||||||
<p class="text-muted small mb-3">Последнее обновление: {{ fetched_at.strftime("%d.%m.%Y %H:%M") }}</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-striped table-hover align-middle">
|
|
||||||
<thead class="table-light">
|
|
||||||
<tr>
|
|
||||||
<th>Название</th>
|
|
||||||
<th>Адрес</th>
|
|
||||||
<th>Фильтр</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for store in stores %}
|
|
||||||
{% set mode = filter_map.get(store.evotor_id) %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ store.name }}</td>
|
|
||||||
<td class="text-muted small">{{ store.address or "—" }}</td>
|
|
||||||
<td>
|
|
||||||
{% if mode == "include" %}
|
|
||||||
<span class="badge bg-success">✓ Включено</span>
|
|
||||||
{% elif mode == "exclude" %}
|
|
||||||
<span class="badge bg-danger">✗ Исключено</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge bg-light text-muted border">— Нет правила</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<div class="d-flex gap-1 justify-content-end">
|
|
||||||
<a href="/catalog/groups?store_id={{ store.evotor_id }}"
|
|
||||||
class="btn btn-outline-secondary btn-sm" title="Группы">
|
|
||||||
<i class="bi bi-arrow-right"></i>
|
|
||||||
</a>
|
|
||||||
<div class="dropdown">
|
|
||||||
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown">
|
|
||||||
<i class="bi bi-funnel"></i>
|
|
||||||
</button>
|
|
||||||
<ul class="dropdown-menu dropdown-menu-end">
|
|
||||||
<li>
|
|
||||||
<form method="post" action="/catalog/filter">
|
|
||||||
<input type="hidden" name="entity_type" value="store">
|
|
||||||
<input type="hidden" name="entity_id" value="{{ store.evotor_id }}">
|
|
||||||
<input type="hidden" name="entity_name" value="{{ store.name }}">
|
|
||||||
<input type="hidden" name="filter_mode" value="include">
|
|
||||||
<input type="hidden" name="redirect_to" value="/catalog">
|
|
||||||
<button type="submit" class="dropdown-item">✓ Включить в синхронизацию</button>
|
|
||||||
</form>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<form method="post" action="/catalog/filter">
|
|
||||||
<input type="hidden" name="entity_type" value="store">
|
|
||||||
<input type="hidden" name="entity_id" value="{{ store.evotor_id }}">
|
|
||||||
<input type="hidden" name="entity_name" value="{{ store.name }}">
|
|
||||||
<input type="hidden" name="filter_mode" value="exclude">
|
|
||||||
<input type="hidden" name="redirect_to" value="/catalog">
|
|
||||||
<button type="submit" class="dropdown-item">✗ Исключить из синхронизации</button>
|
|
||||||
</form>
|
|
||||||
</li>
|
|
||||||
<li><hr class="dropdown-divider"></li>
|
|
||||||
<li>
|
|
||||||
<form method="post" action="/catalog/filter">
|
|
||||||
<input type="hidden" name="entity_type" value="store">
|
|
||||||
<input type="hidden" name="entity_id" value="{{ store.evotor_id }}">
|
|
||||||
<input type="hidden" name="entity_name" value="{{ store.name }}">
|
|
||||||
<input type="hidden" name="filter_mode" value="none">
|
|
||||||
<input type="hidden" name="redirect_to" value="/catalog">
|
|
||||||
<button type="submit" class="dropdown-item text-muted">— Убрать правило</button>
|
|
||||||
</form>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
@@ -2,15 +2,15 @@
|
|||||||
{% block title %}Подтверждение email — ЭВОСИНК{% endblock %}
|
{% block title %}Подтверждение email — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-center">
|
||||||
<div class="col-sm-10 col-md-6 col-lg-5">
|
<div class="col-sm-10 col-md-6 col-lg-5">
|
||||||
<div class="card shadow-sm mt-5 text-center">
|
<article class="card mt-5 text-center">
|
||||||
<div class="card-body p-5">
|
<div class="card-body" style="padding: 2.5rem;">
|
||||||
<i class="bi bi-envelope-check display-4 text-primary mb-3"></i>
|
<i class="bi bi-envelope-check fs-1 text-primary mb-3 d-block"></i>
|
||||||
<h1 class="h4 mb-3">Подтвердите ваш email</h1>
|
<h1 style="font-size:1.3rem;" class="mb-3">Подтвердите ваш email</h1>
|
||||||
<p class="text-muted">Проверьте почту и нажмите на ссылку для подтверждения.</p>
|
<p class="text-muted">Проверьте почту и нажмите на ссылку для подтверждения.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -2,66 +2,208 @@
|
|||||||
{% block title %}Подключения — ЭВОСИНК{% endblock %}
|
{% block title %}Подключения — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
<div class="row justify-center">
|
||||||
<h1 class="h4 mb-0">Подключения</h1>
|
<div class="col-sm-10 col-md-8 col-lg-6">
|
||||||
<a href="/connections/add" class="btn btn-primary btn-sm">
|
|
||||||
<i class="bi bi-plus-lg me-1"></i>Добавить
|
<h1 style="font-size:1.3rem; margin-bottom:1.5rem;">
|
||||||
</a>
|
<i class="bi bi-plug me-2"></i>Подключения
|
||||||
</div>
|
</h1>
|
||||||
|
|
||||||
|
{% if request.query_params.get('success') %}
|
||||||
|
<div role="alert" class="alert alert-success mb-3">
|
||||||
|
<p>Подключение сохранено.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# ── Evotor ── #}
|
||||||
|
<article class="card mb-4">
|
||||||
|
<header class="d-flex align-center justify-between">
|
||||||
|
<span><i class="bi bi-cpu me-2"></i><strong>Эвотор</strong></span>
|
||||||
|
{% if evotor %}
|
||||||
|
<span class="badge badge-success"><i class="bi bi-check-circle me-1"></i>Подключено</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-secondary">Не подключено</span>
|
||||||
|
{% endif %}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% if evotor %}
|
||||||
|
<ul class="list-group mb-3">
|
||||||
|
<li class="list-group-item">
|
||||||
|
<span class="text-muted small">Токен</span>
|
||||||
|
<span class="font-monospace small">{{ evotor.access_token[:8] }}••••••••</span>
|
||||||
|
</li>
|
||||||
|
{% if evotor.evotor_user_id %}
|
||||||
|
<li class="list-group-item">
|
||||||
|
<span class="text-muted small">Evotor User ID</span>
|
||||||
|
<span class="font-monospace small">{{ evotor.evotor_user_id }}</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li class="list-group-item">
|
||||||
|
<span class="text-muted small">Подключено</span>
|
||||||
|
<span>{{ evotor.connected_at | datefmt }}</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item">
|
||||||
|
<span class="text-muted small">Обновлено</span>
|
||||||
|
<span>{{ evotor.updated_at | datefmt }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if connections %}
|
|
||||||
<div class="row g-3">
|
|
||||||
{% for conn in connections %}
|
|
||||||
<div class="col-sm-6 col-lg-4">
|
|
||||||
<div class="card shadow-sm h-100">
|
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex align-items-center mb-3">
|
<details {% if not evotor %}open{% endif %}>
|
||||||
<i class="bi {{ conn.icon }} fs-2 me-3 text-secondary"></i>
|
<summary>
|
||||||
<div>
|
{% if evotor %}Обновить токен{% else %}Ввести API-токен{% endif %}
|
||||||
<h5 class="mb-0">{{ conn.name }}</h5>
|
</summary>
|
||||||
{% if conn.details %}
|
<form method="post" action="/connections/evotor" class="mt-3">
|
||||||
<small class="text-muted">{{ conn.details }}</small>
|
<label>
|
||||||
{% endif %}
|
API-токен Эвотор
|
||||||
</div>
|
<input type="text" name="access_token"
|
||||||
<div class="ms-auto">
|
placeholder="Вставьте токен из личного кабинета Эвотор"
|
||||||
{% if conn.is_online %}
|
value="{{ evotor.access_token if evotor else '' }}"
|
||||||
<i class="bi bi-circle-fill text-success fs-5" title="Онлайн"></i>
|
required autocomplete="off">
|
||||||
{% else %}
|
</label>
|
||||||
<i class="bi bi-circle-fill text-danger fs-5" title="Офлайн"></i>
|
<label>
|
||||||
{% endif %}
|
Evotor User ID <span class="text-muted small">(необязательно)</span>
|
||||||
</div>
|
<input type="text" name="evotor_user_id"
|
||||||
</div>
|
placeholder="Например: 01234567-89ab-cdef-0123-456789abcdef"
|
||||||
|
value="{{ evotor.evotor_user_id if evotor and evotor.evotor_user_id else '' }}"
|
||||||
<div class="d-flex gap-2">
|
autocomplete="off">
|
||||||
<a href="{{ conn.configure_url }}" class="btn btn-outline-primary btn-sm flex-fill">Настроить</a>
|
</label>
|
||||||
<form method="post" action="/connections/delete?type={{ conn.type }}">
|
<button type="submit">
|
||||||
<button type="submit"
|
<i class="bi bi-save me-1"></i>Сохранить
|
||||||
class="btn btn-outline-danger btn-sm"
|
|
||||||
onclick="return confirm('Вы уверены, что хотите отключить {{ conn.name }}?')">
|
|
||||||
Отключить
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{% if evotor %}
|
||||||
|
<div class="d-flex gap-2 mt-3" style="flex-wrap:wrap; align-items:center;">
|
||||||
|
<button type="button" class="outline sm" onclick="testConnection('evotor', this)">
|
||||||
|
<i class="bi bi-wifi me-1"></i>Проверить соединение
|
||||||
|
</button>
|
||||||
|
<span id="evotor-test-result" class="small"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<form method="post" action="/connections/evotor/disconnect"
|
||||||
<div class="card-footer text-muted small">
|
class="mt-2"
|
||||||
{% if conn.last_checked_at %}
|
onsubmit="return confirm('Отключить Эвотор? Кешированные данные каталога останутся.')">
|
||||||
Проверено: {{ conn.last_checked_at.strftime("%d.%m.%Y %H:%M") }}
|
<button type="submit" class="outline danger sm">
|
||||||
{% else %}
|
<i class="bi bi-plug me-1"></i>Отключить
|
||||||
Статус ещё не проверялся
|
</button>
|
||||||
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% else %}
|
{# ── VK ── #}
|
||||||
<div class="text-center py-5 text-muted">
|
<article class="card mb-4">
|
||||||
<i class="bi bi-plug fs-1 mb-3 d-block"></i>
|
<header class="d-flex align-center justify-between">
|
||||||
<p class="mb-3">Нет подключённых сервисов</p>
|
<span><i class="bi bi-badge-vr me-2"></i><strong>ВКонтакте (Маркет)</strong></span>
|
||||||
<a href="/connections/add" class="btn btn-primary">
|
{% if vk %}
|
||||||
<i class="bi bi-plus-lg me-1"></i>Добавить подключение
|
<span class="badge badge-success"><i class="bi bi-check-circle me-1"></i>Подключено</span>
|
||||||
</a>
|
{% else %}
|
||||||
|
<span class="badge badge-secondary">Не подключено</span>
|
||||||
|
{% endif %}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% if vk %}
|
||||||
|
<ul class="list-group mb-3">
|
||||||
|
<li class="list-group-item">
|
||||||
|
<span class="text-muted small">Токен</span>
|
||||||
|
<span class="font-monospace small">{{ vk.access_token[:8] }}••••••••</span>
|
||||||
|
</li>
|
||||||
|
{% if vk.vk_user_id %}
|
||||||
|
<li class="list-group-item">
|
||||||
|
<span class="text-muted small">ID сообщества</span>
|
||||||
|
<span class="font-monospace small">{{ vk.vk_user_id }}</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if vk.first_name or vk.last_name %}
|
||||||
|
<li class="list-group-item">
|
||||||
|
<span class="text-muted small">Аккаунт</span>
|
||||||
|
<span>{{ vk.first_name }} {{ vk.last_name }}</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li class="list-group-item">
|
||||||
|
<span class="text-muted small">Подключено</span>
|
||||||
|
<span>{{ vk.connected_at | datefmt }}</span>
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item">
|
||||||
|
<span class="text-muted small">Обновлено</span>
|
||||||
|
<span>{{ vk.updated_at | datefmt }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<details {% if not vk %}open{% endif %}>
|
||||||
|
<summary>
|
||||||
|
{% if vk %}Обновить подключение{% else %}Подключить ВКонтакте{% endif %}
|
||||||
|
</summary>
|
||||||
|
<p class="text-muted small mt-2">
|
||||||
|
Укажите токен пользователя VK с правами <code>market,photos,groups</code>
|
||||||
|
и ID сообщества, в котором включён Маркет.
|
||||||
|
</p>
|
||||||
|
<form method="post" action="/connections/vk" class="mt-2">
|
||||||
|
<label>
|
||||||
|
Токен доступа VK
|
||||||
|
<input type="text" name="access_token"
|
||||||
|
placeholder="vk1.a.xxxxxxxxxxxxxxxx…"
|
||||||
|
value="{{ vk.access_token if vk else '' }}"
|
||||||
|
required autocomplete="off">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
ID сообщества ВКонтакте
|
||||||
|
<input type="text" name="vk_group_id"
|
||||||
|
placeholder="Например: 229744980"
|
||||||
|
value="{{ vk.vk_user_id if vk and vk.vk_user_id else '' }}"
|
||||||
|
autocomplete="off">
|
||||||
|
<small class="text-muted">Числовой ID группы/паблика с включённым Маркетом (без минуса)</small>
|
||||||
|
</label>
|
||||||
|
<button type="submit">
|
||||||
|
<i class="bi bi-save me-1"></i>Сохранить
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{% if vk %}
|
||||||
|
<div class="d-flex gap-2 mt-3" style="flex-wrap:wrap; align-items:center;">
|
||||||
|
<button type="button" class="outline sm" onclick="testConnection('vk', this)">
|
||||||
|
<i class="bi bi-wifi me-1"></i>Проверить соединение
|
||||||
|
</button>
|
||||||
|
<span id="vk-test-result" class="small"></span>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/connections/vk/disconnect"
|
||||||
|
class="mt-2"
|
||||||
|
onsubmit="return confirm('Отключить ВКонтакте?')">
|
||||||
|
<button type="submit" class="outline danger sm">
|
||||||
|
<i class="bi bi-plug me-1"></i>Отключить
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
async function testConnection(provider, btn) {
|
||||||
|
const resultEl = document.getElementById(provider + '-test-result');
|
||||||
|
btn.disabled = true;
|
||||||
|
resultEl.textContent = 'Проверяем…';
|
||||||
|
resultEl.style.color = '';
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/connections/' + provider + '/test', {method: 'POST'});
|
||||||
|
const data = await resp.json();
|
||||||
|
resultEl.textContent = data.message;
|
||||||
|
resultEl.style.color = data.ok ? 'var(--pico-color-green-500, #2d8a4e)' : 'var(--pico-color-red-500, #c0392b)';
|
||||||
|
} catch (e) {
|
||||||
|
resultEl.textContent = 'Ошибка сети';
|
||||||
|
resultEl.style.color = 'var(--pico-color-red-500, #c0392b)';
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}Добавить подключение — ЭВОСИНК{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="d-flex align-items-center mb-4">
|
|
||||||
<a href="/connections" class="text-muted me-3"><i class="bi bi-arrow-left fs-5"></i></a>
|
|
||||||
<h1 class="h4 mb-0">Добавить подключение</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if available %}
|
|
||||||
<div class="row g-3">
|
|
||||||
{% for svc in available %}
|
|
||||||
<div class="col-sm-6 col-lg-4">
|
|
||||||
<div class="card shadow-sm h-100">
|
|
||||||
<div class="card-body d-flex flex-column">
|
|
||||||
<div class="d-flex align-items-center mb-3">
|
|
||||||
<i class="bi {{ svc.icon }} fs-2 me-3 text-secondary"></i>
|
|
||||||
<h5 class="mb-0">{{ svc.name }}</h5>
|
|
||||||
</div>
|
|
||||||
<p class="text-muted small flex-grow-1">{{ svc.description }}</p>
|
|
||||||
<a href="{{ svc.connect_url }}" class="btn btn-primary btn-sm mt-auto">
|
|
||||||
Подключить <i class="bi bi-arrow-right ms-1"></i>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% else %}
|
|
||||||
<div class="text-center py-5 text-muted">
|
|
||||||
<i class="bi bi-check-circle fs-1 mb-3 d-block text-success"></i>
|
|
||||||
<p class="mb-3">Все доступные сервисы подключены</p>
|
|
||||||
<a href="/connections" class="btn btn-outline-secondary btn-sm">
|
|
||||||
<i class="bi bi-arrow-left me-1"></i>Вернуться к подключениям
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
@@ -2,16 +2,16 @@
|
|||||||
{% block title %}Email подтвержден — ЭВОСИНК{% endblock %}
|
{% block title %}Email подтвержден — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-center">
|
||||||
<div class="col-sm-10 col-md-6 col-lg-5">
|
<div class="col-sm-10 col-md-6 col-lg-5">
|
||||||
<div class="card shadow-sm mt-5 text-center">
|
<article class="card mt-5 text-center">
|
||||||
<div class="card-body p-5">
|
<div class="card-body" style="padding: 2.5rem;">
|
||||||
<i class="bi bi-check-circle display-4 text-success mb-3"></i>
|
<i class="bi bi-check-circle fs-1 text-success mb-3 d-block"></i>
|
||||||
<h1 class="h4 mb-3">Email подтвержден!</h1>
|
<h1 style="font-size:1.3rem;" class="mb-3">Email подтвержден!</h1>
|
||||||
<p class="text-muted">Ваш email успешно подтвержден. Теперь вы можете войти в систему.</p>
|
<p class="text-muted">Ваш email успешно подтвержден. Теперь вы можете войти в систему.</p>
|
||||||
<a href="/login" class="btn btn-primary mt-2">Войти</a>
|
<a href="/login" role="button" class="mt-2">Войти</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}Подключение Эвотор — ЭВОСИНК{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="row justify-content-center">
|
|
||||||
<div class="col-sm-10 col-md-7 col-lg-6">
|
|
||||||
|
|
||||||
{% if error %}
|
|
||||||
<div class="alert alert-danger mt-4">
|
|
||||||
{% if error == "invalid_token" %}
|
|
||||||
<i class="bi bi-exclamation-triangle me-2"></i>Токен недействителен. Проверьте правильность и попробуйте снова.
|
|
||||||
{% elif error == "empty_token" %}
|
|
||||||
<i class="bi bi-exclamation-triangle me-2"></i>Введите токен.
|
|
||||||
{% else %}
|
|
||||||
<i class="bi bi-exclamation-triangle me-2"></i>Произошла ошибка при подключении: {{ error }}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="card shadow-sm mt-4">
|
|
||||||
<div class="card-header">
|
|
||||||
<h1 class="h5 mb-0">Подключение Эвотор</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if connection %}
|
|
||||||
{# ── CONNECTED STATE ── #}
|
|
||||||
<ul class="list-group list-group-flush">
|
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
|
||||||
<span class="text-muted small">Статус</span>
|
|
||||||
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Подключено</span>
|
|
||||||
</li>
|
|
||||||
{% if connection.store_name %}
|
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
|
||||||
<span class="text-muted small">Магазин</span>
|
|
||||||
<span>{{ connection.store_name }}</span>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% if connection.store_id %}
|
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
|
||||||
<span class="text-muted small">ID магазина</span>
|
|
||||||
<span class="font-monospace small text-muted">{{ connection.store_id }}</span>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
|
||||||
<span class="text-muted small">Подключено</span>
|
|
||||||
<span class="small">{{ connection.connected_at.strftime("%d.%m.%Y %H:%M") }}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<div class="card-footer">
|
|
||||||
<p class="text-muted small mb-2">Обновить токен (Личный кабинет Эвотор → <strong>Приложения → ЭвоСинк → Настройки</strong>):</p>
|
|
||||||
<form method="post" action="/evotor/token">
|
|
||||||
<div class="input-group input-group-sm">
|
|
||||||
<input type="text" name="token" class="form-control font-monospace" placeholder="Новый токен" required>
|
|
||||||
<button type="submit" class="btn btn-outline-secondary">Обновить</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="card-body d-grid">
|
|
||||||
<form method="post" action="/evotor/disconnect">
|
|
||||||
<button type="submit" class="btn btn-outline-danger w-100">Отключить аккаунт Эвотор</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% else %}
|
|
||||||
{# ── NOT CONNECTED STATE ── #}
|
|
||||||
<div class="card-body">
|
|
||||||
<p class="text-muted mb-3">
|
|
||||||
Для подключения вам нужно установить приложение <strong>ЭвоСинк</strong> в личном кабинете Эвотор
|
|
||||||
и скопировать токен доступа из его настроек.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ol class="text-muted small mb-4">
|
|
||||||
{% if app_url %}
|
|
||||||
<li class="mb-1">Откройте <a href="{{ app_url }}" target="_blank" rel="noopener">приложение ЭвоСинк в магазине Эвотор <i class="bi bi-box-arrow-up-right small"></i></a> и установите его.</li>
|
|
||||||
{% else %}
|
|
||||||
<li class="mb-1">Найдите приложение <strong>ЭвоСинк</strong> в магазине Эвотор и установите его.</li>
|
|
||||||
{% endif %}
|
|
||||||
<li class="mb-1">Перейдите в раздел <strong>Приложения → ЭвоСинк → Настройки</strong>.</li>
|
|
||||||
<li class="mb-1">Скопируйте токен доступа и вставьте его в поле ниже.</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<form method="post" action="/evotor/token">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label small text-muted">Токен доступа</label>
|
|
||||||
<input type="text" name="token" class="form-control font-monospace" placeholder="Вставьте токен Эвотор" required autofocus>
|
|
||||||
</div>
|
|
||||||
<div class="d-grid">
|
|
||||||
<button type="submit" class="btn btn-primary">Подключить</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-3 text-center">
|
|
||||||
<a href="/connections" class="text-muted small">
|
|
||||||
<i class="bi bi-arrow-left me-1"></i>Вернуться к подключениям
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -2,26 +2,23 @@
|
|||||||
{% block title %}Забыли пароль — ЭВОСИНК{% endblock %}
|
{% block title %}Забыли пароль — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-center">
|
||||||
<div class="col-sm-10 col-md-6 col-lg-5">
|
<div class="col-sm-10 col-md-6 col-lg-5">
|
||||||
<div class="card shadow-sm mt-4">
|
<article class="card mt-4">
|
||||||
<div class="card-body p-4">
|
<div class="card-body">
|
||||||
<h1 class="card-title h4 mb-2">Забыли пароль?</h1>
|
<h1 style="font-size:1.3rem;" class="mb-2">Забыли пароль?</h1>
|
||||||
<p class="text-muted small mb-4">Введите email, указанный при регистрации.</p>
|
<p class="text-muted small mb-4">Введите email, указанный при регистрации.</p>
|
||||||
<form method="post" action="/forgot-password">
|
<form method="post" action="/forgot-password">
|
||||||
<div class="mb-3">
|
<label for="email">Email
|
||||||
<label for="email" class="form-label">Email</label>
|
<input type="email" id="email" name="email" required>
|
||||||
<input type="email" id="email" name="email" class="form-control" required>
|
</label>
|
||||||
</div>
|
<button type="submit" class="w-100">Отправить ссылку для сброса</button>
|
||||||
<div class="d-grid">
|
|
||||||
<button type="submit" class="btn btn-primary">Отправить ссылку для сброса</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
<div class="mt-3 text-center small">
|
<div class="text-center small mt-3">
|
||||||
<a href="/login">Вернуться ко входу</a>
|
<a href="/login">Вернуться ко входу</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
44
web/templates/invite.html
Normal file
44
web/templates/invite.html
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Завершение регистрации — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-center">
|
||||||
|
<div class="col-sm-10 col-md-7 col-lg-6">
|
||||||
|
<article class="card mt-4">
|
||||||
|
<header>
|
||||||
|
<h1><i class="bi bi-person-plus me-2"></i>Добро пожаловать в ЭВОСИНК!</h1>
|
||||||
|
</header>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted mb-4">Ваш аккаунт был создан через Эвотор. Заполните данные профиля и задайте пароль для входа.</p>
|
||||||
|
<form method="post" action="/invite?token={{ token }}">
|
||||||
|
<div class="row gap-2 mb-2">
|
||||||
|
<div class="col">
|
||||||
|
<label for="first_name">Имя <span class="text-danger">*</span>
|
||||||
|
<input type="text" id="first_name" name="first_name" value="{{ form.first_name if form else (invite_user.first_name or '') }}" required>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label for="last_name">Фамилия <span class="text-danger">*</span>
|
||||||
|
<input type="text" id="last_name" name="last_name" value="{{ form.last_name if form else (invite_user.last_name or '') }}" required>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label for="email">Email <span class="text-danger">*</span>
|
||||||
|
<input type="email" id="email" name="email" value="{{ form.email if form else (invite_user.email or '') }}" required>
|
||||||
|
</label>
|
||||||
|
<label for="phone">Телефон <span class="text-danger">*</span>
|
||||||
|
<input type="tel" id="phone" name="phone" value="{{ form.phone if form else (invite_user.phone or '') }}" required>
|
||||||
|
</label>
|
||||||
|
<label for="password">Пароль <span class="text-danger">*</span>
|
||||||
|
<input type="password" id="password" name="password" required minlength="8">
|
||||||
|
</label>
|
||||||
|
<label for="password_confirm">Подтверждение пароля <span class="text-danger">*</span>
|
||||||
|
<input type="password" id="password_confirm" name="password_confirm" required>
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="w-100">Завершить регистрацию</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -2,31 +2,26 @@
|
|||||||
{% block title %}Вход — ЭВОСИНК{% endblock %}
|
{% block title %}Вход — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-center">
|
||||||
<div class="col-sm-10 col-md-6 col-lg-5">
|
<div class="col-sm-10 col-md-6 col-lg-5">
|
||||||
<div class="card shadow-sm mt-4">
|
<article class="card mt-4">
|
||||||
<div class="card-body p-4">
|
<div class="card-body">
|
||||||
<h1 class="card-title h4 mb-4">Вход</h1>
|
<h1 class="mb-4" style="font-size:1.3rem;">Вход</h1>
|
||||||
<form method="post" action="/login">
|
<form method="post" action="/login">
|
||||||
<div class="mb-3">
|
<label for="email">Email
|
||||||
<label for="email" class="form-label">Email</label>
|
<input type="email" id="email" name="email" value="{{ form.email if form else '' }}" required>
|
||||||
<input type="email" id="email" name="email" class="form-control"
|
</label>
|
||||||
value="{{ form.email if form else '' }}" required>
|
<label for="password">Пароль
|
||||||
</div>
|
<input type="password" id="password" name="password" required>
|
||||||
<div class="mb-3">
|
</label>
|
||||||
<label for="password" class="form-label">Пароль</label>
|
<button type="submit" class="w-100">Войти</button>
|
||||||
<input type="password" id="password" name="password" class="form-control" required>
|
|
||||||
</div>
|
|
||||||
<div class="d-grid">
|
|
||||||
<button type="submit" class="btn btn-primary">Войти</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
<div class="mt-3 text-center small">
|
<div class="text-center small mt-3">
|
||||||
<a href="/forgot-password">Забыли пароль?</a><br>
|
<a href="/forgot-password">Забыли пароль?</a><br>
|
||||||
<a href="/register">Зарегистрироваться</a>
|
<a href="/register">Зарегистрироваться</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -2,17 +2,17 @@
|
|||||||
{% block title %}{{ title }} — ЭВОСИНК{% endblock %}
|
{% block title %}{{ title }} — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-center">
|
||||||
<div class="col-sm-10 col-md-6 col-lg-5">
|
<div class="col-sm-10 col-md-6 col-lg-5">
|
||||||
<div class="card shadow-sm mt-5 text-center">
|
<article class="card mt-5 text-center">
|
||||||
<div class="card-body p-5">
|
<div class="card-body" style="padding: 2.5rem;">
|
||||||
<h1 class="h4 mb-3">{{ title }}</h1>
|
<h1 style="font-size:1.3rem;" class="mb-3">{{ title }}</h1>
|
||||||
<p class="text-muted">{{ message }}</p>
|
<p class="text-muted">{{ message }}</p>
|
||||||
{% if link %}
|
{% if link %}
|
||||||
<a href="{{ link }}" class="btn btn-primary mt-2">{{ link_text }}</a>
|
<a href="{{ link }}" role="button" class="mt-2">{{ link_text }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -2,45 +2,30 @@
|
|||||||
{% block title %}Изменить пароль — ЭВОСИНК{% endblock %}
|
{% block title %}Изменить пароль — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-center">
|
||||||
<div class="col-sm-10 col-md-6 col-lg-5">
|
<div class="col-sm-10 col-md-6 col-lg-5">
|
||||||
<div class="card shadow-sm mt-4">
|
<article class="card mt-4">
|
||||||
<div class="card-header">
|
<header>
|
||||||
<h1 class="h5 mb-0"><i class="bi bi-key me-2"></i>Изменить пароль</h1>
|
<h1><i class="bi bi-key me-2"></i>Изменить пароль</h1>
|
||||||
</div>
|
</header>
|
||||||
<div class="card-body p-4">
|
<div class="card-body">
|
||||||
{% if success %}
|
|
||||||
<div class="alert alert-success">{{ success }}</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if errors %}
|
|
||||||
<div class="alert alert-danger">
|
|
||||||
{% for error in errors %}
|
|
||||||
<div>{{ error }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<form method="post" action="/profile/change-password">
|
<form method="post" action="/profile/change-password">
|
||||||
<div class="mb-3">
|
<label for="current_password">Текущий пароль
|
||||||
<label for="current_password" class="form-label">Текущий пароль</label>
|
<input type="password" id="current_password" name="current_password" required>
|
||||||
<input type="password" id="current_password" name="current_password" class="form-control" required>
|
</label>
|
||||||
</div>
|
<label for="password">Новый пароль
|
||||||
<div class="mb-3">
|
<input type="password" id="password" name="password" required>
|
||||||
<label for="password" class="form-label">Новый пароль</label>
|
</label>
|
||||||
<input type="password" id="password" name="password" class="form-control" required>
|
<label for="password_confirm">Подтвердить пароль
|
||||||
</div>
|
<input type="password" id="password_confirm" name="password_confirm" required>
|
||||||
<div class="mb-4">
|
</label>
|
||||||
<label for="password_confirm" class="form-label">Подтвердить пароль</label>
|
|
||||||
<input type="password" id="password_confirm" name="password_confirm" class="form-control" required>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button type="submit" class="btn btn-primary">Изменить пароль</button>
|
<button type="submit">Изменить пароль</button>
|
||||||
<a href="/profile" class="btn btn-outline-secondary">Отмена</a>
|
<a href="/profile" role="button" class="outline secondary">Отмена</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -2,40 +2,30 @@
|
|||||||
{% block title %}Удалить аккаунт — ЭВОСИНК{% endblock %}
|
{% block title %}Удалить аккаунт — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-center">
|
||||||
<div class="col-sm-10 col-md-6 col-lg-5">
|
<div class="col-sm-10 col-md-6 col-lg-5">
|
||||||
<div class="card shadow-sm mt-4 border-danger">
|
<article class="card mt-4" style="border-color: #dc2626;">
|
||||||
<div class="card-header bg-danger text-white">
|
<header class="bg-danger-header">
|
||||||
<h1 class="h5 mb-0"><i class="bi bi-trash me-2"></i>Удалить аккаунт</h1>
|
<h1><i class="bi bi-trash me-2"></i>Удалить аккаунт</h1>
|
||||||
</div>
|
</header>
|
||||||
<div class="card-body p-4">
|
<div class="card-body">
|
||||||
<div class="alert alert-warning">
|
<div role="alert" class="alert alert-warning mb-3">
|
||||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||||
<strong>Внимание!</strong> Это действие необратимо. Все ваши данные будут удалены.
|
<strong>Внимание!</strong> Это действие необратимо. Все ваши данные будут удалены.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if errors %}
|
|
||||||
<div class="alert alert-danger">
|
|
||||||
{% for error in errors %}
|
|
||||||
<div>{{ error }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<form method="post" action="/profile/delete">
|
<form method="post" action="/profile/delete">
|
||||||
<div class="mb-4">
|
<label for="password">Введите пароль для подтверждения
|
||||||
<label for="password" class="form-label">Введите пароль для подтверждения</label>
|
<input type="password" id="password" name="password" required>
|
||||||
<input type="password" id="password" name="password" class="form-control" required>
|
</label>
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button type="submit" class="btn btn-danger">
|
<button type="submit" class="danger">
|
||||||
<i class="bi bi-trash me-1"></i>Удалить мой аккаунт
|
<i class="bi bi-trash me-1"></i>Удалить мой аккаунт
|
||||||
</button>
|
</button>
|
||||||
<a href="/profile" class="btn btn-outline-secondary">Отмена</a>
|
<a href="/profile" role="button" class="outline secondary">Отмена</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -2,54 +2,42 @@
|
|||||||
{% block title %}Редактировать профиль — ЭВОСИНК{% endblock %}
|
{% block title %}Редактировать профиль — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-center">
|
||||||
<div class="col-sm-10 col-md-7 col-lg-6">
|
<div class="col-sm-10 col-md-7 col-lg-6">
|
||||||
<div class="card shadow-sm mt-4">
|
<article class="card mt-4">
|
||||||
<div class="card-header">
|
<header>
|
||||||
<h1 class="h5 mb-0"><i class="bi bi-pencil me-2"></i>Редактировать профиль</h1>
|
<h1><i class="bi bi-pencil me-2"></i>Редактировать профиль</h1>
|
||||||
</div>
|
</header>
|
||||||
<div class="card-body p-4">
|
<div class="card-body">
|
||||||
{% if success %}
|
|
||||||
<div class="alert alert-success">{{ success }}</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if errors %}
|
|
||||||
<div class="alert alert-danger">
|
|
||||||
{% for error in errors %}
|
|
||||||
<div>{{ error }}</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<form method="post" action="/profile/edit">
|
<form method="post" action="/profile/edit">
|
||||||
<div class="row g-3 mb-3">
|
<div class="row gap-2 mb-2">
|
||||||
<div class="col-sm-6">
|
<div class="col">
|
||||||
<label for="first_name" class="form-label">Имя</label>
|
<label for="first_name">Имя
|
||||||
<input type="text" id="first_name" name="first_name" class="form-control"
|
<input type="text" id="first_name" name="first_name"
|
||||||
value="{{ form.first_name if form else user.first_name }}" required>
|
value="{{ form.first_name if form else user.first_name }}" required>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-sm-6">
|
<div class="col">
|
||||||
<label for="last_name" class="form-label">Фамилия</label>
|
<label for="last_name">Фамилия
|
||||||
<input type="text" id="last_name" name="last_name" class="form-control"
|
<input type="text" id="last_name" name="last_name"
|
||||||
value="{{ form.last_name if form else user.last_name }}" required>
|
value="{{ form.last_name if form else user.last_name }}" required>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<label>Email
|
||||||
<label class="form-label text-muted">Email</label>
|
<input type="email" value="{{ user.email }}" disabled>
|
||||||
<input type="email" class="form-control" value="{{ user.email }}" disabled>
|
</label>
|
||||||
</div>
|
<label for="phone">Телефон
|
||||||
<div class="mb-4">
|
<input type="tel" id="phone" name="phone"
|
||||||
<label for="phone" class="form-label">Телефон</label>
|
|
||||||
<input type="tel" id="phone" name="phone" class="form-control"
|
|
||||||
value="{{ form.phone if form else user.phone }}" required>
|
value="{{ form.phone if form else user.phone }}" required>
|
||||||
</div>
|
</label>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
<button type="submit">Сохранить</button>
|
||||||
<a href="/profile" class="btn btn-outline-secondary">Отмена</a>
|
<a href="/profile" role="button" class="outline secondary">Отмена</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user