56 Commits

Author SHA1 Message Date
mguschin
2670a34504 fix: sync settings toggles always saving as off
form.get() returns the first value when both the hidden fallback input
and the checked checkbox are submitted under the same name — so "0"
always won. Switch to form.getlist() and check for "1" in the list.

Also default evo_mirror_enabled to True so new users don't have to
manually enable it before the catalog can populate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 19:51:37 +03:00
mguschin
403d8bce4d fix: show user nav (not admin nav) when impersonating a user
When viewed_user is set, the sidebar should render the full user
navigation (connections, catalog, sync, profile) instead of the
admin-only panel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:54:28 +03:00
mguschin
0c71497522 feat: add delete button to admin users list page
Allow admin (not just system) to delete users directly from the list
without navigating to the detail page. Also enables role management
link and create-user role selector for admin role.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:48:29 +03:00
mguschin
cd777d2bc1 fix: always update password on re-registration in /user/create
When Evotor sends a new /user/create for an existing userId, the new
password should replace the old one so /user/verify stays in sync.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:43:05 +03:00
mguschin
41280fad45 fix: look up user by evotor_user_id first in /user/verify
Evotor sends userId but not necessarily a matching phone/email.
Now tries evotor_user_id first, then falls back to email/phone.
Also removed the requirement for username/phone when userId is present.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:27:34 +03:00
mguschin
e5a55a02d1 debug: log user/verify request fields (excluding password)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:25:33 +03:00
mguschin
1d268a6b58 feat: add Отчество field to admin user detail page
- Add middle_name input to the edit form (3-column name grid)
- Save middle_name in the edit handler
- Show role selector for admin role (previously system-only)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:21:51 +03:00
mguschin
a597639aa7 fix: grant admin role full access to logs, roles, delete, and role changes
Previously these actions were restricted to system role only. Admin and
system are now treated equally across: API logs view, user role editing,
user deletion, and role/permissions management. Regular users remain blocked.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:13:48 +03:00
mguschin
04ca914971 fix: use get_viewed_user in all connection action routes
All POST/action routes in connections.py were using get_current_user,
which returns the real logged-in admin instead of the impersonated user.
Disconnect, test, save and manual token routes now all operate on the
viewed user so admin impersonation works correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:09:56 +03:00
mguschin
5b82f1bc02 fix: upsert evotor_connection by user_id fallback to prevent duplicate insert
When a connection row exists for the user but with a different/null
evotor_user_id, the lookup by evotor_user_id alone missed it and tried
to INSERT, hitting the unique constraint on user_id. Now looks up by
either evotor_user_id or user_id, and always syncs both fields on update.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:04:33 +03:00
mguschin
fa8167af4d feat: accept phone + password in /user/verify webhook
- Accept phone as an alternative to username for user lookup
- On first auth when user has no password set, save the provided
  password and activate the account (same logic as /user/create)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:00:16 +03:00
mguschin
5a67be2c81 feat: support password field in /user/create webhook
When Evotor sends a password in the payload, hash and store it
immediately and set the user to active — skipping the invite flow.
Without a password, behaviour is unchanged (pending status + invite email).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 16:57:58 +03:00
mguschin
74f613a4c3 fix: parse Evotor install/uninstall event structure correctly
userId is nested inside body.data, and event types are
ApplicationInstalled / ApplicationUninstalled (not install/uninstall).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 16:53:34 +03:00
mguschin
052c3b610f feat: add middle_name field and map all three Evotor name fields
- Add middle_name column to users table (migration 0012)
- Map Evotor's second_name → last_name, middle_name → middle_name
  in /user/create webhook handler
- Update name fields on existing users when Evotor sends them

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 16:51:11 +03:00
mguschin
dc32bef7e8 fix: read email/phone from top-level body fields in /user/create
Evotor sends user fields (email, phone_number, first_name, last_name)
at the top level of the webhook body, not inside customField. Now we
check both locations with top-level taking precedence. Also store the
full body in evotor_meta instead of just the customField subset.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 16:48:15 +03:00
mguschin
a3f6697bc4 debug: log raw body on /user/install to identify Evotor payload structure
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 16:45:55 +03:00
mguschin
175f1f4c27 feat: add /user/install webhook + exclude admins from third-party API tasks
- Add POST /user/install endpoint handling Evotor app install/uninstall
  events: uninstall suspends the user and marks connection offline;
  reinstall reactivates a suspended account
- Exclude admin and system role users from refresh_catalog,
  refresh_vk_catalog, and mirror_to_vk periodic tasks by joining users
  table and filtering role = 'user'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 16:41:08 +03:00
mguschin
9f87458e0c fix: exclude admin/system users from all third-party API tasks
Catalog, VK catalog, and VK sync tasks were querying all connections
regardless of user role. Admin and system accounts with stored tokens
were generating unnecessary Evotor and VK API calls. Now all three
tasks join to the users table and filter role = 'user' only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 16:14:26 +03:00
mguschin
e0594f67a8 feat: add favicon (SVG shopping-bag logo + ICO fallback)
Orange #FF5500 rounded-square with shopping-bag icon matching the
sidebar logo. Includes SVG (modern), ICO (16+32px, legacy), and
apple-touch-icon PNG.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 12:50:08 +03:00
mguschin
eb4165e48b feat: apply new Мои Товары design system across all templates
Replace Pico CSS with custom design: dark sidebar layout, Golos Text +
JetBrains Mono fonts, orange accent (#FF5500), new component classes
(cards, tables, buttons, tags, toggles, alerts, tabs, login split-panel).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 12:43:08 +03:00
mguschin
75513e647d revert: restore horizontal scroll table layout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:11:54 +03:00
mguschin
db0a3bb4d6 fix: table compresses to page width with fixed layout and ellipsis overflow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:09:32 +03:00
mguschin
545c6aade6 fix: table fills full card width
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:06:18 +03:00
mguschin
98264d42af fix: card clips overflowing table content to screen width
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:03:57 +03:00
mguschin
fca3ca115e fix: show create user errors inside dialog, pre-fill form on error
Used novalidate + server-side validation so errors appear in the dialog
rather than as browser-native popups. Form fields retain submitted values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:01:46 +03:00
mguschin
52825f70de feat: admin can create users from /admin/users page
Adds a dialog form on the users list. Validates email uniqueness,
password length. Creates user as active with confirmed email.
System role can assign admin role; admin role can only create users.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 20:58:00 +03:00
mguschin
8549e98f8d feat: hide user nav links for admin, redirect admin to /admin/users on login
Connections/Catalog/VK/Sync nav links only shown for regular users or when
admin is viewing as a user. Admin/system users land on /admin/users after login.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 20:48:02 +03:00
mguschin
5e7be16755 feat: remove register, add evo webhooks, admin view-as user
- Remove /register route and nav links (users created via Evotor webhook)
- Fix evotor_webhooks.py: use phone=None instead of phone="" to avoid unique constraint
- Add admin "view as user" feature: POST /admin/users/{id}/view-as sets viewed_user_id
  in session; POST /admin/view-as/stop clears it
- catalog, vk_catalog, sync, connections GET routes use get_viewed_user() so admins
  see another user's data while browsing
- Orange banner shown at top when admin is viewing as another user

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 20:44:25 +03:00
mguschin
1729ff9b7b fix: disabling last store/group no longer resets all to enabled
Added store_filters_seeded / group_filters_seeded flags to SyncConfig.
_enabled_*_ids now returns None (all enabled) only before first toggle,
not when the filter table is empty due to all being disabled.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 14:36:14 +03:00
mguschin
ebcca2a699 fix: make users.phone nullable to allow admin creation without phone
Phone is optional — admin users created via script don't have one.
Added migration 0010 to alter the column, updated create_admin.py to
pass None instead of empty string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 14:17:50 +03:00
mguschin
7860256c37 fix: use NULL for empty phone in create_admin to avoid unique index conflict
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 14:15:40 +03:00
mguschin
ddc3dc0a97 refactor: nginx.conf is source of truth, drop generate script and template
nginx/nginx.conf is symlinked directly into system nginx config.
No need for a per-domain generate script or template — edit the file,
run nginx -t && systemctl reload nginx.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 14:08:11 +03:00
mguschin
ff32812b61 refactor: remove IDN auto-conversion, pass punycode directly to TLS scripts
Simpler than auto-converting: just pass xn----8sbfwtmcso8g.xn--p1ai directly.
Updated usage comments in both scripts to reflect this.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 14:07:29 +03:00
mguschin
dbb1f48da7 fix: correct punycode for мои-товары.рф and add IDN support to generate-nginx-conf.sh
xn--e1afmapc4af.xn--p1af was wrong; correct punycode is xn----8sbfwtmcso8g.xn--p1ai.
generate-nginx-conf.sh now converts IDN domains to punycode before expanding the
template, so cert paths and server_name directives are always ASCII-safe.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 14:06:54 +03:00
mguschin
23e175d9a8 fix: convert IDN/Cyrillic domains to punycode before calling certbot
certbot rejects non-ASCII domain names; convert using Python's idna
encoder per-label so мои-товары.рф becomes xn--e1afmapc4af.xn--p1af.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 14:00:05 +03:00
mguschin
e816672e16 perf: reduce RAM usage for 1GB host
- MariaDB: limit innodb buffer pool to 128M, max 20 connections
- Celery worker: concurrency 2→1
- Flower: moved to 'flower' profile (opt-in, not started by default)
  Start with: docker compose --profile flower up -d flower

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 11:16:58 +03:00
mguschin
7df5da76d7 feat: multi-domain nginx configs and TLS scripts for мои-товары.рф / my-products.ru
- nginx/nginx.conf: pre-generated config for both domains (IDN punycode for .рф)
- scripts/generate-nginx-conf.sh: generates sites-available config from template per domain
- scripts/init-letsencrypt.sh: accepts domain as arg (falls back to .env)
- README.md: updated deploy section, removed stale VK_WEIGHT_PRICE_MULTIPLIER, added sync/logs routes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 10:39:02 +03:00
mguschin
75b3872170 feat: per-task on/off switches on /sync page for staged rollout
Adds evo_mirror_enabled and vk_mirror_enabled flags to SyncConfig.
Each of the three background tasks (Зеркало Эвотор / Зеркало ВК /
Синхронизация) can now be enabled independently from the /sync page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 23:32:02 +03:00
mguschin
5c2b501749 fix: delete VK product immediately when allow_to_sell becomes false
Previously only the create path checked allow_to_sell. The update path
kept syncing disabled products indefinitely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 23:20:18 +03:00
mguschin
72194131c7 fix: include price multiplier quantity in VK description postfix
"цена за 10 г." instead of "цена за г." when multiplier is set.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 23:13:39 +03:00
mguschin
02abddc587 refactor: derive VK description postfix from measure_name, drop global postfix setting
Each product's description is now built as "Name (цена за M.)" using its
own measure_name. The global description_postfix setting is removed —
it couldn't handle per-product units.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 23:08:45 +03:00
mguschin
e169a91146 feat: /sync settings page with price multiplier and description postfix
Adds price_multiplier and description_postfix to SyncConfig. The sync
page at /sync lets users configure them. vk_sync reads these per-user
settings and applies the multiplier to price and appends the postfix
as "(postfix)" to the VK product description.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 23:04:23 +03:00
mguschin
fb3b6e2327 fix: use product name as VK description fallback (VK requires ≥10 chars)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:57:21 +03:00
mguschin
8d97f75fa1 fix: sync Evotor description to VK as-is, remove generated wrapper text
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:53:06 +03:00
mguschin
e0e43f3fc3 fix: VK Market write API expects rubles, read amount field is kopecks
market.add/edit accept price in rubles; market.get returns price.amount
in kopecks. Cache stores rubles (amount/100), send rubles on write.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:49:13 +03:00
mguschin
3ad383d00b fix: remove weight price multiplier, send Evotor prices as-is to VK
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:45:22 +03:00
mguschin
d25caa2b96 fix: VK API returns prices in rubles, not kopecks — remove /100 division
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:40:52 +03:00
mguschin
b926ca0b90 fix: send prices in rubles to VK Market API, not kopecks
VK Market API expects the price field in rubles (float), not kopecks.
Removing the *100 conversion that was inflating all prices by 100x.
Comparison with cached VK prices now also uses rubles consistently.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:37:27 +03:00
mguschin
9960d760a0 feat: API request/response logging with admin log viewer
- Add api_logs table (migration 0007) and ApiLog model
- Add web/lib/api_logger.py — httpx wrapper that records every outbound call
- Wire api_logger into vk_sync, vk_catalog, and connections test endpoints
- Add /admin/logs page with filters (service, method, status, time range, URL search) and expandable request/response detail
- Add "Логи" nav link for admin users

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 22:00:14 +03:00
mguschin
cad0b10fbb fix: delete stale cached albums that no longer exist in VK
After refresh_vk_catalog syncs the album list from VK, remove any
VkCachedAlbum rows whose album_id was not returned by the API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:47:43 +03:00
mguschin
bb9fc71ed8 fix: use item_id (singular) in market.removeFromAlbum call
VK API v5.199 requires item_id not item_ids for market.removeFromAlbum.
The wrong parameter name caused silent failures — products were not
actually removed from their old album when moved to a new one.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:34:14 +03:00
mguschin
83edac4200 fix: read parent_id field from Evotor API and move VK products between albums on group change
- catalog.py: include parent_id as fallback for group_evotor_id (Evotor API returns parent_id instead of group/parentUuid)
- vk_sync.py: on product update, detect album change and call market.removeFromAlbum + market.addToAlbum to move product to the correct album

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 21:30:01 +03:00
mguschin
7b4f52b005 feat: VK OAuth flow, catalog sync improvements, and expanded test suite
- Add VK OAuth implicit flow: /vk-auth redirect, /vk-callback JS page,
  /vk-callback/save endpoint with state validation
- Add VK_CLIENT_ID/VK_CLIENT_SECRET to config
- Add refresh_token/token_expires_at columns to vk_connections (migration 0006)
- Fix vk_catalog task: handle price/thumb_photo as string or dict (VK API v5.199)
- Fix connections/vk/test: use groups.getById instead of market.getAlbums
  (works with both user and group tokens)
- Add orphan deletion to mirror_to_vk: VK products not in Evotor are removed
- Handle ungrouped Evotor products: push to "Без категории" VK album
- Respect SyncConfig.is_enabled in mirror_to_vk
- Add product count column to catalog groups page
- Add group name column to catalog products page
- Expand test suite: 73 new tests covering connections routes, catalog routes,
  vk_sync task logic, and catalog task helpers (138 total, all passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 15:09:47 +03:00
mguschin
4f4081c54c config: make domain configurable via DOMAIN env var
Replace hardcoded evosync.ru with a DOMAIN variable read from .env.
nginx.conf is now generated from nginx.conf.template via envsubst;
init-letsencrypt.sh reads DOMAIN from .env and fails loudly if unset.
README documents the new variable and first-deploy TLS workflow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 14:01:38 +03:00
mguschin
796cf49ff9 feat: Evotor + VK catalog sync, connections, and store/group filters
- Evotor catalog: background Celery task syncing stores/groups/products
  from Evotor API; UI pages with per-store and per-group sync toggles
- VK connection: manual token + group ID entry with inline test button
- Evotor connection: inline test button (calls /stores)
- VK catalog: background task syncing VK Market albums and products;
  separate catalog UI at /vk-catalog/albums
- SyncFilter extended to support entity_type=group with parent_entity_id
- Migration 0004: vk_cached_albums + vk_cached_products tables
- Beat schedule updated to run both refresh_catalog and refresh_vk_catalog
- README updated with new schema, routes, tasks, and config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 18:09:11 +03:00
mguschin
7a06045bef docs: rewrite README in English with full architecture reference
Also add itsdangerous to requirements.txt (missing implicit dep of
starlette SessionMiddleware) and a create_admin.py script for
bootstrapping a system-role user with all permissions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 16:38:14 +03:00
68 changed files with 6151 additions and 1265 deletions

View File

@@ -6,7 +6,8 @@ DB_PASSWORD=evosync
# App
SECRET_KEY=change-me-in-production
BASE_URL=https://evosync.ru
DOMAIN=yourdomain.com
BASE_URL=https://${DOMAIN}
# Evotor
EVOTOR_APP_ID=

2
.gitignore vendored
View File

@@ -18,3 +18,5 @@ __pycache__/
*.pyc
certbot
web-resources
.coverage
password|*

289
README.md
View File

@@ -1,3 +1,288 @@
# ЭВОСИНК (EvoSync)
# EvoSync
Сервис автоматической синхронизации товарного каталога из [Эвотор](https://evotor.ru) в магазин [ВКонтакте](https://vk.com).
Web service for syncing a product catalog from Evotor POS → VK Market. Users connect their Evotor account and a VK community; products from the cash register then appear automatically in the VK store.
---
## Architecture
```
┌─────────────────────────────────────────┐
│ Docker Compose │
│ │
Browser / Evotor ─────► web :8000 (FastAPI + Uvicorn) │
:8080 │ │ │
│ ├── MariaDB :3306 (primary DB) │
│ ├── Redis :6379 (Celery broker) │
│ │ │
│ ├── worker (Celery worker) │
│ ├── beat (Celery beat scheduler) │
│ └── flower :5555 (queue monitor) │
└─────────────────────────────────────────┘
```
### Services
| Service | Image / Dockerfile | Purpose | External port |
|----------|---------------------|-----------------------------------------------|---------------|
| `web` | `Dockerfile.web` | FastAPI app, runs Alembic migrations on start | 8080 → 8000 |
| `worker` | `Dockerfile.web` | Celery worker (sync, health, notifications…) | — |
| `beat` | `Dockerfile.web` | Celery Beat — periodic task scheduler | — |
| `flower` | `Dockerfile.web` | Celery queue monitoring UI | 5555 |
| `db` | `mariadb:11.4` | Primary relational database | — |
| `redis` | `redis:7-alpine` | Celery broker and result backend | — |
### Stack
- **Python 3.12**, FastAPI 0.115, Uvicorn
- **SQLAlchemy 2** + Alembic, MariaDB (PyMySQL)
- **Celery 5** + Redis — background tasks, periodic catalog sync
- **Jinja2** — server-side HTML rendering (`web/templates/`)
- **Pydantic Settings** — configuration from env vars / `.env`
- bcrypt — password hashing
- python-json-logger — structured JSON logs to stdout
---
## Database Schema
| Table | Purpose |
|-----------------------|--------------------------------------------------------------------------------------|
| `users` | User accounts (roles: system / admin / user; statuses: pending / active / suspended) |
| `evotor_connections` | User ↔ Evotor link (access_token, api_token returned to Evotor webhooks) |
| `vk_connections` | User ↔ VK link (user access token + VK community ID) |
| `sync_configs` | Per-user sync settings |
| `sync_filters` | Store / group inclusion filters (entity_type: store / group) |
| `cached_stores` | Cached list of Evotor stores |
| `cached_groups` | Cached Evotor product groups |
| `cached_products` | Cached Evotor products; `vk_product_id` stores the VK market item ID after first push |
| `vk_cached_albums` | Cached VK Market albums (product groups) |
| `vk_cached_products` | Cached VK Market products |
| `roles` | RBAC roles |
| `permissions` | RBAC permissions |
| `role_permissions` | M2M: role ↔ permission |
| `user_roles` | M2M: user ↔ role |
---
## Background Tasks
Periodic tasks run via **Celery Beat** and are executed by the **worker** service.
Beat fires a single **sync pipeline** every `CATALOG_REFRESH_INTERVAL_SECONDS`. The three tasks run as a Celery chain — each step starts only after the previous one completes:
```
run_sync_pipeline (beat entry)
└─► refresh_catalog — fetch Evotor stores / groups / products
└─► refresh_vk_catalog — fetch VK Market albums / products
└─► mirror_to_vk — push Evotor → VK
```
| Task | Queue | Description |
|------|-------|-------------|
| `web.tasks.celery_app.run_sync_pipeline` | default | Beat entry point; dispatches the chain |
| `web.tasks.catalog.refresh_catalog` | default | Fetches Evotor catalog for all connected users; upserts `cached_stores`, `cached_groups`, `cached_products` |
| `web.tasks.vk_catalog.refresh_vk_catalog` | default | Fetches VK Market albums and products for all connected users; upserts `vk_cached_albums`, `vk_cached_products` |
| `web.tasks.vk_sync.mirror_to_vk` | sync | Mirrors enabled Evotor groups/products → VK Market (create or conditional update) |
**Evotor fetch sequence per user:**
1. `GET /stores` → upsert `cached_stores`
2. For each store: `GET /stores/{id}/product-groups` → upsert `cached_groups`
3. For each store: `GET /stores/{id}/products` → upsert `cached_products`
**VK fetch sequence per user:**
1. `market.getAlbums` → upsert `vk_cached_albums`
2. `market.get` (extended=1, paginated) → upsert `vk_cached_products` with album membership
**Mirror logic per user (`mirror_to_vk`):**
- Skips stores not in enabled store filters; skips groups not in enabled group filters
- For each enabled group: ensures a matching VK album exists (creates via `market.addAlbum` if missing)
- For each product in the group:
- **Create** (`allow_to_sell=true`, no `vk_product_id` yet): uploads default photo once per run, calls `market.add`, assigns to album, stores returned `vk_product_id`
- **Update** (has `vk_product_id`): calls `market.edit` only if name, price, description, or stock_amount changed vs the cached VK state
- **Skip**: product disabled and never pushed, or nothing changed
- Price is multiplied by the per-user `price_multiplier` from `sync_configs` (configurable on the `/sync` page)
- Description is built as `"Name (цена за N unit.)"` when the product has a `measure_name`
Per-user failures are logged and skipped — one broken token does not block other users.
Evotor stores that return `402 Payment Required` (subscription limit) are silently skipped at debug log level.
---
## Routes
### Connections
| Method | Path | Description |
|--------|-----------------------------------|------------------------------------------|
| GET | `/connections` | View Evotor and VK connection status |
| POST | `/connections/evotor` | Save / update Evotor API token manually |
| POST | `/connections/evotor/disconnect` | Remove Evotor connection |
| POST | `/connections/evotor/test` | Test Evotor connection (JSON) |
| POST | `/connections/vk` | Save / update VK token and group ID |
| POST | `/connections/vk/disconnect` | Remove VK connection |
| POST | `/connections/vk/test` | Test VK connection (JSON) |
### Public / Authentication
| Method | Path | Description |
|----------|--------------------|----------------------------------------|
| GET | `/` | Redirects to `/profile` or `/login` |
| GET | `/health` | Health check (JSON) |
| GET/POST | `/register` | User registration |
| GET | `/confirm-email` | Email confirmation via token |
| GET | `/resend-confirm` | Resend confirmation email |
| GET/POST | `/login` | Login |
| GET | `/logout` | Logout |
| GET/POST | `/forgot-password` | Request password reset |
| GET/POST | `/reset-password` | Reset password via token |
| GET/POST | `/invite` | Complete registration via invite link |
### Profile (requires session)
| Method | Path | Description |
|----------|----------------------------|----------------------|
| GET | `/profile` | View profile |
| GET/POST | `/profile/edit` | Edit profile |
| GET/POST | `/profile/change-password` | Change password |
| GET/POST | `/profile/delete` | Delete account |
### Admin panel (`/admin`, roles: admin / system)
| Method | Path | Description |
|--------|------------------------------------|---------------------------|
| GET | `/admin/users` | User list |
| GET | `/admin/users/{id}` | User detail |
| POST | `/admin/users/{id}/activate` | Activate user |
| POST | `/admin/users/{id}/suspend` | Suspend user |
| POST | `/admin/users/{id}/reset-password` | Reset user password |
| POST | `/admin/users/{id}/send-invite` | Send invite email |
| POST | `/admin/users/{id}/edit` | Edit user data |
| POST | `/admin/users/{id}/delete` | Delete user |
| GET | `/admin/roles` | Roles and permissions |
| POST | `/admin/roles/{id}/permissions` | Update role permissions |
### Evotor Webhooks (Bearer `EVOTOR_WEBHOOK_SECRET`)
| Method | Path | Description |
|--------|----------------|-----------------------------------------------------------|
| POST | `/user/create` | Evotor creates/links a user; returns api_token |
| POST | `/user/verify` | Evotor verifies user credentials; returns api_token |
| POST | `/user/token` | Evotor delivers its own access_token for a user |
### Evotor Catalog (requires session)
| Method | Path | Description |
|--------|---------------------------------------------------|--------------------------------------------|
| GET | `/catalog` | Redirects to `/catalog/stores` |
| GET | `/catalog/stores` | Evotor stores with per-store sync toggle |
| GET | `/catalog/stores/{id}/groups` | Product groups with per-group sync toggle |
| GET | `/catalog/stores/{id}/products` | Products (filterable by group) |
| POST | `/catalog/stores/{id}/toggle` | Enable / disable store sync |
| POST | `/catalog/stores/{id}/groups/{gid}/toggle` | Enable / disable group sync |
### Sync Settings (requires session)
| Method | Path | Description |
|----------|------------------|------------------------------------------------------|
| GET | `/sync` | Sync settings: task on/off switches, price multiplier |
| POST | `/sync/settings` | Save sync settings |
### VK Catalog (requires session)
| Method | Path | Description |
|--------|---------------------------------------|------------------------------------|
| GET | `/vk-catalog/albums` | VK Market albums (product groups) |
| GET | `/vk-catalog/albums/{id}/products` | Products in a VK album |
### Admin Logs (`/admin`, roles: admin / system)
| Method | Path | Description |
|--------|---------------|-------------------------------------------------------|
| GET | `/admin/logs` | API request/response log viewer with filters and pagination |
### API Docs
| Path | Description |
|----------|-------------|
| `/docs` | Swagger UI |
| `/redoc` | ReDoc |
---
## Configuration
All settings are read from environment variables or a `.env` file:
| Variable | Default | Description |
|------------------------------------|-------------------------------------|---------------------------------------|
| `DATABASE_URL` | `mysql+pymysql://…@db:3306/evosync` | MariaDB connection string |
| `REDIS_URL` | `redis://redis:6379/0` | Redis connection string |
| `SECRET_KEY` | `change-me-in-production` | Session signing key |
| `DOMAIN` | — | Public domain name (e.g. `example.com`); used to derive `BASE_URL` and nginx config |
| `BASE_URL` | `https://${DOMAIN}` | Public URL of the service |
| `EVOTOR_APP_ID` | — | Evotor application ID |
| `EVOTOR_WEBHOOK_SECRET` | — | Bearer secret for webhook endpoints |
| `JIVOSITE_WIDGET_ID` | — | JivoSite widget ID |
| `VK_DEFAULT_PHOTO_PATH` | `/app/default_product.png` | Fallback image path for VK products |
| `VK_API_VERSION` | `5.199` | VK API version |
| `CATALOG_REFRESH_INTERVAL_SECONDS` | `3600` | Sync pipeline interval in seconds |
| `VK_CATEGORY_ID` | `40932` | VK Market category ID for all products |
| `VK_STOCK_AMOUNT` | `1000` | Stock amount set for in-sale products |
| `INVITE_EXPIRE_HOURS` | `48` | Invite link TTL in hours |
| `EMAIL_PROVIDER` | `console` | Email provider (console / smtp / …) |
| `SMS_PROVIDER` | `console` | SMS provider |
| `FLOWER_USER` / `FLOWER_PASSWORD` | `admin` / `changeme` | Basic Auth credentials for Flower |
---
## Running
```bash
cp .env.example .env # set DOMAIN and other values
docker compose up -d --build
```
App is available at `http://localhost:8080`.
Flower (queue monitor) at `http://localhost:5555`.
### First deploy (TLS)
Run once per domain (repeat for every domain pointing to this server):
```bash
# 1. Obtain TLS certificate
sudo ./scripts/init-letsencrypt.sh my-products.ru
sudo ./scripts/init-letsencrypt.sh мои-товары.рф
# 2. Generate nginx site config and symlink it into sites-enabled
sudo ./scripts/generate-nginx-conf.sh my-products.ru
sudo ./scripts/generate-nginx-conf.sh мои-товары.рф
# 3. Reload nginx
sudo systemctl reload nginx
```
`generate-nginx-conf.sh` expands `nginx/nginx.conf.template` with the given domain and writes the result to `/etc/nginx/sites-available/<domain>.conf`, then creates a symlink in `sites-enabled`.
Set up auto-renewal (if not already configured by certbot):
```
0 3 * * * root certbot renew --quiet && systemctl reload nginx
```
### Development
```bash
pip install -r requirements.txt
alembic upgrade head
uvicorn web.main:app --reload --port 8000
```
---
## Tests
```bash
pytest --cov=web
```

View File

@@ -2,6 +2,7 @@ services:
db:
image: mariadb:11.4
restart: unless-stopped
command: --innodb-buffer-pool-size=128M --max-connections=20
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MYSQL_DATABASE: ${DB_NAME}
@@ -39,7 +40,7 @@ services:
DATABASE_URL: mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db:3306/${DB_NAME}
REDIS_URL: redis://redis:6379/0
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
BASE_URL: ${BASE_URL:-https://evosync.ru}
BASE_URL: ${BASE_URL:-https://${DOMAIN}}
EVOTOR_APP_ID: ${EVOTOR_APP_ID:-}
EVOTOR_WEBHOOK_SECRET: ${EVOTOR_WEBHOOK_SECRET:-}
JIVOSITE_WIDGET_ID: ${JIVOSITE_WIDGET_ID:-}
@@ -73,7 +74,7 @@ services:
condition: service_healthy
db:
condition: service_healthy
command: celery -A web.tasks.celery_app worker --loglevel=info --concurrency=2 --queues=default,sync,health,notifications
command: celery -A web.tasks.celery_app worker --loglevel=info --concurrency=1 --queues=default,sync,health,notifications -E
beat:
build:
@@ -84,6 +85,7 @@ services:
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
@@ -96,6 +98,7 @@ services:
context: .
dockerfile: Dockerfile.web
restart: unless-stopped
profiles: [flower]
ports:
- "5555:5555"
environment:

View File

@@ -2,9 +2,11 @@ upstream web {
server 127.0.0.1:8080;
}
# ── мои-товары.рф ─────────────────────────────────────────────────────────────
server {
listen 80;
server_name evosync.ru www.evosync.ru;
server_name xn----8sbfwtmcso8g.xn--p1ai www.xn----8sbfwtmcso8g.xn--p1ai;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
@@ -17,10 +19,45 @@ server {
server {
listen 443 ssl;
server_name evosync.ru www.evosync.ru;
server_name xn----8sbfwtmcso8g.xn--p1ai www.xn----8sbfwtmcso8g.xn--p1ai;
ssl_certificate /etc/letsencrypt/live/evosync.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/evosync.ru/privkey.pem;
ssl_certificate /etc/letsencrypt/live/xn----8sbfwtmcso8g.xn--p1ai/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/xn----8sbfwtmcso8g.xn--p1ai/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
location / {
proxy_pass http://web;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# ── my-products.ru ────────────────────────────────────────────────────────────
server {
listen 80;
server_name my-products.ru www.my-products.ru;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name my-products.ru www.my-products.ru;
ssl_certificate /etc/letsencrypt/live/my-products.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/my-products.ru/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;

View File

@@ -5,6 +5,7 @@ jinja2==3.1.4
sqlalchemy==2.0.36
alembic==1.14.0
pymysql==1.1.1
itsdangerous>=2.1.0
cryptography>=44.0.0
bcrypt>=4.2.1
pydantic-settings==2.6.1

78
scripts/create_admin.py Normal file
View File

@@ -0,0 +1,78 @@
"""
Create (or promote) an admin user with all permissions.
Usage:
python -m scripts.create_admin --email admin@example.com --password secret
python -m scripts.create_admin --email admin@example.com --password secret --role system
"""
import argparse
import sys
from web.auth.password import hash_password
from web.database import SessionLocal
from web.models.rbac import Permission, Role, UserRole, role_permissions
from web.models.user import User, UserRoleEnum, UserStatusEnum
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--email", required=True)
parser.add_argument("--password", required=True)
parser.add_argument("--first-name", default="Admin")
parser.add_argument("--last-name", default="")
parser.add_argument("--phone", default="")
parser.add_argument("--role", default="system", choices=["admin", "system"])
args = parser.parse_args()
db = SessionLocal()
try:
user = db.query(User).filter(User.email == args.email).first()
if user:
user.role = UserRoleEnum(args.role)
user.status = UserStatusEnum.active
user.is_email_confirmed = True
user.password_hash = hash_password(args.password)
print(f"Updated existing user: {args.email} → role={args.role}")
else:
user = User(
first_name=args.first_name,
last_name=args.last_name,
email=args.email,
phone=args.phone or None,
password_hash=hash_password(args.password),
role=UserRoleEnum(args.role),
status=UserStatusEnum.active,
is_email_confirmed=True,
)
db.add(user)
db.flush()
print(f"Created user: {args.email} role={args.role}")
# Assign all permissions via the system role
system_role = db.query(Role).filter(Role.name == args.role).first()
if system_role:
existing = db.query(UserRole).filter(
UserRole.user_id == user.id,
UserRole.role_id == system_role.id,
).first()
if not existing:
db.add(UserRole(user_id=user.id, role_id=system_role.id))
db.commit()
# Report permissions this user now has
perms = (
db.query(Permission.name)
.join(role_permissions, Permission.id == role_permissions.c.permission_id)
.join(UserRole, UserRole.role_id == role_permissions.c.role_id)
.filter(UserRole.user_id == user.id)
.all()
)
print("Permissions:", [p.name for p in perms] or "(inherited via role)")
finally:
db.close()
if __name__ == "__main__":
main()

View File

@@ -1,19 +1,39 @@
#!/bin/bash
# Obtain TLS certificates from Let's Encrypt for evosync.ru
# Run once on first deploy: sudo ./scripts/init-letsencrypt.sh
# Requires nginx running on the host with acme-challenge location configured
# Obtain a TLS certificate from Let's Encrypt for one domain.
#
# Usage:
# sudo ./scripts/init-letsencrypt.sh my-products.ru
# sudo ./scripts/init-letsencrypt.sh xn----8sbfwtmcso8g.xn--p1ai
#
# For IDN/Cyrillic domains, pass the punycode form (certbot requires ASCII).
# If no argument is given, DOMAIN is read from .env.
# Run once per domain on first deploy.
set -euo pipefail
DOMAIN="evosync.ru"
EMAIL="${LETSENCRYPT_EMAIL:-admin@evosync.ru}"
CERTBOT_DIR="./certbot"
# ── resolve domain ────────────────────────────────────────────────────────────
if [ -n "${1:-}" ]; then
DOMAIN="$1"
else
if [ -f .env ]; then
DOMAIN_FROM_ENV=$(grep -E '^DOMAIN=' .env | cut -d= -f2- | tr -d '"'"'" | head -1)
DOMAIN="${DOMAIN:-${DOMAIN_FROM_ENV:-}}"
fi
fi
if [ -z "${DOMAIN:-}" ]; then
echo "ERROR: no domain specified." >&2
echo "Usage: $0 <domain> or set DOMAIN= in .env" >&2
exit 1
fi
EMAIL="${LETSENCRYPT_EMAIL:-admin@$DOMAIN}"
ACME_DIR="/var/www/certbot"
echo "==> Creating certbot directories..."
mkdir -p "$CERTBOT_DIR/conf" "$CERTBOT_DIR/www"
echo "==> Obtaining certificate for: $DOMAIN (www.$DOMAIN)"
echo " Email: $EMAIL"
echo "==> Ensuring acme-challenge directory exists on host..."
echo "==> Ensuring acme-challenge directory exists..."
sudo mkdir -p "$ACME_DIR"
sudo chmod 755 "$ACME_DIR"
@@ -27,20 +47,14 @@ sudo certbot certonly \
-d "$DOMAIN" \
-d "www.$DOMAIN"
echo "==> Copying certificates to project directory..."
sudo cp "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" "$CERTBOT_DIR/conf/fullchain.pem"
sudo cp "/etc/letsencrypt/live/$DOMAIN/privkey.pem" "$CERTBOT_DIR/conf/privkey.pem"
sudo chown "$(whoami):$(whoami)" "$CERTBOT_DIR/conf"/*.pem
echo "==> Done! TLS certificate installed for $DOMAIN"
echo ""
echo "Certificate files:"
echo " - $CERTBOT_DIR/conf/fullchain.pem"
echo " - $CERTBOT_DIR/conf/privkey.pem"
echo "==> Certificate obtained for $DOMAIN"
echo " /etc/letsencrypt/live/$DOMAIN/fullchain.pem"
echo " /etc/letsencrypt/live/$DOMAIN/privkey.pem"
echo ""
echo "Configure nginx:"
echo " ssl_certificate $CERTBOT_DIR/conf/fullchain.pem;"
echo " ssl_certificate_key $CERTBOT_DIR/conf/privkey.pem;"
echo "==> Generate nginx config and reload:"
echo " sudo ./scripts/generate-nginx-conf.sh $DOMAIN"
echo " sudo nginx -t && sudo systemctl reload nginx"
echo ""
echo "Set up auto-renewal with: sudo crontab -e"
echo "Add: 0 3 * * * certbot renew --quiet && systemctl reload nginx"
echo "==> Auto-renewal (add to /etc/cron.d/certbot if not already present):"
echo " 0 3 * * * root certbot renew --quiet && systemctl reload nginx"

View File

@@ -0,0 +1,275 @@
"""Integration tests for /catalog routes (stores, groups, products, toggles)."""
from datetime import datetime
import pytest
from web.models.connections import (
CachedGroup, CachedProduct, CachedStore, SyncConfig, SyncFilter,
)
def _now():
return datetime.utcnow()
async def _login(client, user):
await client.post("/login", data={"email": user.email, "password": "testpass123"},
follow_redirects=False)
def _make_store(db, user_id, evotor_id="s1", name="Магазин 1"):
s = CachedStore(user_id=user_id, evotor_id=evotor_id, name=name, fetched_at=_now())
db.add(s)
db.flush()
return s
def _make_group(db, user_id, store_id, evotor_id="g1", name="Группа 1"):
g = CachedGroup(user_id=user_id, store_evotor_id=store_id,
evotor_id=evotor_id, name=name, fetched_at=_now())
db.add(g)
db.flush()
return g
def _make_product(db, user_id, store_id, group_id=None, evotor_id="p1", name="Товар 1",
price=100, allow_to_sell=True):
p = CachedProduct(
user_id=user_id, store_evotor_id=store_id, group_evotor_id=group_id,
evotor_id=evotor_id, name=name, price=price, allow_to_sell=allow_to_sell,
fetched_at=_now(),
)
db.add(p)
db.flush()
return p
# ── auth guards ───────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_catalog_stores_requires_login(client):
resp = await client.get("/catalog/stores", follow_redirects=False)
assert resp.status_code == 303
assert "/login" in resp.headers["location"]
# ── GET /catalog/stores ───────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_catalog_stores_empty(client, active_user):
await _login(client, active_user)
resp = await client.get("/catalog/stores")
assert resp.status_code == 200
assert "не загружены" in resp.text.lower()
@pytest.mark.asyncio
async def test_catalog_stores_lists_stores(client, active_user, override_db):
await _login(client, active_user)
_make_store(override_db, active_user.id, "s1", "Главный магазин")
_make_store(override_db, active_user.id, "s2", "Второй магазин")
override_db.commit()
resp = await client.get("/catalog/stores")
assert resp.status_code == 200
assert "Главный магазин" in resp.text
assert "Второй магазин" in resp.text
@pytest.mark.asyncio
async def test_catalog_stores_not_shows_other_user(client, active_user, user_factory, override_db):
await _login(client, active_user)
other = user_factory.create()
_make_store(override_db, other.id, "s-other", "Чужой магазин")
override_db.commit()
resp = await client.get("/catalog/stores")
assert "Чужой магазин" not in resp.text
# ── GET /catalog/stores/{id}/groups ──────────────────────────────────────────
@pytest.mark.asyncio
async def test_catalog_groups_shows_product_count(client, active_user, override_db):
await _login(client, active_user)
store = _make_store(override_db, active_user.id, "s1")
group = _make_group(override_db, active_user.id, "s1", "g1", "Чай")
_make_product(override_db, active_user.id, "s1", "g1", "p1", "Пуэр")
_make_product(override_db, active_user.id, "s1", "g1", "p2", "Улун")
override_db.commit()
resp = await client.get(f"/catalog/stores/s1/groups")
assert resp.status_code == 200
assert "Чай" in resp.text
assert "2" in resp.text # product count
@pytest.mark.asyncio
async def test_catalog_groups_zero_count_for_empty_group(client, active_user, override_db):
await _login(client, active_user)
_make_store(override_db, active_user.id, "s1")
_make_group(override_db, active_user.id, "s1", "g1", "Пустая группа")
override_db.commit()
resp = await client.get("/catalog/stores/s1/groups")
assert resp.status_code == 200
assert "Пустая группа" in resp.text
assert "0" in resp.text
@pytest.mark.asyncio
async def test_catalog_groups_unknown_store_redirects(client, active_user):
await _login(client, active_user)
resp = await client.get("/catalog/stores/no-such-store/groups", follow_redirects=False)
assert resp.status_code == 303
# ── GET /catalog/stores/{id}/products ────────────────────────────────────────
@pytest.mark.asyncio
async def test_catalog_products_shows_all(client, active_user, override_db):
await _login(client, active_user)
_make_store(override_db, active_user.id, "s1")
_make_group(override_db, active_user.id, "s1", "g1")
_make_product(override_db, active_user.id, "s1", "g1", "p1", "Пуэр")
_make_product(override_db, active_user.id, "s1", "g1", "p2", "Улун")
override_db.commit()
resp = await client.get("/catalog/stores/s1/products")
assert resp.status_code == 200
assert "Пуэр" in resp.text
assert "Улун" in resp.text
@pytest.mark.asyncio
async def test_catalog_products_filtered_by_group(client, active_user, override_db):
await _login(client, active_user)
_make_store(override_db, active_user.id, "s1")
_make_group(override_db, active_user.id, "s1", "g1", "Группа А")
_make_group(override_db, active_user.id, "s1", "g2", "Группа Б")
_make_product(override_db, active_user.id, "s1", "g1", "p1", "Товар А")
_make_product(override_db, active_user.id, "s1", "g2", "p2", "Товар Б")
override_db.commit()
resp = await client.get("/catalog/stores/s1/products?group=g1")
assert resp.status_code == 200
assert "Товар А" in resp.text
assert "Товар Б" not in resp.text
@pytest.mark.asyncio
async def test_catalog_products_shows_group_column(client, active_user, override_db):
await _login(client, active_user)
_make_store(override_db, active_user.id, "s1")
_make_group(override_db, active_user.id, "s1", "g1", "МояГруппа")
_make_product(override_db, active_user.id, "s1", "g1", "p1", "Товар")
override_db.commit()
resp = await client.get("/catalog/stores/s1/products")
assert "МояГруппа" in resp.text
@pytest.mark.asyncio
async def test_catalog_products_ungrouped_shown(client, active_user, override_db):
await _login(client, active_user)
_make_store(override_db, active_user.id, "s1")
_make_product(override_db, active_user.id, "s1", None, "p1", "Без группы")
override_db.commit()
resp = await client.get("/catalog/stores/s1/products")
assert "Без группы" in resp.text
# ── POST /catalog/stores/{id}/toggle ─────────────────────────────────────────
@pytest.mark.asyncio
async def test_store_toggle_first_disable_seeds_others(client, active_user, override_db):
"""First toggle on a store disables it by seeding include-filters for all other stores."""
await _login(client, active_user)
_make_store(override_db, active_user.id, "s1", "Магазин 1")
_make_store(override_db, active_user.id, "s2", "Магазин 2")
_make_store(override_db, active_user.id, "s3", "Магазин 3")
override_db.commit()
resp = await client.post("/catalog/stores/s1/toggle", follow_redirects=False)
assert resp.status_code == 303
cfg = override_db.query(SyncConfig).filter_by(user_id=active_user.id).first()
filters = override_db.query(SyncFilter).filter_by(
sync_config_id=cfg.id, entity_type="store", filter_mode="include"
).all()
ids = {f.entity_id for f in filters}
# s1 was toggled off → only s2 and s3 are in include list
assert "s1" not in ids
assert "s2" in ids
assert "s3" in ids
@pytest.mark.asyncio
async def test_store_toggle_re_enable(client, active_user, override_db):
"""Toggling a disabled store re-adds it to the include list."""
await _login(client, active_user)
_make_store(override_db, active_user.id, "s1")
_make_store(override_db, active_user.id, "s2")
override_db.commit()
# Disable s1 first
await client.post("/catalog/stores/s1/toggle")
# Now re-enable s1
await client.post("/catalog/stores/s1/toggle")
cfg = override_db.query(SyncConfig).filter_by(user_id=active_user.id).first()
filters = override_db.query(SyncFilter).filter_by(
sync_config_id=cfg.id, entity_type="store", filter_mode="include"
).all()
ids = {f.entity_id for f in filters}
assert "s1" in ids
@pytest.mark.asyncio
async def test_store_toggle_requires_login(client):
resp = await client.post("/catalog/stores/s1/toggle", follow_redirects=False)
assert resp.status_code == 303
assert "/login" in resp.headers["location"]
# ── POST /catalog/stores/{id}/groups/{gid}/toggle ────────────────────────────
@pytest.mark.asyncio
async def test_group_toggle_first_disable(client, active_user, override_db):
await _login(client, active_user)
_make_store(override_db, active_user.id, "s1")
_make_group(override_db, active_user.id, "s1", "g1", "Группа 1")
_make_group(override_db, active_user.id, "s1", "g2", "Группа 2")
override_db.commit()
resp = await client.post("/catalog/stores/s1/groups/g1/toggle", follow_redirects=False)
assert resp.status_code == 303
cfg = override_db.query(SyncConfig).filter_by(user_id=active_user.id).first()
filters = override_db.query(SyncFilter).filter_by(
sync_config_id=cfg.id, entity_type="group", filter_mode="include",
parent_entity_id="s1",
).all()
ids = {f.entity_id for f in filters}
assert "g1" not in ids
assert "g2" in ids
@pytest.mark.asyncio
async def test_group_toggle_re_enable(client, active_user, override_db):
await _login(client, active_user)
_make_store(override_db, active_user.id, "s1")
_make_group(override_db, active_user.id, "s1", "g1")
_make_group(override_db, active_user.id, "s1", "g2")
override_db.commit()
await client.post("/catalog/stores/s1/groups/g1/toggle")
await client.post("/catalog/stores/s1/groups/g1/toggle")
cfg = override_db.query(SyncConfig).filter_by(user_id=active_user.id).first()
filters = override_db.query(SyncFilter).filter_by(
sync_config_id=cfg.id, entity_type="group", parent_entity_id="s1"
).all()
ids = {f.entity_id for f in filters}
assert "g1" in ids

View File

@@ -0,0 +1,352 @@
"""Integration tests for /connections routes."""
import secrets
from datetime import datetime
from unittest.mock import MagicMock, patch
import pytest
from web.models.connections import EvotorConnection, VkConnection
def _login(client, user):
client.cookies.clear()
return client.post("/login", data={"email": user.email, "password": "testpass123"},
follow_redirects=False)
# ── auth guard ────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_connections_requires_login(client):
resp = await client.get("/connections", follow_redirects=False)
assert resp.status_code == 303
assert "/login" in resp.headers["location"]
# ── GET /connections ──────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_connections_get_no_connections(client, active_user):
await _login(client, active_user)
resp = await client.get("/connections")
assert resp.status_code == 200
assert "Эвотор" in resp.text
assert "ВКонтакте" in resp.text
assert "Не подключено" in resp.text
@pytest.mark.asyncio
async def test_connections_get_shows_connected(client, active_user, override_db):
await _login(client, active_user)
conn = EvotorConnection(
user_id=active_user.id,
evotor_user_id="evo-123",
access_token="tok-abc",
api_token="api-tok",
connected_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
override_db.add(conn)
override_db.commit()
resp = await client.get("/connections")
assert resp.status_code == 200
assert "Подключено" in resp.text
assert "tok-abc"[:8] in resp.text
# ── POST /connections/evotor ──────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_connections_evotor_post_creates(client, active_user, override_db):
await _login(client, active_user)
resp = await client.post("/connections/evotor", data={
"access_token": "new-evotor-token",
"evotor_user_id": "",
}, follow_redirects=False)
assert resp.status_code == 303
assert "success=1" in resp.headers["location"]
conn = override_db.query(EvotorConnection).filter_by(user_id=active_user.id).first()
assert conn is not None
assert conn.access_token == "new-evotor-token"
assert conn.api_token is not None
@pytest.mark.asyncio
async def test_connections_evotor_post_updates(client, active_user, override_db):
await _login(client, active_user)
conn = EvotorConnection(
user_id=active_user.id, evotor_user_id="evo-upd",
access_token="old-token", api_token="api",
connected_at=datetime.utcnow(), updated_at=datetime.utcnow(),
)
override_db.add(conn)
override_db.commit()
await client.post("/connections/evotor", data={"access_token": "updated-token"})
override_db.refresh(conn)
assert conn.access_token == "updated-token"
@pytest.mark.asyncio
async def test_connections_evotor_post_empty_token(client, active_user):
await _login(client, active_user)
resp = await client.post("/connections/evotor", data={"access_token": ""})
assert resp.status_code == 200
assert "обязателен" in resp.text.lower()
# ── POST /connections/evotor/disconnect ───────────────────────────────────────
@pytest.mark.asyncio
async def test_connections_evotor_disconnect(client, active_user, override_db):
await _login(client, active_user)
conn = EvotorConnection(
user_id=active_user.id, evotor_user_id="evo-del",
access_token="tok", api_token="api",
connected_at=datetime.utcnow(), updated_at=datetime.utcnow(),
)
override_db.add(conn)
override_db.commit()
resp = await client.post("/connections/evotor/disconnect", follow_redirects=False)
assert resp.status_code == 303
assert override_db.query(EvotorConnection).filter_by(user_id=active_user.id).first() is None
# ── POST /connections/vk (manual token) ──────────────────────────────────────
@pytest.mark.asyncio
async def test_connections_vk_post_creates(client, active_user, override_db):
await _login(client, active_user)
resp = await client.post("/connections/vk", data={
"access_token": "vk1.a.testtoken",
"vk_group_id": "123456789",
}, follow_redirects=False)
assert resp.status_code == 303
assert "success=1" in resp.headers["location"]
conn = override_db.query(VkConnection).filter_by(user_id=active_user.id).first()
assert conn is not None
assert conn.access_token == "vk1.a.testtoken"
assert conn.vk_user_id == "123456789"
@pytest.mark.asyncio
async def test_connections_vk_post_empty_token(client, active_user):
await _login(client, active_user)
resp = await client.post("/connections/vk", data={"access_token": "", "vk_group_id": ""})
assert resp.status_code == 200
assert "обязателен" in resp.text.lower()
# ── POST /connections/vk/disconnect ──────────────────────────────────────────
@pytest.mark.asyncio
async def test_connections_vk_disconnect(client, active_user, override_db):
await _login(client, active_user)
conn = VkConnection(
user_id=active_user.id, access_token="vk-tok",
connected_at=datetime.utcnow(), updated_at=datetime.utcnow(),
)
override_db.add(conn)
override_db.commit()
resp = await client.post("/connections/vk/disconnect", follow_redirects=False)
assert resp.status_code == 303
assert override_db.query(VkConnection).filter_by(user_id=active_user.id).first() is None
# ── GET /vk-auth ──────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_vk_auth_redirects_to_vk(client, active_user, monkeypatch):
monkeypatch.setattr("web.routes.connections.settings.VK_CLIENT_ID", "53265827")
monkeypatch.setattr("web.routes.connections.settings.BASE_URL", "http://test")
await _login(client, active_user)
resp = await client.get("/vk-auth", follow_redirects=False)
assert resp.status_code == 302
assert "oauth.vk.com/authorize" in resp.headers["location"]
assert "client_id=53265827" in resp.headers["location"]
assert "response_type=token" in resp.headers["location"]
@pytest.mark.asyncio
async def test_vk_auth_no_client_id(client, active_user, monkeypatch):
monkeypatch.setattr("web.routes.connections.settings.VK_CLIENT_ID", "")
await _login(client, active_user)
resp = await client.get("/vk-auth", follow_redirects=False)
assert resp.status_code == 303
assert "error=vk_not_configured" in resp.headers["location"]
# ── GET /vk-callback ──────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_vk_callback_page_returns_html(client, active_user):
await _login(client, active_user)
resp = await client.get("/vk-callback")
assert resp.status_code == 200
assert "access_token" in resp.text
assert "fetch" in resp.text
# ── POST /vk-callback/save ────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_vk_callback_save_valid(client, active_user, override_db):
await _login(client, active_user)
# Seed state into session via /vk-auth call
monkeypatch_state = "test-state-xyz"
# Manually set expected state in session by calling the save endpoint
# with a pre-seeded state — we bypass the session by mocking get_current_user
# Instead: call /vk-auth to seed the session state, then intercept
# Since we can't easily inspect session, test save with wrong state first
resp = await client.post("/vk-callback/save", json={
"access_token": "vk1.a.token",
"state": "wrong-state",
"user_id": "12345",
"expires_in": "86400",
})
assert resp.status_code == 200
data = resp.json()
assert data["ok"] is False
assert "state" in data["message"].lower()
@pytest.mark.asyncio
async def test_vk_callback_save_no_token(client, active_user):
await _login(client, active_user)
resp = await client.post("/vk-callback/save", json={
"access_token": "",
"state": "",
})
assert resp.status_code == 200
assert resp.json()["ok"] is False
@pytest.mark.asyncio
async def test_vk_callback_save_unauthenticated(client):
resp = await client.post("/vk-callback/save", json={
"access_token": "tok", "state": "s",
})
assert resp.status_code == 401
# ── POST /connections/evotor/test ─────────────────────────────────────────────
@pytest.mark.asyncio
async def test_evotor_test_no_connection(client, active_user):
await _login(client, active_user)
resp = await client.post("/connections/evotor/test")
assert resp.status_code == 200
assert resp.json()["ok"] is False
assert "не настроено" in resp.json()["message"]
@pytest.mark.asyncio
async def test_evotor_test_success(client, active_user, override_db):
await _login(client, active_user)
conn = EvotorConnection(
user_id=active_user.id, evotor_user_id="evo-t",
access_token="tok", api_token="api",
connected_at=datetime.utcnow(), updated_at=datetime.utcnow(),
)
override_db.add(conn)
override_db.commit()
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {"items": [{"id": "s1"}, {"id": "s2"}]}
with patch("web.routes.connections.httpx.get", return_value=mock_resp):
resp = await client.post("/connections/evotor/test")
assert resp.status_code == 200
data = resp.json()
assert data["ok"] is True
assert "2" in data["message"]
@pytest.mark.asyncio
async def test_evotor_test_invalid_token(client, active_user, override_db):
await _login(client, active_user)
conn = EvotorConnection(
user_id=active_user.id, evotor_user_id="evo-inv",
access_token="bad-tok", api_token="api",
connected_at=datetime.utcnow(), updated_at=datetime.utcnow(),
)
override_db.add(conn)
override_db.commit()
mock_resp = MagicMock()
mock_resp.status_code = 401
with patch("web.routes.connections.httpx.get", return_value=mock_resp):
resp = await client.post("/connections/evotor/test")
data = resp.json()
assert data["ok"] is False
assert "401" in data["message"]
# ── POST /connections/vk/test ─────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_vk_test_no_group_id(client, active_user, override_db):
await _login(client, active_user)
conn = VkConnection(
user_id=active_user.id, access_token="vk-tok",
vk_user_id=None,
connected_at=datetime.utcnow(), updated_at=datetime.utcnow(),
)
override_db.add(conn)
override_db.commit()
resp = await client.post("/connections/vk/test")
assert resp.json()["ok"] is False
assert "сообщества" in resp.json()["message"].lower()
@pytest.mark.asyncio
async def test_vk_test_success(client, active_user, override_db):
await _login(client, active_user)
conn = VkConnection(
user_id=active_user.id, access_token="vk-tok",
vk_user_id="229744980",
connected_at=datetime.utcnow(), updated_at=datetime.utcnow(),
)
override_db.add(conn)
override_db.commit()
mock_resp = MagicMock()
mock_resp.json.return_value = {"response": {"groups": [
{"name": "Тестовая чайная", "market": {"enabled": True}}
]}}
with patch("web.routes.connections.httpx.get", return_value=mock_resp):
resp = await client.post("/connections/vk/test")
data = resp.json()
assert data["ok"] is True
assert "Тестовая чайная" in data["message"]
assert "включён" in data["message"]
@pytest.mark.asyncio
async def test_vk_test_api_error(client, active_user, override_db):
await _login(client, active_user)
conn = VkConnection(
user_id=active_user.id, access_token="vk-tok",
vk_user_id="229744980",
connected_at=datetime.utcnow(), updated_at=datetime.utcnow(),
)
override_db.add(conn)
override_db.commit()
mock_resp = MagicMock()
mock_resp.json.return_value = {"error": {"error_code": 5, "error_msg": "User authorization failed"}}
with patch("web.routes.connections.httpx.get", return_value=mock_resp):
resp = await client.post("/connections/vk/test")
data = resp.json()
assert data["ok"] is False
assert "5" in data["message"]

133
tests/test_tasks_catalog.py Normal file
View File

@@ -0,0 +1,133 @@
"""Unit tests for catalog task helpers and refresh_catalog logic."""
from unittest.mock import MagicMock, patch
import pytest
from web.tasks.catalog import _fetch_groups, _fetch_products, _fetch_stores
# ── _fetch_stores ─────────────────────────────────────────────────────────────
def test_fetch_stores_list_response():
mock = MagicMock()
mock.raise_for_status = MagicMock()
mock.json.return_value = [{"id": "s1", "name": "Магазин"}]
with patch("web.tasks.catalog.httpx.get", return_value=mock):
result = _fetch_stores("tok")
assert result == [{"id": "s1", "name": "Магазин"}]
def test_fetch_stores_dict_with_items():
mock = MagicMock()
mock.raise_for_status = MagicMock()
mock.json.return_value = {"items": [{"id": "s1"}], "total": 1}
with patch("web.tasks.catalog.httpx.get", return_value=mock):
result = _fetch_stores("tok")
assert result == [{"id": "s1"}]
# ── _fetch_groups ─────────────────────────────────────────────────────────────
def test_fetch_groups_success():
mock = MagicMock()
mock.status_code = 200
mock.raise_for_status = MagicMock()
mock.json.return_value = {"items": [{"id": "g1", "name": "Чай"}]}
with patch("web.tasks.catalog.httpx.get", return_value=mock):
result = _fetch_groups("tok", "s1")
assert result == [{"id": "g1", "name": "Чай"}]
@pytest.mark.parametrize("status_code", [402, 403])
def test_fetch_groups_returns_none_on_restricted(status_code):
mock = MagicMock()
mock.status_code = status_code
with patch("web.tasks.catalog.httpx.get", return_value=mock):
result = _fetch_groups("tok", "s1")
assert result is None
# ── _fetch_products ───────────────────────────────────────────────────────────
def test_fetch_products_success():
mock = MagicMock()
mock.status_code = 200
mock.raise_for_status = MagicMock()
mock.json.return_value = [{"id": "p1", "name": "Пуэр", "price": {"sum": 15000}}]
with patch("web.tasks.catalog.httpx.get", return_value=mock):
result = _fetch_products("tok", "s1")
assert len(result) == 1
assert result[0]["name"] == "Пуэр"
@pytest.mark.parametrize("status_code", [402, 403])
def test_fetch_products_returns_none_on_restricted(status_code):
mock = MagicMock()
mock.status_code = status_code
with patch("web.tasks.catalog.httpx.get", return_value=mock):
result = _fetch_products("tok", "s1")
assert result is None
# ── refresh_catalog task (integration with mocked HTTP) ──────────────────────
@pytest.mark.asyncio
async def test_refresh_catalog_upserts_stores(override_db):
from web.database import SessionLocal
from web.models.connections import CachedStore, EvotorConnection
from web.tasks.catalog import _sync_user
user_id = 1
token = "test-tok"
stores_data = [{"id": "s-new", "name": "Новый магазин", "address": "ул. Ленина 1"}]
groups_data = []
products_data = []
with patch("web.tasks.catalog._fetch_stores", return_value=stores_data), \
patch("web.tasks.catalog._fetch_groups", return_value=groups_data), \
patch("web.tasks.catalog._fetch_products", return_value=products_data):
_sync_user(override_db, user_id, token)
store = override_db.query(CachedStore).filter_by(user_id=user_id, evotor_id="s-new").first()
assert store is not None
assert store.name == "Новый магазин"
@pytest.mark.asyncio
async def test_refresh_catalog_upserts_products(override_db):
from web.models.connections import CachedProduct
from web.tasks.catalog import _sync_user
user_id = 2
token = "tok"
stores_data = [{"id": "s1", "name": "Магазин"}]
groups_data = [{"id": "g1", "name": "Чай"}]
products_data = [{
"id": "p1", "name": "Пуэр", "price": 35000,
"quantity": 10, "measureName": "шт", "code": "001",
"allowToSell": True, "group": "g1",
}]
with patch("web.tasks.catalog._fetch_stores", return_value=stores_data), \
patch("web.tasks.catalog._fetch_groups", return_value=groups_data), \
patch("web.tasks.catalog._fetch_products", return_value=products_data):
_sync_user(override_db, user_id, token)
p = override_db.query(CachedProduct).filter_by(user_id=user_id, evotor_id="p1").first()
assert p is not None
assert p.name == "Пуэр"
assert p.group_evotor_id == "g1"
assert p.allow_to_sell is True
@pytest.mark.asyncio
async def test_refresh_catalog_skips_fetch_stores_failure(override_db):
from web.models.connections import CachedStore
from web.tasks.catalog import _sync_user
with patch("web.tasks.catalog._fetch_stores", side_effect=Exception("network error")):
_sync_user(override_db, user_id=99, token="tok")
assert override_db.query(CachedStore).filter_by(user_id=99).count() == 0

169
tests/test_tasks_vk_sync.py Normal file
View File

@@ -0,0 +1,169 @@
"""Unit tests for vk_sync task logic (price calc, name sanitization, orphan deletion)."""
from datetime import datetime
from decimal import Decimal
from unittest.mock import MagicMock, call, patch
import pytest
from web.tasks.vk_sync import (
_build_description,
_calc_price,
_delete_orphans,
_is_weight,
_name_for_vk,
)
# ── _is_weight ────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("measure,expected", [
("г", True),
("г.", True),
("гр", True),
("гр.", True),
("грамм", True),
("граммов", True),
(" Г ", True), # case-insensitive, stripped
("кг", False),
("шт", False),
("л", False),
(None, False),
("", False),
])
def test_is_weight(measure, expected):
assert _is_weight(measure) == expected
# ── _calc_price ───────────────────────────────────────────────────────────────
def test_calc_price_normal(monkeypatch):
monkeypatch.setattr("web.tasks.vk_sync.settings.VK_WEIGHT_PRICE_MULTIPLIER", 10)
assert _calc_price(Decimal("150"), "шт") == 15000 # 150 руб * 100 копеек
def test_calc_price_weight_multiplier(monkeypatch):
monkeypatch.setattr("web.tasks.vk_sync.settings.VK_WEIGHT_PRICE_MULTIPLIER", 10)
# 50 руб/г → 50 * 10 (multiplier) * 100 (kopecks) = 50000
assert _calc_price(Decimal("50"), "г") == 50000
def test_calc_price_none():
assert _calc_price(None, "шт") == 0
def test_calc_price_zero():
assert _calc_price(Decimal("0"), "шт") == 0
# ── _name_for_vk ──────────────────────────────────────────────────────────────
def test_name_replaces_semicolons():
assert _name_for_vk("Чай; зелёный; Китай") == "Чай, зелёный, Китай"
def test_name_no_semicolons():
assert _name_for_vk("Пуэр (выдержанный)") == "Пуэр (выдержанный)"
# ── _build_description ────────────────────────────────────────────────────────
def test_build_description_weight(monkeypatch):
monkeypatch.setattr("web.tasks.vk_sync.settings.VK_WEIGHT_PRICE_MULTIPLIER", 10)
desc = _build_description("Чай", "г", None)
assert "10г" in desc
assert "Чай" in desc
def test_build_description_with_evo_desc(monkeypatch):
monkeypatch.setattr("web.tasks.vk_sync.settings.VK_WEIGHT_PRICE_MULTIPLIER", 10)
desc = _build_description("Чай", "шт", "Вкусный чай из Китая")
assert "Вкусный чай из Китая" in desc
def test_build_description_no_evo_desc(monkeypatch):
monkeypatch.setattr("web.tasks.vk_sync.settings.VK_WEIGHT_PRICE_MULTIPLIER", 10)
desc = _build_description("Чай", "шт", None)
assert "Чай" in desc
# ── _delete_orphans ───────────────────────────────────────────────────────────
def test_delete_orphans_removes_stale_vk_products():
from web.models.connections import VkCachedProduct, CachedProduct
# Build fake VK cached products
vk1 = MagicMock(spec=VkCachedProduct)
vk1.vk_product_id = "111"
vk1.name = "Существующий"
vk2 = MagicMock(spec=VkCachedProduct)
vk2.vk_product_id = "222"
vk2.name = "Удалённый из Эвотор"
db = MagicMock()
# owned_ids contains only "111" — "222" is orphan
owned_ids = {"111"}
# query().filter_by().filter().all() chain
query_mock = MagicMock()
query_mock.filter_by.return_value.filter.return_value.all.return_value = [vk2]
# second query for stale cached_products
query_mock.filter.return_value.all.return_value = []
db.query.return_value = query_mock
results = {"deleted": 0, "errors": 0}
mock_post_resp = {"response": 1}
with patch("web.tasks.vk_sync._vk_post", return_value=mock_post_resp):
_delete_orphans(db, user_id=1, vk_group_id="99", owned_ids=owned_ids,
token="tok", results=results)
assert results["deleted"] == 1
db.delete.assert_called_once_with(vk2)
def test_delete_orphans_vk_api_error_counted():
from web.models.connections import VkCachedProduct
vk1 = MagicMock(spec=VkCachedProduct)
vk1.vk_product_id = "999"
vk1.name = "Сломанный"
db = MagicMock()
query_mock = MagicMock()
query_mock.filter_by.return_value.filter.return_value.all.return_value = [vk1]
query_mock.filter.return_value.all.return_value = []
db.query.return_value = query_mock
results = {"deleted": 0, "errors": 0}
with patch("web.tasks.vk_sync._vk_post", return_value={"error": {"error_code": 15}}):
_delete_orphans(db, user_id=1, vk_group_id="99", owned_ids={"other"},
token="tok", results=results)
assert results["deleted"] == 0
assert results["errors"] == 1
def test_delete_orphans_empty_owned_ids_deletes_all():
"""If no Evotor products exist (owned_ids empty), all VK products are orphans."""
from web.models.connections import VkCachedProduct
vk1 = MagicMock(spec=VkCachedProduct)
vk1.vk_product_id = "1"
vk1.name = "Лишний"
db = MagicMock()
query_mock = MagicMock()
# With empty owned_ids, query without .filter() is used
query_mock.filter_by.return_value.all.return_value = [vk1]
query_mock.filter.return_value.all.return_value = []
db.query.return_value = query_mock
results = {"deleted": 0, "errors": 0}
with patch("web.tasks.vk_sync._vk_post", return_value={"response": 1}):
_delete_orphans(db, user_id=1, vk_group_id="99", owned_ids=set(),
token="tok", results=results)
assert results["deleted"] == 1

View File

@@ -3,7 +3,7 @@ from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from starlette.requests import Request
from web.models.user import User, UserStatusEnum
from web.models.user import User, UserRoleEnum, UserStatusEnum
def get_session_user_id(request: Request) -> int | None:
@@ -21,5 +21,22 @@ def get_current_user(request: Request, db: Session) -> User:
return user
def get_viewed_user(request: Request, db: Session) -> tuple[User, User]:
"""Return (real_user, viewed_user).
Admins/system users can view another user's data by having
`viewed_user_id` set in the session (via /admin/users/{id}/view-as).
For regular users, both values are the same.
"""
real_user = get_current_user(request, db)
is_admin = real_user.role in (UserRoleEnum.admin, UserRoleEnum.system)
viewed_id = request.session.get("viewed_user_id") if is_admin else None
if viewed_id:
viewed = db.get(User, viewed_id)
if viewed:
return real_user, viewed
return real_user, real_user
def login_redirect() -> RedirectResponse:
return RedirectResponse("/login", status_code=303)

View File

@@ -11,9 +11,14 @@ class Settings(BaseSettings):
EVOTOR_APP_ID: str = ""
EVOTOR_WEBHOOK_SECRET: str = ""
VK_CLIENT_ID: str = ""
VK_CLIENT_SECRET: str = ""
JIVOSITE_WIDGET_ID: str = ""
VK_DEFAULT_PHOTO_PATH: str = "/app/default_product.png"
VK_API_VERSION: str = "5.199"
VK_CATEGORY_ID: int = 40932
VK_STOCK_AMOUNT: int = 1000
CATALOG_REFRESH_INTERVAL_SECONDS: int = 3600
INVITE_EXPIRE_HOURS: int = 48

82
web/lib/api_logger.py Normal file
View File

@@ -0,0 +1,82 @@
"""Thin wrapper around httpx that logs every outbound API call to api_logs."""
import json
import time
import urllib.parse
from typing import Any
import httpx
from web.database import SessionLocal
from web.models.connections import ApiLog
_MAX_BODY = 8000 # truncate stored bodies beyond this
def _service_from_url(url: str) -> str:
host = urllib.parse.urlparse(url).netloc
if "evotor" in host:
return "evotor"
if "vk.com" in host:
return "vk"
return "other"
def _truncate(text: str | None) -> str | None:
if text and len(text) > _MAX_BODY:
return text[:_MAX_BODY] + ""
return text
def _record(
user_id: int | None,
method: str,
url: str,
request_body: str | None,
response_status: int | None,
response_body: str | None,
duration_ms: int,
) -> None:
try:
db = SessionLocal()
db.add(ApiLog(
user_id=user_id,
service=_service_from_url(url),
method=method.upper(),
url=url,
request_body=_truncate(request_body),
response_status=response_status,
response_body=_truncate(response_body),
duration_ms=duration_ms,
))
db.commit()
db.close()
except Exception:
pass # never let logging crash the caller
def get(url: str, *, user_id: int | None = None, **kwargs) -> httpx.Response:
t0 = time.monotonic()
resp = httpx.get(url, **kwargs)
ms = int((time.monotonic() - t0) * 1000)
try:
body = resp.text
except Exception:
body = None
_record(user_id, "GET", url, None, resp.status_code, body, ms)
return resp
def post(url: str, *, user_id: int | None = None, data: Any = None, json: Any = None, **kwargs) -> httpx.Response:
t0 = time.monotonic()
resp = httpx.post(url, data=data, json=json, **kwargs)
ms = int((time.monotonic() - t0) * 1000)
try:
req_body = resp.request.content.decode("utf-8", errors="replace") if resp.request.content else None
except Exception:
req_body = None
try:
body = resp.text
except Exception:
body = None
_record(user_id, "POST", url, req_body, resp.status_code, body, ms)
return resp

View File

@@ -1,6 +1,6 @@
import logging
from fastapi import FastAPI, Request
from fastapi import Depends, FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
@@ -35,6 +35,13 @@ from web.routes.invite import router as invite_router # noqa: E402
from web.routes.profile import router as profile_router # noqa: E402
from web.routes.evotor_webhooks import router as evotor_webhooks_router # noqa: E402
from web.routes.admin import router as admin_router # noqa: E402
from web.routes.catalog import router as catalog_router # noqa: E402
from web.routes.connections import router as connections_router # noqa: E402
from web.routes.vk_catalog import router as vk_catalog_router # noqa: E402
from web.routes.logs import router as logs_router # noqa: E402
from web.routes.sync import router as sync_router # noqa: E402
from web.database import get_db # noqa: E402
from web.models.user import User # noqa: E402
app.include_router(auth_router)
app.include_router(reset_router)
@@ -42,6 +49,18 @@ app.include_router(invite_router)
app.include_router(profile_router)
app.include_router(evotor_webhooks_router)
app.include_router(admin_router)
app.include_router(catalog_router)
app.include_router(connections_router)
app.include_router(vk_catalog_router)
app.include_router(logs_router)
app.include_router(sync_router)
# ── Catalog redirect ─────────────────────────────────────────────────────────
@app.get("/catalog")
async def catalog_redirect():
from fastapi.responses import RedirectResponse
return RedirectResponse("/catalog/stores", 302)
# ── Health ────────────────────────────────────────────────────────────────────
@@ -52,10 +71,14 @@ async def health():
# ── Root redirect ─────────────────────────────────────────────────────────────
@app.get("/")
async def root(request: Request):
async def root(request: Request, db=Depends(get_db)):
from fastapi.responses import RedirectResponse
from web.models.user import UserRoleEnum
user_id = request.session.get("user_id")
if user_id:
user = db.get(User, user_id)
if user and user.role in (UserRoleEnum.admin, UserRoleEnum.system):
return RedirectResponse("/admin/users", 303)
return RedirectResponse("/profile", 303)
return RedirectResponse("/login", 303)

View 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")

View File

@@ -0,0 +1,24 @@
"""Add vk_product_id to cached_products
Revision ID: 0005
Revises: 0004
Create Date: 2026-05-01
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0005"
down_revision: Union[str, None] = "0004"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("cached_products", sa.Column("vk_product_id", sa.String(50), nullable=True))
def downgrade() -> None:
op.drop_column("cached_products", "vk_product_id")

View File

@@ -0,0 +1,26 @@
"""Add refresh_token and token_expires_at to vk_connections
Revision ID: 0006
Revises: 0005
Create Date: 2026-05-12
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0006"
down_revision: Union[str, None] = "0005"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("vk_connections", sa.Column("refresh_token", sa.Text, nullable=True))
op.add_column("vk_connections", sa.Column("token_expires_at", sa.DateTime, nullable=True))
def downgrade() -> None:
op.drop_column("vk_connections", "token_expires_at")
op.drop_column("vk_connections", "refresh_token")

View File

@@ -0,0 +1,32 @@
"""Add api_logs table for request/response logging."""
revision = "0007"
down_revision = "0006"
branch_labels = None
depends_on = None
import sqlalchemy as sa
from alembic import op
def upgrade():
op.create_table(
"api_logs",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("service", sa.String(20), nullable=False),
sa.Column("method", sa.String(10), nullable=False),
sa.Column("url", sa.String(1024), nullable=False),
sa.Column("request_body", sa.Text, nullable=True),
sa.Column("response_status", sa.Integer, nullable=True),
sa.Column("response_body", sa.Text, nullable=True),
sa.Column("duration_ms", sa.Integer, nullable=True),
sa.Column("created_at", sa.DateTime, nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_api_logs_user_service", "api_logs", ["user_id", "service"])
op.create_index("ix_api_logs_created_at", "api_logs", ["created_at"])
def downgrade():
op.drop_index("ix_api_logs_created_at", "api_logs")
op.drop_index("ix_api_logs_user_service", "api_logs")
op.drop_table("api_logs")

View File

@@ -0,0 +1,16 @@
"""Add price_multiplier to sync_configs."""
revision = "0008"
down_revision = "0007"
branch_labels = None
depends_on = None
import sqlalchemy as sa
from alembic import op
def upgrade():
op.add_column("sync_configs", sa.Column("price_multiplier", sa.Numeric(10, 4), nullable=False, server_default="1.0"))
def downgrade():
op.drop_column("sync_configs", "price_multiplier")

View File

@@ -0,0 +1,18 @@
"""Add evo_mirror_enabled and vk_mirror_enabled to sync_configs."""
revision = "0009"
down_revision = "0008"
branch_labels = None
depends_on = None
import sqlalchemy as sa
from alembic import op
def upgrade():
op.add_column("sync_configs", sa.Column("evo_mirror_enabled", sa.Boolean, nullable=False, server_default="0"))
op.add_column("sync_configs", sa.Column("vk_mirror_enabled", sa.Boolean, nullable=False, server_default="0"))
def downgrade():
op.drop_column("sync_configs", "vk_mirror_enabled")
op.drop_column("sync_configs", "evo_mirror_enabled")

View File

@@ -0,0 +1,17 @@
"""Make users.phone nullable."""
revision = "0010"
down_revision = "0009"
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.alter_column("users", "phone", existing_type=sa.String(20), nullable=True)
def downgrade():
op.execute("UPDATE users SET phone = '' WHERE phone IS NULL")
op.alter_column("users", "phone", existing_type=sa.String(20), nullable=False)

View File

@@ -0,0 +1,36 @@
"""Add store_filters_seeded and group_filters_seeded to sync_configs."""
revision = "0011"
down_revision = "0010"
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column("sync_configs", sa.Column("store_filters_seeded", sa.Boolean(), nullable=False, server_default="0"))
op.add_column("sync_configs", sa.Column("group_filters_seeded", sa.Boolean(), nullable=False, server_default="0"))
# Mark existing rows as seeded if they already have filters
op.execute("""
UPDATE sync_configs sc
SET store_filters_seeded = 1
WHERE EXISTS (
SELECT 1 FROM sync_filters sf
WHERE sf.sync_config_id = sc.id AND sf.entity_type = 'store'
)
""")
op.execute("""
UPDATE sync_configs sc
SET group_filters_seeded = 1
WHERE EXISTS (
SELECT 1 FROM sync_filters sf
WHERE sf.sync_config_id = sc.id AND sf.entity_type = 'group'
)
""")
def downgrade():
op.drop_column("sync_configs", "group_filters_seeded")
op.drop_column("sync_configs", "store_filters_seeded")

View File

@@ -0,0 +1,16 @@
"""Add middle_name to users."""
revision = "0012"
down_revision = "0011"
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def upgrade():
op.add_column("users", sa.Column("middle_name", sa.String(100), nullable=True))
def downgrade():
op.drop_column("users", "middle_name")

View File

@@ -3,6 +3,7 @@ from sqlalchemy import (
Numeric, String, Text, UniqueConstraint, func,
)
from web.database import Base
@@ -35,6 +36,8 @@ class VkConnection(Base):
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
access_token = Column(Text, nullable=False)
refresh_token = Column(Text, nullable=True)
token_expires_at = Column(DateTime, nullable=True)
vk_user_id = Column(String(50), nullable=True)
first_name = Column(String(255), nullable=True)
last_name = Column(String(255), nullable=True)
@@ -54,7 +57,12 @@ class SyncConfig(Base):
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
is_enabled = Column(Boolean, nullable=False, default=False)
evo_mirror_enabled = Column(Boolean, nullable=False, default=True)
vk_mirror_enabled = Column(Boolean, nullable=False, default=False)
store_filters_seeded = Column(Boolean, nullable=False, default=False)
group_filters_seeded = Column(Boolean, nullable=False, default=False)
confirmed_at = Column(DateTime, nullable=True)
price_multiplier = Column(Numeric(10, 4), nullable=False, default=1.0)
created_at = Column(DateTime, nullable=False, server_default=func.now())
updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now())
@@ -81,6 +89,44 @@ class SyncFilter(Base):
)
class VkCachedAlbum(Base):
__tablename__ = "vk_cached_albums"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
vk_group_id = Column(String(50), nullable=False)
album_id = Column(String(50), nullable=False)
title = Column(String(255), nullable=False)
count = Column(Integer, nullable=True)
fetched_at = Column(DateTime, nullable=False)
__table_args__ = (
UniqueConstraint("user_id", "vk_group_id", "album_id", name="uq_vk_cached_albums"),
Index("ix_vk_cached_albums_user_group", "user_id", "vk_group_id"),
)
class VkCachedProduct(Base):
__tablename__ = "vk_cached_products"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
vk_group_id = Column(String(50), nullable=False)
vk_product_id = Column(String(50), nullable=False)
album_id = Column(String(50), nullable=True)
name = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
price = Column(Numeric(12, 2), nullable=True)
availability = Column(Integer, nullable=True)
thumb_url = Column(String(1024), nullable=True)
fetched_at = Column(DateTime, nullable=False)
__table_args__ = (
UniqueConstraint("user_id", "vk_group_id", "vk_product_id", name="uq_vk_cached_products"),
Index("ix_vk_cached_products_user_group_album", "user_id", "vk_group_id", "album_id"),
)
class CachedStore(Base):
__tablename__ = "cached_stores"
@@ -129,8 +175,29 @@ class CachedProduct(Base):
allow_to_sell = Column(Boolean, nullable=True)
fetched_at = Column(DateTime, nullable=False)
synced_at = Column(DateTime, nullable=True)
vk_product_id = Column(String(50), nullable=True) # VK market item ID after first push
__table_args__ = (
UniqueConstraint("user_id", "evotor_id", name="uq_cached_products_user_evotor"),
Index("ix_cached_products_user_store_group", "user_id", "store_evotor_id", "group_evotor_id"),
)
class ApiLog(Base):
__tablename__ = "api_logs"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
service = Column(String(20), nullable=False) # "evotor" | "vk"
method = Column(String(10), nullable=False) # "GET" | "POST"
url = Column(String(1024), nullable=False)
request_body = Column(Text, nullable=True)
response_status = Column(Integer, nullable=True)
response_body = Column(Text, nullable=True)
duration_ms = Column(Integer, nullable=True)
created_at = Column(DateTime, nullable=False, server_default=func.now())
__table_args__ = (
Index("ix_api_logs_user_service", "user_id", "service"),
Index("ix_api_logs_created_at", "created_at"),
)

View File

@@ -23,8 +23,9 @@ class User(Base):
id = Column(Integer, primary_key=True, autoincrement=True)
first_name = Column(String(100), nullable=False)
last_name = Column(String(100), nullable=False)
middle_name = Column(String(100), nullable=True)
email = Column(String(255), nullable=False)
phone = Column(String(20), nullable=False)
phone = Column(String(20), nullable=True)
password_hash = Column(String(255), nullable=True)
is_email_confirmed = Column(Boolean, nullable=False, default=False)
email_confirm_token = Column(String(255), nullable=True)

View File

@@ -101,6 +101,105 @@ async def admin_user_detail(user_id: int, request: Request, db: Session = Depend
return _render(request, "admin/user_detail.html", {"user": admin, "target": target})
# ── Create user ───────────────────────────────────────────────────────────────
@router.post("/users/create")
async def admin_create_user(request: Request, db: Session = Depends(get_db)):
try:
admin = _admin_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
form = await request.form()
first_name = str(form.get("first_name", "")).strip()
last_name = str(form.get("last_name", "")).strip()
email = str(form.get("email", "")).strip().lower()
phone = str(form.get("phone", "")).strip() or None
password = str(form.get("password", ""))
role_str = str(form.get("role", "user"))
errors = []
if not first_name:
errors.append("Имя обязательно")
if not email:
errors.append("Email обязателен")
if not password or len(password) < 8:
errors.append("Пароль должен содержать минимум 8 символов")
if role_str not in ("user", "admin", "system"):
role_str = "user"
if not errors:
existing = db.query(User).filter(User.email == email).first()
if existing:
errors.append("Пользователь с таким email уже существует")
if errors:
q = db.query(User)
total = q.count()
users = q.order_by(User.created_at.desc()).limit(PAGE_SIZE).all()
return _render(request, "admin/users.html", {
"user": admin,
"users": users,
"search": "",
"status_filter": "",
"role_filter": "",
"page": 1,
"total_pages": max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE),
"total": total,
"create_errors": errors,
"create_form": {
"first_name": first_name,
"last_name": last_name,
"email": email,
"phone": phone or "",
"role": role_str,
},
})
try:
role = UserRoleEnum(role_str)
except ValueError:
role = UserRoleEnum.user
new_user = User(
first_name=first_name,
last_name=last_name,
email=email,
phone=phone,
password_hash=hash_password(password),
role=role,
status=UserStatusEnum.active,
is_email_confirmed=True,
)
db.add(new_user)
db.commit()
return RedirectResponse(f"/admin/users/{new_user.id}?success=saved", 303)
# ── View-as ───────────────────────────────────────────────────────────────────
@router.post("/users/{user_id}/view-as")
async def admin_view_as(user_id: int, request: Request, db: Session = Depends(get_db)):
try:
_admin_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
target = db.get(User, user_id)
if target:
request.session["viewed_user_id"] = user_id
return RedirectResponse("/connections", 303)
@router.post("/view-as/stop")
async def admin_view_as_stop(request: Request, db: Session = Depends(get_db)):
try:
_admin_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
request.session.pop("viewed_user_id", None)
return RedirectResponse("/admin/users", 303)
# ── User actions ──────────────────────────────────────────────────────────────
@router.post("/users/{user_id}/activate")
@@ -197,11 +296,12 @@ async def admin_edit_user(user_id: int, request: Request, db: Session = Depends(
user.first_name = data["first_name"]
user.last_name = data["last_name"]
user.middle_name = data.get("middle_name") or None
if data.get("email"):
user.email = data["email"]
if data.get("phone"):
user.phone = data["phone"]
if data.get("role") and admin.role == UserRoleEnum.system:
if data.get("role"):
try:
user.role = UserRoleEnum(data["role"])
except ValueError:
@@ -216,8 +316,6 @@ async def admin_delete_user(user_id: int, request: Request, db: Session = Depend
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)
@@ -233,8 +331,6 @@ async def admin_roles(request: Request, db: Session = Depends(get_db)):
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]] = {}
@@ -257,9 +353,6 @@ async def admin_update_role_permissions(
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_")}

View File

@@ -1,11 +1,10 @@
import secrets
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy import or_
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from sqlalchemy.orm import Session
from web.auth.password import hash_password, verify_password
from web.auth.password import verify_password
from web.auth.session import get_session_user_id
from web.config import settings
from web.database import get_db
@@ -23,60 +22,13 @@ def _render(request: Request, template: str, ctx: dict) -> HTMLResponse:
@router.get("/register")
async def register_get(request: Request, db: Session = Depends(get_db)):
if get_session_user_id(request):
return RedirectResponse("/profile", 303)
return _render(request, "register.html", {"user": None})
async def register_get(request: Request):
return Response(status_code=404)
@router.post("/register")
async def register_post(request: Request, db: Session = Depends(get_db)):
form = await request.form()
data = {k: str(v).strip() for k, v in form.items()}
errors = []
if not data.get("email"):
errors.append("Email обязателен")
if not data.get("phone"):
errors.append("Телефон обязателен")
if not data.get("password"):
errors.append("Пароль обязателен")
if len(data.get("password", "")) < 8:
errors.append("Пароль должен содержать минимум 8 символов")
if data.get("password") != data.get("password_confirm"):
errors.append("Пароли не совпадают")
if not errors:
existing = db.query(User).filter(
or_(User.email == data["email"], User.phone == data["phone"])
).first()
if existing:
if existing.email == data["email"]:
errors.append("Пользователь с таким email уже существует")
else:
errors.append("Пользователь с таким телефоном уже существует")
if errors:
return _render(request, "register.html", {"user": None, "errors": errors, "form": data})
token = secrets.token_urlsafe(32)
user = User(
first_name=data.get("first_name", ""),
last_name=data.get("last_name", ""),
email=data["email"],
phone=data["phone"],
password_hash=await _hash(data["password"]),
email_confirm_token=token,
status=UserStatusEnum.pending,
)
db.add(user)
db.commit()
confirm_url = f"{settings.BASE_URL}/confirm-email?token={token}"
html = f'<p>Подтвердите email: <a href="{confirm_url}">{confirm_url}</a></p>'
send_email_task.delay(user.email, "Подтвердите ваш email — ЭВОСИНК", html)
return _render(request, "confirm_email.html", {"user": None})
async def register_post(request: Request):
return Response(status_code=404)
@router.get("/confirm-email")
@@ -167,4 +119,5 @@ async def logout(request: Request):
async def _hash(plain: str) -> str:
import asyncio
from web.auth.password import hash_password
return await asyncio.get_event_loop().run_in_executor(None, hash_password, plain)

265
web/routes/catalog.py Normal file
View File

@@ -0,0 +1,265 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy import func
from sqlalchemy.orm import Session
from web.auth.session import get_current_user, get_viewed_user
from web.config import settings
from web.database import get_db
from web.models.connections import CachedGroup, CachedProduct, CachedStore, SyncConfig, SyncFilter
from web.templates_env import templates
router = APIRouter()
def _get_or_create_sync_config(db: Session, user_id: int) -> SyncConfig:
cfg = db.query(SyncConfig).filter_by(user_id=user_id).first()
if not cfg:
cfg = SyncConfig(user_id=user_id, is_enabled=True)
db.add(cfg)
db.flush()
return cfg
def _enabled_store_ids(db: Session, user_id: int) -> set[str] | None:
"""Return set of enabled store evotor_ids, or None if filters not yet seeded (all enabled)."""
cfg = db.query(SyncConfig).filter_by(user_id=user_id).first()
if not cfg or not cfg.store_filters_seeded:
return None
filters = db.query(SyncFilter).filter_by(
sync_config_id=cfg.id, entity_type="store", filter_mode="include"
).all()
return {f.entity_id for f in filters}
def _enabled_group_ids(db: Session, user_id: int, store_evotor_id: str) -> set[str] | None:
"""Return set of enabled group evotor_ids for a store, or None if filters not yet seeded (all enabled)."""
cfg = db.query(SyncConfig).filter_by(user_id=user_id).first()
if not cfg or not cfg.group_filters_seeded:
return None
filters = db.query(SyncFilter).filter_by(
sync_config_id=cfg.id, entity_type="group", filter_mode="include",
parent_entity_id=store_evotor_id,
).all()
return {f.entity_id for f in filters}
def _render(request: Request, template: str, ctx: dict) -> HTMLResponse:
ctx["request"] = request
ctx.setdefault("jivosite_widget_id", settings.JIVOSITE_WIDGET_ID)
return templates.TemplateResponse(ctx.pop("request"), template, ctx)
@router.get("/catalog/stores")
async def catalog_stores(request: Request, db: Session = Depends(get_db)):
try:
real_user, viewed_user = get_viewed_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
stores = (
db.query(CachedStore)
.filter(CachedStore.user_id == viewed_user.id)
.order_by(CachedStore.name)
.all()
)
enabled_ids = _enabled_store_ids(db, viewed_user.id)
return _render(request, "catalog/stores.html", {
"user": real_user,
"viewed_user": viewed_user if viewed_user.id != real_user.id else None,
"stores": stores,
"enabled_ids": enabled_ids,
"refresh_interval": settings.CATALOG_REFRESH_INTERVAL_SECONDS,
})
@router.get("/catalog/stores/{store_evotor_id}/groups")
async def catalog_groups(store_evotor_id: str, request: Request, db: Session = Depends(get_db)):
try:
real_user, viewed_user = get_viewed_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
store = (
db.query(CachedStore)
.filter(CachedStore.user_id == viewed_user.id, CachedStore.evotor_id == store_evotor_id)
.first()
)
if not store:
return RedirectResponse("/catalog/stores", 303)
groups = (
db.query(CachedGroup)
.filter(CachedGroup.user_id == viewed_user.id, CachedGroup.store_evotor_id == store_evotor_id)
.order_by(CachedGroup.name)
.all()
)
enabled_ids = _enabled_group_ids(db, viewed_user.id, store_evotor_id)
counts_q = (
db.query(CachedProduct.group_evotor_id, func.count().label("cnt"))
.filter(CachedProduct.user_id == viewed_user.id, CachedProduct.store_evotor_id == store_evotor_id)
.group_by(CachedProduct.group_evotor_id)
.all()
)
product_counts = {row.group_evotor_id: row.cnt for row in counts_q}
return _render(request, "catalog/groups.html", {
"user": real_user,
"viewed_user": viewed_user if viewed_user.id != real_user.id else None,
"store": store, "groups": groups,
"enabled_ids": enabled_ids,
"product_counts": product_counts,
})
@router.get("/catalog/stores/{store_evotor_id}/products")
async def catalog_products(store_evotor_id: str, request: Request, db: Session = Depends(get_db)):
try:
real_user, viewed_user = get_viewed_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
store = (
db.query(CachedStore)
.filter(CachedStore.user_id == viewed_user.id, CachedStore.evotor_id == store_evotor_id)
.first()
)
if not store:
return RedirectResponse("/catalog/stores", 303)
group_id = request.query_params.get("group")
q = db.query(CachedProduct).filter(
CachedProduct.user_id == viewed_user.id,
CachedProduct.store_evotor_id == store_evotor_id,
)
if group_id:
q = q.filter(CachedProduct.group_evotor_id == group_id)
products = q.order_by(CachedProduct.name).all()
groups = (
db.query(CachedGroup)
.filter(CachedGroup.user_id == viewed_user.id, CachedGroup.store_evotor_id == store_evotor_id)
.order_by(CachedGroup.name)
.all()
)
group_map = {g.evotor_id: g.name for g in groups}
return _render(request, "catalog/products.html", {
"user": real_user,
"viewed_user": viewed_user if viewed_user.id != real_user.id else None,
"store": store,
"products": products,
"groups": groups,
"group_id": group_id,
"group_map": group_map,
})
@router.post("/catalog/stores/{store_evotor_id}/toggle")
async def catalog_store_toggle(store_evotor_id: str, request: Request, db: Session = Depends(get_db)):
try:
user = get_current_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
cfg = _get_or_create_sync_config(db, user.id)
existing = db.query(SyncFilter).filter_by(
sync_config_id=cfg.id, entity_type="store", filter_mode="include"
).all()
existing_ids = {f.entity_id for f in existing}
if cfg.store_filters_seeded:
if store_evotor_id in existing_ids:
# Currently enabled → disable: remove its filter
db.query(SyncFilter).filter_by(
sync_config_id=cfg.id, entity_type="store",
entity_id=store_evotor_id, filter_mode="include",
).delete()
else:
# Currently disabled → re-enable: add its filter back
db.add(SyncFilter(
sync_config_id=cfg.id,
entity_type="store",
entity_id=store_evotor_id,
filter_mode="include",
created_at=datetime.now(timezone.utc).replace(tzinfo=None),
))
else:
# First toggle ever: seed include-filters for all OTHER stores, mark seeded
all_stores = db.query(CachedStore).filter_by(user_id=user.id).all()
now = datetime.now(timezone.utc).replace(tzinfo=None)
for s in all_stores:
if s.evotor_id == store_evotor_id:
continue
db.add(SyncFilter(
sync_config_id=cfg.id,
entity_type="store",
entity_id=s.evotor_id,
entity_name=s.name,
filter_mode="include",
created_at=now,
))
cfg.store_filters_seeded = True
db.commit()
return RedirectResponse("/catalog/stores", 303)
@router.post("/catalog/stores/{store_evotor_id}/groups/{group_evotor_id}/toggle")
async def catalog_group_toggle(
store_evotor_id: str, group_evotor_id: str,
request: Request, db: Session = Depends(get_db),
):
try:
user = get_current_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
cfg = _get_or_create_sync_config(db, user.id)
existing = db.query(SyncFilter).filter_by(
sync_config_id=cfg.id, entity_type="group", filter_mode="include",
parent_entity_id=store_evotor_id,
).all()
existing_ids = {f.entity_id for f in existing}
if cfg.group_filters_seeded:
if group_evotor_id in existing_ids:
db.query(SyncFilter).filter_by(
sync_config_id=cfg.id, entity_type="group",
entity_id=group_evotor_id, filter_mode="include",
).delete()
else:
db.add(SyncFilter(
sync_config_id=cfg.id,
entity_type="group",
entity_id=group_evotor_id,
filter_mode="include",
parent_entity_id=store_evotor_id,
created_at=datetime.now(timezone.utc).replace(tzinfo=None),
))
else:
# First toggle ever: seed include-filters for all OTHER groups in this store, mark seeded
all_groups = db.query(CachedGroup).filter_by(
user_id=user.id, store_evotor_id=store_evotor_id,
).all()
now = datetime.now(timezone.utc).replace(tzinfo=None)
for g in all_groups:
if g.evotor_id == group_evotor_id:
continue
db.add(SyncFilter(
sync_config_id=cfg.id,
entity_type="group",
entity_id=g.evotor_id,
entity_name=g.name,
filter_mode="include",
parent_entity_id=store_evotor_id,
created_at=now,
))
cfg.group_filters_seeded = True
db.commit()
return RedirectResponse(f"/catalog/stores/{store_evotor_id}/groups", 303)

383
web/routes/connections.py Normal file
View File

@@ -0,0 +1,383 @@
import secrets
from datetime import datetime, timedelta, timezone
from urllib.parse import urlencode
import httpx
import web.lib.api_logger as api_logger
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from sqlalchemy.orm import Session
from web.auth.session import get_current_user, get_viewed_user
from web.config import settings
from web.database import get_db
from web.models.connections import EvotorConnection, VkConnection
from web.templates_env import templates
VK_SCOPE = 335876 # photos(4) + wall(8192) + groups(262144) + offline(65536)
router = APIRouter()
def _render(request: Request, template: str, ctx: dict) -> HTMLResponse:
ctx["request"] = request
ctx.setdefault("jivosite_widget_id", settings.JIVOSITE_WIDGET_ID)
return templates.TemplateResponse(ctx.pop("request"), template, ctx)
def _now() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
@router.get("/connections")
async def connections_get(request: Request, db: Session = Depends(get_db)):
try:
real_user, viewed_user = get_viewed_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
evotor = db.query(EvotorConnection).filter_by(user_id=viewed_user.id).first()
vk = db.query(VkConnection).filter_by(user_id=viewed_user.id).first()
return _render(request, "connections.html", {
"user": real_user,
"viewed_user": viewed_user if viewed_user.id != real_user.id else None,
"evotor": evotor,
"vk": vk,
})
@router.post("/connections/evotor")
async def connections_evotor_post(request: Request, db: Session = Depends(get_db)):
try:
_, user = get_viewed_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
form = await request.form()
access_token = str(form.get("access_token", "")).strip()
evotor_user_id = str(form.get("evotor_user_id", "")).strip() or None
if not access_token:
evotor = db.query(EvotorConnection).filter_by(user_id=user.id).first()
return _render(request, "connections.html", {
"user": user,
"evotor": evotor,
"errors": ["API-токен обязателен"],
})
now = _now()
conn = db.query(EvotorConnection).filter_by(user_id=user.id).first()
if conn:
conn.access_token = access_token
if evotor_user_id:
conn.evotor_user_id = evotor_user_id
conn.updated_at = now
else:
conn = EvotorConnection(
user_id=user.id,
evotor_user_id=evotor_user_id,
access_token=access_token,
api_token=secrets.token_urlsafe(32),
connected_at=now,
updated_at=now,
)
db.add(conn)
if evotor_user_id and not user.evotor_user_id:
user.evotor_user_id = evotor_user_id
db.commit()
return RedirectResponse("/connections?success=1", 303)
@router.post("/connections/evotor/disconnect")
async def connections_evotor_disconnect(request: Request, db: Session = Depends(get_db)):
try:
_, user = get_viewed_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
conn = db.query(EvotorConnection).filter_by(user_id=user.id).first()
if conn:
db.delete(conn)
db.commit()
return RedirectResponse("/connections", 303)
@router.post("/connections/vk")
async def connections_vk_post(request: Request, db: Session = Depends(get_db)):
try:
_, user = get_viewed_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
form = await request.form()
access_token = str(form.get("access_token", "")).strip()
vk_group_id = str(form.get("vk_group_id", "")).strip() or None
if not access_token:
evotor = db.query(EvotorConnection).filter_by(user_id=user.id).first()
vk = db.query(VkConnection).filter_by(user_id=user.id).first()
return _render(request, "connections.html", {
"user": user,
"evotor": evotor,
"vk": vk,
"errors": ["Токен VK обязателен"],
})
now = _now()
conn = db.query(VkConnection).filter_by(user_id=user.id).first()
if conn:
conn.access_token = access_token
if vk_group_id:
conn.vk_user_id = vk_group_id
conn.updated_at = now
else:
conn = VkConnection(
user_id=user.id,
access_token=access_token,
vk_user_id=vk_group_id,
connected_at=now,
updated_at=now,
)
db.add(conn)
db.commit()
return RedirectResponse("/connections?success=1", 303)
@router.get("/vk-auth")
async def vk_auth(request: Request):
try:
get_current_user(request, next(get_db()))
except Exception:
return RedirectResponse("/login", 303)
if not settings.VK_CLIENT_ID:
return RedirectResponse("/connections?error=vk_not_configured", 303)
state = secrets.token_urlsafe(16)
request.session["vk_oauth_state"] = state
redirect_uri = f"{settings.BASE_URL}/vk-callback"
params = urlencode({
"client_id": settings.VK_CLIENT_ID,
"redirect_uri": redirect_uri,
"scope": VK_SCOPE,
"response_type": "token",
"display": "page",
"state": state,
"revoke": "1",
})
return RedirectResponse(f"https://oauth.vk.com/authorize?{params}", 302)
@router.get("/vk-callback")
async def vk_callback_page(request: Request):
"""Serves the callback page that reads the token from the URL fragment and POSTs it."""
return HTMLResponse("""<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><title>VK авторизация…</title>
<style>
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center;
min-height: 100vh; margin: 0; background: #f4f4f4; }
.box { background: #fff; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,.1);
text-align: center; max-width: 360px; }
.spinner { width: 36px; height: 36px; border: 4px solid #e0e0e0;
border-top-color: #0077ff; border-radius: 50%;
animation: spin .8s linear infinite; margin: 0 auto 1rem; }
@keyframes spin { to { transform: rotate(360deg); } }
.error { color: #c0392b; }
</style>
</head>
<body>
<div class="box" id="box">
<div class="spinner"></div>
<p>Завершаем авторизацию…</p>
</div>
<script>
(function() {
const hash = window.location.hash.slice(1);
const params = new URLSearchParams(hash);
const token = params.get('access_token');
const state = params.get('state');
const userId = params.get('user_id');
const expiresIn = params.get('expires_in');
if (!token) {
document.getElementById('box').innerHTML =
'<p class="error">Токен не получен. <a href="/connections">Вернуться назад</a></p>';
return;
}
fetch('/vk-callback/save', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ access_token: token, state: state,
user_id: userId, expires_in: expiresIn })
})
.then(r => r.json())
.then(data => {
if (data.ok) {
window.location.href = '/connections?success=1';
} else {
document.getElementById('box').innerHTML =
'<p class="error">' + (data.message || 'Ошибка сохранения') +
' <a href="/connections">Вернуться назад</a></p>';
}
})
.catch(() => {
document.getElementById('box').innerHTML =
'<p class="error">Ошибка сети. <a href="/connections">Вернуться назад</a></p>';
});
})();
</script>
</body>
</html>""")
@router.post("/vk-callback/save")
async def vk_callback_save(request: Request, db: Session = Depends(get_db)):
try:
_, user = get_viewed_user(request, db)
except Exception:
return JSONResponse({"ok": False, "message": "Сессия истекла, войдите снова"}, status_code=401)
body = await request.json()
access_token = (body.get("access_token") or "").strip()
state = body.get("state") or ""
vk_user_id = str(body.get("user_id") or "").strip() or None
expires_in = body.get("expires_in")
expected_state = request.session.pop("vk_oauth_state", None)
if not expected_state or state != expected_state:
return JSONResponse({"ok": False, "message": "Недействительный state, попробуйте снова"})
if not access_token:
return JSONResponse({"ok": False, "message": "Токен не получен"})
token_expires_at = None
if expires_in and str(expires_in) != "0":
try:
token_expires_at = _now() + timedelta(seconds=int(expires_in))
except (ValueError, TypeError):
pass
now = _now()
conn = db.query(VkConnection).filter_by(user_id=user.id).first()
if conn:
conn.access_token = access_token
conn.token_expires_at = token_expires_at
if vk_user_id:
conn.vk_user_id = vk_user_id
conn.updated_at = now
else:
conn = VkConnection(
user_id=user.id,
access_token=access_token,
token_expires_at=token_expires_at,
vk_user_id=vk_user_id,
connected_at=now,
updated_at=now,
)
db.add(conn)
db.commit()
return JSONResponse({"ok": True})
@router.post("/connections/vk/disconnect")
async def connections_vk_disconnect(request: Request, db: Session = Depends(get_db)):
try:
_, user = get_viewed_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
conn = db.query(VkConnection).filter_by(user_id=user.id).first()
if conn:
db.delete(conn)
db.commit()
return RedirectResponse("/connections", 303)
@router.post("/connections/evotor/test")
async def connections_evotor_test(request: Request, db: Session = Depends(get_db)):
try:
_, user = get_viewed_user(request, db)
except Exception:
return JSONResponse({"ok": False, "message": "Не авторизован"}, status_code=401)
conn = db.query(EvotorConnection).filter_by(user_id=user.id).first()
if not conn:
return JSONResponse({"ok": False, "message": "Подключение не настроено"})
try:
r = api_logger.get(
"https://api.evotor.ru/stores",
user_id=user.id,
headers={
"Authorization": f"Bearer {conn.access_token}",
"Accept": "application/vnd.evotor.v2+json",
},
timeout=10,
)
if r.status_code == 200:
data = r.json()
items = data.get("items", data) if isinstance(data, dict) else data
count = len(items) if isinstance(items, list) else "?"
return JSONResponse({"ok": True, "message": f"Успешно. Найдено магазинов: {count}"})
elif r.status_code == 401:
return JSONResponse({"ok": False, "message": "Токен недействителен (401)"})
else:
return JSONResponse({"ok": False, "message": f"Ошибка API: HTTP {r.status_code}"})
except httpx.TimeoutException:
return JSONResponse({"ok": False, "message": "Таймаут запроса к Эвотор"})
except Exception as e:
return JSONResponse({"ok": False, "message": f"Ошибка: {e}"})
@router.post("/connections/vk/test")
async def connections_vk_test(request: Request, db: Session = Depends(get_db)):
try:
_, user = get_viewed_user(request, db)
except Exception:
return JSONResponse({"ok": False, "message": "Не авторизован"}, status_code=401)
conn = db.query(VkConnection).filter_by(user_id=user.id).first()
if not conn:
return JSONResponse({"ok": False, "message": "Подключение не настроено"})
try:
if not conn.vk_user_id:
return JSONResponse({"ok": False, "message": "Укажите ID сообщества для проверки подключения."})
r = api_logger.get(
"https://api.vk.com/method/groups.getById",
user_id=user.id,
params={
"group_id": conn.vk_user_id,
"fields": "market",
"access_token": conn.access_token,
"v": settings.VK_API_VERSION,
},
timeout=10,
)
data = r.json()
if "error" in data:
code = data["error"].get("error_code")
msg = data["error"].get("error_msg", "Неизвестная ошибка")
return JSONResponse({"ok": False, "message": f"Ошибка VK API ({code}): {msg}"})
groups = data.get("response", {}).get("groups", [])
if not groups:
return JSONResponse({"ok": False, "message": "Сообщество не найдено"})
group = groups[0]
name = group.get("name", "")
market = group.get("market", {})
market_status = "включён" if market.get("enabled") else "выключен"
return JSONResponse({"ok": True, "message": f"Успешно. Сообщество: «{name}», Маркет {market_status}"})
except httpx.TimeoutException:
return JSONResponse({"ok": False, "message": "Таймаут запроса к VK"})
except Exception as e:
return JSONResponse({"ok": False, "message": f"Ошибка: {e}"})

View File

@@ -1,9 +1,10 @@
"""
Evotor webhook endpoints.
POST /user/create — Evotor creates a new subscriber; we create/link a local user and return a token.
POST /user/verify — Evotor verifies credentials for a user trying to log in via the Evotor interface.
POST /user/token — Evotor sends us its own API token for the user.
POST /user/create — Evotor creates a new subscriber; we create/link a local user and return a token.
POST /user/verify — Evotor verifies credentials for a user trying to log in via the Evotor interface.
POST /user/token — Evotor sends us its own API token for the user.
POST /user/install — Evotor notifies about app install or uninstall for a user.
"""
import json
import logging
@@ -16,7 +17,7 @@ from fastapi.responses import JSONResponse
from sqlalchemy import or_
from sqlalchemy.orm import Session
from web.auth.password import verify_password
from web.auth.password import hash_password, verify_password
from web.config import settings
from web.database import get_db
from web.models.connections import EvotorConnection
@@ -62,13 +63,18 @@ def _upsert_evotor_connection(
"""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
or_(
EvotorConnection.evotor_user_id == evotor_user_id,
EvotorConnection.user_id == user_id,
)
).first()
now = datetime.now(timezone.utc).replace(tzinfo=None)
if conn:
conn.api_token = api_token
if user_id is not None:
conn.user_id = user_id
if evotor_user_id:
conn.evotor_user_id = evotor_user_id
if access_token:
conn.access_token = access_token
conn.updated_at = now
@@ -101,10 +107,13 @@ async def user_create(request: Request, db: Session = Depends(get_db)):
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
# Evotor sends fields both at top level and inside customField
email = (body.get("email") or custom.get("email") or "").strip().lower() or None
phone = (body.get("phone_number") or body.get("phone") or custom.get("phone_number") or custom.get("phone") or "").strip() or None
first_name = (body.get("first_name") or body.get("firstName") or custom.get("first_name") or custom.get("firstName") or "").strip() or None
last_name = (body.get("second_name") or body.get("last_name") or body.get("lastName") or custom.get("second_name") or custom.get("last_name") or custom.get("lastName") or "").strip() or None
middle_name = (body.get("middle_name") or custom.get("middle_name") or "").strip() or None
password = (body.get("password") or custom.get("password") or "").strip() or None
# Try to find existing user
user: User | None = None
@@ -123,51 +132,61 @@ async def user_create(request: Request, db: Session = Depends(get_db)):
now = datetime.now(timezone.utc).replace(tzinfo=None)
if user:
# Link Evotor to existing user
# Link Evotor to existing user; update name fields if Evotor provided them
user.evotor_user_id = evotor_user_id
user.evotor_meta = custom or body
user.evotor_meta = body
if first_name:
user.first_name = first_name
if last_name:
user.last_name = last_name
if middle_name:
user.middle_name = middle_name
if password:
user.password_hash = hash_password(password)
if user.status == UserStatusEnum.pending:
user.status = UserStatusEnum.active
db.flush()
else:
# Create new pending user
# Create new user; active immediately if password provided, else pending
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,
middle_name=middle_name,
email=email or f"{evotor_user_id}@evotor.invalid",
phone=phone or None,
password_hash=hash_password(password) if password else None,
role=UserRoleEnum.user,
status=UserStatusEnum.pending,
status=UserStatusEnum.active if password else UserStatusEnum.pending,
evotor_user_id=evotor_user_id,
evotor_meta=custom or body,
evotor_meta=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)
if not password:
# No password provided — send invite link so user can set one
invite_token = secrets.token_urlsafe(32)
user.invite_token = invite_token
user.invite_expires = now + timedelta(hours=settings.INVITE_EXPIRE_HOURS)
db.commit()
if email:
invite_url = f"{settings.BASE_URL}/invite?token={invite_token}"
html = (
f"<p>Здравствуйте!</p>"
f"<p>Вам открыт доступ к ЭВОСИНК. Завершите регистрацию по ссылке:</p>"
f'<p><a href="{invite_url}">{invite_url}</a></p>'
f"<p>Ссылка действительна {settings.INVITE_EXPIRE_HOURS} часов.</p>"
)
send_email_task.delay(email, "Приглашение в ЭВОСИНК", html)
else:
logger.info("No email for evotor_user_id=%s, invite URL: %s/invite?token=%s",
evotor_user_id, settings.BASE_URL, invite_token)
else:
logger.info("No email for evotor_user_id=%s, invite URL: %s/invite?token=%s",
evotor_user_id, settings.BASE_URL, invite_token)
db.commit()
return JSONResponse({"userId": evotor_user_id, "token": api_token})
@@ -182,25 +201,38 @@ async def user_verify(request: Request, db: Session = Depends(get_db)):
except Exception:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
logger.info("user/verify body=%s", {k: v for k, v in body.items() if k != "password"})
evotor_user_id: str = body.get("userId", "")
username: str = body.get("username", "").strip()
phone: str = body.get("phone", "").strip()
password: str = body.get("password", "")
if not username or not password:
return JSONResponse({"error": "username and password required"}, status_code=400)
login = username or phone
if not password:
return JSONResponse({"error": "password required"}, status_code=400)
# username is email or phone
user = db.query(User).filter(
or_(User.email == username, User.phone == username)
).first()
# 1. Match by evotor_user_id (most reliable — Evotor always sends userId)
user = db.query(User).filter(User.evotor_user_id == evotor_user_id).first() if evotor_user_id else None
if not user or not user.password_hash:
# 2. Fall back to email or phone
if not user and login:
user = db.query(User).filter(
or_(User.email == login, User.phone == login)
).first()
if not user:
return JSONResponse({"error": "Неверные данные"}, status_code=401)
if user.status == UserStatusEnum.suspended:
return JSONResponse({"error": "Аккаунт заблокирован"}, status_code=403)
if not verify_password(password, user.password_hash):
if not user.password_hash:
# First auth with password — save it and activate the account
user.password_hash = hash_password(password)
if user.status == UserStatusEnum.pending:
user.status = UserStatusEnum.active
elif not verify_password(password, user.password_hash):
return JSONResponse({"error": "Неверные данные"}, status_code=401)
# Get or create connection to retrieve api_token
@@ -267,3 +299,54 @@ async def user_token(request: Request, db: Session = Depends(get_db)):
db.commit()
return JSONResponse({})
@router.post("/user/install")
async def user_install(request: Request, db: Session = Depends(get_db)):
"""Handle app install / uninstall events from Evotor."""
if not _verify_secret(request):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
# userId is nested inside "data"; type is e.g. "ApplicationInstalled" / "ApplicationUninstalled"
data: dict = body.get("data", {})
evotor_user_id: str = data.get("userId", "") or body.get("userId", "")
event_type: str = body.get("type", "").lower() # "applicationinstalled" / "applicationuninstalled"
if not evotor_user_id:
logger.warning("user/install missing userId, body=%s", body)
return JSONResponse({"error": "userId required"}, status_code=400)
logger.info("user/install event type=%s userId=%s", event_type, evotor_user_id)
user = db.query(User).filter(User.evotor_user_id == evotor_user_id).first()
if not user:
# Unknown user — nothing to act on, but acknowledge the event
return JSONResponse({})
now = datetime.now(timezone.utc).replace(tzinfo=None)
if event_type == "applicationuninstalled":
user.status = UserStatusEnum.suspended
user.updated_at = now
conn = db.query(EvotorConnection).filter(
EvotorConnection.evotor_user_id == evotor_user_id
).first()
if conn:
conn.is_online = False
conn.updated_at = now
db.commit()
logger.info("user suspended on uninstall: userId=%s", evotor_user_id)
elif event_type == "applicationinstalled":
if user.status == UserStatusEnum.suspended:
user.status = UserStatusEnum.active
user.updated_at = now
db.commit()
logger.info("user reactivated on reinstall: userId=%s", evotor_user_id)
return JSONResponse({})

84
web/routes/logs.py Normal file
View File

@@ -0,0 +1,84 @@
"""API request/response log viewer (admin only)."""
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, Request
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from web.auth.session import get_current_user
from web.database import get_db
from web.models.connections import ApiLog
from web.models.user import UserRoleEnum
from web.templates_env import templates
router = APIRouter()
PAGE_SIZE = 50
def _render(request, template, ctx):
return templates.TemplateResponse(template, {"request": request, **ctx})
@router.get("/admin/logs")
async def admin_logs(
request: Request,
db: Session = Depends(get_db),
service: str = "",
method: str = "",
status: str = "",
q: str = "",
page: int = 1,
hours: int = 168,
):
try:
user = get_current_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
if user.role not in (UserRoleEnum.admin, UserRoleEnum.system):
return RedirectResponse("/login", 303)
since = datetime.utcnow() - timedelta(hours=hours)
query = db.query(ApiLog).filter(ApiLog.created_at >= since)
if service:
query = query.filter(ApiLog.service == service)
if method:
query = query.filter(ApiLog.method == method)
if status:
try:
st = int(status)
query = query.filter(ApiLog.response_status == st)
except ValueError:
if status == "error":
query = query.filter(ApiLog.response_status >= 400)
elif status == "ok":
query = query.filter(ApiLog.response_status < 400)
if q:
like = f"%{q}%"
query = query.filter(
ApiLog.url.like(like) | ApiLog.response_body.like(like)
)
total = query.count()
logs = (
query.order_by(ApiLog.created_at.desc())
.offset((page - 1) * PAGE_SIZE)
.limit(PAGE_SIZE)
.all()
)
total_pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
return _render(request, "admin/logs.html", {
"user": user,
"logs": logs,
"total": total,
"page": page,
"total_pages": total_pages,
"page_size": PAGE_SIZE,
"filter_service": service,
"filter_method": method,
"filter_status": status,
"filter_q": q,
"filter_hours": hours,
})

77
web/routes/sync.py Normal file
View File

@@ -0,0 +1,77 @@
"""Sync settings page."""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Request
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from web.auth.session import get_current_user, get_viewed_user
from web.config import settings
from web.database import get_db
from web.models.connections import SyncConfig
from web.templates_env import templates
router = APIRouter()
def _render(request: Request, ctx: dict):
ctx["request"] = request
ctx.setdefault("jivosite_widget_id", settings.JIVOSITE_WIDGET_ID)
return templates.TemplateResponse(ctx.pop("request"), "sync.html", ctx)
@router.get("/sync")
async def sync_get(request: Request, db: Session = Depends(get_db)):
try:
real_user, viewed_user = get_viewed_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
config = db.query(SyncConfig).filter_by(user_id=viewed_user.id).first()
return _render(request, {
"user": real_user,
"viewed_user": viewed_user if viewed_user.id != real_user.id else None,
"config": config,
"saved": request.query_params.get("saved"),
})
@router.post("/sync/settings")
async def sync_settings_post(request: Request, db: Session = Depends(get_db)):
try:
user = get_current_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
form = await request.form()
evo_mirror_enabled = "1" in form.getlist("evo_mirror_enabled")
vk_mirror_enabled = "1" in form.getlist("vk_mirror_enabled")
sync_enabled = "1" in form.getlist("is_enabled")
raw_multiplier = str(form.get("price_multiplier", "1")).strip()
try:
multiplier = float(raw_multiplier)
if multiplier <= 0:
raise ValueError
except ValueError:
multiplier = 1.0
config = db.query(SyncConfig).filter_by(user_id=user.id).first()
if config:
config.evo_mirror_enabled = evo_mirror_enabled
config.vk_mirror_enabled = vk_mirror_enabled
config.is_enabled = sync_enabled
config.price_multiplier = multiplier
else:
config = SyncConfig(
user_id=user.id,
evo_mirror_enabled=evo_mirror_enabled,
vk_mirror_enabled=vk_mirror_enabled,
is_enabled=sync_enabled,
price_multiplier=multiplier,
)
db.add(config)
db.commit()
return RedirectResponse("/sync?saved=1", 303)

65
web/routes/vk_catalog.py Normal file
View File

@@ -0,0 +1,65 @@
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from web.auth.session import get_viewed_user
from web.config import settings
from web.database import get_db
from web.models.connections import VkCachedAlbum, VkCachedProduct, VkConnection
from web.templates_env import templates
router = APIRouter()
def _render(request: Request, template: str, ctx: dict) -> HTMLResponse:
ctx["request"] = request
ctx.setdefault("jivosite_widget_id", settings.JIVOSITE_WIDGET_ID)
return templates.TemplateResponse(ctx.pop("request"), template, ctx)
@router.get("/vk-catalog/albums")
async def vk_catalog_albums(request: Request, db: Session = Depends(get_db)):
try:
real_user, viewed_user = get_viewed_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
vk_conn = db.query(VkConnection).filter_by(user_id=viewed_user.id).first()
albums = (
db.query(VkCachedAlbum)
.filter(VkCachedAlbum.user_id == viewed_user.id)
.order_by(VkCachedAlbum.title)
.all()
)
return _render(request, "vk_catalog/albums.html", {
"user": real_user,
"viewed_user": viewed_user if viewed_user.id != real_user.id else None,
"albums": albums,
"vk_conn": vk_conn,
"refresh_interval": settings.CATALOG_REFRESH_INTERVAL_SECONDS,
})
@router.get("/vk-catalog/albums/{album_id}/products")
async def vk_catalog_products(album_id: str, request: Request, db: Session = Depends(get_db)):
try:
real_user, viewed_user = get_viewed_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
album = db.query(VkCachedAlbum).filter_by(user_id=viewed_user.id, album_id=album_id).first()
if not album:
return RedirectResponse("/vk-catalog/albums", 303)
products = (
db.query(VkCachedProduct)
.filter(VkCachedProduct.user_id == viewed_user.id, VkCachedProduct.album_id == album_id)
.order_by(VkCachedProduct.name)
.all()
)
return _render(request, "vk_catalog/products.html", {
"user": real_user,
"viewed_user": viewed_user if viewed_user.id != real_user.id else None,
"album": album,
"products": products,
})

BIN
web/static/favicon-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 B

BIN
web/static/favicon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 B

BIN
web/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 B

6
web/static/favicon.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#FF5500"/>
<path d="M10 13h12l-1.8 10H11.8L10 13Z" fill="white" opacity="0.95"/>
<path d="M13 13v-2.5a3 3 0 016 0V13" stroke="white" stroke-width="1.8" stroke-linecap="round" fill="none"/>
<line x1="13.5" y1="17.5" x2="18.5" y2="17.5" stroke="#FF5500" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 437 B

View File

@@ -1,431 +1,415 @@
/* Brand colors */
:root {
--pico-primary: #F05023;
--pico-primary-hover: #d44420;
--pico-primary-focus: rgba(240, 80, 35, 0.25);
--pico-primary-inverse: #fff;
--brand-primary: #F05023;
--brand-secondary: #0986E2;
--brand-secondary-hover: #0770c0;
}
/* ─── Reset ──────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body { font-family: 'Golos Text', sans-serif; background: #F4F5F7; color: #1C1F2E; font-size: 14px; }
a { text-decoration: none; color: inherit; }
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #E4E6EE; border-radius: 3px; }
/* 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 {
font-size: 1.3rem;
font-weight: 700;
color: var(--brand-primary) !important;
text-decoration: none;
}
.nav-links {
flex: 1;
justify-content: flex-end;
}
.nav-links a {
color: var(--pico-color);
text-decoration: none;
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.nav-links a:hover {
color: var(--brand-primary);
}
.nav-links a.secondary {
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; }
/* ─── Utility ─────────────────────────────────────────────────────────── */
.mono { font-family: 'JetBrains Mono', monospace; }
.hr { height: 1px; background: #E4E6EE; margin: 18px 0; }
.w-100 { width: 100%; }
.h-100 { height: 100%; }
.text-center { text-align: center; }
.d-none { display: none; }
/* Table */
.table-scroll {
overflow-x: auto;
/* ─── Layout ──────────────────────────────────────────────────────────── */
.shell { display: flex; min-height: 100vh; }
.sidebar {
width: 228px; min-height: 100vh; flex-shrink: 0;
background: #1C1F2E;
display: flex; flex-direction: column;
position: sticky; top: 0; height: 100vh; overflow-y: auto;
}
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
table.align-middle td,
table.align-middle th {
vertical-align: middle;
/* ─── Sidebar ─────────────────────────────────────────────────────────── */
.sb-logo {
padding: 22px 20px 18px;
border-bottom: 1px solid rgba(255,255,255,0.07);
display: flex; align-items: center; gap: 11px;
}
/* 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);
.sb-logo-icon {
width: 36px; height: 36px; border-radius: 9px;
background: #FF5500;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.breadcrumb-item + .breadcrumb-item::before {
content: "/";
margin-right: 0.25rem;
color: var(--pico-muted-color);
.sb-logo-name { font-size: 15px; font-weight: 800; color: #fff; line-height: 1.1; letter-spacing: -0.02em; }
.sb-logo-sub { font-size: 10px; color: #5C6278; font-weight: 500; margin-top: 1px; letter-spacing: 0.03em; }
.sb-nav { flex: 1; padding: 10px 12px; display: flex; flex-direction: column; gap: 1px; }
.sb-section {
font-size: 9.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em;
color: #5C6278; padding: 14px 8px 5px;
}
.breadcrumb-item.active { color: var(--pico-color); }
/* Dropdown */
.dropdown {
position: relative;
display: inline-block;
.sb-item {
display: flex; align-items: center; gap: 9px;
padding: 9px 10px; border-radius: 8px; cursor: pointer;
font-size: 13.5px; font-weight: 500; color: #A8ADC3;
transition: all 0.13s; text-decoration: none; position: relative;
}
.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;
.sb-item:hover { background: #252A3D; color: #fff; }
.sb-item.active { background: rgba(255,85,0,0.18); color: #fff; }
.sb-item.active .sb-icon { color: #FF5500; }
.sb-icon { font-size: 16px; width: 20px; text-align: center; flex-shrink: 0; }
.sb-badge {
margin-left: auto; font-size: 10px; font-weight: 700;
padding: 1px 6px; border-radius: 9px;
background: rgba(255,85,0,0.25); color: #FF5500;
font-family: 'JetBrains Mono', monospace;
}
.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;
.sb-badge.err { background: rgba(229,57,53,0.2); color: #E53935; }
.sb-user {
padding: 14px 16px; border-top: 1px solid rgba(255,255,255,0.07);
display: flex; align-items: center; gap: 10px; cursor: pointer;
text-decoration: none;
}
.dropdown-item:hover {
background: var(--pico-muted-background-color);
.sb-user:hover { background: #252A3D; }
.avatar {
width: 34px; height: 34px; border-radius: 50%; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 700; color: #fff;
background: linear-gradient(135deg, #FF5500 0%, #FF8C42 100%);
text-transform: uppercase;
}
.dropdown-item.muted { color: var(--pico-muted-color); }
.dropdown-divider {
border: none;
border-top: 1px solid var(--pico-border-color);
margin: 0.25rem 0;
.avatar.admin { background: linear-gradient(135deg, #6B48FF 0%, #A78BFA 100%); }
.sb-user-name { font-size: 12.5px; font-weight: 600; color: #fff; }
.sb-user-role { font-size: 10.5px; color: #5C6278; margin-top: 1px; }
.role-chip {
display: inline-block; font-size: 9px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.05em;
padding: 1px 5px; border-radius: 4px;
}
.role-chip.user { background: rgba(255,85,0,0.2); color: #FF5500; }
.role-chip.admin { background: rgba(107,72,255,0.2); color: #A78BFA; }
/* Spinner */
/* ─── Topbar ──────────────────────────────────────────────────────────── */
.topbar {
height: 56px; border-bottom: 1px solid #E4E6EE;
display: flex; align-items: center; padding: 0 28px; gap: 14px;
background: #FFFFFF; position: sticky; top: 0; z-index: 10;
flex-shrink: 0;
}
.topbar-title { font-size: 15px; font-weight: 700; flex: 1; color: #1C1F2E; }
.conn-pill {
display: flex; align-items: center; gap: 6px; padding: 5px 12px;
border-radius: 20px; border: 1px solid #E4E6EE;
font-size: 12px; font-weight: 500; color: #5C6278; background: #F4F5F7;
}
.dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.dot.g { background: #17A865; box-shadow: 0 0 5px #17A865; }
.dot.r { background: #E53935; box-shadow: 0 0 5px #E53935; }
.dot.y { background: #F59E0B; box-shadow: 0 0 5px #F59E0B; }
.dot.d { background: #CDD0DC; }
.dot.pulse { animation: blink 2s ease-in-out infinite; }
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.4} }
/* ─── Content ─────────────────────────────────────────────────────────── */
.content { flex: 1; padding: 28px 32px; overflow-y: auto; }
.pg-title { font-size: 22px; font-weight: 800; color: #1C1F2E; letter-spacing: -0.02em; }
.pg-sub { font-size: 13px; color: #5C6278; margin-top: 3px; margin-bottom: 22px; }
/* ─── Cards ───────────────────────────────────────────────────────────── */
.card {
background: #FFFFFF; border: 1px solid #E4E6EE;
border-radius: 12px; padding: 22px 24px;
}
.card-title { font-size: 14px; font-weight: 700; color: #1C1F2E; }
.card-sub { font-size: 12px; color: #5C6278; margin-top: 2px; }
.card-hd { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 16px; }
/* ─── Stat cards ──────────────────────────────────────────────────────── */
.stat-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 14px; margin-bottom: 20px; }
.stat-card {
background: #FFFFFF; border: 1px solid #E4E6EE; border-radius: 12px;
padding: 18px 20px; position: relative; overflow: hidden;
}
.stat-card::before {
content:''; position: absolute; top: 0; left: 0; right: 0; height: 3px; border-radius: 12px 12px 0 0;
}
.stat-card.or::before { background: #FF5500; }
.stat-card.gr::before { background: #17A865; }
.stat-card.yl::before { background: #F59E0B; }
.stat-card.rd::before { background: #E53935; }
.stat-card.bl::before { background: #3B6FFF; }
.stat-lbl { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.07em; color: #9EA8BE; }
.stat-val { font-size: 30px; font-weight: 800; color: #1C1F2E; margin: 5px 0 2px; font-family: 'JetBrains Mono', monospace; letter-spacing: -0.03em; }
.stat-delta { font-size: 12px; color: #5C6278; }
.stat-icon { position: absolute; right: 16px; top: 50%; transform: translateY(-50%); font-size: 28px; opacity: 0.1; }
/* ─── Grids ───────────────────────────────────────────────────────────── */
.g2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.g3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 14px; }
.g4 { display: grid; grid-template-columns: repeat(4,1fr); gap: 14px; }
/* ─── Buttons ─────────────────────────────────────────────────────────── */
.btn {
display: inline-flex; align-items: center; gap: 7px;
padding: 8px 18px; border-radius: 8px; font-size: 13px; font-weight: 600;
cursor: pointer; border: none; font-family: 'Golos Text', sans-serif;
transition: all 0.14s; white-space: nowrap; text-decoration: none;
}
.btn-primary { background: #FF5500; color: #fff; }
.btn-primary:hover { background: #E64D00; box-shadow: 0 3px 12px rgba(255,85,0,0.3); }
.btn-outline { background: transparent; border: 1.5px solid #E4E6EE; color: #5C6278; }
.btn-outline:hover { border-color: #CDD0DC; color: #1C1F2E; background: #F9FAFB; }
.btn-ghost { background: transparent; color: #5C6278; padding: 6px 10px; border: none; }
.btn-ghost:hover { color: #1C1F2E; background: #F4F5F7; }
.btn-danger { background: #FEF1F1; color: #E53935; border: 1.5px solid #F4AEAE; }
.btn-danger:hover { background: #fce4e4; }
.btn-sm { padding: 6px 13px; font-size: 12px; border-radius: 7px; }
.btn-xs { padding: 4px 9px; font-size: 11.5px; border-radius: 6px; }
/* ─── Tags ────────────────────────────────────────────────────────────── */
.tag {
display: inline-flex; align-items: center; gap: 5px;
padding: 3px 8px; border-radius: 5px; font-size: 11.5px; font-weight: 600;
}
.tag-gr { background: #EDFAF4; color: #17A865; border: 1px solid #A7E8CC; }
.tag-rd { background: #FEF1F1; color: #E53935; border: 1px solid #F4AEAE; }
.tag-yl { background: #FFFBEB; color: #F59E0B; border: 1px solid #FCD678; }
.tag-or { background: #FFF2EC; color: #FF5500; border: 1px solid #FFD9C7; }
.tag-dim { background: #F4F5F7; color: #5C6278; border: 1px solid #E4E6EE; }
.tag-bl { background: #EEF3FF; color: #3B6FFF; border: 1px solid #C7D7FF; }
/* ─── Table ───────────────────────────────────────────────────────────── */
.tbl { width: 100%; border-collapse: collapse; }
.tbl th {
text-align: left; font-size: 10.5px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.08em; color: #9EA8BE;
padding: 10px 16px; border-bottom: 1.5px solid #E4E6EE;
white-space: nowrap;
}
.tbl td { padding: 12px 16px; font-size: 13px; border-bottom: 1px solid #E4E6EE; vertical-align: middle; }
.tbl tr:last-child td { border-bottom: none; }
.tbl tbody tr:hover td { background: #F9FAFB; }
.tbl-name { font-weight: 600; color: #1C1F2E; }
.tbl-sub { font-size: 11px; color: #5C6278; margin-top: 1px; }
/* ─── Toggle ──────────────────────────────────────────────────────────── */
.tog {
width: 38px; height: 22px; border-radius: 11px;
background: #E4E6EE; position: relative; cursor: pointer; transition: background 0.18s; flex-shrink: 0;
display: inline-block;
}
.tog.on { background: #FF5500; }
.tog::after {
content:''; position: absolute; top: 3px; left: 3px;
width: 16px; height: 16px; background: #fff; border-radius: 50%;
transition: transform 0.18s; box-shadow: 0 1px 4px rgba(0,0,0,0.15);
}
.tog.on::after { transform: translateX(16px); }
/* ─── Progress ────────────────────────────────────────────────────────── */
.prog { height: 5px; background: #E4E6EE; border-radius: 3px; overflow: hidden; }
.prog-fill { height: 100%; border-radius: 3px; transition: width 0.4s; }
.prog-fill.or { background: #FF5500; }
.prog-fill.gr { background: #17A865; }
/* ─── Inputs ──────────────────────────────────────────────────────────── */
.inp {
background: #FFFFFF; border: 1.5px solid #E4E6EE; border-radius: 8px;
padding: 8px 12px; font-size: 13px; color: #1C1F2E;
font-family: 'Golos Text', sans-serif; outline: none;
transition: border-color 0.14s; width: 100%;
display: block;
}
.inp:focus { border-color: #FF5500; box-shadow: 0 0 0 3px #FFF2EC; }
.inp::placeholder { color: #9EA8BE; }
select.inp { cursor: pointer; }
.form-row { margin-bottom: 14px; }
.form-lbl { font-size: 11.5px; font-weight: 600; color: #5C6278; margin-bottom: 5px; text-transform: uppercase; letter-spacing: 0.04em; display: block; }
/* ─── Alerts ──────────────────────────────────────────────────────────── */
.alert { border-radius: 9px; padding: 11px 14px; font-size: 12.5px; display: flex; gap: 9px; margin-bottom: 16px; }
.alert-gr { background: #EDFAF4; border: 1px solid #A7E8CC; color: #136B41; }
.alert-yl { background: #FFFBEB; border: 1px solid #FCD678; color: #92680A; }
.alert-bl { background: #EEF3FF; border: 1px solid #C7D7FF; color: #3B6FFF; }
.alert-rd { background: #FEF1F1; border: 1px solid #F4AEAE; color: #E53935; }
/* ─── Tabs ────────────────────────────────────────────────────────────── */
.tabs { display: flex; gap: 0; border-bottom: 1.5px solid #E4E6EE; margin-bottom: 20px; }
.tab {
padding: 9px 18px; font-size: 13px; font-weight: 600; color: #5C6278;
cursor: pointer; border-bottom: 2.5px solid transparent; margin-bottom: -1.5px; transition: all 0.13s;
text-decoration: none; display: inline-block;
}
.tab:hover { color: #1C1F2E; }
.tab.active { color: #FF5500; border-bottom-color: #FF5500; }
/* ─── Conn detail ─────────────────────────────────────────────────────── */
.conn-detail {
background: #F4F5F7; border: 1px solid #E4E6EE; border-radius: 9px;
padding: 12px 16px; display: flex; flex-direction: column; gap: 9px;
}
.conn-row { display: flex; align-items: center; justify-content: space-between; }
.conn-k { font-size: 12px; color: #9EA8BE; }
.conn-v { font-size: 12px; font-weight: 600; color: #1C1F2E; font-family: 'JetBrains Mono', monospace; }
/* ─── Activity ────────────────────────────────────────────────────────── */
.act-item { display: flex; gap: 10px; padding: 10px 0; border-bottom: 1px solid #E4E6EE; }
.act-item:last-child { border-bottom: none; }
.act-icon { width: 32px; height: 32px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 14px; flex-shrink: 0; }
.act-text { font-size: 12.5px; color: #1C1F2E; }
.act-time { font-size: 11px; color: #9EA8BE; margin-top: 2px; font-family: 'JetBrains Mono', monospace; }
/* ─── Section sep ─────────────────────────────────────────────────────── */
.section-sep { display: flex; align-items: center; gap: 10px; margin: 20px 0; }
.section-sep-line { flex: 1; height: 1px; background: #E4E6EE; }
.section-sep-label { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: #9EA8BE; }
/* ─── Pipeline ────────────────────────────────────────────────────────── */
.pipeline { display: flex; align-items: center; padding: 18px 0; }
.pipe-step { display: flex; flex-direction: column; align-items: center; gap: 7px; flex: 1; }
.pipe-node { width: 46px; height: 46px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 18px; border: 2px solid; }
.pipe-node.done { border-color: #17A865; background: #EDFAF4; }
.pipe-node.active { border-color: #FF5500; background: #FFF2EC; }
.pipe-node.idle { border-color: #E4E6EE; background: #F4F5F7; }
.pipe-lbl { font-size: 11px; font-weight: 600; color: #5C6278; text-align: center; }
.pipe-line { flex: 1; height: 2px; max-width: 52px; align-self: flex-start; margin-top: 22px; }
.pipe-line.done { background: #17A865; }
.pipe-line.idle { background: #E4E6EE; }
/* ─── Sync log ────────────────────────────────────────────────────────── */
.log-row { display: flex; align-items: flex-start; gap: 10px; padding: 10px 0; border-bottom: 1px solid #E4E6EE; }
.log-row:last-child { border-bottom: none; }
.log-bullet { width: 8px; height: 8px; border-radius: 50%; margin-top: 4px; flex-shrink: 0; }
.log-msg { font-size: 12.5px; color: #1C1F2E; }
.log-meta { font-size: 11px; color: #9EA8BE; font-family: 'JetBrains Mono', monospace; margin-top: 2px; }
/* ─── User row actions ────────────────────────────────────────────────── */
.action-row { display: flex; gap: 6px; }
/* ─── Inline status ───────────────────────────────────────────────────── */
.inline-st { display: flex; align-items: center; gap: 5px; font-size: 12.5px; font-weight: 500; }
/* ─── List group (legacy compat) ──────────────────────────────────────── */
.list-group { list-style: none; padding: 0; margin: 0; }
.list-group-item {
padding: 10px 0; border-bottom: 1px solid #E4E6EE;
display: flex; justify-content: space-between; align-items: center;
font-size: 13px;
}
.list-group-item:last-child { border-bottom: none; }
.list-group-item .lbl { font-size: 12px; color: #9EA8BE; }
.list-group-item .val { font-size: 12px; font-weight: 600; color: #1C1F2E; font-family: 'JetBrains Mono', monospace; }
/* ─── Breadcrumb ──────────────────────────────────────────────────────── */
.breadcrumb { display: flex; align-items: center; gap: 6px; margin-bottom: 16px; font-size: 12px; color: #9EA8BE; list-style: none; padding: 0; }
.breadcrumb a { color: #5C6278; text-decoration: none; }
.breadcrumb a:hover { color: #FF5500; }
.breadcrumb li + li::before { content: "/"; margin-right: 6px; color: #CDD0DC; }
/* ─── Empty state ─────────────────────────────────────────────────────── */
.empty-state {
text-align: center; padding: 48px 24px; color: #9EA8BE;
}
.empty-state i { font-size: 2.5rem; display: block; margin-bottom: 12px; }
.empty-state p { font-size: 13px; }
/* ─── Table scroll wrapper ────────────────────────────────────────────── */
.table-wrap { overflow-x: auto; width: 100%; }
/* ─── Pagination ──────────────────────────────────────────────────────── */
.pagination { display: flex; align-items: center; justify-content: center; gap: 8px; padding: 16px; }
/* ─── Spinner ─────────────────────────────────────────────────────────── */
.spinner {
display: inline-block;
width: 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;
display: inline-block; width: 20px; height: 20px;
border: 2px solid #E4E6EE; border-top-color: #FF5500;
border-radius: 50%; animation: spin 0.75s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Input group */
.input-group {
display: flex;
gap: 0;
/* ─── Code ────────────────────────────────────────────────────────────── */
pre, code { font-family: 'JetBrains Mono', monospace; font-size: 12px; }
pre {
background: #F4F5F7; border: 1px solid #E4E6EE; border-radius: 8px;
padding: 12px 14px; overflow-x: auto; white-space: pre-wrap;
}
.input-group input {
border-radius: var(--pico-border-radius) 0 0 var(--pico-border-radius);
margin: 0;
flex: 1;
/* ─── Dialog ──────────────────────────────────────────────────────────── */
dialog {
border: none; border-radius: 14px; padding: 0;
box-shadow: 0 8px 40px rgba(0,0,0,0.16);
max-width: 520px; width: 100%;
}
dialog::backdrop { background: rgba(0,0,0,0.45); }
.dialog-hd {
padding: 20px 24px 0; display: flex; align-items: center;
justify-content: space-between; margin-bottom: 16px;
}
.dialog-title { font-size: 16px; font-weight: 800; color: #1C1F2E; }
.dialog-body { padding: 0 24px 24px; }
.dialog-close {
background: none; border: none; cursor: pointer; font-size: 18px;
color: #9EA8BE; padding: 4px; border-radius: 6px; transition: color 0.13s;
}
.dialog-close:hover { color: #1C1F2E; }
/* ─── Filter bar ──────────────────────────────────────────────────────── */
.filter-bar {
display: flex; gap: 8px; flex-wrap: wrap; align-items: center;
padding: 14px 20px; border-bottom: 1px solid #E4E6EE;
}
.input-group button {
border-radius: 0 var(--pico-border-radius) var(--pico-border-radius) 0;
margin: 0;
white-space: nowrap;
/* ─── Viewed-as banner ────────────────────────────────────────────────── */
.view-as-bar {
background: #E64D00; color: #fff; text-align: center;
padding: 6px 16px; font-size: 13px;
}
.view-as-bar strong { font-weight: 700; }
/* Empty state */
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: var(--pico-muted-color);
/* ──────────────────────────────────────────────────────────────────────── */
/* LOGIN PAGE */
/* ──────────────────────────────────────────────────────────────────────── */
.login-wrap { min-height: 100vh; display: flex; background: #F4F5F7; }
.login-left {
flex: 1; background: #1C1F2E; display: flex; flex-direction: column;
justify-content: space-between; padding: 48px;
position: relative; overflow: hidden;
}
.empty-state .empty-icon {
font-size: 3rem;
display: block;
margin-bottom: 0.75rem;
.login-left-bg {
position: absolute; inset: 0; pointer-events: none;
background:
radial-gradient(ellipse 70% 55% at 0% 110%, rgba(255,85,0,0.18) 0%, transparent 65%),
radial-gradient(ellipse 50% 40% at 100% -10%, rgba(255,85,0,0.10) 0%, transparent 60%);
}
.login-left-pattern {
position: absolute; inset: 0; pointer-events: none;
background-image: radial-gradient(circle, rgba(255,255,255,0.04) 1px, transparent 1px);
background-size: 28px 28px;
}
.login-brand { display: flex; align-items: center; gap: 12px; position: relative; z-index: 1; }
.login-brand-icon { width: 44px; height: 44px; border-radius: 11px; background: #FF5500; display: flex; align-items: center; justify-content: center; }
.login-brand-name { font-size: 20px; font-weight: 800; color: #fff; letter-spacing: -0.02em; }
.login-brand-sub { font-size: 11px; color: #5C6278; }
.login-hero { position: relative; z-index: 1; }
.login-hero-title { font-size: 32px; font-weight: 800; color: #fff; line-height: 1.25; letter-spacing: -0.03em; margin-bottom: 14px; }
.login-hero-title em { color: #FF5500; font-style: normal; }
.login-hero-body { font-size: 14px; color: #A8ADC3; line-height: 1.6; max-width: 340px; }
.login-chips { display: flex; gap: 10px; margin-top: 22px; flex-wrap: wrap; }
.login-chip {
display: flex; align-items: center; gap: 7px; padding: 7px 13px;
border-radius: 20px; background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.1);
font-size: 12px; color: rgba(255,255,255,0.65); font-weight: 500;
}
.login-footer { font-size: 11px; color: #5C6278; position: relative; z-index: 1; }
.login-right { width: 460px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; padding: 40px; }
.login-box { width: 100%; max-width: 360px; }
.login-box-title { font-size: 24px; font-weight: 800; color: #1C1F2E; letter-spacing: -0.02em; margin-bottom: 4px; }
.login-box-sub { font-size: 13px; color: #5C6278; margin-bottom: 28px; }
.login-btn {
width: 100%; padding: 11px; border-radius: 9px; border: none; cursor: pointer;
background: #FF5500; color: #fff; font-size: 14px; font-weight: 700;
font-family: 'Golos Text', sans-serif; transition: all 0.15s; margin-top: 6px; display: block;
}
.login-btn:hover { background: #E64D00; box-shadow: 0 6px 20px rgba(255,85,0,0.3); }
.login-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.login-divider { display: flex; align-items: center; gap: 10px; margin: 18px 0; }
.login-divider span { font-size: 11px; color: #9EA8BE; }
.login-divider::before, .login-divider::after { content:''; flex: 1; height: 1px; background: #E4E6EE; }
.login-hint { font-size: 11.5px; color: #9EA8BE; text-align: center; margin-top: 14px; line-height: 1.5; }
.login-hint a { color: #FF5500; text-decoration: none; }
.login-hint a:hover { text-decoration: underline; }

233
web/tasks/catalog.py Normal file
View File

@@ -0,0 +1,233 @@
"""
Periodic catalog sync: fetch stores / product-groups / products from Evotor
for every connected user and upsert into cached_* tables.
Beat schedule entry (set in celery_app.py):
refresh_catalog — runs every CATALOG_REFRESH_INTERVAL_SECONDS seconds
"""
import logging
from datetime import datetime, timezone
from celery import shared_task
from web.config import settings
from web.database import SessionLocal
import web.lib.api_logger as api_logger
from web.models.connections import CachedGroup, CachedProduct, CachedStore, EvotorConnection, SyncConfig, SyncFilter
from web.models.user import User, UserRoleEnum
logger = logging.getLogger(__name__)
EVO_API = "https://api.evotor.ru"
def _headers(token: str) -> dict:
return {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.evotor.v2+json",
"Content-Type": "application/json",
}
def _now() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
# ── per-user helpers ──────────────────────────────────────────────────────────
def _fetch_stores(token: str, user_id: int | None = None) -> list[dict]:
r = api_logger.get(f"{EVO_API}/stores", user_id=user_id, headers=_headers(token), timeout=15)
r.raise_for_status()
data = r.json()
return data.get("items", data) if isinstance(data, dict) else data
def _fetch_groups(token: str, store_id: str, user_id: int | None = None) -> list[dict] | None:
"""Returns None if the store is not accessible (402/403), list otherwise."""
r = api_logger.get(
f"{EVO_API}/stores/{store_id}/product-groups",
user_id=user_id, headers=_headers(token), timeout=15,
)
if r.status_code in (402, 403):
return None
r.raise_for_status()
data = r.json()
return data.get("items", data) if isinstance(data, dict) else data
def _fetch_products(token: str, store_id: str, user_id: int | None = None) -> list[dict] | None:
"""Returns None if the store is not accessible (402/403), list otherwise."""
r = api_logger.get(
f"{EVO_API}/stores/{store_id}/products",
user_id=user_id, headers=_headers(token), timeout=30,
)
if r.status_code in (402, 403):
return None
r.raise_for_status()
data = r.json()
return data.get("items", data) if isinstance(data, dict) else data
def _sync_user(db, user_id: int, token: str) -> None:
now = _now()
# ── stores ────────────────────────────────────────────────────────────────
try:
stores = _fetch_stores(token)
except Exception as e:
logger.warning("user=%s fetch stores failed: %s", user_id, e)
return
store_ids = []
for s in stores:
evo_id = s.get("id") or s.get("uuid")
if not evo_id:
continue
store_ids.append(evo_id)
row = db.query(CachedStore).filter_by(user_id=user_id, evotor_id=evo_id).first()
if row:
row.name = s.get("name", "")
row.address = s.get("address", {}).get("str") if isinstance(s.get("address"), dict) else s.get("address")
row.fetched_at = now
else:
db.add(CachedStore(
user_id=user_id,
evotor_id=evo_id,
name=s.get("name", ""),
address=s.get("address", {}).get("str") if isinstance(s.get("address"), dict) else s.get("address"),
fetched_at=now,
))
db.flush()
# ── apply store filter ────────────────────────────────────────────────────
cfg = db.query(SyncConfig).filter_by(user_id=user_id).first()
if cfg:
include_filters = db.query(SyncFilter).filter_by(
sync_config_id=cfg.id, entity_type="store", filter_mode="include"
).all()
if include_filters:
allowed = {f.entity_id for f in include_filters}
store_ids = [s for s in store_ids if s in allowed]
# ── groups & products per store ───────────────────────────────────────────
for store_evo_id in store_ids:
# groups
try:
groups = _fetch_groups(token, store_evo_id)
except Exception as e:
logger.warning("user=%s store=%s fetch groups failed: %s", user_id, store_evo_id, e)
groups = []
if groups is None:
logger.debug("user=%s store=%s groups not available (402/403), skipping", user_id, store_evo_id)
continue
for g in groups:
evo_id = g.get("id") or g.get("uuid")
if not evo_id:
continue
row = db.query(CachedGroup).filter_by(user_id=user_id, evotor_id=evo_id).first()
if row:
row.name = g.get("name", "")
row.store_evotor_id = store_evo_id
row.fetched_at = now
else:
db.add(CachedGroup(
user_id=user_id,
evotor_id=evo_id,
store_evotor_id=store_evo_id,
name=g.get("name", ""),
fetched_at=now,
))
# products
try:
products = _fetch_products(token, store_evo_id)
except Exception as e:
logger.warning("user=%s store=%s fetch products failed: %s", user_id, store_evo_id, e)
products = []
if products is None:
logger.debug("user=%s store=%s products not available (402/403), skipping", user_id, store_evo_id)
products = []
for p in products:
evo_id = p.get("id") or p.get("uuid")
if not evo_id:
continue
price = p.get("price")
quantity = p.get("quantity")
row = db.query(CachedProduct).filter_by(user_id=user_id, evotor_id=evo_id).first()
if row:
row.store_evotor_id = store_evo_id
row.group_evotor_id = p.get("group") or p.get("parentUuid") or p.get("parent_id")
row.name = p.get("name", "")
row.price = float(price) if price is not None else None
row.quantity = float(quantity) if quantity is not None else None
row.measure_name = p.get("measureName") or p.get("measure_name")
row.article_number = p.get("code") or p.get("article_number")
row.allow_to_sell = p.get("allowToSell") if p.get("allowToSell") is not None else p.get("allow_to_sell")
row.fetched_at = now
else:
db.add(CachedProduct(
user_id=user_id,
evotor_id=evo_id,
store_evotor_id=store_evo_id,
group_evotor_id=p.get("group") or p.get("parentUuid") or p.get("parent_id"),
name=p.get("name", ""),
price=float(price) if price is not None else None,
quantity=float(quantity) if quantity is not None else None,
measure_name=p.get("measureName") or p.get("measure_name"),
article_number=p.get("code") or p.get("article_number"),
allow_to_sell=p.get("allowToSell") if p.get("allowToSell") is not None else p.get("allow_to_sell"),
fetched_at=now,
))
db.commit()
logger.info(
"user=%s catalog synced: %d stores, %d groups, %d products",
user_id,
len(stores),
sum(1 for _ in db.query(CachedGroup).filter_by(user_id=user_id)),
sum(1 for _ in db.query(CachedProduct).filter_by(user_id=user_id)),
)
# ── Celery task ───────────────────────────────────────────────────────────────
@shared_task(
name="web.tasks.catalog.refresh_catalog",
queue="default",
bind=True,
max_retries=2,
default_retry_delay=60,
)
def refresh_catalog(self) -> dict:
"""Fetch and cache stores/groups/products for all connected Evotor users."""
db = SessionLocal()
results = {"ok": 0, "failed": 0}
try:
connections = (
db.query(EvotorConnection)
.join(User, User.id == EvotorConnection.user_id)
.filter(
EvotorConnection.user_id.isnot(None),
EvotorConnection.access_token.isnot(None),
EvotorConnection.access_token != "",
User.role == UserRoleEnum.user,
)
.all()
)
for conn in connections:
cfg = db.query(SyncConfig).filter_by(user_id=conn.user_id).first()
if not cfg or not cfg.evo_mirror_enabled:
continue
try:
_sync_user(db, conn.user_id, conn.access_token)
results["ok"] += 1
except Exception as exc:
logger.error("catalog sync failed for user=%s: %s", conn.user_id, exc)
results["failed"] += 1
finally:
db.close()
logger.info("refresh_catalog done: %s", results)
return results

View File

@@ -1,4 +1,5 @@
from celery import Celery
from celery.schedules import timedelta
from web.config import settings
@@ -16,8 +17,45 @@ celery_app.conf.update(
broker_connection_retry_on_startup=True,
task_routes={
"web.tasks.sync.*": {"queue": "sync"},
"web.tasks.vk_sync.*": {"queue": "sync"},
"web.tasks.health.*": {"queue": "health"},
"web.tasks.catalog.*": {"queue": "default"},
"web.tasks.vk_catalog.*": {"queue": "default"},
"web.notifications.tasks.*": {"queue": "notifications"},
},
beat_schedule={
# Chain: fetch Evotor → fetch VK catalog → mirror Evotor→VK
# Beat fires the launcher task which chains all three sequentially.
"sync-pipeline": {
"task": "web.tasks.celery_app.run_sync_pipeline",
"schedule": timedelta(seconds=settings.CATALOG_REFRESH_INTERVAL_SECONDS),
},
},
)
# Register task modules so beat/worker can discover them
celery_app.autodiscover_tasks([
"web.tasks.catalog",
"web.tasks.vk_catalog",
"web.tasks.vk_sync",
"web.tasks.celery_app",
])
@celery_app.task(name="web.tasks.celery_app.run_sync_pipeline", queue="default")
def run_sync_pipeline() -> str:
"""
Beat entry point. Chains refresh_catalog → refresh_vk_catalog → mirror_to_vk
so that mirror only runs after both catalog fetches are complete.
"""
from celery import chain
from web.tasks.catalog import refresh_catalog
from web.tasks.vk_catalog import refresh_vk_catalog
from web.tasks.vk_sync import mirror_to_vk
chain(
refresh_catalog.si(),
refresh_vk_catalog.si(),
mirror_to_vk.si(),
).apply_async()
return "pipeline dispatched"

190
web/tasks/vk_catalog.py Normal file
View File

@@ -0,0 +1,190 @@
"""
Periodic VK catalog sync: fetch albums and products from VK Market
for every connected user and upsert into vk_cached_* tables.
"""
import logging
from datetime import datetime, timezone
from celery import shared_task
import web.lib.api_logger as api_logger
from web.config import settings
from web.database import SessionLocal
from web.models.connections import SyncConfig, VkCachedAlbum, VkCachedProduct, VkConnection
from web.models.user import User, UserRoleEnum
logger = logging.getLogger(__name__)
VK_API = "https://api.vk.com/method"
def _now() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
def _vk_get(method: str, params: dict, token: str, user_id: int | None = None) -> dict:
params = {**params, "access_token": token, "v": settings.VK_API_VERSION}
r = api_logger.get(f"{VK_API}/{method}", user_id=user_id, params=params, timeout=20)
r.raise_for_status()
return r.json()
def _sync_user(db, user_id: int, token: str, group_id: str) -> None:
now = _now()
owner_id = f"-{group_id}"
# ── albums ────────────────────────────────────────────────────────────────
try:
data = _vk_get("market.getAlbums", {"owner_id": owner_id, "count": 100}, token, user_id=user_id)
except Exception as e:
logger.warning("user=%s vk fetch albums failed: %s", user_id, e)
return
if "error" in data:
logger.warning("user=%s vk albums error: %s", user_id, data["error"])
return
albums = data.get("response", {}).get("items", [])
album_ids = []
for a in albums:
aid = str(a["id"])
album_ids.append(aid)
row = db.query(VkCachedAlbum).filter_by(user_id=user_id, vk_group_id=group_id, album_id=aid).first()
if row:
row.title = a.get("title", "")
row.count = a.get("count")
row.fetched_at = now
else:
db.add(VkCachedAlbum(
user_id=user_id,
vk_group_id=group_id,
album_id=aid,
title=a.get("title", ""),
count=a.get("count"),
fetched_at=now,
))
# Delete cached albums that no longer exist in VK
(
db.query(VkCachedAlbum)
.filter(
VkCachedAlbum.user_id == user_id,
VkCachedAlbum.vk_group_id == group_id,
VkCachedAlbum.album_id.notin_(album_ids),
)
.delete(synchronize_session=False)
)
db.flush()
# ── products (extended=1 gives albums_ids per product) ───────────────────
offset = 0
all_products = []
while True:
try:
data = _vk_get(
"market.get",
{"owner_id": owner_id, "count": 200, "offset": offset, "extended": 1},
token,
user_id=user_id,
)
except Exception as e:
logger.warning("user=%s vk fetch products (extended) failed: %s", user_id, e)
break
if "error" in data:
logger.warning("user=%s vk products (extended) error: %s", user_id, data["error"])
break
items = data.get("response", {}).get("items", [])
all_products.extend(items)
if len(items) < 200:
break
offset += 200
for p in all_products:
pid = str(p["id"])
album_id = str(p["albums_ids"][0]) if p.get("albums_ids") else None
price_field = p.get("price")
if isinstance(price_field, dict):
price = float(price_field.get("amount", 0)) / 100 if price_field.get("amount") is not None else None
else:
price = float(price_field) if price_field is not None else None
thumb = None
thumb_field = p.get("thumb_photo")
if isinstance(thumb_field, dict):
sizes = thumb_field.get("sizes", [])
if sizes:
thumb = sizes[-1].get("url")
elif isinstance(thumb_field, str):
thumb = thumb_field
row = db.query(VkCachedProduct).filter_by(
user_id=user_id, vk_group_id=group_id, vk_product_id=pid,
).first()
if row:
row.album_id = album_id
row.name = p.get("title", "")
row.description = p.get("description")
row.price = price
row.availability = p.get("availability")
row.thumb_url = thumb
row.fetched_at = now
else:
db.add(VkCachedProduct(
user_id=user_id,
vk_group_id=group_id,
vk_product_id=pid,
album_id=album_id,
name=p.get("title", ""),
description=p.get("description"),
price=price,
availability=p.get("availability"),
thumb_url=thumb,
fetched_at=now,
))
db.commit()
logger.info(
"user=%s vk catalog synced: group=%s albums=%d products=%d",
user_id, group_id, len(albums), len(all_products),
)
@shared_task(
name="web.tasks.vk_catalog.refresh_vk_catalog",
queue="default",
bind=True,
max_retries=2,
default_retry_delay=60,
)
def refresh_vk_catalog(self) -> dict:
"""Fetch and cache VK Market albums and products for all connected users."""
db = SessionLocal()
results = {"ok": 0, "failed": 0}
try:
connections = (
db.query(VkConnection)
.join(User, User.id == VkConnection.user_id)
.filter(
VkConnection.user_id.isnot(None),
VkConnection.access_token.isnot(None),
VkConnection.access_token != "",
VkConnection.vk_user_id.isnot(None),
VkConnection.vk_user_id != "",
User.role == UserRoleEnum.user,
)
.all()
)
for conn in connections:
cfg = db.query(SyncConfig).filter_by(user_id=conn.user_id).first()
if not cfg or not cfg.vk_mirror_enabled:
continue
try:
_sync_user(db, conn.user_id, conn.access_token, conn.vk_user_id)
results["ok"] += 1
except Exception as exc:
logger.error("vk catalog sync failed for user=%s: %s", conn.user_id, exc)
results["failed"] += 1
finally:
db.close()
logger.info("refresh_vk_catalog done: %s", results)
return results

441
web/tasks/vk_sync.py Normal file
View File

@@ -0,0 +1,441 @@
"""
Mirror Evotor product catalog → VK Market.
Runs after both refresh_catalog and refresh_vk_catalog complete.
Only processes stores and groups that have sync enabled (via SyncFilter).
Updates VK products only when at least one synced field has changed.
"""
import logging
import os
from datetime import datetime, timezone
from decimal import Decimal
from celery import shared_task
import web.lib.api_logger as api_logger
from web.config import settings
from web.database import SessionLocal
from web.models.connections import (
CachedGroup, CachedProduct, CachedStore,
SyncConfig, SyncFilter,
VkCachedAlbum, VkConnection,
)
from web.models.user import User, UserRoleEnum
logger = logging.getLogger(__name__)
VK_API = "https://api.vk.com/method"
_PHOTO_CACHE: dict[int, str] = {} # user_id → uploaded photo_id for this run
def _now() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
def _calc_price(price: Decimal | None) -> float:
"""Return price in rubles for VK Market API."""
if price is None:
return 0.0
return float(price)
def _name_for_vk(name: str) -> str:
return name.replace(";", ",")
def _vk_post(method: str, data: dict, token: str, user_id: int | None = None) -> dict:
data = {**data, "access_token": token, "v": settings.VK_API_VERSION}
r = api_logger.post(f"{VK_API}/{method}", user_id=user_id, data=data, timeout=30)
r.raise_for_status()
return r.json()
def _upload_photo(token: str, group_id: str, user_id: int | None = None) -> str | None:
"""Upload the default product photo and return photo_id, or None on failure."""
photo_path = settings.VK_DEFAULT_PHOTO_PATH
if not os.path.exists(photo_path):
logger.warning("Default photo not found at %s", photo_path)
return None
try:
# Step 1: get upload URL
resp = _vk_post("market.getProductPhotoUploadServer", {"group_id": group_id}, token, user_id=user_id)
if "error" in resp:
logger.warning("getProductPhotoUploadServer error: %s", resp["error"])
return None
upload_url = resp["response"]["upload_url"]
# Step 2: upload file
with open(photo_path, "rb") as f:
up = api_logger.post(upload_url, user_id=user_id, files={"file": f}, timeout=30)
up.raise_for_status()
upload_obj = up.text
# Step 3: save
resp2 = _vk_post("market.saveProductPhoto", {"upload_response": upload_obj}, token, user_id=user_id)
if "error" in resp2:
logger.warning("saveProductPhoto error: %s", resp2["error"])
return None
return str(resp2["response"]["photo_id"])
except Exception as e:
logger.warning("Photo upload failed: %s", e)
return None
def _get_photo_id(user_id: int, token: str, group_id: str) -> str | None:
"""Upload photo once per sync run per user, cache the result."""
if user_id not in _PHOTO_CACHE:
_PHOTO_CACHE[user_id] = _upload_photo(token, group_id, user_id=user_id)
return _PHOTO_CACHE[user_id]
def _enabled_store_ids(db, user_id: int) -> set[str] | None:
cfg = db.query(SyncConfig).filter_by(user_id=user_id).first()
if not cfg:
return None
filters = db.query(SyncFilter).filter_by(
sync_config_id=cfg.id, entity_type="store", filter_mode="include",
).all()
return None if not filters else {f.entity_id for f in filters}
def _enabled_group_ids(db, user_id: int, store_evotor_id: str) -> set[str] | None:
cfg = db.query(SyncConfig).filter_by(user_id=user_id).first()
if not cfg:
return None
filters = db.query(SyncFilter).filter_by(
sync_config_id=cfg.id, entity_type="group", filter_mode="include",
parent_entity_id=store_evotor_id,
).all()
return None if not filters else {f.entity_id for f in filters}
def _ensure_album(db, user_id: int, vk_group_id: str, group_name: str, token: str) -> str | None:
"""Return VK album_id for the given group name, creating it if needed."""
album = db.query(VkCachedAlbum).filter_by(
user_id=user_id, vk_group_id=vk_group_id,
).filter(VkCachedAlbum.title == group_name).first()
if album:
return album.album_id
# Create album in VK
resp = _vk_post("market.addAlbum", {
"owner_id": f"-{vk_group_id}",
"title": group_name,
}, token, user_id=user_id)
if "error" in resp:
logger.warning("market.addAlbum error for '%s': %s", group_name, resp["error"])
return None
album_id = str(resp["response"]["market_album_id"])
db.add(VkCachedAlbum(
user_id=user_id,
vk_group_id=vk_group_id,
album_id=album_id,
title=group_name,
fetched_at=_now(),
))
db.flush()
logger.info("user=%s created VK album '%s' id=%s", user_id, group_name, album_id)
return album_id
def _sync_product(
db,
user_id: int,
product: CachedProduct,
album_id: str,
vk_group_id: str,
token: str,
sync_config=None,
) -> None:
name = _name_for_vk(product.name)
multiplier = float(sync_config.price_multiplier) if sync_config and sync_config.price_multiplier else 1.0
price_rubles = _calc_price(product.price) * multiplier
measure = (product.measure_name or "").strip()
if measure:
qty = int(multiplier) if multiplier == int(multiplier) else multiplier
desc = f"{product.name} (цена за {qty} {measure}.)"
else:
desc = product.name
stock = settings.VK_STOCK_AMOUNT if product.allow_to_sell else 0
owner_id = f"-{vk_group_id}"
now = _now()
if product.vk_product_id:
# Delete from VK if product is no longer for sale
if not product.allow_to_sell:
resp = _vk_post("market.delete", {
"owner_id": owner_id,
"item_id": product.vk_product_id,
}, token, user_id=user_id)
if "error" not in resp:
from web.models.connections import VkCachedProduct
vk_p = db.query(VkCachedProduct).filter_by(
user_id=user_id, vk_group_id=vk_group_id, vk_product_id=product.vk_product_id,
).first()
if vk_p:
db.delete(vk_p)
product.vk_product_id = None
logger.info("user=%s deleted VK product '%s' (disabled)", user_id, name)
else:
logger.warning("market.delete error for disabled product %s: %s", product.evotor_id, resp["error"])
return
# Check if update needed
changed = False
# Re-read current VK state from cache
from web.models.connections import VkCachedProduct
vk_p = db.query(VkCachedProduct).filter_by(
user_id=user_id, vk_group_id=vk_group_id, vk_product_id=product.vk_product_id,
).first()
album_changed = False
if vk_p:
vk_price = float(vk_p.price) if vk_p.price is not None else 0.0
vk_stock = settings.VK_STOCK_AMOUNT if vk_p.availability == 0 else 0
vk_name = vk_p.name or ""
vk_desc = (vk_p.description or "").strip()
curr_desc = desc
album_changed = str(vk_p.album_id) != str(album_id) if album_id else False
changed = (
name != vk_name
or price_rubles != vk_price
or curr_desc != vk_desc
or stock != vk_stock
or album_changed
)
else:
changed = True # cached VK product gone, push update
if not changed:
return
resp = _vk_post("market.edit", {
"owner_id": owner_id,
"item_id": product.vk_product_id,
"name": name,
"description": desc,
"category_id": settings.VK_CATEGORY_ID,
"price": price_rubles,
"stock_amount": stock,
}, token, user_id=user_id)
if "error" in resp:
logger.warning("market.edit error product=%s: %s", product.evotor_id, resp["error"])
return
if album_changed and album_id and vk_p:
old_album_id = str(vk_p.album_id)
_vk_post("market.removeFromAlbum", {
"owner_id": owner_id,
"item_id": product.vk_product_id,
"album_ids": old_album_id,
}, token, user_id=user_id)
resp_album = _vk_post("market.addToAlbum", {
"owner_id": owner_id,
"item_ids": product.vk_product_id,
"album_ids": album_id,
}, token, user_id=user_id)
if "error" in resp_album:
logger.warning("market.addToAlbum error product=%s: %s", product.evotor_id, resp_album["error"])
else:
logger.info("user=%s moved VK product '%s' album %s%s", user_id, name, old_album_id, album_id)
product.synced_at = now
logger.info("user=%s updated VK product '%s' id=%s", user_id, name, product.vk_product_id)
else:
# Create — only if allow_to_sell
if not product.allow_to_sell:
return
photo_id = _get_photo_id(user_id, token, vk_group_id)
if not photo_id:
logger.warning("user=%s skipping create for '%s': no photo", user_id, name)
return
resp = _vk_post("market.add", {
"owner_id": owner_id,
"name": name,
"description": desc,
"category_id": settings.VK_CATEGORY_ID,
"price": price_rubles,
"main_photo_id": photo_id,
"stock_amount": stock,
}, token, user_id=user_id)
if "error" in resp:
logger.warning("market.add error product=%s: %s", product.evotor_id, resp["error"])
return
vk_item_id = str(resp["response"]["market_item_id"])
product.vk_product_id = vk_item_id
product.synced_at = now
# Add to album
resp2 = _vk_post("market.addToAlbum", {
"owner_id": owner_id,
"item_ids": vk_item_id,
"album_ids": album_id,
}, token, user_id=user_id)
if "error" in resp2:
logger.warning("market.addToAlbum error product=%s: %s", product.evotor_id, resp2["error"])
logger.info("user=%s created VK product '%s' id=%s", user_id, name, vk_item_id)
def _sync_product_list(db, user_id, products, album_id, vk_group_id, token, results, owned_ids, sync_config=None):
for product in products:
was_new = product.vk_product_id is None
try:
_sync_product(db, user_id, product, album_id, vk_group_id, token, sync_config)
if product.vk_product_id:
owned_ids.add(product.vk_product_id)
if product.synced_at:
if was_new and product.vk_product_id:
results["created"] += 1
elif not was_new:
results["updated"] += 1
else:
results["skipped"] += 1
else:
results["skipped"] += 1
except Exception as e:
logger.error("user=%s sync_product failed '%s': %s", user_id, product.name, e)
results["errors"] += 1
def _delete_orphans(db, user_id, vk_group_id, owned_ids, token, results):
from web.models.connections import VkCachedProduct
orphans = db.query(VkCachedProduct).filter_by(
user_id=user_id, vk_group_id=vk_group_id,
).filter(VkCachedProduct.vk_product_id.notin_(owned_ids)).all() if owned_ids else \
db.query(VkCachedProduct).filter_by(user_id=user_id, vk_group_id=vk_group_id).all()
owner_id = f"-{vk_group_id}"
for vk_p in orphans:
try:
resp = _vk_post("market.delete", {
"owner_id": owner_id,
"item_id": vk_p.vk_product_id,
}, token, user_id=user_id)
if "error" in resp:
logger.warning("market.delete error id=%s: %s", vk_p.vk_product_id, resp["error"])
results["errors"] += 1
else:
logger.info("user=%s deleted VK product id=%s '%s'", user_id, vk_p.vk_product_id, vk_p.name)
db.delete(vk_p)
results["deleted"] += 1
except Exception as e:
logger.error("user=%s delete failed id=%s: %s", user_id, vk_p.vk_product_id, e)
results["errors"] += 1
# Also clear vk_product_id on any cached_products that pointed to deleted VK items
if orphans:
deleted_vk_ids = {vk_p.vk_product_id for vk_p in orphans}
stale = db.query(CachedProduct).filter(
CachedProduct.user_id == user_id,
CachedProduct.vk_product_id.in_(deleted_vk_ids),
).all()
for p in stale:
p.vk_product_id = None
def _sync_user(db, user_id: int, token: str, vk_group_id: str) -> dict:
results = {"created": 0, "updated": 0, "skipped": 0, "deleted": 0, "errors": 0}
owned_ids: set[str] = set() # VK product IDs that Evotor owns this run
sync_config = db.query(SyncConfig).filter_by(user_id=user_id).first()
enabled_stores = _enabled_store_ids(db, user_id)
stores = db.query(CachedStore).filter_by(user_id=user_id).all()
for store in stores:
if enabled_stores is not None and store.evotor_id not in enabled_stores:
continue
enabled_groups = _enabled_group_ids(db, user_id, store.evotor_id)
groups = db.query(CachedGroup).filter_by(
user_id=user_id, store_evotor_id=store.evotor_id,
).all()
for group in groups:
if enabled_groups is not None and group.evotor_id not in enabled_groups:
continue
try:
album_id = _ensure_album(db, user_id, vk_group_id, group.name, token)
except Exception as e:
logger.error("user=%s ensure_album failed for '%s': %s", user_id, group.name, e)
results["errors"] += 1
continue
if not album_id:
results["errors"] += 1
continue
products = db.query(CachedProduct).filter_by(
user_id=user_id, store_evotor_id=store.evotor_id, group_evotor_id=group.evotor_id,
).all()
_sync_product_list(db, user_id, products, album_id, vk_group_id, token, results, owned_ids, sync_config)
# Ungrouped products → "Без категории" album
ungrouped = db.query(CachedProduct).filter_by(
user_id=user_id, store_evotor_id=store.evotor_id, group_evotor_id=None,
).all()
if ungrouped:
try:
fallback_album_id = _ensure_album(db, user_id, vk_group_id, "Без категории", token)
except Exception as e:
logger.error("user=%s ensure_album failed for 'Без категории': %s", user_id, e)
fallback_album_id = None
if fallback_album_id:
_sync_product_list(db, user_id, ungrouped, fallback_album_id, vk_group_id, token, results, owned_ids, sync_config)
# Delete VK products not owned by any Evotor product
_delete_orphans(db, user_id, vk_group_id, owned_ids, token, results)
db.commit()
return results
@shared_task(
name="web.tasks.vk_sync.mirror_to_vk",
queue="sync",
bind=True,
max_retries=1,
default_retry_delay=120,
)
def mirror_to_vk(self) -> dict:
"""Mirror Evotor catalog → VK Market for all users with both connections active."""
_PHOTO_CACHE.clear()
db = SessionLocal()
totals = {"ok": 0, "failed": 0}
try:
vk_connections = (
db.query(VkConnection)
.join(User, User.id == VkConnection.user_id)
.filter(
VkConnection.user_id.isnot(None),
VkConnection.access_token.isnot(None),
VkConnection.access_token != "",
VkConnection.vk_user_id.isnot(None),
VkConnection.vk_user_id != "",
User.role == UserRoleEnum.user,
)
.all()
)
for conn in vk_connections:
cfg = db.query(SyncConfig).filter_by(user_id=conn.user_id).first()
if not cfg or not cfg.is_enabled:
continue
try:
result = _sync_user(db, conn.user_id, conn.access_token, conn.vk_user_id)
logger.info("user=%s mirror_to_vk: %s", conn.user_id, result)
totals["ok"] += 1
except Exception as exc:
logger.error("mirror_to_vk failed for user=%s: %s", conn.user_id, exc)
totals["failed"] += 1
finally:
db.close()
logger.info("mirror_to_vk done: %s", totals)
return totals

View File

@@ -0,0 +1,147 @@
{% extends "base.html" %}
{% block title %}API Логи — Мои Товары{% endblock %}
{% block page_title %}API Логи{% endblock %}
{% block content %}
<div class="pg-title">API Логи</div>
<div class="pg-sub">Журнал всех исходящих запросов · Найдено: {{ total }}</div>
<!-- Filters -->
<div class="card" style="margin-bottom:14px;padding:14px 20px;">
<form method="get" action="/admin/logs" style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;">
<select class="inp" name="service" style="width:auto;">
<option value="" {% if not filter_service %}selected{% endif %}>Все сервисы</option>
<option value="evotor" {% if filter_service == 'evotor' %}selected{% endif %}>Эвотор</option>
<option value="vk" {% if filter_service == 'vk' %}selected{% endif %}>ВКонтакте</option>
<option value="other" {% if filter_service == 'other' %}selected{% endif %}>Другое</option>
</select>
<select class="inp" name="method" style="width:auto;">
<option value="" {% if not filter_method %}selected{% endif %}>Все методы</option>
<option value="GET" {% if filter_method == 'GET' %}selected{% endif %}>GET</option>
<option value="POST" {% if filter_method == 'POST' %}selected{% endif %}>POST</option>
</select>
<select class="inp" name="status" style="width:auto;">
<option value="" {% if not filter_status %}selected{% endif %}>Любой статус</option>
<option value="ok" {% if filter_status == 'ok' %}selected{% endif %}>2xx / 3xx</option>
<option value="error" {% if filter_status == 'error' %}selected{% endif %}>4xx / 5xx</option>
<option value="200" {% if filter_status == '200' %}selected{% endif %}>200</option>
<option value="401" {% if filter_status == '401' %}selected{% endif %}>401</option>
<option value="403" {% if filter_status == '403' %}selected{% endif %}>403</option>
<option value="429" {% if filter_status == '429' %}selected{% endif %}>429</option>
<option value="500" {% if filter_status == '500' %}selected{% endif %}>500</option>
</select>
<select class="inp" name="hours" style="width:auto;">
<option value="1" {% if filter_hours == 1 %}selected{% endif %}>Последний час</option>
<option value="6" {% if filter_hours == 6 %}selected{% endif %}>6 часов</option>
<option value="24" {% if filter_hours == 24 %}selected{% endif %}>24 часа</option>
<option value="168" {% if filter_hours == 168 or (not filter_hours) %}selected{% endif %}>7 дней</option>
<option value="720" {% if filter_hours == 720 %}selected{% endif %}>30 дней</option>
</select>
<input class="inp" type="search" name="q" value="{{ filter_q }}"
placeholder="URL или тело ответа…" style="flex:1;min-width:160px;">
<button type="submit" class="btn btn-primary btn-sm">Применить</button>
{% if filter_service or filter_method or filter_status or filter_q or filter_hours != 24 %}
<a href="/admin/logs" class="btn btn-outline btn-sm">Сбросить</a>
{% endif %}
</form>
</div>
<div class="card" style="padding:0;">
{% if logs %}
<div class="table-wrap">
<table class="tbl" style="font-size:12px;">
<thead>
<tr>
<th style="width:150px;">Время</th>
<th style="width:80px;">Сервис</th>
<th style="width:50px;">Метод</th>
<th style="width:60px;">Статус</th>
<th style="width:70px;">Мс</th>
<th>URL</th>
<th style="width:32px;"></th>
</tr>
</thead>
<tbody>
{% for log in logs %}
{% set is_error = log.response_status and log.response_status >= 400 %}
<tr style="cursor:pointer;" onclick="toggleDetail({{ log.id }})">
<td><span class="mono" style="font-size:11px;color:#9EA8BE;">{{ log.created_at | datefmt }}</span></td>
<td>
{% if log.service == 'evotor' %}
<span class="tag tag-bl" style="font-size:10px;padding:2px 6px;">{{ log.service }}</span>
{% elif log.service == 'vk' %}
<span class="tag tag-or" style="font-size:10px;padding:2px 6px;">{{ log.service }}</span>
{% else %}
<span class="tag tag-dim" style="font-size:10px;padding:2px 6px;">{{ log.service }}</span>
{% endif %}
</td>
<td><span class="mono" style="font-size:11px;">{{ log.method }}</span></td>
<td>
{% if log.response_status %}
<span class="mono" style="font-size:11px;color:{% if is_error %}#E53935{% else %}#17A865{% endif %};">{{ log.response_status }}</span>
{% else %}
<span style="color:#9EA8BE;"></span>
{% endif %}
</td>
<td><span class="mono" style="font-size:11px;color:#9EA8BE;">{{ log.duration_ms if log.duration_ms is not none else '—' }}</span></td>
<td style="max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
<span class="mono" style="font-size:11px;{% if is_error %}color:#E53935;{% endif %}" title="{{ log.url }}">{{ log.url }}</span>
</td>
<td style="color:#9EA8BE;"><i class="bi bi-chevron-down"></i></td>
</tr>
<tr id="detail-{{ log.id }}" style="display:none;background:#F9FAFB;">
<td colspan="7" style="padding:14px 20px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;">
<div>
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.06em;color:#9EA8BE;margin-bottom:6px;">URL</div>
<code style="word-break:break-all;font-size:11px;">{{ log.url }}</code>
{% if log.request_body %}
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.06em;color:#9EA8BE;margin:10px 0 6px;">Request body</div>
<pre style="font-size:11px;max-height:180px;overflow:auto;">{{ log.request_body }}</pre>
{% endif %}
</div>
<div>
<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.06em;color:#9EA8BE;margin-bottom:6px;">Response ({{ log.response_status }})</div>
{% if log.response_body %}
<pre style="font-size:11px;max-height:180px;overflow:auto;">{{ log.response_body }}</pre>
{% else %}
<span style="color:#9EA8BE;font-size:12px;"></span>
{% endif %}
</div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if total_pages > 1 %}
<div class="pagination">
{% if page > 1 %}
<a href="?service={{ filter_service }}&method={{ filter_method }}&status={{ filter_status }}&q={{ filter_q }}&hours={{ filter_hours }}&page={{ page - 1 }}" class="btn btn-outline btn-sm">← Назад</a>
{% endif %}
<span style="font-size:12px;color:#9EA8BE;">Стр. {{ page }} / {{ total_pages }}</span>
{% if page < total_pages %}
<a href="?service={{ filter_service }}&method={{ filter_method }}&status={{ filter_status }}&q={{ filter_q }}&hours={{ filter_hours }}&page={{ page + 1 }}" class="btn btn-outline btn-sm">Вперёд →</a>
{% endif %}
</div>
{% endif %}
{% else %}
<div class="empty-state">
<i class="bi bi-journal-x"></i>
<p>Записей не найдено за выбранный период.</p>
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
function toggleDetail(id) {
const row = document.getElementById('detail-' + id);
row.style.display = row.style.display === 'none' ? 'table-row' : 'none';
}
</script>
{% endblock %}

View File

@@ -1,40 +1,44 @@
{% extends "base.html" %}
{% block title %}Роли и права — ЭВОСИНК{% endblock %}
{% block title %}Роли и права — Мои Товары{% endblock %}
{% block page_title %}Роли и права{% endblock %}
{% block content %}
<nav class="breadcrumb">
<li class="breadcrumb-item"><a href="/admin/users">Пользователи</a></li>
<li class="breadcrumb-item active">Роли и права</li>
</nav>
<ol class="breadcrumb">
<li><a href="/admin/users">Пользователи</a></li>
<li>Роли и права</li>
</ol>
<h1 style="font-size:1.3rem;" class="mb-3"><i class="bi bi-shield-lock me-2"></i>Роли и права</h1>
<div class="pg-title">Роли и права</div>
<div class="pg-sub">Управление разрешениями для каждой роли</div>
{% 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 class="card" style="margin-bottom:14px;">
<div class="card-hd">
<div>
<div class="card-title"><i class="bi bi-shield-lock" style="margin-right:6px;"></i>{{ role.name }}</div>
{% if role.description %}
<div class="card-sub">{{ role.description }}</div>
{% endif %}
</div>
</article>
</div>
<form method="post" action="/admin/roles/{{ role.id }}/permissions">
<div style="display:flex;flex-wrap:wrap;gap:12px;margin-bottom:16px;">
{% for perm in permissions %}
<label style="display:flex;align-items:center;gap:7px;cursor:pointer;font-size:13px;padding:6px 10px;border:1px solid #E4E6EE;border-radius:7px;background:#F9FAFB;">
<input type="checkbox" name="perm_{{ perm.id }}" value="{{ perm.id }}"
{% if perm.id in role_perm_ids[role.id] %}checked{% endif %}
style="accent-color:#FF5500;">
{{ perm.name }}
{% if perm.description %}
<span style="font-size:11px;color:#9EA8BE;">({{ perm.description }})</span>
{% endif %}
</label>
{% endfor %}
</div>
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-save"></i> Сохранить права для «{{ role.name }}»
</button>
</form>
</div>
{% endfor %}
{% endblock %}

View File

@@ -1,147 +1,180 @@
{% extends "base.html" %}
{% block title %}{{ target.first_name }} {{ target.last_name }} — Админ — ЭВОСИНК{% endblock %}
{% block title %}{{ target.first_name }} {{ target.last_name }} — Админ — Мои Товары{% endblock %}
{% block page_title %}Пользователь{% 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>
<ol class="breadcrumb">
<li><a href="/admin/users">Пользователи</a></li>
<li>{{ target.first_name }} {{ target.last_name }}</li>
</ol>
{% if request.query_params.get('success') == 'reset_sent' %}
<div class="alert alert-success mb-3"><p>Ссылка для сброса пароля отправлена.</p></div>
<div class="alert alert-gr"><span><i class="bi bi-check-circle"></i></span><div>Ссылка для сброса пароля отправлена.</div></div>
{% elif request.query_params.get('success') == 'invite_sent' %}
<div class="alert alert-success mb-3"><p>Приглашение отправлено.</p></div>
<div class="alert alert-gr"><span><i class="bi bi-check-circle"></i></span><div>Приглашение отправлено.</div></div>
{% elif request.query_params.get('success') == 'saved' %}
<div class="alert alert-success mb-3"><p>Данные сохранены.</p></div>
<div class="alert alert-gr"><span><i class="bi bi-check-circle"></i></span><div>Данные сохранены.</div></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>
<!-- User header -->
<div style="display:flex;align-items:center;gap:14px;margin-bottom:24px;">
<div class="avatar" style="width:48px;height:48px;font-size:16px;">
{{ target.first_name[0] if target.first_name else '?' }}{{ target.last_name[0] if target.last_name else '' }}
</div>
<div>
<div style="font-size:18px;font-weight:800;letter-spacing:-0.02em;">{{ target.first_name }} {{ target.last_name }}</div>
<div class="mono" style="font-size:12px;color:#9EA8BE;">{{ target.email }}</div>
</div>
</div>
{% 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>
<div class="g2" style="align-items:start;">
<!-- Left column -->
<div style="display:flex;flex-direction:column;gap:14px;">
<!-- Profile -->
<div class="card">
<div class="card-hd"><div><div class="card-title">Профиль</div></div></div>
<div class="conn-detail">
<div class="conn-row"><span class="conn-k">ID</span><span class="conn-v">{{ target.id }}</span></div>
<div class="conn-row">
<span class="conn-k">Email</span>
<span class="conn-v" style="display:flex;align-items:center;gap:6px;">
{{ target.email }}
{% if target.is_email_confirmed %}
<span class="tag tag-gr" style="font-size:10px;padding:1px 6px;">подтверждён</span>
{% else %}
<span class="tag tag-yl" style="font-size:10px;padding:1px 6px;">не подтверждён</span>
{% endif %}
</span>
</div>
<div class="conn-row"><span class="conn-k">Телефон</span><span class="conn-v">{{ target.phone or '—' }}</span></div>
<div class="conn-row">
<span class="conn-k">Роль</span>
<span class="conn-v" style="font-family:inherit;">
{% if target.role == 'system' %}<span class="tag tag-rd" style="font-size:10.5px;">Системный</span>
{% elif target.role == 'admin' %}<span class="tag tag-or" style="font-size:10.5px;">Администратор</span>
{% else %}<span class="tag tag-dim" style="font-size:10.5px;">Пользователь</span>
{% endif %}
</span>
</div>
<div class="conn-row">
<span class="conn-k">Статус</span>
<span class="conn-v" style="font-family:inherit;">
{% if target.status == 'active' %}<span class="tag tag-gr"><span class="dot g"></span>Активен</span>
{% elif target.status == 'pending' %}<span class="tag tag-yl"><span class="dot y pulse"></span>Ожидает</span>
{% else %}<span class="tag tag-rd"><span class="dot r"></span>Заблокирован</span>
{% endif %}
</span>
</div>
<div class="conn-row"><span class="conn-k">Регистрация</span><span class="conn-v">{{ target.created_at | datefmt }}</span></div>
{% if target.evotor_user_id %}
<div class="conn-row"><span class="conn-k">Эвотор ID</span><span class="conn-v">{{ target.evotor_user_id }}</span></div>
{% endif %}
{% if target.invite_token %}
<div class="conn-row"><span class="conn-k">Приглашение до</span><span class="conn-v">{{ target.invite_expires | datefmt }}</span></div>
{% endif %}
</div>
</div>
<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>
{% if target.evotor_meta %}
<div class="card">
<div class="card-hd"><div><div class="card-title">Данные Эвотор</div></div></div>
<pre>{{ target.evotor_meta | tojson(indent=2) }}</pre>
</div>
{% endif %}
</div>
<!-- Right column -->
<div style="display:flex;flex-direction:column;gap:14px;">
<!-- Actions -->
<div class="card">
<div class="card-title" style="margin-bottom:14px;">Действия</div>
<div style="display:flex;flex-direction:column;gap:8px;">
{% if target.status != 'active' %}
<form method="post" action="/admin/users/{{ target.id }}/activate">
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-check-circle"></i> Активировать
</button>
</form>
{% endif %}
{% if target.status != 'suspended' %}
<form method="post" action="/admin/users/{{ target.id }}/suspend">
<button type="submit" class="btn btn-danger w-100">
<i class="bi bi-slash-circle"></i> Заблокировать
</button>
</form>
{% endif %}
<form method="post" action="/admin/users/{{ target.id }}/reset-password">
<button type="submit" class="btn btn-outline w-100">
<i class="bi bi-key"></i> Сбросить пароль
</button>
</form>
<form method="post" action="/admin/users/{{ target.id }}/send-invite">
<button type="submit" class="btn btn-outline w-100">
<i class="bi bi-envelope"></i> Отправить приглашение
</button>
</form>
<form method="post" action="/admin/users/{{ target.id }}/view-as">
<button type="submit" class="btn btn-outline w-100">
<i class="bi bi-eye"></i> Просмотр от имени пользователя
</button>
</form>
{% if user.role == 'system' and target.id != user.id %}
<form method="post" action="/admin/users/{{ target.id }}/delete"
onsubmit="return confirm('Удалить пользователя {{ target.email }}? Это действие необратимо.')">
<button type="submit" class="btn btn-danger btn-sm w-100">
<i class="bi bi-trash"></i> Удалить
</button>
</form>
{% endif %}
</div>
</div>
<!-- Edit -->
<div class="card">
<div class="card-title" style="margin-bottom:14px;">Редактировать</div>
<form method="post" action="/admin/users/{{ target.id }}/edit">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;">
<div class="form-row">
<label class="form-lbl" for="first_name">Имя</label>
<input class="inp" type="text" id="first_name" name="first_name" value="{{ target.first_name }}" required>
</div>
<div class="form-row">
<label class="form-lbl" for="last_name">Фамилия</label>
<input class="inp" type="text" id="last_name" name="last_name" value="{{ target.last_name }}" required>
</div>
<div class="form-row">
<label class="form-lbl" for="middle_name">Отчество</label>
<input class="inp" type="text" id="middle_name" name="middle_name" value="{{ target.middle_name or '' }}">
</div>
</div>
<div class="form-row">
<label class="form-lbl" for="email">Email</label>
<input class="inp" type="email" id="email" name="email" value="{{ target.email }}">
</div>
<div class="form-row">
<label class="form-lbl" for="phone">Телефон</label>
<input class="inp" type="tel" id="phone" name="phone" value="{{ target.phone }}">
</div>
{% if user.role in ('system', 'admin') %}
<div class="form-row">
<label class="form-lbl" for="role">Роль</label>
<select class="inp" id="role" name="role">
<option value="user" {% if target.role == 'user' %}selected{% endif %}>Пользователь</option>
<option value="admin" {% if target.role == 'admin' %}selected{% endif %}>Администратор</option>
<option value="system" {% if target.role == 'system' %}selected{% endif %}>Системный</option>
</select>
</div>
{% endif %}
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-save"></i> Сохранить
</button>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,118 +1,206 @@
{% extends "base.html" %}
{% block title %}Пользователи — Администрирование — ЭВОСИНК{% endblock %}
{% block title %}Пользователи — Администрирование — Мои Товары{% endblock %}
{% block page_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 class="pg-title">Пользователи</div>
<div class="pg-sub">Управление аккаунтами и подключениями пользователей</div>
<!-- Topbar action -->
<div style="display:flex;justify-content:flex-end;margin-bottom:16px;">
<button class="btn btn-primary btn-sm" onclick="document.getElementById('create-user-dialog').showModal()">
<i class="bi bi-person-plus"></i> Создать пользователя
</button>
</div>
<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>
<!-- Create user dialog -->
<dialog id="create-user-dialog">
<div class="dialog-hd">
<div class="dialog-title">Создать пользователя</div>
<button class="dialog-close" onclick="document.getElementById('create-user-dialog').close()">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="dialog-body">
{% if create_errors %}
<div class="alert alert-rd" style="margin-bottom:14px;">
<span><i class="bi bi-x-circle"></i></span>
<div>{% for e in create_errors %}<div>{{ e }}</div>{% endfor %}</div>
</div>
</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>
<form method="post" action="/admin/users/create" novalidate>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div class="form-row">
<label class="form-lbl" for="cu_first_name">Имя</label>
<input class="inp" type="text" id="cu_first_name" name="first_name"
value="{{ create_form.first_name if create_form else '' }}" required>
</div>
<div class="form-row">
<label class="form-lbl" for="cu_last_name">Фамилия</label>
<input class="inp" type="text" id="cu_last_name" name="last_name"
value="{{ create_form.last_name if create_form else '' }}">
</div>
</div>
<div class="form-row">
<label class="form-lbl" for="cu_email">Email</label>
<input class="inp" type="text" id="cu_email" name="email"
value="{{ create_form.email if create_form else '' }}" required>
</div>
<div class="form-row">
<label class="form-lbl" for="cu_phone">Телефон</label>
<input class="inp" type="tel" id="cu_phone" name="phone"
value="{{ create_form.phone if create_form else '' }}" placeholder="+7 (999) 999-99-99">
</div>
<div class="form-row">
<label class="form-lbl" for="cu_password">Пароль</label>
<input class="inp" type="password" id="cu_password" name="password" required>
</div>
{% if user.role in ('system', 'admin') %}
<div class="form-row">
<label class="form-lbl" for="cu_role">Роль</label>
<select class="inp" id="cu_role" name="role">
<option value="user" {% if not create_form or create_form.role == 'user' %}selected{% endif %}>Пользователь</option>
<option value="admin" {% if create_form and create_form.role == 'admin' %}selected{% endif %}>Администратор</option>
</select>
</div>
{% endif %}
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px;">
<button type="button" class="btn btn-outline" onclick="document.getElementById('create-user-dialog').close()">Отмена</button>
<button type="submit" class="btn btn-primary">Создать</button>
</div>
</form>
</div>
</dialog>
{% if 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>
{% if create_errors %}
<script>document.addEventListener('DOMContentLoaded', () => document.getElementById('create-user-dialog').showModal());</script>
{% endif %}
<!-- Search / filter bar -->
<div class="card" style="margin-bottom:14px;padding:14px 20px;">
<form method="get" action="/admin/users" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<input class="inp" type="text" name="search" value="{{ search }}"
placeholder="Поиск по имени, email, телефону" style="flex:1;min-width:200px;">
<select class="inp" name="status" style="width:auto;">
<option value="">Все статусы</option>
<option value="pending" {% if status_filter == 'pending' %}selected{% endif %}>Ожидает</option>
<option value="active" {% if status_filter == 'active' %}selected{% endif %}>Активен</option>
<option value="suspended" {% if status_filter == 'suspended' %}selected{% endif %}>Заблокирован</option>
</select>
<select class="inp" name="role" style="width:auto;">
<option value="">Все роли</option>
<option value="user" {% if role_filter == 'user' %}selected{% endif %}>Пользователь</option>
<option value="admin" {% if role_filter == 'admin' %}selected{% endif %}>Администратор</option>
<option value="system" {% if role_filter == 'system' %}selected{% endif %}>Системный</option>
</select>
<button type="submit" class="btn btn-primary btn-sm">Найти</button>
{% if search or status_filter or role_filter %}
<a href="/admin/users" class="btn btn-outline btn-sm">Сбросить</a>
{% endif %}
</form>
</div>
<!-- Users table -->
<div class="card" style="padding:0;">
<div class="table-wrap">
<table class="tbl">
<thead>
<tr>
<th>ID</th>
<th>Пользователь</th>
<th>Телефон</th>
<th>Роль</th>
<th>Статус</th>
<th>Эвотор</th>
<th>Дата</th>
<th></th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td><span class="mono" style="font-size:11px;color:#9EA8BE;">{{ u.id }}</span></td>
<td>
<div style="display:flex;align-items:center;gap:10px;">
<div class="avatar" style="width:30px;height:30px;font-size:10px;">
{{ u.first_name[0] if u.first_name else '?' }}{{ u.last_name[0] if u.last_name else '' }}
</div>
<div>
<div class="tbl-name">{{ u.first_name }} {{ u.last_name }}</div>
<div class="tbl-sub">
{{ u.email }}
{% if not u.is_email_confirmed %}
<span class="tag tag-yl" style="font-size:9.5px;padding:0 5px;margin-left:4px;"><i class="bi bi-exclamation-circle"></i></span>
{% endif %}
</div>
</div>
</div>
</td>
<td style="font-size:12px;color:#9EA8BE;">{{ u.phone or '—' }}</td>
<td>
{% if u.role == 'system' %}
<span class="tag tag-rd" style="font-size:10.5px;">Системный</span>
{% elif u.role == 'admin' %}
<span class="tag tag-or" style="font-size:10.5px;">Админ</span>
{% else %}
<span class="tag tag-dim" style="font-size:10.5px;">Польз.</span>
{% endif %}
</td>
<td>
{% if u.status == 'active' %}
<span class="tag tag-gr"><span class="dot g"></span>Активен</span>
{% elif u.status == 'pending' %}
<span class="tag tag-yl"><span class="dot y pulse"></span>Ожидает</span>
{% else %}
<span class="tag tag-rd"><span class="dot r"></span>Заблок.</span>
{% endif %}
</td>
<td>
{% if u.evotor_user_id %}
<span class="tag tag-gr" style="font-size:10.5px;"><i class="bi bi-check-circle"></i></span>
{% else %}
<span style="color:#9EA8BE;font-size:12px;"></span>
{% endif %}
</td>
<td><span class="mono" style="font-size:11px;color:#9EA8BE;">{{ u.created_at | datefmt }}</span></td>
<td style="white-space:nowrap;">
<a href="/admin/users/{{ u.id }}" class="btn btn-outline btn-xs">
<i class="bi bi-eye"></i>
</a>
<button type="button" class="btn btn-outline btn-xs" style="color:#E53935;border-color:#F4AEAE;margin-left:4px;"
onclick="if(confirm('Удалить пользователя {{ u.first_name }} {{ u.last_name }} ({{ u.email }})?')) { document.getElementById('del-{{ u.id }}').submit(); }">
<i class="bi bi-trash"></i>
</button>
<form id="del-{{ u.id }}" method="post" action="/admin/users/{{ u.id }}/delete" style="display:none;"></form>
</td>
</tr>
{% else %}
<tr>
<td colspan="8" class="text-center" style="padding:32px;color:#9EA8BE;">Пользователи не найдены</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if total_pages > 1 %}
<div class="pagination">
{% if page > 1 %}
<a href="?page={{ page - 1 }}&search={{ search }}&status={{ status_filter }}&role={{ role_filter }}" class="btn btn-outline btn-sm">« Назад</a>
{% endif %}
<span style="font-size:12px;color:#9EA8BE;">Стр. {{ page }} из {{ total_pages }}</span>
{% if page < total_pages %}
<a href="?page={{ page + 1 }}&search={{ search }}&status={{ status_filter }}&role={{ role_filter }}" class="btn btn-outline btn-sm">Вперёд »</a>
{% endif %}
</div>
{% endif %}
</div>
{% if user.role in ('system', 'admin') %}
<div style="margin-top:14px;text-align:right;">
<a href="/admin/roles" class="btn btn-outline btn-sm">
<i class="bi bi-shield-lock"></i> Управление ролями
</a>
</div>
{% endif %}
{% endblock %}

View File

@@ -1,106 +1,158 @@
<!DOCTYPE html>
<html lang="ru" data-theme="light">
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}ЭВОСИНК{% endblock %}</title>
<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="/static/style.css">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Мои Товары{% endblock %}</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16 32x32" type="image/x-icon">
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/favicon-32.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Golos+Text:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<header class="site-header">
<nav class="container">
<ul>
<li><a href="/" class="brand-logo">ЭВОСИНК</a></li>
</ul>
<ul class="nav-links">
{% if user %}
<li><a href="/connections">Подключения</a></li>
<li><a href="/catalog">Каталог</a></li>
<li><a href="/sync">Синхронизация</a></li>
{% if user.role in ('admin', 'system') %}
<li><a href="/admin/users"><i class="bi bi-shield-lock"></i> Админ</a></li>
{% 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="/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>
</details>
{% else %}
<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>
{% block body %}
<div class="shell">
<main class="container py-4">
{% if errors %}
<div role="alert" class="alert alert-danger">
{% for error in errors %}
<p>{{ error }}</p>
{% endfor %}
</div>
<!-- ── Sidebar ── -->
<aside class="sidebar">
<div class="sb-logo">
<div class="sb-logo-icon">
<svg width="22" height="22" viewBox="0 0 28 28" fill="none">
<path d="M9 11h10l-1.5 9H10.5L9 11Z" fill="white" opacity="0.95"/>
<path d="M11.5 11V9a2.5 2.5 0 015 0v2" stroke="white" stroke-width="1.6" stroke-linecap="round" fill="none"/>
<line x1="12" y1="15" x2="16" y2="15" stroke="#FF5500" stroke-width="1.3" stroke-linecap="round"/>
</svg>
</div>
<div>
<div class="sb-logo-name">Мои Товары</div>
<div class="sb-logo-sub">мои-товары.рф</div>
</div>
</div>
<nav class="sb-nav">
{% if user %}
{% if user.role in ('admin', 'system') and not viewed_user %}
<div class="sb-section">Управление</div>
<a href="/admin/users" class="sb-item {% if request.url.path.startswith('/admin/users') %}active{% endif %}">
<span class="sb-icon"><i class="bi bi-people"></i></span>
<span>Пользователи</span>
</a>
{% if user.role in ('admin', 'system') %}
<a href="/admin/logs" class="sb-item {% if request.url.path.startswith('/admin/logs') %}active{% endif %}">
<span class="sb-icon"><i class="bi bi-journal-text"></i></span>
<span>Логи</span>
</a>
{% endif %}
{% if success %}
<div role="alert" class="alert alert-success">
<p>{{ success }}</p>
</div>
{% else %}
<div class="sb-section">Главное</div>
<a href="/connections" class="sb-item {% if request.url.path == '/connections' %}active{% endif %}">
<span class="sb-icon"><i class="bi bi-plug"></i></span>
<span>Подключения</span>
</a>
<a href="/catalog/stores" class="sb-item {% if request.url.path.startswith('/catalog') %}active{% endif %}">
<span class="sb-icon"><i class="bi bi-shop"></i></span>
<span>Каталог Эвотор</span>
</a>
<a href="/vk-catalog/albums" class="sb-item {% if request.url.path.startswith('/vk-catalog') %}active{% endif %}">
<span class="sb-icon"><i class="bi bi-bag"></i></span>
<span>Каталог ВК</span>
</a>
<a href="/sync" class="sb-item {% if request.url.path == '/sync' %}active{% endif %}">
<span class="sb-icon"><i class="bi bi-arrow-repeat"></i></span>
<span>Синхронизация</span>
</a>
<div class="sb-section" style="margin-top:6px;">Аккаунт</div>
<a href="/profile" class="sb-item {% if request.url.path.startswith('/profile') %}active{% endif %}">
<span class="sb-icon"><i class="bi bi-person"></i></span>
<span>Профиль</span>
</a>
{% endif %}
{% endif %}
</nav>
{% block content %}{% endblock %}
</main>
{% if jivosite_widget_id %}
<script src="//code.jivosite.com/widget/{{ jivosite_widget_id }}" async></script>
{% if user %}
<a href="/profile" class="sb-user">
<div class="avatar {% if user.role in ('admin','system') %}admin{% endif %}">
{{ user.first_name[0] if user.first_name else '?' }}{{ user.last_name[0] if user.last_name else '' }}
</div>
<div style="flex:1; min-width:0;">
<div class="sb-user-name">{{ user.first_name }} {{ user.last_name }}</div>
<div class="sb-user-role">
<span class="role-chip {% if user.role in ('admin','system') %}admin{% else %}user{% endif %}">
{% if user.role == 'system' %}SYSTEM{% elif user.role == 'admin' %}ADMIN{% else %}USER{% endif %}
</span>
</div>
</div>
</a>
{% endif %}
<script src="https://cdn.jsdelivr.net/npm/inputmask@5.0.9/dist/inputmask.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
var phoneInputs = document.querySelectorAll('input[name="phone"]');
if (phoneInputs.length) {
Inputmask('+7 (999) 999-99-99', {
placeholder: '_',
showMaskOnHover: false,
clearMaskOnLostFocus: false
}).mask(phoneInputs);
}
});
</script>
<script>
document.addEventListener('invalid', function(e) {
if (e.target.validity.valueMissing) {
e.target.setCustomValidity('Пожалуйста, заполните это поле');
} else if (e.target.validity.typeMismatch) {
e.target.setCustomValidity('Пожалуйста, введите корректное значение');
}
}, true);
document.addEventListener('input', function(e) {
if (e.target.required) e.target.setCustomValidity('');
}, true);
</script>
{% block scripts %}{% endblock %}
</aside>
<!-- ── Main ── -->
<div class="main">
<!-- Topbar -->
<div class="topbar">
<div class="topbar-title">{% block page_title %}{% endblock %}</div>
{% block topbar_extras %}{% endblock %}
{% if user %}
<a href="/logout" class="btn btn-ghost btn-sm" style="margin-left:auto;" title="Выйти">
<i class="bi bi-box-arrow-right"></i>
</a>
{% endif %}
</div>
{% if viewed_user %}
<div class="view-as-bar">
<i class="bi bi-eye"></i> Просмотр от имени: <strong>{{ viewed_user.first_name }} {{ viewed_user.last_name }}</strong> ({{ viewed_user.email }})
<form method="post" action="/admin/view-as/stop" style="display:inline;margin-left:1rem;">
<button type="submit" style="background:none;border:1px solid rgba(255,255,255,0.6);color:#fff;padding:2px 10px;font-size:12px;cursor:pointer;border-radius:4px;font-family:inherit;">Выйти</button>
</form>
</div>
{% endif %}
<div class="content">
{% if errors %}
<div class="alert alert-rd" style="margin-bottom:16px;">
<span><i class="bi bi-x-circle"></i></span>
<div>{% for e in errors %}<div>{{ e }}</div>{% endfor %}</div>
</div>
{% endif %}
{% if success %}
<div class="alert alert-gr" style="margin-bottom:16px;">
<span><i class="bi bi-check-circle"></i></span>
<div>{{ success }}</div>
</div>
{% endif %}
{% block content %}{% endblock %}
</div>
</div>
</div>
{% endblock %}
{% if jivosite_widget_id %}
<script src="//code.jivosite.com/widget/{{ jivosite_widget_id }}" async></script>
{% endif %}
<script src="https://cdn.jsdelivr.net/npm/inputmask@5.0.9/dist/inputmask.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
var phoneInputs = document.querySelectorAll('input[name="phone"]');
if (phoneInputs.length) {
Inputmask('+7 (999) 999-99-99', { placeholder: '_', showMaskOnHover: false, clearMaskOnLostFocus: false }).mask(phoneInputs);
}
});
document.addEventListener('invalid', function(e) {
if (e.target.validity.valueMissing) e.target.setCustomValidity('Пожалуйста, заполните это поле');
else if (e.target.validity.typeMismatch) e.target.setCustomValidity('Пожалуйста, введите корректное значение');
}, true);
document.addEventListener('input', function(e) {
if (e.target.required) e.target.setCustomValidity('');
}, true);
</script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,63 @@
{% extends "base.html" %}
{% block title %}Группы — {{ store.name }} — Мои Товары{% endblock %}
{% block page_title %}Каталог Эвотор{% endblock %}
{% block content %}
<ol class="breadcrumb">
<li><a href="/catalog/stores">Магазины</a></li>
<li>{{ store.name }}</li>
<li>Группы</li>
</ol>
<div class="pg-title">Группы товаров — {{ store.name }}</div>
<div class="pg-sub">Управление категориями · Всего: {{ groups | length }}</div>
<div class="card" style="padding:0;">
{% if groups %}
<div class="table-wrap">
<table class="tbl">
<thead>
<tr>
<th>Синхронизация</th>
<th>Группа</th>
<th>Товаров</th>
<th>ID</th>
<th>Обновлено</th>
<th></th>
</tr>
</thead>
<tbody>
{% for g in groups %}
{% set is_enabled = (enabled_ids is none) or (g.evotor_id in enabled_ids) %}
<tr>
<td>
<form method="post" action="/catalog/stores/{{ store.evotor_id }}/groups/{{ g.evotor_id }}/toggle" style="margin:0;">
<button type="submit" class="tog {% if is_enabled %}on{% endif %}"
title="{% if is_enabled %}Отключить синхронизацию{% else %}Включить синхронизацию{% endif %}"
style="border:none;background:none;padding:0;cursor:pointer;"></button>
</form>
</td>
<td>
<div class="tbl-name"><i class="bi bi-folder2" style="color:#9EA8BE;margin-right:6px;"></i>{{ g.name }}</div>
</td>
<td><span class="mono" style="font-size:12px;">{{ product_counts.get(g.evotor_id, 0) }}</span></td>
<td><span class="mono" style="font-size:11px;color:#9EA8BE;">{{ g.evotor_id }}</span></td>
<td><span class="mono" style="font-size:11px;color:#9EA8BE;">{{ g.fetched_at | datefmt }}</span></td>
<td>
<a href="/catalog/stores/{{ store.evotor_id }}/products?group={{ g.evotor_id }}" class="btn btn-outline btn-xs">
<i class="bi bi-box-seam"></i> Товары
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<i class="bi bi-folder"></i>
<p>Группы для этого магазина ещё не загружены.</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,78 @@
{% extends "base.html" %}
{% block title %}Товары — {{ store.name }} — Мои Товары{% endblock %}
{% block page_title %}Каталог Эвотор{% endblock %}
{% block content %}
<ol class="breadcrumb">
<li><a href="/catalog/stores">Магазины</a></li>
<li>{{ store.name }}</li>
<li>Товары</li>
</ol>
<div class="pg-title">Товары — {{ store.name }}</div>
<div class="pg-sub">Всего: {{ products | length }}</div>
{% if groups %}
<div class="card" style="margin-bottom:16px;padding:14px 20px;">
<form method="get" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<select class="inp" name="group" style="width:auto;" onchange="this.form.submit()">
<option value="">Все группы</option>
{% for g in groups %}
<option value="{{ g.evotor_id }}" {% if group_id == g.evotor_id %}selected{% endif %}>{{ g.name }}</option>
{% endfor %}
</select>
{% if group_id %}
<a href="/catalog/stores/{{ store.evotor_id }}/products" class="btn btn-outline btn-sm">Сбросить</a>
{% endif %}
</form>
</div>
{% endif %}
<div class="card" style="padding:0;">
{% if products %}
<div class="table-wrap">
<table class="tbl">
<thead>
<tr>
<th>Название</th>
<th>Группа</th>
<th>Артикул</th>
<th>Цена</th>
<th>Остаток</th>
<th>Ед.</th>
<th>Продаётся</th>
<th>Обновлено</th>
</tr>
</thead>
<tbody>
{% for p in products %}
<tr>
<td><div class="tbl-name">{{ p.name }}</div></td>
<td style="font-size:12px;color:#9EA8BE;">{{ group_map.get(p.group_evotor_id) or '—' }}</td>
<td><span class="mono" style="font-size:11px;color:#9EA8BE;">{{ p.article_number or '—' }}</span></td>
<td><span class="mono">{% if p.price is not none %}{{ p.price | price }}{% else %}—{% endif %}</span></td>
<td><span class="mono" style="color:{% if p.quantity is not none and p.quantity == 0 %}#E53935{% else %}#1C1F2E{% endif %};">{% if p.quantity is not none %}{{ p.quantity }}{% else %}—{% endif %}</span></td>
<td style="font-size:12px;color:#9EA8BE;">{{ p.measure_name or '—' }}</td>
<td>
{% if p.allow_to_sell %}
<span class="tag tag-gr" style="font-size:10.5px;"><i class="bi bi-check-circle"></i></span>
{% elif p.allow_to_sell == false %}
<span class="tag tag-rd" style="font-size:10.5px;"><i class="bi bi-x-circle"></i></span>
{% else %}
<span style="color:#9EA8BE;"></span>
{% endif %}
</td>
<td><span class="mono" style="font-size:11px;color:#9EA8BE;">{{ p.fetched_at | datefmt }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<i class="bi bi-box-seam"></i>
<p>Товары не найдены.</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,62 @@
{% extends "base.html" %}
{% block title %}Магазины — Мои Товары{% endblock %}
{% block page_title %}Каталог Эвотор{% endblock %}
{% block content %}
<div class="pg-title">Магазины Эвотор</div>
<div class="pg-sub">Выберите магазины для синхронизации · Всего: {{ stores | length }}</div>
<div class="card" style="padding:0;">
{% if stores %}
<div class="table-wrap">
<table class="tbl">
<thead>
<tr>
<th>Синхронизация</th>
<th>Название</th>
<th>Адрес</th>
<th>ID</th>
<th>Обновлено</th>
<th></th>
</tr>
</thead>
<tbody>
{% for s in stores %}
{% set is_enabled = (enabled_ids is none) or (s.evotor_id in enabled_ids) %}
<tr>
<td>
<form method="post" action="/catalog/stores/{{ s.evotor_id }}/toggle" style="margin:0;">
<button type="submit" class="tog {% if is_enabled %}on{% endif %}"
title="{% if is_enabled %}Отключить синхронизацию{% else %}Включить синхронизацию{% endif %}"
style="border:none;background:none;padding:0;cursor:pointer;"></button>
</form>
</td>
<td>
<div class="tbl-name">{{ s.name }}</div>
</td>
<td style="color:#9EA8BE;font-size:12px;">{{ s.address or '—' }}</td>
<td><span class="mono" style="font-size:11px;color:#9EA8BE;">{{ s.evotor_id }}</span></td>
<td><span class="mono" style="font-size:11px;color:#9EA8BE;">{{ s.fetched_at | datefmt }}</span></td>
<td>
<div style="display:flex;gap:6px;">
<a href="/catalog/stores/{{ s.evotor_id }}/products" class="btn btn-outline btn-xs">
<i class="bi bi-box-seam"></i> Товары
</a>
<a href="/catalog/stores/{{ s.evotor_id }}/groups" class="btn btn-outline btn-xs">
<i class="bi bi-folder"></i> Группы
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<i class="bi bi-shop"></i>
<p>Магазины ещё не загружены.<br>Синхронизация выполняется каждые {{ refresh_interval }} сек. автоматически.</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -1,16 +1,22 @@
{% extends "base.html" %}
{% block title %}Подтверждение email — ЭВОСИНК{% endblock %}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Подтверждение email — Мои Товары</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Golos+Text:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="/static/style.css">
</head>
<body style="display:flex;align-items:center;justify-content:center;min-height:100vh;background:#F4F5F7;">
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<article class="card mt-5 text-center">
<div class="card-body" style="padding: 2.5rem;">
<i class="bi bi-envelope-check fs-1 text-primary mb-3 d-block"></i>
<h1 style="font-size:1.3rem;" class="mb-3">Подтвердите ваш email</h1>
<p class="text-muted">Проверьте почту и нажмите на ссылку для подтверждения.</p>
</div>
</article>
</div>
<div class="card" style="max-width:400px;width:100%;padding:40px 32px;text-align:center;">
<i class="bi bi-envelope-check" style="font-size:48px;color:#FF5500;display:block;margin-bottom:16px;"></i>
<div class="pg-title" style="margin-bottom:8px;">Подтвердите ваш email</div>
<div style="font-size:13px;color:#5C6278;">Проверьте почту и нажмите на ссылку для подтверждения.</div>
</div>
{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,212 @@
{% extends "base.html" %}
{% block title %}Подключения — Мои Товары{% endblock %}
{% block page_title %}Подключения{% endblock %}
{% block content %}
<div class="pg-title">Подключения</div>
<div class="pg-sub">Управление интеграциями с Эвотор и VK Market</div>
{% if request.query_params.get('success') %}
<div class="alert alert-gr">
<span><i class="bi bi-check-circle"></i></span>
<div>Подключение сохранено.</div>
</div>
{% endif %}
<div class="g2" style="align-items:start;">
{# ── Evotor ── #}
<div class="card">
<div class="card-hd">
<div>
<div class="card-title"><i class="bi bi-cpu" style="margin-right:6px;"></i>Эвотор</div>
<div class="card-sub">Платформа кассовых решений и товарного учёта</div>
</div>
{% if evotor %}
<span class="tag tag-gr"><span class="dot g"></span>Подключено</span>
{% else %}
<span class="tag tag-dim"><span class="dot d"></span>Не подключено</span>
{% endif %}
</div>
{% if evotor %}
<div class="conn-detail" style="margin-bottom:16px;">
<div class="conn-row">
<span class="conn-k">Токен</span>
<span class="conn-v">{{ evotor.access_token[:8] }}••••••••</span>
</div>
{% if evotor.evotor_user_id %}
<div class="conn-row">
<span class="conn-k">Evotor User ID</span>
<span class="conn-v">{{ evotor.evotor_user_id }}</span>
</div>
{% endif %}
<div class="conn-row">
<span class="conn-k">Подключено</span>
<span class="conn-v">{{ evotor.connected_at | datefmt }}</span>
</div>
<div class="conn-row">
<span class="conn-k">Обновлено</span>
<span class="conn-v">{{ evotor.updated_at | datefmt }}</span>
</div>
</div>
{% endif %}
<details {% if not evotor %}open{% endif %} style="margin-bottom:14px;">
<summary class="btn btn-outline btn-sm" style="cursor:pointer;list-style:none;display:inline-flex;align-items:center;gap:6px;">
<i class="bi bi-pencil"></i>
{% if evotor %}Обновить токен{% else %}Ввести API-токен{% endif %}
</summary>
<form method="post" action="/connections/evotor" style="margin-top:12px;">
<div class="form-row">
<label class="form-lbl">API-токен Эвотор</label>
<input class="inp" type="text" name="access_token"
placeholder="Вставьте токен из личного кабинета Эвотор"
value="{{ evotor.access_token if evotor else '' }}"
required autocomplete="off">
</div>
<div class="form-row">
<label class="form-lbl">Evotor User ID <span style="font-weight:400;text-transform:none;letter-spacing:0;">(необязательно)</span></label>
<input class="inp" type="text" name="evotor_user_id"
placeholder="Например: 01234567-89ab-cdef-0123-456789abcdef"
value="{{ evotor.evotor_user_id if evotor and evotor.evotor_user_id else '' }}"
autocomplete="off">
</div>
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-save"></i> Сохранить
</button>
</form>
</details>
{% if evotor %}
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<button type="button" class="btn btn-outline btn-sm" onclick="testConnection('evotor', this)">
<i class="bi bi-wifi"></i> Проверить соединение
</button>
<span id="evotor-test-result" style="font-size:12px;"></span>
</div>
<form method="post" action="/connections/evotor/disconnect" style="margin-top:10px;"
onsubmit="return confirm('Отключить Эвотор? Кешированные данные каталога останутся.')">
<button type="submit" class="btn btn-danger btn-sm">
<i class="bi bi-plug"></i> Отключить
</button>
</form>
{% endif %}
</div>
{# ── VK ── #}
<div class="card">
<div class="card-hd">
<div>
<div class="card-title"><i class="bi bi-badge-vr" style="margin-right:6px;"></i>ВКонтакте (Маркет)</div>
<div class="card-sub">market.* API, версия 5.199</div>
</div>
{% if vk %}
<span class="tag tag-gr"><span class="dot g"></span>Подключено</span>
{% else %}
<span class="tag tag-dim"><span class="dot d"></span>Не подключено</span>
{% endif %}
</div>
{% if vk %}
<div class="conn-detail" style="margin-bottom:16px;">
<div class="conn-row">
<span class="conn-k">Токен</span>
<span class="conn-v">{{ vk.access_token[:8] }}••••••••</span>
</div>
{% if vk.vk_user_id %}
<div class="conn-row">
<span class="conn-k">ID сообщества</span>
<span class="conn-v">{{ vk.vk_user_id }}</span>
</div>
{% endif %}
{% if vk.first_name or vk.last_name %}
<div class="conn-row">
<span class="conn-k">Аккаунт</span>
<span class="conn-v">{{ vk.first_name }} {{ vk.last_name }}</span>
</div>
{% endif %}
<div class="conn-row">
<span class="conn-k">Подключено</span>
<span class="conn-v">{{ vk.connected_at | datefmt }}</span>
</div>
<div class="conn-row">
<span class="conn-k">Обновлено</span>
<span class="conn-v">{{ vk.updated_at | datefmt }}</span>
</div>
</div>
{% endif %}
<div style="margin-bottom:14px;">
<a href="/vk-auth" class="btn btn-primary btn-sm">
<i class="bi bi-box-arrow-in-right"></i>
{% if vk %}Переподключить ВКонтакте{% else %}Войти через ВКонтакте{% endif %}
</a>
</div>
<details style="margin-bottom:14px;">
<summary class="btn btn-outline btn-sm" style="cursor:pointer;list-style:none;display:inline-flex;align-items:center;gap:6px;">
<i class="bi bi-key"></i> Ввести токен вручную
</summary>
<form method="post" action="/connections/vk" style="margin-top:12px;">
<div class="form-row">
<label class="form-lbl">Токен доступа VK</label>
<input class="inp" type="text" name="access_token"
placeholder="vk1.a.xxxxxxxxxxxxxxxx…"
value="{{ vk.access_token if vk else '' }}"
required autocomplete="off">
</div>
<div class="form-row">
<label class="form-lbl">ID сообщества ВКонтакте</label>
<input class="inp" type="text" name="vk_group_id"
placeholder="Например: 229744980"
value="{{ vk.vk_user_id if vk and vk.vk_user_id else '' }}"
autocomplete="off">
<div style="font-size:11px;color:#9EA8BE;margin-top:4px;">Числовой ID группы/паблика с включённым Маркетом (без минуса)</div>
</div>
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-save"></i> Сохранить
</button>
</form>
</details>
{% if vk %}
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<button type="button" class="btn btn-outline btn-sm" onclick="testConnection('vk', this)">
<i class="bi bi-wifi"></i> Проверить соединение
</button>
<span id="vk-test-result" style="font-size:12px;"></span>
</div>
<form method="post" action="/connections/vk/disconnect" style="margin-top:10px;"
onsubmit="return confirm('Отключить ВКонтакте?')">
<button type="submit" class="btn btn-danger btn-sm">
<i class="bi bi-plug"></i> Отключить
</button>
</form>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
async function testConnection(provider, btn) {
const resultEl = document.getElementById(provider + '-test-result');
btn.disabled = true;
resultEl.textContent = 'Проверяем…';
resultEl.style.color = '';
try {
const resp = await fetch('/connections/' + provider + '/test', {method: 'POST'});
const data = await resp.json();
resultEl.textContent = data.message;
resultEl.style.color = data.ok ? '#17A865' : '#E53935';
} catch (e) {
resultEl.textContent = 'Ошибка сети';
resultEl.style.color = '#E53935';
} finally {
btn.disabled = false;
}
}
</script>
{% endblock %}

View File

@@ -1,17 +1,23 @@
{% extends "base.html" %}
{% block title %}Email подтвержден — ЭВОСИНК{% endblock %}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Email подтверждён — Мои Товары</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Golos+Text:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="/static/style.css">
</head>
<body style="display:flex;align-items:center;justify-content:center;min-height:100vh;background:#F4F5F7;">
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<article class="card mt-5 text-center">
<div class="card-body" style="padding: 2.5rem;">
<i class="bi bi-check-circle fs-1 text-success mb-3 d-block"></i>
<h1 style="font-size:1.3rem;" class="mb-3">Email подтвержден!</h1>
<p class="text-muted">Ваш email успешно подтвержден. Теперь вы можете войти в систему.</p>
<a href="/login" role="button" class="mt-2">Войти</a>
</div>
</article>
</div>
<div class="card" style="max-width:400px;width:100%;padding:40px 32px;text-align:center;">
<i class="bi bi-check-circle" style="font-size:48px;color:#17A865;display:block;margin-bottom:16px;"></i>
<div class="pg-title" style="margin-bottom:8px;">Email подтверждён!</div>
<div style="font-size:13px;color:#5C6278;margin-bottom:20px;">Ваш email успешно подтверждён. Теперь вы можете войти в систему.</div>
<a href="/login" class="btn btn-primary">Войти</a>
</div>
{% endblock %}
</body>
</html>

View File

@@ -1,24 +1,48 @@
{% extends "base.html" %}
{% block title %}Забыли пароль — ЭВОСИНК{% endblock %}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Забыли пароль — Мои Товары</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Golos+Text:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="/static/style.css">
</head>
<body style="display:flex;align-items:center;justify-content:center;min-height:100vh;background:#F4F5F7;">
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<article class="card mt-4">
<div class="card-body">
<h1 style="font-size:1.3rem;" class="mb-2">Забыли пароль?</h1>
<p class="text-muted small mb-4">Введите email, указанный при регистрации.</p>
<form method="post" action="/forgot-password">
<label for="email">Email
<input type="email" id="email" name="email" required>
</label>
<button type="submit" class="w-100">Отправить ссылку для сброса</button>
</form>
<div class="text-center small mt-3">
<a href="/login">Вернуться ко входу</a>
</div>
</div>
</article>
<div class="card" style="max-width:400px;width:100%;padding:32px;">
<div style="text-align:center;margin-bottom:24px;">
<div style="width:44px;height:44px;border-radius:11px;background:#FF5500;display:flex;align-items:center;justify-content:center;margin:0 auto 16px;">
<svg width="24" height="24" viewBox="0 0 28 28" fill="none">
<path d="M9 11h10l-1.5 9H10.5L9 11Z" fill="white" opacity="0.95"/>
<path d="M11.5 11V9a2.5 2.5 0 015 0v2" stroke="white" stroke-width="1.6" stroke-linecap="round" fill="none"/>
</svg>
</div>
<div class="login-box-title">Забыли пароль?</div>
<div style="font-size:13px;color:#5C6278;margin-top:4px;">Введите email, указанный при регистрации</div>
</div>
{% if errors %}
<div class="alert alert-rd" style="margin-bottom:14px;">
<span><i class="bi bi-x-circle"></i></span>
<div>{% for e in errors %}<div>{{ e }}</div>{% endfor %}</div>
</div>
{% endif %}
<form method="post" action="/forgot-password">
<div class="form-row">
<label class="form-lbl" for="email">Email</label>
<input class="inp" type="email" id="email" name="email" placeholder="you@store.ru" required>
</div>
<button type="submit" class="login-btn">Отправить ссылку для сброса</button>
</form>
<div class="login-hint" style="margin-top:16px;">
<a href="/login">← Вернуться ко входу</a>
</div>
</div>
{% endblock %}
</body>
</html>

View File

@@ -1,44 +1,79 @@
{% extends "base.html" %}
{% block title %}Завершение регистрации — ЭВОСИНК{% endblock %}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Завершение регистрации — Мои Товары</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Golos+Text:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="/static/style.css">
</head>
<body style="display:flex;align-items:center;justify-content:center;min-height:100vh;background:#F4F5F7;padding:24px;">
{% 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 class="card" style="max-width:480px;width:100%;padding:32px;">
<div style="text-align:center;margin-bottom:24px;">
<div style="width:44px;height:44px;border-radius:11px;background:#FF5500;display:flex;align-items:center;justify-content:center;margin:0 auto 16px;">
<svg width="24" height="24" viewBox="0 0 28 28" fill="none">
<path d="M9 11h10l-1.5 9H10.5L9 11Z" fill="white" opacity="0.95"/>
<path d="M11.5 11V9a2.5 2.5 0 015 0v2" stroke="white" stroke-width="1.6" stroke-linecap="round" fill="none"/>
</svg>
</div>
<div class="login-box-title">Добро пожаловать!</div>
<div style="font-size:13px;color:#5C6278;margin-top:4px;">Ваш аккаунт создан через Эвотор. Заполните профиль и задайте пароль для входа.</div>
</div>
{% if errors %}
<div class="alert alert-rd" style="margin-bottom:14px;">
<span><i class="bi bi-x-circle"></i></span>
<div>{% for e in errors %}<div>{{ e }}</div>{% endfor %}</div>
</div>
{% endif %}
<form method="post" action="/invite?token={{ token }}">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div class="form-row">
<label class="form-lbl" for="first_name">Имя <span style="color:#E53935;">*</span></label>
<input class="inp" type="text" id="first_name" name="first_name"
value="{{ form.first_name if form else (invite_user.first_name or '') }}" required>
</div>
<div class="form-row">
<label class="form-lbl" for="last_name">Фамилия <span style="color:#E53935;">*</span></label>
<input class="inp" type="text" id="last_name" name="last_name"
value="{{ form.last_name if form else (invite_user.last_name or '') }}" required>
</div>
</div>
<div class="form-row">
<label class="form-lbl" for="email">Email <span style="color:#E53935;">*</span></label>
<input class="inp" type="email" id="email" name="email"
value="{{ form.email if form else (invite_user.email or '') }}" required>
</div>
<div class="form-row">
<label class="form-lbl" for="phone">Телефон <span style="color:#E53935;">*</span></label>
<input class="inp" type="tel" id="phone" name="phone"
value="{{ form.phone if form else (invite_user.phone or '') }}" required>
</div>
<div class="form-row">
<label class="form-lbl" for="password">Пароль <span style="color:#E53935;">*</span></label>
<input class="inp" type="password" id="password" name="password" required minlength="8">
</div>
<div class="form-row">
<label class="form-lbl" for="password_confirm">Подтверждение пароля <span style="color:#E53935;">*</span></label>
<input class="inp" type="password" id="password_confirm" name="password_confirm" required>
</div>
<button type="submit" class="login-btn">Завершить регистрацию</button>
</form>
</div>
{% endblock %}
<script src="https://cdn.jsdelivr.net/npm/inputmask@5.0.9/dist/inputmask.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
var phoneInputs = document.querySelectorAll('input[name="phone"]');
if (phoneInputs.length) {
Inputmask('+7 (999) 999-99-99', { placeholder: '_', showMaskOnHover: false, clearMaskOnLostFocus: false }).mask(phoneInputs);
}
});
</script>
</body>
</html>

View File

@@ -1,27 +1,108 @@
{% extends "base.html" %}
{% block title %}Вход — ЭВОСИНК{% endblock %}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Вход — Мои Товары</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16 32x32" type="image/x-icon">
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/favicon-32.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Golos+Text:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<article class="card mt-4">
<div class="card-body">
<h1 class="mb-4" style="font-size:1.3rem;">Вход</h1>
<form method="post" action="/login">
<label for="email">Email
<input type="email" id="email" name="email" value="{{ form.email if form else '' }}" required>
</label>
<label for="password">Пароль
<input type="password" id="password" name="password" required>
</label>
<button type="submit" class="w-100">Войти</button>
</form>
<div class="text-center small mt-3">
<a href="/forgot-password">Забыли пароль?</a><br>
<a href="/register">Зарегистрироваться</a>
</div>
</div>
</article>
<div class="login-wrap">
<!-- Left panel -->
<div class="login-left">
<div class="login-left-bg"></div>
<div class="login-left-pattern"></div>
<div class="login-brand">
<div class="login-brand-icon">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
<path d="M9 11h10l-1.5 9H10.5L9 11Z" fill="white" opacity="0.95"/>
<path d="M11.5 11V9a2.5 2.5 0 015 0v2" stroke="white" stroke-width="1.6" stroke-linecap="round" fill="none"/>
<line x1="12" y1="15" x2="16" y2="15" stroke="#FF5500" stroke-width="1.3" stroke-linecap="round"/>
</svg>
</div>
<div>
<div class="login-brand-name">Мои Товары</div>
<div class="login-brand-sub">мои-товары.рф</div>
</div>
</div>
<div class="login-hero">
<div class="login-hero-title">
Управляйте товарами<br>и продавайте<br><em>везде сразу</em>
</div>
<div class="login-hero-body">
Синхронизируйте ассортимент вашего магазина Эвотор с онлайн-площадками — быстро, без ручной работы.
</div>
<div class="login-chips">
<div class="login-chip"><i class="bi bi-shop"></i> Эвотор</div>
<div class="login-chip"><i class="bi bi-badge-vr"></i> VK Market</div>
<div class="login-chip"><i class="bi bi-arrow-repeat"></i> Авто-синхронизация</div>
</div>
</div>
<div class="login-footer">© 2025 Мои Товары · мои-товары.рф</div>
</div>
<!-- Right panel -->
<div class="login-right">
<div class="login-box">
<div style="margin-bottom:28px;">
<div style="margin-bottom:18px;">
<div style="width:36px;height:36px;border-radius:9px;background:#FF5500;display:flex;align-items:center;justify-content:center;">
<svg width="20" height="20" viewBox="0 0 28 28" fill="none">
<path d="M9 11h10l-1.5 9H10.5L9 11Z" fill="white" opacity="0.95"/>
<path d="M11.5 11V9a2.5 2.5 0 015 0v2" stroke="white" stroke-width="1.6" stroke-linecap="round" fill="none"/>
</svg>
</div>
</div>
<div class="login-box-title">Вход в аккаунт</div>
<div class="login-box-sub">Введите данные, полученные после регистрации</div>
</div>
{% if errors %}
<div class="alert alert-rd" style="margin-bottom:14px;">
<span><i class="bi bi-x-circle"></i></span>
<div>{% for e in errors %}<div>{{ e }}</div>{% endfor %}</div>
</div>
{% endif %}
<form method="post" action="/login">
<div class="form-row">
<label class="form-lbl" for="email">Email</label>
<input class="inp" type="email" id="email" name="email"
placeholder="you@store.ru"
value="{{ form.email if form else '' }}" required>
</div>
<div class="form-row">
<label class="form-lbl" for="password">Пароль</label>
<input class="inp" type="password" id="password" name="password"
placeholder="••••••••" required>
</div>
<button type="submit" class="login-btn">Войти →</button>
</form>
<div class="login-hint" style="margin-top:16px;">
<a href="/forgot-password">Забыли пароль?</a>
</div>
<div class="login-hint">
Нет аккаунта? Купите приложение на
<a href="https://market.evotor.ru" target="_blank" rel="noreferrer">Эвотор.Маркет</a>
и получите доступ автоматически.
</div>
</div>
</div>
</div>
{% endblock %}
</body>
</html>

View File

@@ -1,18 +1,24 @@
{% extends "base.html" %}
{% block title %}{{ title }} — ЭВОСИНК{% endblock %}
{% block title %}{{ title }} — Мои Товары{% endblock %}
{% block page_title %}{{ title }}{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<article class="card mt-5 text-center">
<div class="card-body" style="padding: 2.5rem;">
<h1 style="font-size:1.3rem;" class="mb-3">{{ title }}</h1>
<p class="text-muted">{{ message }}</p>
{% if link %}
<a href="{{ link }}" role="button" class="mt-2">{{ link_text }}</a>
{% endif %}
</div>
</article>
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh;">
<div class="card" style="max-width:440px;width:100%;text-align:center;padding:40px 32px;">
<div style="font-size:48px;margin-bottom:16px;">
{% if 'ошибка' in title|lower or 'error' in title|lower %}
<i class="bi bi-x-circle" style="color:#E53935;"></i>
{% elif 'успешно' in title|lower or 'готово' in title|lower or 'подтвержден' in title|lower %}
<i class="bi bi-check-circle" style="color:#17A865;"></i>
{% else %}
<i class="bi bi-info-circle" style="color:#3B6FFF;"></i>
{% endif %}
</div>
<div class="pg-title" style="margin-bottom:8px;">{{ title }}</div>
<div style="font-size:13px;color:#5C6278;margin-bottom:20px;">{{ message }}</div>
{% if link %}
<a href="{{ link }}" class="btn btn-primary">{{ link_text }}</a>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -1,31 +1,31 @@
{% extends "base.html" %}
{% block title %}Изменить пароль — ЭВОСИНК{% endblock %}
{% block title %}Изменить пароль — Мои Товары{% endblock %}
{% block page_title %}Изменить пароль{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<article class="card mt-4">
<header>
<h1><i class="bi bi-key me-2"></i>Изменить пароль</h1>
</header>
<div class="card-body">
<form method="post" action="/profile/change-password">
<label for="current_password">Текущий пароль
<input type="password" id="current_password" name="current_password" required>
</label>
<label for="password">Новый пароль
<input type="password" id="password" name="password" required>
</label>
<label for="password_confirm">Подтвердить пароль
<input type="password" id="password_confirm" name="password_confirm" required>
</label>
<div class="d-flex gap-2">
<button type="submit">Изменить пароль</button>
<a href="/profile" role="button" class="outline secondary">Отмена</a>
</div>
</form>
</div>
</article>
<div class="pg-title">Изменить пароль</div>
<div class="pg-sub">Обновите пароль для входа в систему</div>
<div class="card" style="max-width:440px;">
<form method="post" action="/profile/change-password">
<div class="form-row">
<label class="form-lbl" for="current_password">Текущий пароль</label>
<input class="inp" type="password" id="current_password" name="current_password" required>
</div>
<div class="form-row">
<label class="form-lbl" for="password">Новый пароль</label>
<input class="inp" type="password" id="password" name="password" required>
</div>
<div class="form-row">
<label class="form-lbl" for="password_confirm">Подтвердить пароль</label>
<input class="inp" type="password" id="password_confirm" name="password_confirm" required>
</div>
<div style="display:flex;gap:8px;">
<button type="submit" class="btn btn-primary">
<i class="bi bi-key"></i> Изменить пароль
</button>
<a href="/profile" class="btn btn-outline">Отмена</a>
</div>
</form>
</div>
{% endblock %}

View File

@@ -1,31 +1,27 @@
{% extends "base.html" %}
{% block title %}Удалить аккаунт — ЭВОСИНК{% endblock %}
{% block title %}Удалить аккаунт — Мои Товары{% endblock %}
{% block page_title %}Удалить аккаунт{% endblock %}
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<article class="card mt-4" style="border-color: #dc2626;">
<header class="bg-danger-header">
<h1><i class="bi bi-trash me-2"></i>Удалить аккаунт</h1>
</header>
<div class="card-body">
<div role="alert" class="alert alert-warning mb-3">
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Внимание!</strong> Это действие необратимо. Все ваши данные будут удалены.
</div>
<form method="post" action="/profile/delete">
<label for="password">Введите пароль для подтверждения
<input type="password" id="password" name="password" required>
</label>
<div class="d-flex gap-2">
<button type="submit" class="danger">
<i class="bi bi-trash me-1"></i>Удалить мой аккаунт
</button>
<a href="/profile" role="button" class="outline secondary">Отмена</a>
</div>
</form>
</div>
</article>
<div class="pg-title">Удалить аккаунт</div>
<div class="pg-sub">Это действие необратимо</div>
<div class="card" style="max-width:440px;border-color:#F4AEAE;">
<div class="alert alert-yl" style="margin-bottom:18px;">
<span><i class="bi bi-exclamation-triangle"></i></span>
<div><strong>Внимание!</strong> Все ваши данные, подключения и история синхронизации будут удалены без возможности восстановления.</div>
</div>
<form method="post" action="/profile/delete">
<div class="form-row">
<label class="form-lbl" for="password">Введите пароль для подтверждения</label>
<input class="inp" type="password" id="password" name="password" required>
</div>
<div style="display:flex;gap:8px;">
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash"></i> Удалить мой аккаунт
</button>
<a href="/profile" class="btn btn-outline">Отмена</a>
</div>
</form>
</div>
{% endblock %}

View File

@@ -1,43 +1,40 @@
{% extends "base.html" %}
{% block title %}Редактировать профиль — ЭВОСИНК{% endblock %}
{% block title %}Редактировать профиль — Мои Товары{% endblock %}
{% block page_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-pencil me-2"></i>Редактировать профиль</h1>
</header>
<div class="card-body">
<form method="post" action="/profile/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="{{ form.first_name if form else user.first_name }}" required>
</label>
</div>
<div class="col">
<label for="last_name">Фамилия
<input type="text" id="last_name" name="last_name"
value="{{ form.last_name if form else user.last_name }}" required>
</label>
</div>
</div>
<label>Email
<input type="email" value="{{ user.email }}" disabled>
</label>
<label for="phone">Телефон
<input type="tel" id="phone" name="phone"
value="{{ form.phone if form else user.phone }}" required>
</label>
<div class="d-flex gap-2">
<button type="submit">Сохранить</button>
<a href="/profile" role="button" class="outline secondary">Отмена</a>
</div>
</form>
</div>
</article>
<div class="pg-title">Редактировать профиль</div>
<div class="pg-sub">Обновите ваши личные данные</div>
<div class="card" style="max-width:540px;">
<form method="post" action="/profile/edit">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div class="form-row">
<label class="form-lbl" for="first_name">Имя</label>
<input class="inp" type="text" id="first_name" name="first_name"
value="{{ form.first_name if form else user.first_name }}" required>
</div>
<div class="form-row">
<label class="form-lbl" for="last_name">Фамилия</label>
<input class="inp" type="text" id="last_name" name="last_name"
value="{{ form.last_name if form else user.last_name }}" required>
</div>
</div>
<div class="form-row">
<label class="form-lbl">Email</label>
<input class="inp" type="email" value="{{ user.email }}" disabled style="opacity:0.6;cursor:not-allowed;">
</div>
<div class="form-row">
<label class="form-lbl" for="phone">Телефон</label>
<input class="inp" type="tel" id="phone" name="phone"
value="{{ form.phone if form else user.phone }}" required>
</div>
<div style="display:flex;gap:8px;">
<button type="submit" class="btn btn-primary">
<i class="bi bi-save"></i> Сохранить
</button>
<a href="/profile" class="btn btn-outline">Отмена</a>
</div>
</form>
</div>
{% endblock %}

View File

@@ -1,86 +1,113 @@
{% extends "base.html" %}
{% block title %}Личный кабинет — ЭВОСИНК{% endblock %}
{% block title %}Профиль — Мои Товары{% endblock %}
{% block page_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-circle me-2"></i>Личный кабинет</h1>
</header>
<ul class="list-group">
<li class="list-group-item">
<span class="text-muted small">Имя</span>
<span>{{ user.first_name }}</span>
</li>
<li class="list-group-item">
<span class="text-muted small">Фамилия</span>
<span>{{ user.last_name }}</span>
</li>
<li class="list-group-item">
<span class="text-muted small">Email</span>
<span>
{{ user.email }}
{% if user.is_email_confirmed %}
<span class="badge badge-success ms-1"><i class="bi bi-check-circle"></i> подтверждён</span>
{% else %}
<span class="badge badge-warning ms-1"><i class="bi bi-exclamation-circle"></i> не подтверждён</span>
{% endif %}
</span>
</li>
<li class="list-group-item">
<span class="text-muted small">Телефон</span>
<span>{{ user.phone }}</span>
</li>
<li class="list-group-item">
<span class="text-muted small">Роль</span>
<span>
{% if user.role == 'system' %}<span class="badge badge-danger">Системный</span>
{% elif user.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 user.status == 'active' %}<span class="badge badge-success">Активен</span>
{% elif user.status == 'pending' %}<span class="badge badge-warning">Ожидает подтверждения</span>
{% else %}<span class="badge badge-danger">Заблокирован</span>
{% endif %}
</span>
</li>
{% if user.evotor_user_id %}
<li class="list-group-item">
<span class="text-muted small">Эвотор ID</span>
<span class="font-monospace small">{{ user.evotor_user_id }}</span>
</li>
{% endif %}
<li class="list-group-item">
<span class="text-muted small">Регистрация</span>
<span>{{ user.created_at | datefmt }}</span>
</li>
</ul>
<div class="card-body d-grid gap-2">
<a href="/profile/edit" role="button">
<i class="bi bi-pencil me-1"></i>Редактировать профиль
</a>
<a href="/profile/change-password" role="button" class="secondary">
<i class="bi bi-key me-1"></i>Изменить пароль
</a>
{% if not user.is_email_confirmed %}
<a href="/resend-confirm" role="button" class="outline secondary">
<i class="bi bi-envelope me-1"></i>Отправить письмо с подтверждением
</a>
{% endif %}
<a href="/logout" role="button" class="outline secondary">
<i class="bi bi-box-arrow-right me-1"></i>Выход
</a>
<a href="/profile/delete" role="button" class="outline danger sm mt-2">
<i class="bi bi-trash me-1"></i>Удалить аккаунт
</a>
</div>
</article>
<div class="pg-title">Личный кабинет</div>
<div class="pg-sub">Ваши данные и настройки аккаунта</div>
<div class="g2" style="align-items:start;">
<div class="card">
<div class="card-hd">
<div>
<div class="card-title">Данные профиля</div>
<div class="card-sub">Основная информация об аккаунте</div>
</div>
<div class="avatar {% if user.role in ('admin','system') %}admin{% endif %}" style="width:44px;height:44px;font-size:15px;">
{{ user.first_name[0] if user.first_name else '?' }}{{ user.last_name[0] if user.last_name else '' }}
</div>
</div>
<div class="conn-detail">
<div class="conn-row">
<span class="conn-k">Имя</span>
<span class="conn-v" style="font-family:'Golos Text',sans-serif;">{{ user.first_name }}</span>
</div>
<div class="conn-row">
<span class="conn-k">Фамилия</span>
<span class="conn-v" style="font-family:'Golos Text',sans-serif;">{{ user.last_name }}</span>
</div>
<div class="conn-row">
<span class="conn-k">Email</span>
<span class="conn-v" style="display:flex;align-items:center;gap:6px;">
{{ user.email }}
{% if user.is_email_confirmed %}
<span class="tag tag-gr" style="font-size:10px;padding:1px 6px;"><i class="bi bi-check-circle"></i> подтверждён</span>
{% else %}
<span class="tag tag-yl" style="font-size:10px;padding:1px 6px;"><i class="bi bi-exclamation-circle"></i> не подтверждён</span>
{% endif %}
</span>
</div>
<div class="conn-row">
<span class="conn-k">Телефон</span>
<span class="conn-v">{{ user.phone or '—' }}</span>
</div>
<div class="conn-row">
<span class="conn-k">Роль</span>
<span class="conn-v" style="font-family:'Golos Text',sans-serif;">
{% if user.role == 'system' %}
<span class="tag tag-rd">Системный</span>
{% elif user.role == 'admin' %}
<span class="tag tag-or">Администратор</span>
{% else %}
<span class="tag tag-dim">Пользователь</span>
{% endif %}
</span>
</div>
<div class="conn-row">
<span class="conn-k">Статус</span>
<span class="conn-v" style="font-family:'Golos Text',sans-serif;">
{% if user.status == 'active' %}
<span class="tag tag-gr"><span class="dot g"></span>Активен</span>
{% elif user.status == 'pending' %}
<span class="tag tag-yl"><span class="dot y pulse"></span>Ожидает</span>
{% else %}
<span class="tag tag-rd"><span class="dot r"></span>Заблокирован</span>
{% endif %}
</span>
</div>
{% if user.evotor_user_id %}
<div class="conn-row">
<span class="conn-k">Эвотор ID</span>
<span class="conn-v">{{ user.evotor_user_id }}</span>
</div>
{% endif %}
<div class="conn-row">
<span class="conn-k">Регистрация</span>
<span class="conn-v">{{ user.created_at | datefmt }}</span>
</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:12px;">
<div class="card">
<div class="card-title" style="margin-bottom:14px;">Действия</div>
<div style="display:flex;flex-direction:column;gap:8px;">
<a href="/profile/edit" class="btn btn-primary">
<i class="bi bi-pencil"></i> Редактировать профиль
</a>
<a href="/profile/change-password" class="btn btn-outline">
<i class="bi bi-key"></i> Изменить пароль
</a>
{% if not user.is_email_confirmed %}
<a href="/resend-confirm" class="btn btn-outline">
<i class="bi bi-envelope"></i> Отправить письмо с подтверждением
</a>
{% endif %}
<a href="/logout" class="btn btn-outline">
<i class="bi bi-box-arrow-right"></i> Выйти
</a>
</div>
</div>
<div class="card" style="border-color:#F4AEAE;">
<div class="card-title" style="color:#E53935;margin-bottom:10px;"><i class="bi bi-exclamation-triangle" style="margin-right:6px;"></i>Опасная зона</div>
<div class="card-sub" style="margin-bottom:12px;">Необратимые действия с аккаунтом</div>
<a href="/profile/delete" class="btn btn-danger btn-sm">
<i class="bi bi-trash"></i> Удалить аккаунт
</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,44 +1,83 @@
{% extends "base.html" %}
{% block title %}Регистрация — ЭВОСИНК{% endblock %}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Регистрация — Мои Товары</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Golos+Text:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="/static/style.css">
</head>
<body style="display:flex;align-items:center;justify-content:center;min-height:100vh;background:#F4F5F7;padding:24px;">
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-7 col-lg-6">
<article class="card mt-4">
<div class="card-body">
<h1 class="mb-4" style="font-size:1.3rem;">Регистрация</h1>
<form method="post" action="/register">
<div class="row gap-2 mb-2">
<div class="col">
<label for="first_name">Имя
<input type="text" id="first_name" name="first_name" value="{{ form.first_name if form else '' }}">
</label>
</div>
<div class="col">
<label for="last_name">Фамилия
<input type="text" id="last_name" name="last_name" value="{{ form.last_name if form else '' }}">
</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 '' }}" required>
</label>
<label for="phone">Телефон <span class="text-danger">*</span>
<input type="tel" id="phone" name="phone" value="{{ form.phone if form else '' }}" required>
</label>
<label for="password">Пароль <span class="text-danger">*</span>
<input type="password" id="password" name="password" required>
</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 class="text-center small mt-3">
<a href="/login">Уже есть аккаунт? Войти</a>
</div>
</div>
</article>
<div class="card" style="max-width:480px;width:100%;padding:32px;">
<div style="text-align:center;margin-bottom:24px;">
<div style="width:44px;height:44px;border-radius:11px;background:#FF5500;display:flex;align-items:center;justify-content:center;margin:0 auto 16px;">
<svg width="24" height="24" viewBox="0 0 28 28" fill="none">
<path d="M9 11h10l-1.5 9H10.5L9 11Z" fill="white" opacity="0.95"/>
<path d="M11.5 11V9a2.5 2.5 0 015 0v2" stroke="white" stroke-width="1.6" stroke-linecap="round" fill="none"/>
</svg>
</div>
<div class="login-box-title">Регистрация</div>
<div style="font-size:13px;color:#5C6278;margin-top:4px;">Создайте аккаунт для работы с Мои Товары</div>
</div>
{% if errors %}
<div class="alert alert-rd" style="margin-bottom:14px;">
<span><i class="bi bi-x-circle"></i></span>
<div>{% for e in errors %}<div>{{ e }}</div>{% endfor %}</div>
</div>
{% endif %}
<form method="post" action="/register">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
<div class="form-row">
<label class="form-lbl" for="first_name">Имя</label>
<input class="inp" type="text" id="first_name" name="first_name"
value="{{ form.first_name if form else '' }}">
</div>
<div class="form-row">
<label class="form-lbl" for="last_name">Фамилия</label>
<input class="inp" type="text" id="last_name" name="last_name"
value="{{ form.last_name if form else '' }}">
</div>
</div>
<div class="form-row">
<label class="form-lbl" for="email">Email <span style="color:#E53935;">*</span></label>
<input class="inp" type="email" id="email" name="email"
value="{{ form.email if form else '' }}" required>
</div>
<div class="form-row">
<label class="form-lbl" for="phone">Телефон <span style="color:#E53935;">*</span></label>
<input class="inp" type="tel" id="phone" name="phone"
value="{{ form.phone if form else '' }}" required>
</div>
<div class="form-row">
<label class="form-lbl" for="password">Пароль <span style="color:#E53935;">*</span></label>
<input class="inp" type="password" id="password" name="password" required>
</div>
<div class="form-row">
<label class="form-lbl" for="password_confirm">Подтверждение пароля <span style="color:#E53935;">*</span></label>
<input class="inp" type="password" id="password_confirm" name="password_confirm" required>
</div>
<button type="submit" class="login-btn">Зарегистрироваться</button>
</form>
<div class="login-hint" style="margin-top:14px;">
<a href="/login">Уже есть аккаунт? Войти</a>
</div>
</div>
{% endblock %}
<script src="https://cdn.jsdelivr.net/npm/inputmask@5.0.9/dist/inputmask.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
var phoneInputs = document.querySelectorAll('input[name="phone"]');
if (phoneInputs.length) {
Inputmask('+7 (999) 999-99-99', { placeholder: '_', showMaskOnHover: false, clearMaskOnLostFocus: false }).mask(phoneInputs);
}
});
</script>
</body>
</html>

View File

@@ -1,23 +1,45 @@
{% extends "base.html" %}
{% block title %}Новый пароль — ЭВОСИНК{% endblock %}
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Новый пароль — Мои Товары</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Golos+Text:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="/static/style.css">
</head>
<body style="display:flex;align-items:center;justify-content:center;min-height:100vh;background:#F4F5F7;">
{% block content %}
<div class="row justify-center">
<div class="col-sm-10 col-md-6 col-lg-5">
<article class="card mt-4">
<div class="card-body">
<h1 style="font-size:1.3rem;" class="mb-4">Новый пароль</h1>
<form method="post" action="/reset-password?token={{ token }}">
<label for="password">Новый пароль
<input type="password" id="password" name="password" required>
</label>
<label for="password_confirm">Подтверждение пароля
<input type="password" id="password_confirm" name="password_confirm" required>
</label>
<button type="submit" class="w-100">Сменить пароль</button>
</form>
</div>
</article>
<div class="card" style="max-width:400px;width:100%;padding:32px;">
<div style="text-align:center;margin-bottom:24px;">
<div style="width:44px;height:44px;border-radius:11px;background:#FF5500;display:flex;align-items:center;justify-content:center;margin:0 auto 16px;">
<i class="bi bi-key" style="color:#fff;font-size:20px;"></i>
</div>
<div class="login-box-title">Новый пароль</div>
<div style="font-size:13px;color:#5C6278;margin-top:4px;">Введите и подтвердите новый пароль</div>
</div>
{% if errors %}
<div class="alert alert-rd" style="margin-bottom:14px;">
<span><i class="bi bi-x-circle"></i></span>
<div>{% for e in errors %}<div>{{ e }}</div>{% endfor %}</div>
</div>
{% endif %}
<form method="post" action="/reset-password?token={{ token }}">
<div class="form-row">
<label class="form-lbl" for="password">Новый пароль</label>
<input class="inp" type="password" id="password" name="password" required>
</div>
<div class="form-row">
<label class="form-lbl" for="password_confirm">Подтверждение пароля</label>
<input class="inp" type="password" id="password_confirm" name="password_confirm" required>
</div>
<button type="submit" class="login-btn">Сменить пароль</button>
</form>
</div>
{% endblock %}
</body>
</html>

102
web/templates/sync.html Normal file
View File

@@ -0,0 +1,102 @@
{% extends "base.html" %}
{% block title %}Синхронизация — Мои Товары{% endblock %}
{% block page_title %}Синхронизация{% endblock %}
{% block content %}
<div class="pg-title">Синхронизация</div>
<div class="pg-sub">Настройка и управление синхронизацией товаров Эвотор → VK Market</div>
{% if saved %}
<div class="alert alert-gr">
<span><i class="bi bi-check-circle"></i></span>
<div>Настройки сохранены.</div>
</div>
{% endif %}
<form method="post" action="/sync/settings">
<div class="g2" style="align-items:start; margin-bottom:16px;">
<div class="card">
<div class="card-hd">
<div>
<div class="card-title">Фоновые задачи</div>
<div class="card-sub">Включайте поочерёдно: сначала Эвотор, затем ВК, затем синхронизацию</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:0;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 0;border-bottom:1px solid #E4E6EE;">
<div>
<div style="font-size:13px;font-weight:600;color:#1C1F2E;">Зеркало Эвотор</div>
<div style="font-size:12px;color:#9EA8BE;margin-top:2px;">Импортирует товары, группы и магазины из Эвотор в локальную базу</div>
</div>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;margin:0;">
<input type="hidden" name="evo_mirror_enabled" value="0">
<input type="checkbox" name="evo_mirror_enabled" value="1"
{% if config and config.evo_mirror_enabled %}checked{% endif %}
id="evo_mirror_cb" style="display:none;">
<div class="tog {% if config and config.evo_mirror_enabled %}on{% endif %}"
onclick="this.previousElementSibling.click(); this.classList.toggle('on')"
id="evo_mirror_tog"></div>
</label>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 0;border-bottom:1px solid #E4E6EE;">
<div>
<div style="font-size:13px;font-weight:600;color:#1C1F2E;">Зеркало ВК</div>
<div style="font-size:12px;color:#9EA8BE;margin-top:2px;">Импортирует альбомы и товары из VK Market в локальный кэш</div>
</div>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;margin:0;">
<input type="hidden" name="vk_mirror_enabled" value="0">
<input type="checkbox" name="vk_mirror_enabled" value="1"
{% if config and config.vk_mirror_enabled %}checked{% endif %}
id="vk_mirror_cb" style="display:none;">
<div class="tog {% if config and config.vk_mirror_enabled %}on{% endif %}"
onclick="this.previousElementSibling.click(); this.classList.toggle('on')"></div>
</label>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 0;">
<div>
<div style="font-size:13px;font-weight:600;color:#1C1F2E;">Синхронизация</div>
<div style="font-size:12px;color:#9EA8BE;margin-top:2px;">Зеркалит каталог Эвотор в VK Market: создаёт, обновляет и удаляет товары</div>
</div>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;margin:0;">
<input type="hidden" name="is_enabled" value="0">
<input type="checkbox" name="is_enabled" value="1"
{% if config and config.is_enabled %}checked{% endif %}
id="sync_cb" style="display:none;">
<div class="tog {% if config and config.is_enabled %}on{% endif %}"
onclick="this.previousElementSibling.click(); this.classList.toggle('on')"></div>
</label>
</div>
</div>
</div>
<div class="card">
<div class="card-hd">
<div>
<div class="card-title">Настройки цены</div>
<div class="card-sub">Трансформация цен при передаче в VK Market</div>
</div>
</div>
<div class="form-row">
<label class="form-lbl">Множитель цены</label>
<input class="inp" type="number" name="price_multiplier" step="0.0001" min="0.0001"
value="{{ config.price_multiplier if config else '1' }}"
placeholder="1" style="max-width:160px;">
<div style="font-size:11px;color:#9EA8BE;margin-top:5px;">Цена из Эвотор умножается на это значение перед отправкой в ВК. По умолчанию: 1.</div>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-save"></i> Сохранить настройки
</button>
</form>
{% endblock %}

View File

@@ -0,0 +1,53 @@
{% extends "base.html" %}
{% block title %}Альбомы ВК — Мои Товары{% endblock %}
{% block page_title %}Каталог ВКонтакте{% endblock %}
{% block content %}
<div class="pg-title">Каталог ВКонтакте — Альбомы</div>
<div class="pg-sub">Альбомы из VK Market · Всего: {{ albums | length }}</div>
<div class="card" style="padding:0;">
{% if not vk_conn %}
<div class="empty-state">
<i class="bi bi-plug"></i>
<p>ВКонтакте не подключён.<br><a href="/connections" style="color:#FF5500;">Перейти к подключениям</a></p>
</div>
{% elif albums %}
<div class="table-wrap">
<table class="tbl">
<thead>
<tr>
<th>Название</th>
<th>Товаров</th>
<th>ID</th>
<th>Обновлено</th>
<th></th>
</tr>
</thead>
<tbody>
{% for a in albums %}
<tr>
<td>
<div class="tbl-name"><i class="bi bi-collection" style="color:#9EA8BE;margin-right:6px;"></i>{{ a.title }}</div>
</td>
<td><span class="mono" style="font-size:12px;">{{ a.count if a.count is not none else '—' }}</span></td>
<td><span class="mono" style="font-size:11px;color:#9EA8BE;">{{ a.album_id }}</span></td>
<td><span class="mono" style="font-size:11px;color:#9EA8BE;">{{ a.fetched_at | datefmt }}</span></td>
<td>
<a href="/vk-catalog/albums/{{ a.album_id }}/products" class="btn btn-outline btn-xs">
<i class="bi bi-box-seam"></i> Товары
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<i class="bi bi-collection"></i>
<p>Альбомы ещё не загружены.<br>Синхронизация выполняется каждые {{ refresh_interval }} сек. автоматически.</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,72 @@
{% extends "base.html" %}
{% block title %}Товары ВК — {{ album.title }} — Мои Товары{% endblock %}
{% block page_title %}Каталог ВКонтакте{% endblock %}
{% block content %}
<ol class="breadcrumb">
<li><a href="/vk-catalog/albums">Альбомы ВК</a></li>
<li>{{ album.title }}</li>
</ol>
<div class="pg-title">{{ album.title }}</div>
<div class="pg-sub">Товары из VK Market · Всего: {{ products | length }}</div>
<div class="card" style="padding:0;">
{% if products %}
<div class="table-wrap">
<table class="tbl">
<thead>
<tr>
<th style="width:56px;"></th>
<th>Название</th>
<th>Цена</th>
<th>Статус</th>
<th>ID</th>
<th>Обновлено</th>
</tr>
</thead>
<tbody>
{% for p in products %}
<tr>
<td>
{% if p.thumb_url %}
<img src="{{ p.thumb_url }}" alt="" style="width:40px;height:40px;object-fit:cover;border-radius:6px;">
{% else %}
<div style="width:40px;height:40px;border-radius:6px;background:#F4F5F7;display:flex;align-items:center;justify-content:center;color:#9EA8BE;">
<i class="bi bi-image"></i>
</div>
{% endif %}
</td>
<td>
<div class="tbl-name">{{ p.name }}</div>
{% if p.description %}
<div class="tbl-sub">{{ p.description[:80] }}{% if p.description|length > 80 %}…{% endif %}</div>
{% endif %}
</td>
<td><span class="mono">{% if p.price is not none %}{{ p.price | price }}{% else %}—{% endif %}</span></td>
<td>
{% if p.availability == 0 %}
<span class="tag tag-gr"><span class="dot g"></span>В наличии</span>
{% elif p.availability == 1 %}
<span class="tag tag-dim"><span class="dot d"></span>Удалён</span>
{% elif p.availability == 2 %}
<span class="tag tag-yl"><span class="dot y"></span>Недоступен</span>
{% else %}
<span style="color:#9EA8BE;font-size:12px;"></span>
{% endif %}
</td>
<td><span class="mono" style="font-size:11px;color:#9EA8BE;">{{ p.vk_product_id }}</span></td>
<td><span class="mono" style="font-size:11px;color:#9EA8BE;">{{ p.fetched_at | datefmt }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<i class="bi bi-box-seam"></i>
<p>Товары в этом альбоме не найдены.</p>
</div>
{% endif %}
</div>
{% endblock %}