66 Commits

Author SHA1 Message Date
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
mguschin
fc65e591b3 test: add test suite with 65 tests, 73% coverage
- Unit tests: password hashing, notification providers, webhook field parsing
- Integration tests: auth routes (register/login/confirm-email/logout),
  invite flow, Evotor webhooks (/user/create, /user/verify, /user/token),
  admin panel (access control, activate/suspend/delete/reset-password)
- conftest: SQLite in-memory engine, transactional sessions, factory-boy
  factories (UserFactory with UserRoleEnum variants)
- Fix bcrypt: replace passlib (broken on Python 3.14 + bcrypt 5.x) with
  direct bcrypt calls; drop passlib from requirements.txt
- Fix datetime.utcnow() deprecation across routes and tests
- Fix Jinja2 TemplateResponse signature (request as first positional arg)
- Add coverage config to pyproject.toml

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 12:27:42 +03:00
mguschin
5ead89e0cf feat: Evotor user lifecycle, RBAC, admin panel
- Receive Evotor webhooks: POST /user/create, /user/verify, /user/token
- Create users in pending status; match to existing users by email/phone
- Send invite link via Celery notification task; user sets password at /invite
- Abstract EmailProvider/SMSProvider with ConsoleEmailProvider default
- Role-based access control: role enum on users + roles/permissions tables
- Admin panel: /admin/users (list, filter, search, paginate), user detail card
  with activate/suspend/reset-password/send-invite/edit/delete actions
- Admin roles management: /admin/roles with per-role permission assignment
- Extend user profile card: role, status, Evotor ID, email confirmation badge
- Auth routes: register, login, logout, confirm-email, forgot/reset password
- Alembic migrations 0002 (full schema + new fields) and 0003 (RBAC + seeds)
- Port Pico CSS + Bootstrap Icons UI from Node.js commit (854c912)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 12:01:36 +03:00
ba34adbbcf docs: add implementation plan for Evotor user lifecycle + RBAC + admin panel 2026-04-28 11:46:49 +03:00
mguschin
2df4898098 Ignore web-resources directory
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 23:08:08 +03:00
mguschin
15a362ca42 Add EvoSync v3 environment scaffold
FastAPI + Celery + Redis + MariaDB stack with 6-service docker-compose.
Includes project skeleton (config, database, models, tasks, migrations)
and health endpoint with passing test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 23:04:50 +03:00
mguschin
049e82654d revert v2. 2026-03-27 15:42:52 +03:00
mguschin
854c912a88 Migrate web app from Python/FastAPI to Node.js/TypeScript
Replace the entire Python/FastAPI backend with a Node.js/TypeScript stack:
- Framework: Hono + @hono/node-server
- Templates: Nunjucks (.njk) replacing Jinja2 (.html)
- ORM: Drizzle ORM with mysql2 (same MariaDB schema, no migrations needed)
- Sessions: hono-sessions with CookieStore
- CSS: Pico CSS v2 replacing Bootstrap 5 (Bootstrap Icons CDN kept)
- Dev: tsx watch; Prod: tsc + node dist/index.js

Original Python app preserved in web-python/ as backup.
Updated Dockerfile.web and docker-compose.yml for Node.js deployment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 19:33:32 +03:00
mguschin
db0c1cbed3 Release version 1.9.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 17:21:24 +03:00
mguschin
fd7d0022ea Add VK OAuth implicit flow and fix sync issues
- Replace manual community token entry with OAuth button that redirects
  to VK authorization and auto-saves token via /vk/callback
- Fix groups.get API call (was groups.getById) to correctly retrieve
  admin group id and name from user token response
- Fix price comparison: VK price.amount is in roubles, not kopecks
- Keep manual token input as fallback when VK_CLIENT_ID is not set

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 17:21:16 +03:00
mguschin
1bf82adbfc Add sync engine and wire it into the web app
- Add sync_engine.py: background asyncio loop syncing Evotor products to VK market
- Wire sync_loop into lifespan alongside health_check_loop
- Add SYNC_INTERVAL_SECONDS and VK_DEFAULT_PHOTO_PATH settings to config
- Mount default product image in docker-compose
- Add synced_at column to CachedProduct model + migration
- Show synced_at status in catalog products template
- Fix VK groups API response parsing (handle list vs dict)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 17:05:37 +03:00
mguschin
9a68c083e3 Update README with comprehensive project documentation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 17:02:14 +03:00
mguschin
aaeaa4f658 Fix product page dropdown clipped by table-responsive overflow
Set overflow: visible on table-responsive and use data-bs-strategy="fixed"
so the filter dropdown renders outside the scroll container.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 15:57:43 +03:00
mguschin
aea28ead9c Fix VK connect_url to point to /vk instead of /vk/connect
/vk/connect no longer exists after switching to manual token entry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 15:40:39 +03:00
mguschin
cde2069d74 Remove unused VK OAuth env vars (VK_CLIENT_ID/SECRET/SCOPES)
VK connection now uses manual community token entry, so OAuth credentials
are no longer needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 15:35:53 +03:00
mguschin
debb2efb3d Replace VK OAuth with manual community token entry
Resolves #4 — VK OAuth flow caused "Security Error" because market sync
requires a community access token, not a personal user token. Replaced
OAuth with manual token input (same pattern as Evotor). Added
step-by-step instructions. Updated health checker to validate community
tokens via groups.getById instead of users.get.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 15:32:13 +03:00
mguschin
4d4d5b0118 Add Jivosite live chat widget support
Resolves #3 — widget is loaded on every page via base.html when
JIVOSITE_WIDGET_ID env var is set. Centralized Jinja2Templates instance
in web/templates_env.py with jivosite_widget_id as a global.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 14:11:25 +03:00
mguschin
00b74b8aa9 Simplify Evotor connect to manual token entry only
Resolves #2 — removes semi-automatic OAuth flow (Переподключить button,
/evotor/connect and /evotor/link routes) and makes manual token entry
the sole connect option. Adds step-by-step instructions with a direct
link to the app on Evotor marketplace (opens in new tab).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 13:01:12 +03:00
mguschin
577c5de200 Add background catalog cache refresh to health check loop
Resolves #1 — the health checker now refreshes catalog cache for all
online Evotor connections when cache is missing or older than
CATALOG_REFRESH_INTERVAL_SECONDS (default: 3600s).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 12:53:44 +03:00
mguschin
0926757b7a Fix dropdown clipping using fixed positioning strategy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 18:17:25 +03:00
mguschin
13c32e9181 Fix dropdown clipping in product table using data-bs-boundary
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 18:16:33 +03:00
mguschin
6b9eb562ba Fix dropdown clipping in product table by allowing overflow
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 18:13:07 +03:00
mguschin
9558333c94 Handle 402 Payment Required from Evotor API gracefully
Return empty list for groups/products when Evotor returns 402,
instead of crashing the refresh with an unhandled HTTP error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 18:04:13 +03:00
mguschin
40e7abd012 Fix typo and redirect connections/add to evotor page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 17:39:31 +03:00
mguschin
3d7a456299 Add manual token entry for Evotor connection
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 17:35:17 +03:00
mguschin
5acf597944 Redirect to app page on Evotor market instead of generic market URL
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 17:33:00 +03:00
mguschin
3f4bbcbb0d Fix alter_column missing existing_type for MySQL
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 17:26:32 +03:00
mguschin
c8beeaf1b1 Fix migration to skip already-existing evotor_user_id column/indexes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 17:25:36 +03:00
mguschin
5ee8419c7c Replace EVOTOR_CLIENT_ID/SECRET with EVOTOR_APP_ID/WEBHOOK_SECRET in config
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 17:23:50 +03:00
mguschin
e376c86fbe Switch Evotor integration to webhook-based token delivery flow
Replace OAuth 2.0 authorization code flow with Evotor's proprietary
webhook token delivery: POST /evotor/callback receives token server-to-server,
GET /evotor/link links it to the logged-in user's account.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 17:18:25 +03:00
mguschin
69e21a18c9 fix docker compose. 2026-03-09 16:47:35 +03:00
mguschin
90a2f7be1f fix docker compose. 2026-03-09 16:41:59 +03:00
mguschin
d4633a0f46 Update nginx conf. 2026-03-09 16:41:09 +03:00
mguschin
b72b0e78b0 Nginx upstream. 2026-03-09 16:23:59 +03:00
mguschin
2a04099f95 Fix tls script. 2026-03-09 16:11:03 +03:00
mguschin
58f9b74a1c Change default port. 2026-03-09 15:54:19 +03:00
mguschin
8c9c328302 chore(release): v1.8.2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 18:41:43 +03:00
126 changed files with 8409 additions and 3805 deletions

View File

@@ -1,8 +1,18 @@
DATABASE_URL=mysql+pymysql://evosync:evosync@db:3306/evosync
SECRET_KEY=your-random-secret-key-here
BASE_URL=http://localhost:8000
DB_ROOT_PASSWORD=rootpass
# Database
DB_ROOT_PASSWORD=rootpassword
DB_NAME=evosync
DB_USER=evosync
DB_PASSWORD=evosync
# App
SECRET_KEY=change-me-in-production
DOMAIN=yourdomain.com
BASE_URL=https://${DOMAIN}
# Evotor
EVOTOR_APP_ID=
EVOTOR_WEBHOOK_SECRET=
# Celery Flower
FLOWER_USER=admin
FLOWER_PASSWORD=changeme

4
.gitignore vendored
View File

@@ -16,3 +16,7 @@ passwords.txt
.env
__pycache__/
*.pyc
certbot
web-resources
.coverage
password|*

View File

@@ -1,65 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.8.0] - 2026-03-06
### Added
- Connections dashboard (`/connections`) — unified page for all service integrations with online/offline status indicators
- Add-connection page (`/connections/add`) — shows only unconnected services; "all connected" state when none remain
- VK OAuth connection — connect VK account via OAuth, store access token and profile info
- Background health checker — runs every 10 minutes, checks Evotor and VK tokens, updates `is_online` / `last_checked_at`
- Sync configuration page (`/sync`) — master enable/disable toggle, confirm-and-start button, filter summary, warnings for missing connections
- Catalog browser (`/catalog`) — browse Evotor stores, groups, and products in table views with cache auto-refresh on first visit
- Catalog filter management — include/exclude rules per store/group/product via inline dropdown; rules stored in `sync_filters` table
- Catalog CSV export — download stores, groups, or products as UTF-8 BOM CSV (Excel-compatible)
- Alembic migrations for all new tables: `evotor_connections` (health fields), `vk_connections`, `sync_configs`, `sync_filters`, `cached_stores`, `cached_groups`, `cached_products`
- `run/read_config.py` — CLI helper for shell sync scripts to read per-user sync config as JSON
### Changed
- Navbar: replaced "Эвотор" link with "Подключения", added "Каталог" and "Синхронизация" links
- Evotor and VK connect/disconnect flows now redirect to `/connections`
- Back links on `/evotor` and `/vk` pages updated to "Вернуться к подключениям"
- VK connection card icon changed to `bi-bag` (shopping bag) to reflect VK Market use case
- Password reset and email confirmation pages: replaced dev-mode console instructions with user-facing copy
## [1.7.3] - 2026-03-06
### Added
- Nginx reverse proxy configuration — SSL termination, HTTP→HTTPS redirect, proxy to uvicorn
- `scripts/init-letsencrypt.sh` — automated Let's Encrypt certificate provisioning via certbot webroot challenge
- Request logging for Evotor token exchange errors to aid debugging
### Fixed
- Evotor OAuth token exchange — move `client_id` and `client_secret` from HTTP Basic Auth to form body fields (resolves `invalid_client` errors)
- Docker environment — pass `EVOTOR_CLIENT_ID` and `EVOTOR_CLIENT_SECRET` to web container via docker-compose
### Changed
- Default `BASE_URL` changed to `https://evosync.ru` for production deployment
- `docker-compose.yml` — web service now exposes port 8000 internally only (accessed via nginx)
- Added `refresh_token` field to `EvotorConnection` model for future token refresh logic
## [1.7.2] - 2026-03-05
### Other
- Add user registration and auth web app
- Update docker-compose.yml: remove database service, adjust ports and host
- Integrate Bootstrap 5 and Bootstrap Icons into UI
## [1.0.0] - 2026-02-02
### Other
- Initial commit
- V1.

View File

@@ -1,13 +1,16 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install --upgrade pip && pip install -r requirements.txt
COPY web/ ./web/
COPY alembic.ini .
COPY docker-entrypoint.sh .
RUN chmod +x docker-entrypoint.sh
COPY . .
CMD ["./docker-entrypoint.sh"]
CMD ["uvicorn", "web.main:app", "--host", "0.0.0.0", "--port", "8000"]

289
README.md
View File

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

View File

@@ -3,11 +3,6 @@ script_location = web/migrations
prepend_sys_path = .
version_path_separator = os
# URL is set dynamically in env.py from DATABASE_URL env var
sqlalchemy.url =
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic

View File

@@ -1,45 +0,0 @@
[changelog]
header = """# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n
"""
body = """
{% if version %}\
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [Unreleased]
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim | upper_first }}
{% for commit in commits %}
- {% if commit.scope %}**{{ commit.scope }}**: {% endif %}\
{{ commit.message | split(pat="\n") | first | upper_first }}\
{% endfor %}
{% endfor %}\n
"""
trim = true
footer = ""
[git]
conventional_commits = true
filter_unconventional = false
split_commits = false
commit_parsers = [
{ message = "^feat", group = "Added" },
{ message = "^fix", group = "Fixed" },
{ message = "^doc", group = "Documentation" },
{ message = "^perf", group = "Performance" },
{ message = "^refactor", group = "Changed" },
{ message = "^style", group = "Styling" },
{ message = "^test", group = "Testing" },
{ message = "^chore\\(release\\)", skip = true },
{ message = "^chore", group = "Miscellaneous" },
{ message = "^ci", group = "CI/CD" },
{ body = ".*security", group = "Security" },
{ message = ".*", group = "Other" },
]
filter_commits = false
tag_pattern = "v[0-9].*"

View File

@@ -1,32 +1,113 @@
version: "3.8"
services:
db:
image: mariadb:11.4
restart: unless-stopped
command: --innodb-buffer-pool-size=128M --max-connections=20
environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
volumes:
- db_data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --save 60 1 --loglevel warning
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
web:
build:
context: .
dockerfile: Dockerfile.web
expose:
- "8000"
environment:
- DATABASE_URL=mysql+pymysql://${DB_USER}:${DB_PASSWORD}@172.25.0.1:3306/${DB_NAME}
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}
- BASE_URL=${BASE_URL:-https://evosync.ru}
- EVOTOR_CLIENT_ID=${EVOTOR_CLIENT_ID}
- EVOTOR_CLIENT_SECRET=${EVOTOR_CLIENT_SECRET}
- VK_CLIENT_ID=${VK_CLIENT_ID}
- VK_CLIENT_SECRET=${VK_CLIENT_SECRET}
volumes:
- ./web:/app/web
- ./alembic.ini:/app/alembic.ini
- ./docker-entrypoint.sh:/app/docker-entrypoint.sh
restart: unless-stopped
ports:
- "8080:8000"
environment:
DATABASE_URL: mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db:3306/${DB_NAME}
REDIS_URL: redis://redis:6379/0
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
BASE_URL: ${BASE_URL:-https://${DOMAIN}}
EVOTOR_APP_ID: ${EVOTOR_APP_ID:-}
EVOTOR_WEBHOOK_SECRET: ${EVOTOR_WEBHOOK_SECRET:-}
JIVOSITE_WIDGET_ID: ${JIVOSITE_WIDGET_ID:-}
VK_DEFAULT_PHOTO_PATH: /app/default_product.png
volumes:
- ./5393364294319597854.png:/app/default_product.png:ro
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
command: >
sh -c "alembic upgrade head && uvicorn web.main:app --host 0.0.0.0 --port 8000"
# sync:
# build:
# context: .
# dockerfile: Dockerfile
# volumes:
# - ./evo:/var/www/evo
# - ./vk:/var/www/vk
# - ./run:/var/www/run
# - ./logs:/var/www/logs
worker:
build:
context: .
dockerfile: Dockerfile.web
restart: unless-stopped
environment:
DATABASE_URL: mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db:3306/${DB_NAME}
REDIS_URL: redis://redis:6379/0
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
EVOTOR_APP_ID: ${EVOTOR_APP_ID:-}
EVOTOR_WEBHOOK_SECRET: ${EVOTOR_WEBHOOK_SECRET:-}
VK_DEFAULT_PHOTO_PATH: /app/default_product.png
volumes:
- ./5393364294319597854.png:/app/default_product.png:ro
depends_on:
redis:
condition: service_healthy
db:
condition: service_healthy
command: celery -A web.tasks.celery_app worker --loglevel=info --concurrency=1 --queues=default,sync,health,notifications -E
beat:
build:
context: .
dockerfile: Dockerfile.web
restart: unless-stopped
environment:
DATABASE_URL: mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db:3306/${DB_NAME}
REDIS_URL: redis://redis:6379/0
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
CATALOG_REFRESH_INTERVAL_SECONDS: ${CATALOG_REFRESH_INTERVAL_SECONDS:-3600}
depends_on:
redis:
condition: service_healthy
db:
condition: service_healthy
command: celery -A web.tasks.celery_app beat --loglevel=info --scheduler celery.beat:PersistentScheduler --schedule /tmp/celerybeat-schedule
flower:
build:
context: .
dockerfile: Dockerfile.web
restart: unless-stopped
profiles: [flower]
ports:
- "5555:5555"
environment:
REDIS_URL: redis://redis:6379/0
FLOWER_BASIC_AUTH: ${FLOWER_USER:-admin}:${FLOWER_PASSWORD:-changeme}
depends_on:
- redis
command: celery -A web.tasks.celery_app flower --port=5555 --basic_auth=${FLOWER_USER:-admin}:${FLOWER_PASSWORD:-changeme}
volumes:
db_data:
redis_data:

View File

@@ -0,0 +1,296 @@
# Plan: Evotor User Lifecycle + RBAC + Admin Panel
## Context
Evotor (Russian POS ecosystem) sends webhook calls to register and verify users who buy the EvoSync app on their marketplace. Currently the web app has no webhook receivers for user lifecycle events, no role-based access control, and no admin panel. We need to:
1. Receive Evotor user webhooks (`/user/create`, `/user/verify`, `/user/token`)
2. Create users in `pending` status, link them to a local account, send an invite to set password
3. Abstract email/SMS providers (no concrete provider chosen yet — console output for now)
4. Add role-based access control (system / admin / user) with a full roles+permissions table structure
5. Build an admin panel for user management
6. Extend the user profile card with role/status/Evotor metadata
**Target:** Python/FastAPI on current HEAD (`15a362c`). The v3 scaffold is minimal — `main.py` is a bare FastAPI app, `web/models/` is empty, `web/tasks/celery_app.py` is a stub.
**Reference:** The Node.js commit `854c912` has all existing features (auth, profile, catalog, sync) — use it as the schema and UI reference for porting.
---
## Architecture Decisions
- **Session:** Starlette `SessionMiddleware` (already a FastAPI dep) — `request.session["user_id"]` cookie, mirroring Node.js pattern. No extra package.
- **RBAC:** `role` enum directly on `users` table (fast path in middleware) **plus** `roles` / `permissions` / `user_roles` / `role_permissions` tables for fine-grained, admin-configurable permissions.
- **Evotor app token:** Store a random UUID token in `evotor_connections.api_token` (new column). No `itsdangerous` dependency needed. Return this token in webhook responses.
- **`password_hash` nullable:** Users arriving via `/user/create` have no password yet; they set it via invite link. Migration must `ALTER COLUMN` to allow NULL.
- **Notifications:** Celery tasks on a new `notifications` queue → abstract `EmailProvider`/`SMSProvider``ConsoleEmailProvider`/`ConsoleSMSProvider` by default.
- **Migrations:** Three incremental migrations. 0002 creates the full schema (conditionally, since live DB may have Node.js tables). 0003 creates RBAC tables + seeds default roles/permissions. 0004 is optional seed for a system user.
---
## File Structure
```
web/
├── main.py — add SessionMiddleware, Jinja2, static, router includes
├── config.py — add INVITE_EXPIRE_HOURS, EMAIL_PROVIDER, SMS_PROVIDER
├── database.py — add get_db() dependency (sync Session)
├── templates_env.py — NEW: Jinja2Templates singleton with datefmt/price filters
├── models/
│ ├── __init__.py — import all models (for Alembic autogenerate)
│ ├── user.py — NEW: User, UserRoleEnum, UserStatusEnum
│ ├── rbac.py — NEW: Role, Permission, role_permissions, UserRole
│ └── connections.py — NEW: EvotorConnection, VkConnection, SyncConfig, etc.
├── auth/
│ ├── __init__.py
│ ├── session.py — NEW: get_current_user() helper, session utils
│ ├── password.py — NEW: hash_password(), verify_password() via passlib
│ └── rbac.py — NEW: require_role(), require_permission() FastAPI deps
├── notifications/
│ ├── __init__.py
│ ├── base.py — NEW: abstract EmailProvider, SMSProvider
│ ├── console.py — NEW: ConsoleEmailProvider, ConsoleSMSProvider
│ ├── registry.py — NEW: get_email_provider(), get_sms_provider() factories
│ └── tasks.py — NEW: send_email_task, send_sms_task Celery tasks
├── routes/
│ ├── __init__.py
│ ├── auth.py — NEW: register, login, logout, confirm-email
│ ├── reset.py — NEW: forgot-password, reset-password
│ ├── invite.py — NEW: GET/POST /invite (Evotor invite flow)
│ ├── profile.py — NEW: /profile, /profile/edit, change-password, delete
│ ├── connections.py — NEW: /connections (port from Node.js)
│ ├── evotor.py — NEW: /evotor UI + /evotor/token (port from Node.js)
│ ├── evotor_webhooks.py — NEW: POST /user/create, /user/verify, /user/token
│ ├── vk.py — NEW: /vk + /vk/callback (port from Node.js)
│ ├── catalog.py — NEW: /catalog (port from Node.js)
│ ├── sync.py — NEW: /sync (port from Node.js)
│ └── admin.py — NEW: /admin/* panel
├── tasks/
│ ├── celery_app.py — add notifications queue + route
│ ├── sync.py — NEW: sync Celery tasks
│ └── health.py — NEW: health-check tasks
├── templates/
│ ├── base.html — port base.njk; nav shows Admin link if role=admin/system
│ ├── login.html, register.html, confirm_email.html, email_confirmed.html
│ ├── forgot_password.html, reset_password.html
│ ├── invite.html — NEW: set-password form for invite flow
│ ├── message.html, profile_view.html, profile_edit.html
│ ├── profile_change_password.html, profile_delete.html
│ ├── connections.html, connections_add.html
│ ├── evotor.html, vk.html, vk_callback.html
│ ├── catalog_stores.html, catalog_groups.html, catalog_products.html
│ ├── sync.html
│ └── admin/
│ ├── users.html — paginated user list with filters
│ ├── user_detail.html — user card + Evotor meta JSON + action buttons
│ └── roles.html — roles and permission assignment
├── static/
│ └── style.css — port from 854c912:web/static/style.css
└── migrations/versions/
├── 0001_initial.py — exists (empty placeholder)
├── 0002_users_and_connections.py — NEW: full schema + new user fields
├── 0003_rbac_tables.py — NEW: RBAC tables + seed roles/permissions
└── 0004_seed_system_user.py — NEW (optional): seed system user from env vars
```
---
## DB Schema Additions
### `web/models/user.py`
```python
class UserRoleEnum(str, enum.Enum):
system = "system"
admin = "admin"
user = "user"
class UserStatusEnum(str, enum.Enum):
pending = "pending"
active = "active"
suspended = "suspended"
class User(Base):
__tablename__ = "users"
# existing fields (port from 854c912 schema.ts)
id, first_name, last_name, email, phone
password_hash = Column(String(255), nullable=True) # nullable: invite flow
is_email_confirmed, email_confirm_token
password_reset_token, password_reset_expires
created_at, updated_at
# new fields
role = Column(Enum(UserRoleEnum), default=UserRoleEnum.user, index=True)
status = Column(Enum(UserStatusEnum), default=UserStatusEnum.pending, index=True)
evotor_user_id = Column(String(255), unique=True, nullable=True)
evotor_meta = Column(JSON, nullable=True)
invite_token = Column(String(255), nullable=True)
invite_expires = Column(DateTime, nullable=True)
phone_otp = Column(String(10), nullable=True)
phone_otp_expires = Column(DateTime, nullable=True)
```
### `web/models/connections.py`
Port all tables from `854c912:web/src/db/schema.ts` verbatim:
`evotor_connections` (add `api_token varchar(255)` column for our app token), `vk_connections`, `sync_configs`, `sync_filters`, `cached_stores`, `cached_groups`, `cached_products`.
### `web/models/rbac.py`
```python
class Role(Base): # name: system/admin/user
class Permission(Base): # name: e.g. "admin.users.edit"
role_permissions = Table(...) # join table Role ↔ Permission
class UserRole(Base): # join table User ↔ Role (fine-grained assignment)
```
---
## Route List
### Evotor Webhooks (`web/routes/evotor_webhooks.py`)
| Method | Path | Auth |
|--------|------|------|
| POST | `/user/create` | Bearer `EVOTOR_WEBHOOK_SECRET` |
| POST | `/user/verify` | Bearer `EVOTOR_WEBHOOK_SECRET` |
| POST | `/user/token` | Bearer `EVOTOR_WEBHOOK_SECRET` |
**`/user/create` flow:**
1. Verify Bearer token (401 if wrong, skip check if secret is unset — dev mode)
2. Parse `{userId, customField}` — attempt JSON parse of `customField`; extract `email`, `phone`, `first_name`, `last_name`
3. Try to find existing user by email → phone → `evotor_user_id`
4. If found: set `evotor_user_id`, update `evotor_meta`, set `status=active` if was pending
5. If not found: create `User(status=pending, role=user, evotor_user_id=..., password_hash=None)`
6. Generate `invite_token = secrets.token_urlsafe(32)`, `invite_expires = now + 48h`
7. Dispatch `send_email_task.delay(email, "Приглашение в ЭвоСинк", html)` via Celery
8. Generate a random `api_token = secrets.token_urlsafe(32)`, upsert into `evotor_connections`
9. Return `{"userId": payload.userId, "token": api_token}`
**`/user/verify` flow:**
1. Verify Bearer; parse `{userId, username, password}`
2. Find user by email OR phone (username field)
3. Check: `password_hash` not None, status not suspended, `verify_password(password, hash)`
4. Return `{"userId": user.evotor_user_id, "token": evotor_connection.api_token}`
**`/user/token` flow:**
1. Verify Bearer; parse `{userId, token}`
2. Find user by `evotor_user_id`; upsert `evotor_connections.access_token = token`
3. Return HTTP 200 `{}`
### Admin Panel (`web/routes/admin.py`) — all require `Depends(require_role("admin", "system"))`
| Method | Path |
|--------|------|
| GET | `/admin/users` — paginated list with role/status/search filters |
| GET | `/admin/users/{id}` — user card, evotor_meta JSON display |
| POST | `/admin/users/{id}/activate` — set status=active |
| POST | `/admin/users/{id}/suspend` — set status=suspended |
| POST | `/admin/users/{id}/reset-password` — generate token, dispatch email task |
| POST | `/admin/users/{id}/send-invite` — regenerate invite_token, dispatch email task |
| POST | `/admin/users/{id}/edit` — update name/email/phone/role |
| POST | `/admin/users/{id}/delete` — hard delete (system only) |
| GET | `/admin/roles` — role + permission list (system only) |
| POST | `/admin/roles/{id}/permissions` — set permissions for role |
### User Profile (`web/routes/profile.py`)
Extend existing profile card with: `role`, `status`, Evotor connection info, "Confirm email" action (if `is_email_confirmed=False`).
### Auth/Invite (new)
| Method | Path |
|--------|------|
| GET/POST | `/invite?token=...` — Evotor-invited user sets password + activates account |
---
## Notifications Layer
```python
# base.py
class EmailProvider(ABC):
@abstractmethod
def send(self, to: str, subject: str, html_body: str) -> None: ...
class SMSProvider(ABC):
@abstractmethod
def send(self, to: str, text: str) -> None: ...
# console.py — prints formatted block to stdout (like Node.js "="*40 pattern)
# registry.py — factory: EMAIL_PROVIDER env var selects implementation
# tasks.py
@celery_app.task(queue="notifications")
def send_email_task(to, subject, html_body): get_email_provider().send(...)
@celery_app.task(queue="notifications")
def send_sms_task(to, text): get_sms_provider().send(...)
```
Add to docker-compose worker command: `--queues=default,sync,health,notifications`
---
## RBAC Middleware
```python
# auth/rbac.py
def require_role(*roles):
def dep(request, db=Depends(get_db)):
user = get_current_user(request, db)
if user.role.value not in roles: raise HTTPException(403)
return user
return Depends(dep)
def require_permission(permission_name):
def dep(request, db=Depends(get_db)):
user = get_current_user(request, db)
if user.role == UserRoleEnum.system: return user # bypass
# walk user_roles → role_permissions → permissions
...
return Depends(dep)
```
---
## Migration Strategy
**0002:** Full schema (conditionally — `IF NOT EXISTS` / `ADD COLUMN IF NOT EXISTS` for live DBs with Node.js tables). Includes `ALTER COLUMN password_hash` to nullable. Adds `api_token` to `evotor_connections`. Adds `role`, `status`, `evotor_user_id`, `evotor_meta`, `invite_token`, `invite_expires`, `phone_otp`, `phone_otp_expires` to `users`.
**0003:** RBAC tables (`roles`, `permissions`, `role_permissions`, `user_roles`). Seeds three default Role rows and baseline permission set (`admin.users.view`, `admin.users.edit`, `admin.users.delete`, `admin.roles.manage`).
**0004 (optional):** Seeds a system user from `SYSTEM_USER_EMAIL` / `SYSTEM_USER_PASSWORD` env vars.
---
## Order of Implementation
1. **Models**`user.py`, `connections.py`, `rbac.py`, `__init__.py`
2. **Migrations** — 0002, 0003 (run `alembic upgrade head` to verify)
3. **Auth foundation**`auth/password.py`, `auth/session.py`, `templates_env.py`, update `main.py`
4. **Core auth routes**`routes/auth.py`, `routes/reset.py` + templates (`base.html`, login, register, etc.)
5. **Notifications**`notifications/` package + `tasks.py` + update `celery_app.py`
6. **Invite flow**`routes/invite.py` + `invite.html`
7. **Evotor webhooks**`routes/evotor_webhooks.py` (the most novel piece)
8. **Profile + Connections** — port from Node.js `854c912`
9. **RBAC middleware**`auth/rbac.py`
10. **Admin panel**`routes/admin.py` + admin templates
11. **Catalog + Sync** — port remaining routes and Celery tasks from Node.js
---
## Verification
1. `alembic upgrade head` — all migrations run clean on a fresh DB
2. `POST /user/create` with `curl` + Bearer token → check user created in DB, invite email printed to console
3. `GET /invite?token=<token>` → set password → check `status=active`, `is_email_confirmed=True`
4. `POST /login` with the set password → session created, redirect to `/profile`
5. `POST /user/verify` with username+password → returns `{userId, token}`
6. `POST /user/token` with `{userId, token}` → 200, evotor_connection updated
7. Login as admin user, visit `/admin/users` — user list renders
8. Admin activates/suspends a user — status changes in DB
9. `POST /login` as suspended user → rejected
10. `uvicorn web.main:app --reload` — no import errors, health check returns 200

View File

@@ -1,262 +0,0 @@
# Catalog Browser with Filter Management & CSV Export
## Context
Users need to browse their Evotor catalog (stores, groups, products) in a table view, manage sync whitelist/blacklist rules inline, and export data to CSV.
This feature **replaces** the separate `/sync/stores`, `/sync/groups`, `/sync/products` pages from the sync-configuration plan. The catalog browser becomes the unified place for both viewing data and managing filter rules.
Data is cached in DB with a refresh mechanism — not fetched live on every page load.
## Data Model
### Catalog Cache Tables
```
tablename: "cached_stores"
- id (Integer, PK)
- user_id (Integer, FK users.id CASCADE)
- evotor_id (String 255) # Evotor UUID
- name (String 255)
- address (String 500, nullable)
- fetched_at (DateTime) # when this snapshot was taken
UniqueConstraint: (user_id, evotor_id)
Index: user_id
```
```
tablename: "cached_groups"
- id (Integer, PK)
- user_id (Integer, FK users.id CASCADE)
- evotor_id (String 255) # Evotor UUID
- store_evotor_id (String 255) # parent store UUID
- name (String 255)
- fetched_at (DateTime)
UniqueConstraint: (user_id, evotor_id)
Index: (user_id, store_evotor_id)
```
```
tablename: "cached_products"
- id (Integer, PK)
- user_id (Integer, FK users.id CASCADE)
- evotor_id (String 255) # Evotor UUID
- store_evotor_id (String 255) # parent store UUID
- group_evotor_id (String 255, nullable) # parent group UUID
- name (String 255)
- price (Numeric(12,2), nullable)
- quantity (Numeric(12,3), nullable)
- measure_name (String 20, nullable)
- article_number (String 100, nullable)
- allow_to_sell (Boolean, nullable)
- fetched_at (DateTime)
UniqueConstraint: (user_id, evotor_id)
Index: (user_id, store_evotor_id, group_evotor_id)
```
### `SyncFilter` (from sync-configuration plan, unchanged)
```
tablename: "sync_filters"
- sync_config_id, entity_type, entity_id, entity_name, filter_mode, parent_entity_id
```
The catalog browser reads from cache tables for display and from `sync_filters` for the current filter state of each entity.
### Cache Refresh
`web/evotor_api.py` gets a new function:
```python
async def refresh_catalog_cache(user_id: int, access_token: str, db: Session):
"""Fetch all stores, groups, products from Evotor API and upsert into cache tables."""
```
Triggered by:
- Manual "Обновить" button on the catalog page
- Background job (optional, can reuse health_checker interval or separate setting)
- First visit to catalog if cache is empty
## Plan
### 1. New Models — `web/models.py`
Add `CachedStore`, `CachedGroup`, `CachedProduct` models as described above.
### 2. Alembic Migration
Create `cached_stores`, `cached_groups`, `cached_products` tables.
### 3. Evotor API Helper — `web/evotor_api.py`
Extend with:
```python
async def fetch_stores(access_token: str) -> list[dict]
async def fetch_groups(access_token: str, store_id: str) -> list[dict]
async def fetch_products(access_token: str, store_id: str) -> list[dict]
async def refresh_catalog_cache(user_id: int, access_token: str, db: Session)
```
`refresh_catalog_cache` does:
1. Fetch all stores
2. For each store, fetch groups and products
3. Upsert into cache tables (delete old rows for user, insert fresh)
4. Update `fetched_at` timestamps
### 4. Catalog Route — `web/routes/catalog.py` (new)
**`GET /catalog`** — Stores table. Requires auth + Evotor connection.
- Reads `cached_stores` for user
- If cache is empty, triggers refresh
- Shows table with columns: Название, Адрес, Статус фильтра, Действия
- Each row shows the store's current `SyncFilter` state (included/excluded/no rule)
- Link to drill into groups for each store
- "Обновить каталог" button, "Экспорт CSV" button, back link
**`GET /catalog/groups?store_id=UUID`** — Groups table for a store.
- Reads `cached_groups` filtered by `store_evotor_id`
- Table columns: Название, Статус фильтра, Кол-во товаров, Действия
- Each row shows group's `SyncFilter` state
- Link to drill into products for each group
- "Экспорт CSV" button, back to stores
**`GET /catalog/products?store_id=UUID&group_id=UUID`** — Products table for a group.
- Reads `cached_products` filtered by `store_evotor_id` and `group_evotor_id`
- Table columns: Название, Артикул, Цена, Кол-во, Ед. изм., В продаже, Статус фильтра, Действия
- Each row shows product's `SyncFilter` state
- "Экспорт CSV" button, back to groups
**`GET /catalog/products?store_id=UUID`** — All products for a store (no group filter).
- Same table, but shows all products in the store with a "Группа" column added
**`POST /catalog/filter`** — Toggle filter for an entity.
- Body: `entity_type`, `entity_id`, `entity_name`, `filter_mode` (include/exclude/none), `parent_entity_id`
- Creates, updates, or deletes the `SyncFilter` row
- Redirects back to the referring page
**`POST /catalog/refresh`** — Manual cache refresh.
- Calls `refresh_catalog_cache()`
- Redirects back to `/catalog`
**`GET /catalog/export?type=stores|groups|products&store_id=UUID&group_id=UUID`** — CSV export.
- Reads from cache tables
- Returns `StreamingResponse` with `text/csv` content type and `Content-Disposition: attachment`
- Filename: `{type}_{date}.csv`
### 5. Templates
**`web/templates/catalog_stores.html`** — Stores table:
```
┌──────────────────────────────────────────────────────────────┐
│ Каталог [Обновить] [Экспорт CSV] │
│ Последнее обновление: 06.03.2026 14:30 │
├──────────────────────────────────────────────────────────────┤
│ │
│ Магазины │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Название │ Адрес │ Фильтр │ │ │
│ ├──────────────────────────────────────────────────────┤ │
│ │ Чайная │ ул. Мира, 1 │ ✓ Вкл │ [→][▼] │ │
│ │ Склад │ — │ ✗ Выкл │ [→][▼] │ │
│ │ Точка 2 │ ул. Мира, 5 │ — Нет │ [→][▼] │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ [→] = перейти к группам │
│ [▼] = dropdown: Включить / Исключить / Убрать правило │
│ │
└──────────────────────────────────────────────────────────────┘
```
**Filter status column** shows:
- `✓ Включено` (green badge) — entity has an "include" rule
- `✗ Исключено` (red badge) — entity has an "exclude" rule
- `— Нет правила` (grey badge) — no filter rule (follows default behavior)
**Actions column** per row:
- Link icon → drill into children (groups for stores, products for groups)
- Dropdown button with filter actions: "Включить в синхронизацию" / "Исключить из синхронизации" / "Убрать правило". Each is a small POST form to `/catalog/filter`.
**`web/templates/catalog_groups.html`** — Groups table:
- Breadcrumb: Каталог > {Store name} > Группы
- Same table pattern, columns: Название, Кол-во товаров, Фильтр, Действия
- Drill-down link to products per group
**`web/templates/catalog_products.html`** — Products table:
- Breadcrumb: Каталог > {Store name} > {Group name} > Товары
- Columns: Название, Артикул, Цена, Кол-во, Ед. изм., В продаже, Фильтр, Действия
- "В продаже" column: green check / red cross based on `allow_to_sell`
All tables use Bootstrap table styling (`table table-striped table-hover`) with responsive wrapper.
### 6. CSV Export Format
**Stores CSV:**
```
Название,Адрес,ID,Фильтр
Чайная,"ул. Мира, 1",uuid-123,Включено
```
**Groups CSV:**
```
Магазин,Название,ID,Фильтр
Чайная,Белый чай,uuid-456,Включено
```
**Products CSV:**
```
Магазин,Группа,Название,Артикул,Цена,Количество,Ед. измерения,В продаже,ID,Фильтр
Чайная,Белый чай,Бай Му Дань,1005,350.00,180.0,г,Да,uuid-789,Включено
```
UTF-8 with BOM (`\ufeff`) for Excel compatibility. Delimiter: comma.
### 7. Update Sync Configuration Plan
The `/sync` page links to `/catalog` instead of separate filter pages:
- "Настроить фильтры" button → `/catalog`
- Filter summary on `/sync` reads from `SyncFilter` table (unchanged)
- Remove `/sync/stores`, `/sync/groups`, `/sync/products` routes from sync-configuration plan — replaced by catalog browser
### 8. Navbar / Navigation
Add "Каталог" link to navbar for logged-in users. Order: Подключения → Каталог → Синхронизация → Личный кабинет → Выход.
### 9. Register Route — `web/main.py`
```python
from web.routes import catalog
app.include_router(catalog.router)
```
## Files Summary
| File | Action |
|------|--------|
| `web/models.py` | Modify — add `CachedStore`, `CachedGroup`, `CachedProduct` |
| `web/evotor_api.py` | Create — API fetch + cache refresh functions |
| `web/routes/catalog.py` | Create — catalog routes (tables, filter toggle, refresh, CSV export) |
| `web/templates/catalog_stores.html` | Create — stores table |
| `web/templates/catalog_groups.html` | Create — groups table |
| `web/templates/catalog_products.html` | Create — products table |
| `web/templates/base.html` | Modify — add "Каталог" nav link |
| `web/main.py` | Modify — register catalog router |
| `docs/plans/sync-configuration.md` | Update — remove /sync/stores,groups,products; link to /catalog |
| Alembic migration | Create — cache tables |
## Verification
1. Run `alembic upgrade head`
2. Visit `/catalog` without Evotor connection → warning to connect first
3. Connect Evotor, visit `/catalog` → triggers first cache refresh, shows stores table
4. Click store → shows groups table with group names from Evotor
5. Click group → shows products table with full product details
6. Toggle filter on a product → badge changes, `SyncFilter` row created in DB
7. Go to `/sync` → filter summary reflects the change
8. Click "Экспорт CSV" on products page → downloads CSV, opens correctly in Excel
9. Click "Обновить каталог" → re-fetches from Evotor API, updates cache
10. Verify breadcrumb navigation works correctly through the hierarchy

View File

@@ -1,192 +0,0 @@
# Connections Dashboard with Background Health Checks
## Context
Users currently access Evotor connection via a dedicated `/evotor` page linked from the navbar. As more integrations are planned, we need a unified **Connections** page where users can manage all their connections: add new ones, view status, edit, and delete. The dashboard starts empty — users explicitly add each connection they need.
Supported connection types: **Evotor**, **VK** (one per type per user).
## Data Design
### Current state (separate models)
`EvotorConnection` and `VkConnection` remain as-is — they hold service-specific fields (store_id/store_name for Evotor, vk_user_id/first_name/last_name for VK). The connections dashboard reads from both tables.
No new unified "connection" table needed. The dashboard builds a virtual list by querying both tables. The "add" flow is just a gateway to the existing per-service OAuth pages.
### Model additions (both `EvotorConnection` and `VkConnection`)
Already planned:
- `is_online` (Boolean, default=False, server_default="0")
- `last_checked_at` (DateTime, nullable)
## Plan
### 1. Model Changes — `web/models.py`
Add `is_online` and `last_checked_at` to both `EvotorConnection` and `VkConnection`.
### 2. Alembic Migration
Add health check fields to both connection tables.
### 3. Config Addition — `web/config.py`
Add `HEALTH_CHECK_INTERVAL_SECONDS: int = 600` (10 minutes default).
### 4. Background Health Checker — `web/health_checker.py` (new)
- `check_evotor_connection(access_token) -> bool` — async, `GET https://api.evotor.ru/stores` with Bearer token
- `check_vk_connection(access_token) -> bool` — async, `GET https://api.vk.com/method/users.get` with token
- `run_health_checks()` — queries all connection rows, checks each, updates `is_online` and `last_checked_at`
- `health_check_loop(interval)` — infinite loop with `asyncio.sleep`
### 5. Wire Background Task — `web/main.py`
Add FastAPI lifespan context manager:
- On startup: `asyncio.create_task(health_check_loop(...))`
- On shutdown: cancel the task
- Register connections router
### 6. Connections Route — `web/routes/connections.py` (new)
**`GET /connections`** — Main dashboard. Requires auth.
Queries both `EvotorConnection` and `VkConnection` for the current user. Builds a list of available service types and their connection state:
```python
SERVICE_TYPES = [
{"type": "evotor", "name": "Эвотор", "icon": "bi-shop", "connect_url": "/evotor", "disconnect_url": "/evotor/disconnect"},
{"type": "vk", "name": "ВКонтакте", "icon": "bi-chat-dots", "connect_url": "/vk", "disconnect_url": "/vk/disconnect"},
]
```
For each type, attach the connection record (or None). Template renders based on state.
**`GET /connections/add`** — "Add connection" page.
Shows only service types the user has NOT yet connected:
- Card per available type with service name, icon, short description
- "Подключить" button linking to the service's OAuth page (`/evotor` or `/vk`)
- If all types already connected — message "Все доступные сервисы подключены"
- Back link to `/connections`
**`POST /connections/delete?type=evotor|vk`** — Delete a connection.
Same as existing disconnect endpoints but accessed from the dashboard. Deletes the connection record, redirects to `/connections`.
(The existing `/evotor/disconnect` and `/vk/disconnect` routes remain as aliases.)
### 7. Templates
**`web/templates/connections.html`** — Dashboard:
```
┌─────────────────────────────────────────────────┐
│ Подключения [+ Добавить] │
├─────────────────────────────────────────────────┤
│ │
│ ┌─ Card ─────────────────────────────────────┐ │
│ │ 🏪 Эвотор ● (green) │ │
│ │ Магазин "Чайная" │ │
│ │ Последняя проверка: 06.03.2026 14:30 │ │
│ │ │ │
│ │ [Настроить] [Отключить] │ │
│ └────────────────────────────────────────────┘ │
│ │
│ ┌─ Card ─────────────────────────────────────┐ │
│ │ 💬 ВКонтакте ● (green) │ │
│ │ Иван Иванов │ │
│ │ Последняя проверка: 06.03.2026 14:30 │ │
│ │ │ │
│ │ [Настроить] [Отключить] │ │
│ └────────────────────────────────────────────┘ │
│ │
│ (Нет подключений — нажмите «Добавить») │
│ │
└─────────────────────────────────────────────────┘
```
Each connection card:
- Icon + service name + status indicator (green/red/grey)
- Details line (store name for Evotor, profile name for VK)
- Last checked timestamp in card footer
- "Настроить" button → links to service page (`/evotor` or `/vk`) for reconnect/details
- "Отключить" button → POST to `/connections/delete?type=...` with confirmation
Empty state: message prompting user to add their first connection.
**`web/templates/connections_add.html`** — Add connection page:
```
┌─────────────────────────────────────────────────┐
│ Добавить подключение │
├─────────────────────────────────────────────────┤
│ │
│ ┌─ Card ─────────────────────────────────────┐ │
│ │ 🏪 Эвотор │ │
│ │ Подключите кассу Эвотор для синхронизации │ │
│ │ каталога товаров. │ │
│ │ [Подключить →] │ │
│ └────────────────────────────────────────────┘ │
│ │
│ ┌─ Card ─────────────────────────────────────┐ │
│ │ 💬 ВКонтакте │ │
│ │ Подключите аккаунт ВКонтакте для │ │
│ │ публикации товаров в вашу группу. │ │
│ │ [Подключить →] │ │
│ └────────────────────────────────────────────┘ │
│ │
│ ← Вернуться к подключениям │
│ │
└─────────────────────────────────────────────────┘
```
### 8. Navbar Update — `web/templates/base.html`
Replace "Эвотор" link with "Подключения" → `/connections`.
### 9. Evotor/VK Callback Updates
On successful OAuth callback in both `/evotor/callback` and `/vk/callback`:
- Set `is_online=True` and `last_checked_at=now()`
- Redirect to `/connections` (already done for Evotor)
### 10. Evotor/VK Template Back Links
Change back links on `/evotor` and `/vk` pages: "Вернуться к подключениям" → `/connections`.
### 11. Delete Confirmation
The "Отключить" button on the dashboard uses a simple JS `confirm()` dialog: "Вы уверены, что хотите отключить {service name}?" before submitting the POST form.
## Files Summary
| File | Action |
|------|--------|
| `web/models.py` | Modify — add `is_online`, `last_checked_at` to both connection models |
| `web/config.py` | Modify — add `HEALTH_CHECK_INTERVAL_SECONDS` |
| `web/main.py` | Modify — lifespan + register connections router |
| `web/routes/evotor.py` | Modify — set online on callback, redirect to /connections |
| `web/routes/vk.py` | Modify — set online on callback, redirect to /connections |
| `web/routes/connections.py` | Create — dashboard, add page, delete endpoint |
| `web/health_checker.py` | Create — background checks for both Evotor and VK |
| `web/templates/connections.html` | Create — dashboard with cards |
| `web/templates/connections_add.html` | Create — add connection page |
| `web/templates/base.html` | Modify — navbar link |
| `web/templates/evotor.html` | Modify — back link to /connections |
| `web/templates/vk.html` | Modify — back link to /connections |
| Alembic migration | Create |
## Verification
1. Run `alembic upgrade head`
2. Start the app, verify background task logs appear
3. Visit `/connections` — empty state, "Добавить" button visible
4. Click "Добавить" → shows Evotor and VK as available services
5. Add Evotor → goes through OAuth → returns to `/connections` with green status card
6. Add VK → same flow → both connections visible
7. Click "Добавить" again → shows "Все доступные сервисы подключены"
8. Click "Отключить" on Evotor → confirmation dialog → connection removed → card disappears
9. Click "Добавить" → Evotor is available again
10. Wait for health check cycle → verify `is_online` and `last_checked_at` update on remaining connections

View File

@@ -1,151 +0,0 @@
# Sync Configuration Feature
## Context
EvoSync syncs product catalogs from Evotor → VK. Currently sync runs as a shell-based cron service with a hardcoded store ID and a flat-file whitelist of group names (`vk/whitelist`). This doesn't support multi-user or per-user configuration.
Users need a web UI to:
- Enable/disable the whole sync process
- Configure which stores, groups, and products to sync (whitelist/blacklist)
- Explicitly confirm before sync starts
The web app will store config in DB; the shell sync service will read from DB instead of flat files.
## Data Model
### `SyncConfig` — per-user master switch
```
tablename: "sync_configs"
- id (Integer, PK)
- user_id (Integer, FK users.id CASCADE, unique)
- is_enabled (Boolean, default=False) # master on/off
- confirmed_at (DateTime, nullable) # NULL = never confirmed/started
- created_at (DateTime, server_default=now)
- updated_at (DateTime, server_default=now, onupdate=now)
Relationship: User.sync_config (one-to-one)
```
### `SyncFilter` — stores, groups, products filter rules
```
tablename: "sync_filters"
- id (Integer, PK)
- sync_config_id (Integer, FK sync_configs.id CASCADE)
- entity_type (String, enum: "store", "group", "product")
- entity_id (String 255) # Evotor UUID
- entity_name (String 255) # human-readable, cached
- filter_mode (String, enum: "include", "exclude")
- parent_entity_id (String 255, nullable) # store_id for groups, group_id for products
- created_at (DateTime, server_default=now)
UniqueConstraint: (sync_config_id, entity_type, entity_id)
Relationship: SyncConfig.filters (one-to-many)
```
### Filter Logic
The filter model uses **explicit include/exclude rules** with these semantics:
- **No rules for an entity type** = sync everything of that type (default permissive)
- **Any "include" rule exists for a type** = ONLY sync included entities (whitelist mode)
- **Only "exclude" rules for a type** = sync everything EXCEPT excluded (blacklist mode)
- Hierarchy: store filters → group filters → product filters. If a store is excluded, all its groups/products are excluded regardless of their individual rules.
## Plan
### 1. New Models — `web/models.py`
Add `SyncConfig` and `SyncFilter` as described above. Add `sync_config` relationship to `User`.
### 2. Alembic Migration
Create `sync_configs` and `sync_filters` tables.
### 3. Evotor API Helper — `web/evotor_api.py` (new)
Async functions to fetch data from Evotor API using a user's stored access token:
```python
async def fetch_stores(access_token: str) -> list[dict]:
"""GET https://api.evotor.ru/stores → [{"id": "uuid", "name": "..."}]"""
async def fetch_groups(access_token: str, store_id: str) -> list[dict]:
"""GET https://api.evotor.ru/stores/{store_id}/product-groups → [{"id": "uuid", "name": "..."}]"""
async def fetch_products(access_token: str, store_id: str) -> list[dict]:
"""GET https://api.evotor.ru/stores/{store_id}/products → [{"id": "uuid", "name": "...", "parent_id": "..."}]"""
```
Uses `httpx.AsyncClient`. Returns simplified dicts. Raises on auth failure.
### 4. Sync Config Route — `web/routes/sync.py` (new)
**`GET /sync`** — Main sync configuration page.
- Requires auth + active Evotor connection
- Loads `SyncConfig` (creates default if missing)
- Shows: master enable/disable toggle, confirm button, link to filter config
**`POST /sync/toggle`** — Enable/disable sync.
- Toggles `is_enabled`. If enabling for the first time and no filters configured, stays on page with message to configure filters first.
**`POST /sync/confirm`** — Confirm and start sync.
- Sets `confirmed_at = now()`. Only works if `is_enabled=True` and at least one store is configured.
**Filter management is handled by the Catalog Browser** (see `docs/plans/catalog-browser.md`).
The `/catalog` page provides table views of stores, groups, and products with inline filter toggle actions. No separate `/sync/stores`, `/sync/groups`, `/sync/products` routes needed.
### 5. Templates
**`web/templates/sync.html`** — Main sync page:
- Card with master toggle (on/off switch)
- Status: "Не настроено" / "Настроено, ожидает подтверждения" / "Активна"
- Warning if Evotor not connected (link to /evotor)
- Warning if VK not connected (link to /vk)
- "Настроить фильтры" button → `/catalog` (catalog browser)
- "Подтвердить и запустить" button (disabled until filters configured)
- Summary of current filter rules (X stores, Y groups, Z products)
### 6. Navbar / Navigation
Add "Синхронизация" link to navbar (for logged-in users), or add it as a card on the `/connections` page since sync depends on connections.
### 7. Register Route — `web/main.py`
```python
from web.routes import sync
app.include_router(sync.router)
```
### 8. Shell Script DB Integration
Modify the sync service to read configuration from DB instead of flat files:
- Add a Python helper script `run/read_config.py` that queries `sync_configs` + `sync_filters` for a given user and outputs JSON config
- Shell scripts call this helper to get: enabled flag, store IDs, whitelisted/blacklisted group names, product exclusions
- The sync service only runs for users where `is_enabled=True` AND `confirmed_at IS NOT NULL`
- Replaces the flat `vk/whitelist` file
## Files Summary
| File | Action |
|------|--------|
| `web/models.py` | Modify — add `SyncConfig`, `SyncFilter` + User relationship |
| `web/routes/sync.py` | Create — sync config routes (toggle, confirm) |
| `web/templates/sync.html` | Create — main sync config page |
| `web/templates/base.html` | Modify — add sync nav link |
| `web/main.py` | Modify — register sync router |
| `run/read_config.py` | Create — DB config reader for shell scripts |
| Alembic migration | Create — sync_configs + sync_filters tables |
## Verification
1. Run `alembic upgrade head`
2. Visit `/sync` without Evotor connection → shows warning to connect first
3. Connect Evotor, visit `/sync` → shows disabled state, "Настроить фильтры" button
4. Go to `/sync/stores` → fetches live stores from Evotor API, shows checkboxes
5. Select stores, save → drill into groups, select groups, save → drill into products
6. Back to `/sync` → shows summary of configured filters
7. Enable sync toggle → confirm → `confirmed_at` set
8. Verify `run/read_config.py` outputs correct JSON for the user's config
9. Disable sync → `is_enabled=False`, sync service stops processing this user

View File

@@ -1,189 +0,0 @@
# VK OAuth Connection Feature
## Context
EvoSync syncs product catalogs from Evotor to VK. Users already connect their Evotor account via OAuth. Now we need the same for VK — users authorize via VK OAuth, we store the access token, and show connection status on the connections dashboard.
## VK OAuth Flow (Web)
- **Authorize URL**: `https://oauth.vk.com/authorize`
- **Token URL**: `https://oauth.vk.com/access_token`
- **Verify endpoint**: `GET https://api.vk.com/method/users.get?access_token={token}&v=5.131`
- Error code 5 = token invalid/expired
- **Scopes**: `market groups offline` (offline = permanent token, no expiry)
- **Token response fields**: `access_token`, `user_id`, `expires_in` (0 if offline scope used)
With `offline` scope, tokens don't expire — no refresh logic needed. If a user revokes access on VK's side, the health checker will detect it.
## Plan
### 1. New Model — `VkConnection` in `web/models.py`
```python
class VkConnection(Base):
__tablename__ = "vk_connections"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False)
access_token = Column(Text, nullable=False)
vk_user_id = Column(String(50), nullable=True) # VK user ID from token response
first_name = Column(String(255), nullable=True) # VK profile first name
last_name = Column(String(255), nullable=True) # VK profile last name
is_online = Column(Boolean, default=False, server_default="0", nullable=False)
last_checked_at = Column(DateTime, nullable=True)
connected_at = Column(DateTime, server_default=func.now(), nullable=False)
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
user = relationship("User", back_populates="vk_connection")
```
Add to `User` model:
```python
vk_connection = relationship("VkConnection", back_populates="user", uselist=False)
```
### 2. Alembic Migration
Generate migration for the new `vk_connections` table and the relationship.
### 3. Config — `web/config.py`
Add:
```python
VK_CLIENT_ID: str = ""
VK_CLIENT_SECRET: str = ""
VK_SCOPES: str = "market groups offline"
VK_API_VERSION: str = "5.131"
```
### 4. VK Route — `web/routes/vk.py` (new)
Follow the same pattern as `web/routes/evotor.py`:
**Constants:**
```python
VK_AUTHORIZE_URL = "https://oauth.vk.com/authorize"
VK_TOKEN_URL = "https://oauth.vk.com/access_token"
VK_API_URL = "https://api.vk.com/method"
```
**Endpoints:**
- `GET /vk` — Connection page. Shows connected state (VK profile name, user_id) or disconnected state with explanation and connect button.
- `GET /vk/connect` — Generate state token, save in session, redirect to:
```
https://oauth.vk.com/authorize?client_id={id}&response_type=code
&redirect_uri={BASE_URL}/vk/callback&scope={scopes}&state={state}
&display=page&v=5.131
```
- `GET /vk/callback` — OAuth callback:
1. Validate state from session
2. Exchange code for token via GET to `https://oauth.vk.com/access_token` with params: `client_id`, `client_secret`, `code`, `redirect_uri` (NOTE: VK uses GET, not POST, and params in query string, not body)
3. Response contains: `access_token`, `user_id`, `expires_in`
4. Fetch user profile via `users.get` to get first_name, last_name
5. Save/update `VkConnection` record with `is_online=True`, `last_checked_at=now()`
6. Redirect to `/connections`
- `POST /vk/disconnect` — Delete VkConnection record, redirect to `/vk`
### 5. VK Template — `web/templates/vk.html` (new)
Same structure as `evotor.html`:
**Connected state:**
- Status badge: "Подключено" (green)
- VK profile: first_name + last_name
- VK user ID (monospace)
- Connected timestamp
- Buttons: "Переподключить", "Отключить аккаунт ВКонтакте"
**Disconnected state:**
- Explanation text: "Подключите ваш аккаунт ВКонтакте, чтобы система могла автоматически синхронизировать каталог товаров из Эвотор в вашу группу ВКонтакте."
- Bullet points: redirect to VK for auth, auto-setup after confirmation, can disconnect anytime
- Button: "Подключить ВКонтакте"
**Error display:** same pattern as evotor.html (invalid_state, token_exchange, no_token)
**Back link:** "Вернуться к подключениям" → `/connections`
### 6. Register Route — `web/main.py`
```python
from web.routes import vk
app.include_router(vk.router)
```
### 7. Add to Connections Dashboard — `web/routes/connections.py`
Add VK entry to the connections list:
```python
vk_conn = db.query(VkConnection).filter(VkConnection.user_id == user.id).first()
connections.append({
"name": "ВКонтакте",
"icon": "bi-chat-dots", # or another suitable Bootstrap icon
"connected": vk_conn is not None,
"is_online": vk_conn.is_online if vk_conn else False,
"last_checked_at": vk_conn.last_checked_at if vk_conn else None,
"details": f"{vk_conn.first_name} {vk_conn.last_name}" if vk_conn and vk_conn.first_name else None,
"connect_url": "/vk",
"disconnect_url": "/vk/disconnect",
})
```
### 8. Background Health Check — `web/health_checker.py`
Add VK check alongside existing Evotor check:
```python
async def check_vk_connection(access_token: str) -> bool:
"""Call users.get to verify VK token is valid."""
async with httpx.AsyncClient() as client:
resp = await client.get(
"https://api.vk.com/method/users.get",
params={"access_token": access_token, "v": "5.131"},
timeout=10,
)
if resp.status_code != 200:
return False
data = resp.json()
# Error code 5 = invalid token
if "error" in data:
return False
return True
```
In `run_health_checks()`, add a loop over `VkConnection` rows with the same pattern as Evotor checks.
## Files Summary
| File | Action |
|------|--------|
| `web/models.py` | Modify — add `VkConnection` model + User relationship |
| `web/config.py` | Modify — add `VK_*` settings |
| `web/main.py` | Modify — register vk router |
| `web/routes/vk.py` | Create — OAuth flow (connect/callback/disconnect/page) |
| `web/routes/connections.py` | Modify — add VK to connections list |
| `web/health_checker.py` | Modify — add VK health check |
| `web/templates/vk.html` | Create — VK connection page |
| Alembic migration | Create — `vk_connections` table |
## Env Config Needed
```
VK_CLIENT_ID=your_vk_app_id
VK_CLIENT_SECRET=your_vk_app_secret
VK_SCOPES=market groups offline
```
## Verification
1. Run `alembic upgrade head`
2. Visit `/connections` — should show VK as disconnected (grey)
3. Click VK → "Подключить ВКонтакте" → redirects to VK auth
4. After VK auth → callback saves token → redirects to `/connections` → VK shows green
5. Visit `/vk` — shows connected state with VK profile info
6. Disconnect → VK returns to grey on connections page
7. Wait for health check cycle — verify `is_online` and `last_checked_at` update

View File

@@ -1,6 +1,12 @@
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;
@@ -13,17 +19,52 @@ 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:8000;
proxy_pass http://web;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# ── my-products.ru ────────────────────────────────────────────────────────────
server {
listen 80;
server_name my-products.ru www.my-products.ru;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name my-products.ru www.my-products.ru;
ssl_certificate /etc/letsencrypt/live/my-products.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/my-products.ru/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
location / {
proxy_pass http://web;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

11
pyproject.toml Normal file
View File

@@ -0,0 +1,11 @@
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
addopts = "--tb=short"
[tool.coverage.run]
source = ["web"]
omit = ["web/migrations/*"]
[tool.coverage.report]
show_missing = true

View File

@@ -1,13 +1,20 @@
fastapi==0.115.0
uvicorn[standard]==0.30.0
sqlalchemy==2.0.35
pymysql==1.1.1
cryptography>=41.0.0
jinja2==3.1.4
fastapi==0.115.5
uvicorn[standard]==0.32.1
python-multipart==0.0.12
passlib[bcrypt]==1.7.4
bcrypt==4.2.0
pydantic-settings==2.5.2
itsdangerous==2.1.2
httpx==0.27.2
alembic==1.13.3
jinja2==3.1.4
sqlalchemy==2.0.36
alembic==1.14.0
pymysql==1.1.1
itsdangerous>=2.1.0
cryptography>=44.0.0
bcrypt>=4.2.1
pydantic-settings==2.6.1
httpx==0.28.1
celery[redis]==5.4.0
redis==5.2.1
flower==2.0.1
python-json-logger==3.2.1
pytest==8.3.4
pytest-asyncio==0.24.0
pytest-cov==6.0.0
factory-boy==3.3.1

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

@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Obtain an Evotor developer access token via password grant (no browser required).
# Uses dev.evotor.ru credentials (your Evotor developer account).
#
# Usage: ./scripts/evotor-get-token.sh
set -euo pipefail
# Load .env if present
if [[ -f .env ]]; then
set -a; source .env; set +a
fi
EVOTOR_TOKEN_URL="https://dev.evotor.ru/oauth/token"
# Prompt for credentials
read -rp "Evotor developer login (email): " EVOTOR_LOGIN
read -rsp "Evotor developer password: " EVOTOR_PASSWORD
echo
echo
echo "A 2FA code will be sent to your email if this IP is not recognized."
read -rp "2FA code (leave blank if not required): " EVOTOR_2FA
# Build request body
BODY="type=LOGIN&grant_type=password&client_id=Evo-UI&username=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$EVOTOR_LOGIN")&password=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$EVOTOR_PASSWORD")"
EXTRA_HEADERS=()
if [[ -n "$EVOTOR_2FA" ]]; then
EXTRA_HEADERS+=(-H "2fa_confirmation: $EVOTOR_2FA")
fi
echo
echo "Requesting token..."
RESPONSE=$(curl -s -X POST "$EVOTOR_TOKEN_URL" \
-H "Content-Type: application/x-www-form-urlencoded" \
"${EXTRA_HEADERS[@]}" \
-d "$BODY")
echo
echo "Response:"
echo "$RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$RESPONSE"
ACCESS_TOKEN=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('access_token',''))" 2>/dev/null || true)
if [[ -z "$ACCESS_TOKEN" ]]; then
echo
echo "ERROR: No access_token in response." >&2
exit 1
fi
echo
echo "Access token:"
echo "$ACCESS_TOKEN"
echo
echo "To save this token to .env, add or update:"
echo " EVOTOR_ACCESS_TOKEN=$ACCESS_TOKEN"

View File

@@ -1,57 +1,60 @@
#!/bin/bash
# Obtain TLS certificates from Let's Encrypt for evosync.ru
# Run once on first deploy: sudo ./scripts/init-letsencrypt.sh
# 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}"
COMPOSE="docker compose"
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
echo "==> Creating certbot directories..."
mkdir -p "$CERTBOT_DIR/conf" "$CERTBOT_DIR/www"
if [ -z "${DOMAIN:-}" ]; then
echo "ERROR: no domain specified." >&2
echo "Usage: $0 <domain> or set DOMAIN= in .env" >&2
exit 1
fi
echo "==> Starting nginx (HTTP only, for ACME challenge)..."
# Temporarily use a basic config that doesn't require certs
cat > nginx/nginx-temp.conf <<'TMPCONF'
server {
listen 80;
server_name evosync.ru www.evosync.ru;
EMAIL="${LETSENCRYPT_EMAIL:-admin@$DOMAIN}"
ACME_DIR="/var/www/certbot"
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
echo "==> Obtaining certificate for: $DOMAIN (www.$DOMAIN)"
echo " Email: $EMAIL"
location / {
return 200 'Setting up TLS...';
add_header Content-Type text/plain;
}
}
TMPCONF
$COMPOSE up -d nginx
echo "==> Ensuring acme-challenge directory exists..."
sudo mkdir -p "$ACME_DIR"
sudo chmod 755 "$ACME_DIR"
echo "==> Requesting certificate from Let's Encrypt..."
docker run --rm \
-v "$(pwd)/$CERTBOT_DIR/conf:/etc/letsencrypt" \
-v "$(pwd)/$CERTBOT_DIR/www:/var/www/certbot" \
--network "${COMPOSE_PROJECT_NAME:-evo-syncgit}_default" \
certbot/certbot certonly \
sudo certbot certonly \
--webroot \
--webroot-path=/var/www/certbot \
--webroot-path="$ACME_DIR" \
--email "$EMAIL" \
--agree-tos \
--no-eff-email \
-d "$DOMAIN" \
-d "www.$DOMAIN"
echo "==> Restoring production nginx config..."
rm -f nginx/nginx-temp.conf
echo "==> Restarting nginx with TLS..."
$COMPOSE restart nginx
echo "==> Done! TLS certificate installed for $DOMAIN"
echo " Set up auto-renewal with: sudo crontab -e"
echo " Add: 0 3 * * * cd $(pwd) && docker run --rm -v $(pwd)/$CERTBOT_DIR/conf:/etc/letsencrypt -v $(pwd)/$CERTBOT_DIR/www:/var/www/certbot certbot/certbot renew --quiet && docker compose restart nginx"
echo ""
echo "==> Certificate obtained for $DOMAIN"
echo " /etc/letsencrypt/live/$DOMAIN/fullchain.pem"
echo " /etc/letsencrypt/live/$DOMAIN/privkey.pem"
echo ""
echo "==> Generate nginx config and reload:"
echo " sudo ./scripts/generate-nginx-conf.sh $DOMAIN"
echo " sudo nginx -t && sudo systemctl reload nginx"
echo ""
echo "==> Auto-renewal (add to /etc/cron.d/certbot if not already present):"
echo " 0 3 * * * root certbot renew --quiet && systemctl reload nginx"

0
tests/__init__.py Normal file
View File

103
tests/conftest.py Normal file
View File

@@ -0,0 +1,103 @@
import pytest
import factory
from httpx import ASGITransport, AsyncClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
import web.models # noqa: F401 — ensure all tables are registered on Base.metadata
from web.auth.password import hash_password
from web.database import Base, get_db
from web.main import app
from web.models.user import User, UserRoleEnum, UserStatusEnum
# ── Database fixtures ─────────────────────────────────────────────────────────
@pytest.fixture(scope="session")
def engine():
eng = create_engine(
"sqlite:///:memory:",
echo=False,
connect_args={"check_same_thread": False},
)
Base.metadata.create_all(eng)
yield eng
Base.metadata.drop_all(eng)
@pytest.fixture
def db_session(engine):
connection = engine.connect()
transaction = connection.begin()
Session = sessionmaker(bind=connection)
session = Session()
yield session
session.close()
transaction.rollback()
connection.close()
@pytest.fixture
def override_db(db_session):
"""Override FastAPI's get_db dependency with the transactional test session."""
app.dependency_overrides[get_db] = lambda: db_session
yield db_session
app.dependency_overrides.clear()
# ── HTTP client ───────────────────────────────────────────────────────────────
@pytest.fixture
async def client(override_db):
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as c:
yield c
# ── Factories ─────────────────────────────────────────────────────────────────
class UserFactory(factory.alchemy.SQLAlchemyModelFactory):
class Meta:
model = User
sqlalchemy_session_persistence = "commit"
first_name = factory.Faker("first_name")
last_name = factory.Faker("last_name")
email = factory.Sequence(lambda n: f"user{n}@test.com")
phone = factory.Sequence(lambda n: f"+7900{n:07d}")
password_hash = factory.LazyFunction(lambda: hash_password("testpass123"))
status = UserStatusEnum.active
is_email_confirmed = True
role = UserRoleEnum.user
@pytest.fixture
def user_factory(db_session):
UserFactory._meta.sqlalchemy_session = db_session
return UserFactory
@pytest.fixture
def active_user(user_factory):
return user_factory.create()
@pytest.fixture
def admin_user(user_factory):
return user_factory.create(role=UserRoleEnum.admin)
@pytest.fixture
def system_user(user_factory):
return user_factory.create(role=UserRoleEnum.system)
@pytest.fixture
def pending_user(user_factory):
return user_factory.create(
status=UserStatusEnum.pending,
is_email_confirmed=False,
password_hash=None,
)

View File

@@ -0,0 +1,26 @@
from web.auth.password import hash_password, verify_password
def test_hash_is_not_plaintext():
h = hash_password("secret123")
assert h != "secret123"
assert len(h) > 20
def test_verify_correct_password():
h = hash_password("mysecret")
assert verify_password("mysecret", h) is True
def test_verify_wrong_password():
h = hash_password("mysecret")
assert verify_password("wrongpassword", h) is False
def test_two_hashes_differ():
# bcrypt uses random salt — same plaintext produces different hashes
h1 = hash_password("same")
h2 = hash_password("same")
assert h1 != h2
assert verify_password("same", h1)
assert verify_password("same", h2)

12
tests/test_health.py Normal file
View File

@@ -0,0 +1,12 @@
import pytest
from httpx import ASGITransport, AsyncClient
from web.main import app
@pytest.mark.asyncio
async def test_health_returns_ok():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
resp = await client.get("/health")
assert resp.status_code == 200
assert resp.json() == {"status": "ok"}

View File

@@ -0,0 +1,47 @@
import logging
import pytest
from web.notifications.console import ConsoleEmailProvider, ConsoleSMSProvider
from web.notifications.registry import get_email_provider, get_sms_provider
def test_console_email_logs(caplog):
provider = ConsoleEmailProvider()
with caplog.at_level(logging.INFO, logger="web.notifications.console"):
provider.send("user@example.com", "Тест", '<a href="http://example.com/link">click</a>')
assert "user@example.com" in caplog.text
assert "Тест" in caplog.text
assert "http://example.com/link" in caplog.text
def test_console_sms_logs(caplog):
provider = ConsoleSMSProvider()
with caplog.at_level(logging.INFO, logger="web.notifications.console"):
provider.send("+79001234567", "Ваш код: 1234")
assert "+79001234567" in caplog.text
assert "Ваш код: 1234" in caplog.text
def test_registry_returns_console_email(monkeypatch):
monkeypatch.setattr("web.notifications.registry.settings.EMAIL_PROVIDER", "console")
provider = get_email_provider()
assert isinstance(provider, ConsoleEmailProvider)
def test_registry_returns_console_sms(monkeypatch):
monkeypatch.setattr("web.notifications.registry.settings.SMS_PROVIDER", "console")
provider = get_sms_provider()
assert isinstance(provider, ConsoleSMSProvider)
def test_registry_unknown_email_provider_raises(monkeypatch):
monkeypatch.setattr("web.notifications.registry.settings.EMAIL_PROVIDER", "sendgrid")
with pytest.raises(ValueError, match="sendgrid"):
get_email_provider()
def test_registry_unknown_sms_provider_raises(monkeypatch):
monkeypatch.setattr("web.notifications.registry.settings.SMS_PROVIDER", "twilio")
with pytest.raises(ValueError, match="twilio"):
get_sms_provider()

181
tests/test_routes_admin.py Normal file
View File

@@ -0,0 +1,181 @@
"""Integration tests for admin panel routes."""
import pytest
from web.models.user import User, UserRoleEnum, UserStatusEnum
def _set_session(client, user_id: int):
"""Inject a session cookie so the client appears logged in as user_id."""
client.cookies.set("session", "") # will be overwritten by actual login
# We inject directly into the app's session via a helper request
# The simplest approach: use the login endpoint to set the real session cookie
return user_id
async def _login(client, user):
"""Log in as user via the /login endpoint to get a real session cookie."""
resp = await client.post("/login", data={
"email": user.email,
"password": "testpass123",
}, follow_redirects=False)
assert resp.status_code == 303, f"Login failed: {resp.text}"
# ── Access control ────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_admin_users_requires_auth(client):
resp = await client.get("/admin/users", follow_redirects=False)
# Unauthenticated → redirect to login
assert resp.status_code in (302, 303, 307)
@pytest.mark.asyncio
async def test_admin_users_requires_admin_role(client, active_user):
await _login(client, active_user)
resp = await client.get("/admin/users", follow_redirects=False)
# Regular user → redirect (not admin)
assert resp.status_code in (302, 303, 307)
@pytest.mark.asyncio
async def test_admin_users_accessible_by_admin(client, admin_user):
await _login(client, admin_user)
resp = await client.get("/admin/users")
assert resp.status_code == 200
assert "Пользователи" in resp.text
@pytest.mark.asyncio
async def test_admin_users_accessible_by_system(client, system_user):
await _login(client, system_user)
resp = await client.get("/admin/users")
assert resp.status_code == 200
# ── User list + filters ───────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_admin_users_shows_all_users(client, admin_user, user_factory):
extra = user_factory.create(email="findme@test.com")
await _login(client, admin_user)
resp = await client.get("/admin/users")
assert resp.status_code == 200
assert extra.email in resp.text
@pytest.mark.asyncio
async def test_admin_users_search_filter(client, admin_user, user_factory):
target = user_factory.create(email="searchable@test.com")
await _login(client, admin_user)
resp = await client.get("/admin/users?search=searchable")
assert resp.status_code == 200
assert target.email in resp.text
# ── User detail ───────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_admin_user_detail(client, admin_user, active_user):
await _login(client, admin_user)
resp = await client.get(f"/admin/users/{active_user.id}")
assert resp.status_code == 200
assert active_user.email in resp.text
@pytest.mark.asyncio
async def test_admin_user_detail_not_found(client, admin_user):
await _login(client, admin_user)
resp = await client.get("/admin/users/99999", follow_redirects=False)
assert resp.status_code in (302, 303, 307)
# ── Activate / suspend ────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_admin_activate_user(client, admin_user, user_factory, override_db):
target = user_factory.create(status=UserStatusEnum.suspended)
await _login(client, admin_user)
resp = await client.post(f"/admin/users/{target.id}/activate", follow_redirects=False)
assert resp.status_code == 303
override_db.refresh(target)
assert target.status == UserStatusEnum.active
@pytest.mark.asyncio
async def test_admin_suspend_user(client, admin_user, active_user, override_db):
await _login(client, admin_user)
resp = await client.post(f"/admin/users/{active_user.id}/suspend", follow_redirects=False)
assert resp.status_code == 303
override_db.refresh(active_user)
assert active_user.status == UserStatusEnum.suspended
# ── Delete (system only) ──────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_admin_delete_user_by_system(client, system_user, user_factory, override_db):
target = user_factory.create()
target_id = target.id
await _login(client, system_user)
resp = await client.post(f"/admin/users/{target_id}/delete", follow_redirects=False)
assert resp.status_code == 303
assert override_db.get(User, target_id) is None
@pytest.mark.asyncio
async def test_admin_delete_blocked_for_admin_role(client, admin_user, active_user, override_db):
target_id = active_user.id
await _login(client, admin_user)
resp = await client.post(f"/admin/users/{target_id}/delete", follow_redirects=False)
assert resp.status_code == 303
# Admin cannot delete — user still exists
assert override_db.get(User, target_id) is not None
# ── Reset password / send invite ──────────────────────────────────────────────
@pytest.mark.asyncio
async def test_admin_reset_password_generates_token(client, admin_user, active_user, override_db):
from unittest.mock import patch
with patch("web.routes.admin.send_email_task") as mock_task:
await _login(client, admin_user)
resp = await client.post(
f"/admin/users/{active_user.id}/reset-password", follow_redirects=False
)
assert resp.status_code == 303
override_db.refresh(active_user)
assert active_user.password_reset_token is not None
mock_task.delay.assert_called_once()
@pytest.mark.asyncio
async def test_admin_send_invite(client, admin_user, active_user, override_db):
from unittest.mock import patch
with patch("web.routes.admin.send_email_task") as mock_task:
await _login(client, admin_user)
resp = await client.post(
f"/admin/users/{active_user.id}/send-invite", follow_redirects=False
)
assert resp.status_code == 303
override_db.refresh(active_user)
assert active_user.invite_token is not None
mock_task.delay.assert_called_once()
# ── Roles page (system only) ──────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_admin_roles_accessible_by_system(client, system_user):
await _login(client, system_user)
resp = await client.get("/admin/roles")
assert resp.status_code == 200
assert "Роли" in resp.text
@pytest.mark.asyncio
async def test_admin_roles_blocked_for_admin(client, admin_user):
await _login(client, admin_user)
resp = await client.get("/admin/roles", follow_redirects=False)
# Admin is redirected away from roles page
assert resp.status_code in (302, 303, 307)

182
tests/test_routes_auth.py Normal file
View File

@@ -0,0 +1,182 @@
"""Integration tests for auth routes (register / login / confirm-email / logout)."""
import secrets
from unittest.mock import patch
import pytest
from web.models.user import User, UserStatusEnum
# ── /register ────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_register_get(client):
resp = await client.get("/register")
assert resp.status_code == 200
assert "Регистрация" in resp.text
@pytest.mark.asyncio
@patch("web.routes.auth.send_email_task")
async def test_register_creates_pending_user(mock_task, client, override_db):
resp = await client.post("/register", data={
"first_name": "Иван",
"last_name": "Иванов",
"email": "ivan@test.com",
"phone": "+79001234567",
"password": "password123",
"password_confirm": "password123",
})
assert resp.status_code == 200
assert "Подтвердите" in resp.text
user = override_db.query(User).filter(User.email == "ivan@test.com").first()
assert user is not None
assert user.status == UserStatusEnum.pending
assert user.is_email_confirmed is False
assert user.email_confirm_token is not None
mock_task.delay.assert_called_once()
@pytest.mark.asyncio
@patch("web.routes.auth.send_email_task")
async def test_register_duplicate_email(mock_task, client, active_user):
resp = await client.post("/register", data={
"first_name": "X",
"last_name": "Y",
"email": active_user.email,
"phone": "+79999999999",
"password": "password123",
"password_confirm": "password123",
})
assert resp.status_code == 200
assert "уже существует" in resp.text
mock_task.delay.assert_not_called()
@pytest.mark.asyncio
async def test_register_password_mismatch(client):
resp = await client.post("/register", data={
"email": "new@test.com",
"phone": "+79000000001",
"password": "password123",
"password_confirm": "different",
})
assert resp.status_code == 200
assert "не совпадают" in resp.text
@pytest.mark.asyncio
async def test_register_short_password(client):
resp = await client.post("/register", data={
"email": "new@test.com",
"phone": "+79000000002",
"password": "short",
"password_confirm": "short",
})
assert resp.status_code == 200
assert "минимум 8" in resp.text
# ── /confirm-email ────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_confirm_email_valid_token(client, override_db, user_factory):
token = secrets.token_urlsafe(32)
user = user_factory.create(
is_email_confirmed=False,
email_confirm_token=token,
status=UserStatusEnum.pending,
)
resp = await client.get(f"/confirm-email?token={token}")
assert resp.status_code == 200
assert "подтвержден" in resp.text.lower()
override_db.refresh(user)
assert user.is_email_confirmed is True
assert user.status == UserStatusEnum.active
assert user.email_confirm_token is None
@pytest.mark.asyncio
async def test_confirm_email_invalid_token(client):
resp = await client.get("/confirm-email?token=bogustoken")
assert resp.status_code == 200
assert "Ошибка" in resp.text
@pytest.mark.asyncio
async def test_confirm_email_missing_token(client):
resp = await client.get("/confirm-email")
assert resp.status_code == 200
assert "Ошибка" in resp.text
# ── /login ────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_login_get(client):
resp = await client.get("/login")
assert resp.status_code == 200
assert "Вход" in resp.text
@pytest.mark.asyncio
async def test_login_success(client, active_user):
resp = await client.post("/login", data={
"email": active_user.email,
"password": "testpass123",
}, follow_redirects=False)
assert resp.status_code == 303
assert resp.headers["location"] == "/profile"
@pytest.mark.asyncio
async def test_login_wrong_password(client, active_user):
resp = await client.post("/login", data={
"email": active_user.email,
"password": "wrongpassword",
})
assert resp.status_code == 200
assert "Неверный" in resp.text
@pytest.mark.asyncio
async def test_login_unknown_email(client):
resp = await client.post("/login", data={
"email": "nobody@test.com",
"password": "testpass123",
})
assert resp.status_code == 200
assert "Неверный" in resp.text
@pytest.mark.asyncio
async def test_login_suspended_user(client, user_factory):
user = user_factory.create(status=UserStatusEnum.suspended)
resp = await client.post("/login", data={
"email": user.email,
"password": "testpass123",
})
assert resp.status_code == 200
assert "заблокирован" in resp.text.lower()
@pytest.mark.asyncio
async def test_login_unconfirmed_email(client, user_factory):
user = user_factory.create(is_email_confirmed=False, status=UserStatusEnum.pending)
resp = await client.post("/login", data={
"email": user.email,
"password": "testpass123",
})
assert resp.status_code == 200
assert "подтвердите" in resp.text.lower()
# ── /logout ───────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_logout_redirects(client):
resp = await client.get("/logout", follow_redirects=False)
assert resp.status_code == 303
assert resp.headers["location"] == "/login"

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"]

View File

@@ -0,0 +1,196 @@
"""Integration tests for Evotor webhook endpoints."""
import json
from unittest.mock import patch
import pytest
from web.models.connections import EvotorConnection
from web.models.user import User, UserStatusEnum
WEBHOOK_SECRET = "test-secret-abc"
def auth_headers(secret=WEBHOOK_SECRET):
return {"Authorization": f"Bearer {secret}"}
# ── /user/create ──────────────────────────────────────────────────────────────
@pytest.mark.asyncio
@patch("web.routes.evotor_webhooks.send_email_task")
async def test_user_create_new_user(mock_task, client, override_db, monkeypatch):
monkeypatch.setattr("web.routes.evotor_webhooks.settings.EVOTOR_WEBHOOK_SECRET", WEBHOOK_SECRET)
monkeypatch.setattr("web.routes.evotor_webhooks.settings.INVITE_EXPIRE_HOURS", 48)
monkeypatch.setattr("web.routes.evotor_webhooks.settings.BASE_URL", "http://test")
payload = {
"userId": "evo-001",
"customField": json.dumps({"email": "newuser@test.com", "phone": "+79001234501"}),
}
resp = await client.post("/user/create", json=payload, headers=auth_headers())
assert resp.status_code == 200
data = resp.json()
assert data["userId"] == "evo-001"
assert "token" in data
assert len(data["token"]) > 10
user = override_db.query(User).filter(User.evotor_user_id == "evo-001").first()
assert user is not None
assert user.status == UserStatusEnum.pending
assert user.invite_token is not None
assert user.password_hash is None
conn = override_db.query(EvotorConnection).filter(
EvotorConnection.evotor_user_id == "evo-001"
).first()
assert conn is not None
assert conn.api_token == data["token"]
mock_task.delay.assert_called_once()
@pytest.mark.asyncio
@patch("web.routes.evotor_webhooks.send_email_task")
async def test_user_create_links_existing_user_by_email(mock_task, client, override_db, user_factory, monkeypatch):
monkeypatch.setattr("web.routes.evotor_webhooks.settings.EVOTOR_WEBHOOK_SECRET", WEBHOOK_SECRET)
monkeypatch.setattr("web.routes.evotor_webhooks.settings.INVITE_EXPIRE_HOURS", 48)
monkeypatch.setattr("web.routes.evotor_webhooks.settings.BASE_URL", "http://test")
existing = user_factory.create(email="existing@test.com")
assert existing.evotor_user_id is None
payload = {
"userId": "evo-link-001",
"customField": json.dumps({"email": "existing@test.com"}),
}
resp = await client.post("/user/create", json=payload, headers=auth_headers())
assert resp.status_code == 200
override_db.refresh(existing)
assert existing.evotor_user_id == "evo-link-001"
@pytest.mark.asyncio
async def test_user_create_wrong_secret(client, monkeypatch):
monkeypatch.setattr("web.routes.evotor_webhooks.settings.EVOTOR_WEBHOOK_SECRET", WEBHOOK_SECRET)
resp = await client.post("/user/create", json={"userId": "x"}, headers={"Authorization": "Bearer wrong"})
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_user_create_missing_user_id(client, monkeypatch):
monkeypatch.setattr("web.routes.evotor_webhooks.settings.EVOTOR_WEBHOOK_SECRET", "")
resp = await client.post("/user/create", json={"customField": "{}"})
assert resp.status_code == 400
@pytest.mark.asyncio
@patch("web.routes.evotor_webhooks.send_email_task")
async def test_user_create_no_secret_dev_mode(mock_task, client, override_db, monkeypatch):
monkeypatch.setattr("web.routes.evotor_webhooks.settings.EVOTOR_WEBHOOK_SECRET", "")
monkeypatch.setattr("web.routes.evotor_webhooks.settings.INVITE_EXPIRE_HOURS", 48)
monkeypatch.setattr("web.routes.evotor_webhooks.settings.BASE_URL", "http://test")
resp = await client.post("/user/create", json={"userId": "evo-dev-001"})
assert resp.status_code == 200
assert resp.json()["userId"] == "evo-dev-001"
# ── /user/verify ──────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_user_verify_success(client, override_db, user_factory, monkeypatch):
monkeypatch.setattr("web.routes.evotor_webhooks.settings.EVOTOR_WEBHOOK_SECRET", WEBHOOK_SECRET)
user = user_factory.create(evotor_user_id="evo-verify-001")
conn = EvotorConnection(
user_id=user.id,
evotor_user_id="evo-verify-001",
access_token="evotor-access-token",
api_token="my-api-token-xyz",
)
override_db.add(conn)
override_db.commit()
resp = await client.post("/user/verify", json={
"userId": "evo-verify-001",
"username": user.email,
"password": "testpass123",
}, headers=auth_headers())
assert resp.status_code == 200
data = resp.json()
assert data["userId"] == "evo-verify-001"
assert "token" in data
@pytest.mark.asyncio
async def test_user_verify_wrong_password(client, user_factory, monkeypatch):
monkeypatch.setattr("web.routes.evotor_webhooks.settings.EVOTOR_WEBHOOK_SECRET", WEBHOOK_SECRET)
user = user_factory.create()
resp = await client.post("/user/verify", json={
"userId": "x",
"username": user.email,
"password": "wrongpass",
}, headers=auth_headers())
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_user_verify_suspended(client, user_factory, monkeypatch):
monkeypatch.setattr("web.routes.evotor_webhooks.settings.EVOTOR_WEBHOOK_SECRET", WEBHOOK_SECRET)
user = user_factory.create(status=UserStatusEnum.suspended)
resp = await client.post("/user/verify", json={
"userId": "x",
"username": user.email,
"password": "testpass123",
}, headers=auth_headers())
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_user_verify_no_password_hash(client, user_factory, monkeypatch):
monkeypatch.setattr("web.routes.evotor_webhooks.settings.EVOTOR_WEBHOOK_SECRET", WEBHOOK_SECRET)
user = user_factory.create(password_hash=None)
resp = await client.post("/user/verify", json={
"userId": "x",
"username": user.email,
"password": "anything",
}, headers=auth_headers())
assert resp.status_code == 401
# ── /user/token ───────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_user_token_updates_connection(client, override_db, user_factory, monkeypatch):
monkeypatch.setattr("web.routes.evotor_webhooks.settings.EVOTOR_WEBHOOK_SECRET", WEBHOOK_SECRET)
user = user_factory.create(evotor_user_id="evo-token-001")
old_conn = EvotorConnection(
user_id=user.id,
evotor_user_id="evo-token-001",
access_token="old-token",
api_token="api-tok",
)
override_db.add(old_conn)
override_db.commit()
resp = await client.post("/user/token", json={
"userId": "evo-token-001",
"token": "new-evotor-token-xyz",
}, headers=auth_headers())
assert resp.status_code == 200
assert resp.json() == {}
override_db.refresh(old_conn)
assert old_conn.access_token == "new-evotor-token-xyz"
assert old_conn.is_online is True
@pytest.mark.asyncio
async def test_user_token_unknown_user(client, monkeypatch):
monkeypatch.setattr("web.routes.evotor_webhooks.settings.EVOTOR_WEBHOOK_SECRET", WEBHOOK_SECRET)
resp = await client.post("/user/token", json={
"userId": "does-not-exist",
"token": "some-token",
}, headers=auth_headers())
assert resp.status_code == 404

View File

@@ -0,0 +1,95 @@
"""Integration tests for the Evotor invite flow (/invite)."""
import secrets
from datetime import datetime, timezone, timedelta
from unittest.mock import patch
import pytest
from web.models.user import User, UserStatusEnum
@pytest.mark.asyncio
async def test_invite_get_valid_token(client, override_db, user_factory):
token = secrets.token_urlsafe(32)
user = user_factory.create(
invite_token=token,
invite_expires=datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(hours=48),
password_hash=None,
status=UserStatusEnum.pending,
is_email_confirmed=False,
)
resp = await client.get(f"/invite?token={token}")
assert resp.status_code == 200
assert "Завершение регистрации" in resp.text or "ЭВОСИНК" in resp.text
@pytest.mark.asyncio
async def test_invite_get_expired_token(client, user_factory):
token = secrets.token_urlsafe(32)
user_factory.create(
invite_token=token,
invite_expires=datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(hours=1),
password_hash=None,
status=UserStatusEnum.pending,
)
resp = await client.get(f"/invite?token={token}")
assert resp.status_code == 200
assert "недействительна" in resp.text
@pytest.mark.asyncio
async def test_invite_get_bogus_token(client):
resp = await client.get("/invite?token=notexist")
assert resp.status_code == 200
assert "недействительна" in resp.text
@pytest.mark.asyncio
async def test_invite_post_activates_user(client, override_db, user_factory):
token = secrets.token_urlsafe(32)
user = user_factory.create(
email="invite@test.com",
phone="+79001119999",
invite_token=token,
invite_expires=datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(hours=48),
password_hash=None,
status=UserStatusEnum.pending,
is_email_confirmed=False,
)
resp = await client.post(f"/invite?token={token}", data={
"first_name": "Петр",
"last_name": "Петров",
"email": "invite@test.com",
"phone": "+79001119999",
"password": "newpassword1",
"password_confirm": "newpassword1",
})
assert resp.status_code == 200
assert "активирован" in resp.text.lower()
override_db.refresh(user)
assert user.status == UserStatusEnum.active
assert user.is_email_confirmed is True
assert user.password_hash is not None
assert user.invite_token is None
@pytest.mark.asyncio
async def test_invite_post_password_mismatch(client, user_factory):
token = secrets.token_urlsafe(32)
user_factory.create(
invite_token=token,
invite_expires=datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(hours=48),
password_hash=None,
status=UserStatusEnum.pending,
)
resp = await client.post(f"/invite?token={token}", data={
"first_name": "А",
"last_name": "Б",
"email": "x@test.com",
"phone": "+79001112233",
"password": "password123",
"password_confirm": "different",
})
assert resp.status_code == 200
assert "не совпадают" in resp.text

133
tests/test_tasks_catalog.py Normal file
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

@@ -0,0 +1,40 @@
"""Unit tests for _parse_custom_fields — no DB or HTTP needed."""
import json
from web.routes.evotor_webhooks import _parse_custom_fields
def test_none_returns_empty():
assert _parse_custom_fields(None) == {}
def test_dict_passthrough():
d = {"email": "a@b.com", "phone": "+79001234567"}
assert _parse_custom_fields(d) == d
def test_json_string_parsed():
raw = json.dumps({"email": "a@b.com", "firstName": "Иван"})
result = _parse_custom_fields(raw)
assert result["email"] == "a@b.com"
assert result["firstName"] == "Иван"
def test_plain_string_returns_empty():
# A plain non-JSON string cannot be parsed into fields
assert _parse_custom_fields("just some text") == {}
def test_json_array_returns_empty():
# A JSON array is not a dict — treat as unparseable
assert _parse_custom_fields("[1, 2, 3]") == {}
def test_empty_string_returns_empty():
assert _parse_custom_fields("") == {}
def test_nested_values_preserved():
raw = {"email": "x@y.com", "meta": {"key": "val"}}
result = _parse_custom_fields(raw)
assert result["meta"]["key"] == "val"

View File

@@ -1 +0,0 @@
1.8.1

View File

@@ -1,23 +0,0 @@
from fastapi import Request, Depends
from sqlalchemy.orm import Session
from passlib.context import CryptContext
from web.database import get_db
from web.models import User
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def get_current_user(request: Request, db: Session = Depends(get_db)) -> User | None:
user_id = request.session.get("user_id")
if not user_id:
return None
return db.query(User).filter(User.id == user_id).first()

0
web/auth/__init__.py Normal file
View File

12
web/auth/password.py Normal file
View File

@@ -0,0 +1,12 @@
import bcrypt
def hash_password(plain: str) -> str:
return bcrypt.hashpw(plain.encode(), bcrypt.gensalt(rounds=12)).decode()
def verify_password(plain: str, hashed: str) -> bool:
try:
return bcrypt.checkpw(plain.encode(), hashed.encode())
except Exception:
return False

35
web/auth/rbac.py Normal file
View File

@@ -0,0 +1,35 @@
from fastapi import Depends, HTTPException
from sqlalchemy.orm import Session
from starlette.requests import Request
from web.auth.session import get_current_user
from web.database import get_db
from web.models.rbac import Permission, UserRole, role_permissions
from web.models.user import User, UserRoleEnum
def require_role(*roles: str):
def dep(request: Request, db: Session = Depends(get_db)) -> User:
user = get_current_user(request, db)
if user.role.value not in roles:
raise HTTPException(status_code=403, detail="Недостаточно прав")
return user
return Depends(dep)
def require_permission(permission_name: str):
def dep(request: Request, db: Session = Depends(get_db)) -> User:
user = get_current_user(request, db)
if user.role == UserRoleEnum.system:
return user
has = (
db.query(Permission)
.join(role_permissions, Permission.id == role_permissions.c.permission_id)
.join(UserRole, UserRole.role_id == role_permissions.c.role_id)
.filter(UserRole.user_id == user.id, Permission.name == permission_name)
.first()
)
if not has:
raise HTTPException(status_code=403, detail="Недостаточно прав")
return user
return Depends(dep)

42
web/auth/session.py Normal file
View File

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

View File

@@ -2,29 +2,40 @@ from pydantic_settings import BaseSettings
class Settings(BaseSettings):
DATABASE_URL: str = "mysql+pymysql://evosync:evosync@localhost:3306/evosync"
DATABASE_URL: str = "mysql+pymysql://evosync:evosync@db:3306/evosync"
REDIS_URL: str = "redis://redis:6379/0"
SECRET_KEY: str = "change-me-in-production"
BASE_URL: str = "http://localhost:8000"
PASSWORD_RESET_EXPIRE_MINUTES: int = 60
EVOTOR_CLIENT_ID: str = ""
EVOTOR_CLIENT_SECRET: str = ""
EVOTOR_SCOPES: str = "store:read product:read"
HEALTH_CHECK_INTERVAL_SECONDS: int = 600
EVOTOR_APP_ID: str = ""
EVOTOR_WEBHOOK_SECRET: str = ""
VK_CLIENT_ID: str = ""
VK_CLIENT_SECRET: str = ""
VK_SCOPES: str = "market groups offline"
VK_API_VERSION: str = "5.131"
# Docker compose vars (ignored in app, kept for env compatibility)
JIVOSITE_WIDGET_ID: str = ""
VK_DEFAULT_PHOTO_PATH: str = "/app/default_product.png"
VK_API_VERSION: str = "5.199"
VK_CATEGORY_ID: int = 40932
VK_STOCK_AMOUNT: int = 1000
CATALOG_REFRESH_INTERVAL_SECONDS: int = 3600
INVITE_EXPIRE_HOURS: int = 48
EMAIL_PROVIDER: str = "console"
SMS_PROVIDER: str = "console"
SYSTEM_USER_EMAIL: str = ""
SYSTEM_USER_PASSWORD: str = ""
FLOWER_USER: str = "admin"
FLOWER_PASSWORD: str = "changeme"
DB_ROOT_PASSWORD: str = ""
DB_NAME: str = ""
DB_USER: str = ""
DB_PASSWORD: str = ""
model_config = {"env_file": ".env", "case_sensitive": False}
model_config = {"env_file": ".env", "case_sensitive": False, "extra": "ignore"}
settings = Settings()

View File

@@ -1,10 +1,15 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from web.config import settings
engine = create_engine(settings.DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
engine = create_engine(
settings.DATABASE_URL,
pool_pre_ping=True,
pool_recycle=3600,
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):

View File

@@ -1,115 +0,0 @@
from datetime import datetime
import httpx
from sqlalchemy.orm import Session
EVOTOR_API_BASE = "https://api.evotor.ru"
async def fetch_stores(access_token: str) -> list[dict]:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{EVOTOR_API_BASE}/stores",
headers={"Authorization": f"Bearer {access_token}"},
timeout=15,
)
resp.raise_for_status()
data = resp.json()
items = data.get("items", data) if isinstance(data, dict) else data
return [
{
"id": s.get("uuid") or s.get("id"),
"name": s.get("name"),
"address": s.get("address"),
}
for s in items
]
async def fetch_groups(access_token: str, store_id: str) -> list[dict]:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{EVOTOR_API_BASE}/stores/{store_id}/product-groups",
headers={"Authorization": f"Bearer {access_token}"},
timeout=15,
)
resp.raise_for_status()
data = resp.json()
items = data.get("items", data) if isinstance(data, dict) else data
return [{"id": g.get("uuid") or g.get("id"), "name": g.get("name")} for g in items]
async def fetch_products(access_token: str, store_id: str) -> list[dict]:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{EVOTOR_API_BASE}/stores/{store_id}/products",
headers={"Authorization": f"Bearer {access_token}"},
timeout=15,
)
resp.raise_for_status()
data = resp.json()
items = data.get("items", data) if isinstance(data, dict) else data
return [
{
"id": p.get("uuid") or p.get("id"),
"name": p.get("name"),
"parent_id": p.get("parentUuid") or p.get("parent_id"),
"price": p.get("price"),
"quantity": p.get("quantity"),
"measure_name": p.get("measureName") or p.get("measure_name"),
"article_number": p.get("code") or p.get("article_number"),
"allow_to_sell": p.get("allowToSell") if p.get("allowToSell") is not None else p.get("allow_to_sell"),
}
for p in items
]
async def refresh_catalog_cache(user_id: int, access_token: str, db: Session) -> None:
from web.models import CachedStore, CachedGroup, CachedProduct
now = datetime.utcnow()
# Delete old cache for user
db.query(CachedProduct).filter(CachedProduct.user_id == user_id).delete()
db.query(CachedGroup).filter(CachedGroup.user_id == user_id).delete()
db.query(CachedStore).filter(CachedStore.user_id == user_id).delete()
db.commit()
stores = await fetch_stores(access_token)
for store in stores:
db.add(CachedStore(
user_id=user_id,
evotor_id=store["id"],
name=store["name"] or "",
address=store.get("address"),
fetched_at=now,
))
db.commit()
for store in stores:
groups = await fetch_groups(access_token, store["id"])
for group in groups:
db.add(CachedGroup(
user_id=user_id,
evotor_id=group["id"],
store_evotor_id=store["id"],
name=group["name"] or "",
fetched_at=now,
))
products = await fetch_products(access_token, store["id"])
for product in products:
db.add(CachedProduct(
user_id=user_id,
evotor_id=product["id"],
store_evotor_id=store["id"],
group_evotor_id=product.get("parent_id"),
name=product["name"] or "",
price=product.get("price"),
quantity=product.get("quantity"),
measure_name=product.get("measure_name"),
article_number=product.get("article_number"),
allow_to_sell=product.get("allow_to_sell"),
fetched_at=now,
))
db.commit()

View File

@@ -1,134 +0,0 @@
import asyncio
import logging
from datetime import datetime, timedelta
import httpx
from web.database import SessionLocal
from web.models import EvotorConnection, VkConnection
logger = logging.getLogger("uvicorn.error")
EVOTOR_STORES_URL = "https://api.evotor.ru/stores"
EVOTOR_TOKEN_URL = "https://oauth.evotor.ru/oauth/token"
VK_USERS_GET_URL = "https://api.vk.com/method/users.get"
VK_API_VERSION = "5.131"
# Refresh Evotor token if it expires within this window
REFRESH_BEFORE_EXPIRY = timedelta(hours=2)
async def _refresh_evotor_token(conn: EvotorConnection) -> str | None:
"""Attempt to refresh the Evotor access token. Returns new access token or None."""
from web.config import settings
if not conn.refresh_token:
return None
try:
async with httpx.AsyncClient() as client:
resp = await client.post(
EVOTOR_TOKEN_URL,
data={
"grant_type": "refresh_token",
"refresh_token": conn.refresh_token,
},
auth=(settings.EVOTOR_CLIENT_ID, settings.EVOTOR_CLIENT_SECRET),
timeout=15,
)
if resp.status_code != 200:
return None
data = resp.json()
return data if data.get("access_token") else None
except Exception:
return None
async def check_evotor_connection(access_token: str) -> bool:
try:
async with httpx.AsyncClient() as client:
response = await client.get(
EVOTOR_STORES_URL,
headers={"Authorization": f"Bearer {access_token}"},
timeout=15,
)
return response.status_code == 200
except Exception:
return False
async def check_vk_connection(access_token: str) -> bool:
try:
async with httpx.AsyncClient() as client:
resp = await client.get(
VK_USERS_GET_URL,
params={"access_token": access_token, "v": VK_API_VERSION},
timeout=10,
)
if resp.status_code != 200:
return False
data = resp.json()
return "error" not in data
except Exception:
return False
async def run_health_checks() -> None:
db = SessionLocal()
try:
now = datetime.utcnow()
evotor_connections = db.query(EvotorConnection).all()
for conn in evotor_connections:
# Proactively refresh if token expires soon
needs_refresh = (
conn.refresh_token and
conn.token_expires_at and
conn.token_expires_at - now < REFRESH_BEFORE_EXPIRY
)
if needs_refresh:
token_data = await _refresh_evotor_token(conn)
if token_data:
conn.access_token = token_data["access_token"]
conn.refresh_token = token_data.get("refresh_token", conn.refresh_token)
expires_in = token_data.get("expires_in")
conn.token_expires_at = now + timedelta(seconds=expires_in) if expires_in else None
logger.info("Refreshed Evotor token for user_id=%d", conn.user_id)
is_online = await check_evotor_connection(conn.access_token)
# If offline and not yet tried refresh, attempt it now
if not is_online and conn.refresh_token and not needs_refresh:
token_data = await _refresh_evotor_token(conn)
if token_data:
conn.access_token = token_data["access_token"]
conn.refresh_token = token_data.get("refresh_token", conn.refresh_token)
expires_in = token_data.get("expires_in")
conn.token_expires_at = now + timedelta(seconds=expires_in) if expires_in else None
is_online = await check_evotor_connection(conn.access_token)
if is_online:
logger.info("Evotor token refreshed after failed check for user_id=%d", conn.user_id)
conn.is_online = is_online
conn.last_checked_at = now
vk_connections = db.query(VkConnection).all()
for conn in vk_connections:
conn.is_online = await check_vk_connection(conn.access_token)
conn.last_checked_at = now
db.commit()
logger.info(
"Health checks completed: %d Evotor, %d VK",
len(evotor_connections),
len(vk_connections),
)
except Exception:
logger.exception("Error during health checks")
db.rollback()
finally:
db.close()
async def health_check_loop(interval: int) -> None:
while True:
await run_health_checks()
await asyncio.sleep(interval)

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,47 +1,100 @@
import asyncio
from contextlib import asynccontextmanager
import logging
from fastapi import FastAPI, Depends, Request
from fastapi.responses import RedirectResponse
from fastapi import Depends, FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from web.auth import get_current_user
from web.config import settings
from web.health_checker import health_check_loop
from web.models import User
from web.routes import auth, profile, reset, evotor, vk, sync, catalog
from web.routes import connections
try:
from pythonjsonlogger import jsonlogger
handler = logging.StreamHandler()
handler.setFormatter(jsonlogger.JsonFormatter("%(asctime)s %(levelname)s %(name)s %(message)s"))
logging.root.addHandler(handler)
except ImportError:
logging.basicConfig(level=logging.INFO)
logging.root.setLevel(logging.INFO)
from web.config import settings # noqa: E402 — after logging setup
from web.templates_env import templates # noqa: E402
@asynccontextmanager
async def lifespan(app: FastAPI):
task = asyncio.create_task(health_check_loop(settings.HEALTH_CHECK_INTERVAL_SECONDS))
yield
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
app = FastAPI(title="ЭвоСинк")
app.add_middleware(
SessionMiddleware,
secret_key=settings.SECRET_KEY,
max_age=86400 * 30,
https_only=False,
)
app = FastAPI(title="ЭВОСИНК — Личный кабинет", lifespan=lifespan)
app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY)
app.mount("/static", StaticFiles(directory="web/static"), name="static")
app.include_router(auth.router)
app.include_router(profile.router)
app.include_router(reset.router)
app.include_router(evotor.router)
app.include_router(connections.router)
app.include_router(vk.router)
app.include_router(sync.router)
app.include_router(catalog.router)
# ── Routers ───────────────────────────────────────────────────────────────────
from web.routes.auth import router as auth_router # noqa: E402
from web.routes.reset import router as reset_router # noqa: E402
from web.routes.invite import router as invite_router # noqa: E402
from web.routes.profile import router as profile_router # noqa: E402
from web.routes.evotor_webhooks import router as evotor_webhooks_router # noqa: E402
from web.routes.admin import router as admin_router # noqa: E402
from web.routes.catalog import router as catalog_router # noqa: E402
from web.routes.connections import router as connections_router # noqa: E402
from web.routes.vk_catalog import router as vk_catalog_router # noqa: E402
from web.routes.logs import router as logs_router # noqa: E402
from web.routes.sync import router as sync_router # noqa: E402
from web.database import get_db # noqa: E402
from web.models.user import User # noqa: E402
app.include_router(auth_router)
app.include_router(reset_router)
app.include_router(invite_router)
app.include_router(profile_router)
app.include_router(evotor_webhooks_router)
app.include_router(admin_router)
app.include_router(catalog_router)
app.include_router(connections_router)
app.include_router(vk_catalog_router)
app.include_router(logs_router)
app.include_router(sync_router)
# ── Catalog redirect ─────────────────────────────────────────────────────────
@app.get("/catalog")
async def catalog_redirect():
from fastapi.responses import RedirectResponse
return RedirectResponse("/catalog/stores", 302)
# ── Health ────────────────────────────────────────────────────────────────────
@app.get("/health")
async def health():
return {"status": "ok"}
# ── Root redirect ─────────────────────────────────────────────────────────────
@app.get("/")
def home(request: Request, user: User | None = Depends(get_current_user)):
if user:
return RedirectResponse("/profile", 302)
return RedirectResponse("/login", 302)
async def root(request: Request, db=Depends(get_db)):
from fastapi.responses import RedirectResponse
from web.models.user import UserRoleEnum
user_id = request.session.get("user_id")
if user_id:
user = db.get(User, user_id)
if user and user.role in (UserRoleEnum.admin, UserRoleEnum.system):
return RedirectResponse("/admin/users", 303)
return RedirectResponse("/profile", 303)
return RedirectResponse("/login", 303)
# ── 403 handler ───────────────────────────────────────────────────────────────
from fastapi import HTTPException # noqa: E402
from fastapi.exception_handlers import http_exception_handler # noqa: E402
@app.exception_handler(403)
async def forbidden_handler(request: Request, exc: HTTPException) -> HTMLResponse:
return templates.TemplateResponse(request, "message.html", {
"user": None,
"title": "Нет доступа",
"message": "У вас недостаточно прав для просмотра этой страницы.",
"link": "/profile",
"link_text": "В личный кабинет",
"jivosite_widget_id": settings.JIVOSITE_WIDGET_ID,
}, status_code=403)

View File

@@ -1 +0,0 @@
Generic single-database configuration.

View File

@@ -1,21 +1,16 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
from sqlalchemy import engine_from_config, pool
from web.config import settings
from web.database import Base
from web.models import User, EvotorConnection # noqa: F401 — register models with Base
import web.models # noqa: F401 — ensure all models are imported before autogenerate
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
target_metadata = Base.metadata
@@ -27,23 +22,24 @@ def run_migrations_offline() -> None:
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
from web.config import settings
configuration = config.get_section(config.config_ini_section, {})
configuration["sqlalchemy.url"] = settings.DATABASE_URL
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()

View File

@@ -11,7 +11,6 @@ from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}

View File

@@ -0,0 +1,21 @@
"""initial
Revision ID: 0001
Revises:
Create Date: 2026-04-27
"""
from typing import Sequence, Union
revision: str = "0001"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass

View File

@@ -0,0 +1,238 @@
"""users and connections schema
Revision ID: 0002
Revises: 0001
Create Date: 2026-04-28
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0002"
down_revision: Union[str, None] = "0001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
# ── users ────────────────────────────────────────────────────────────────
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL,
phone VARCHAR(20) NOT NULL,
password_hash VARCHAR(255) NULL,
is_email_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
email_confirm_token VARCHAR(255) NULL,
password_reset_token VARCHAR(255) NULL,
password_reset_expires DATETIME NULL,
role ENUM('system','admin','user') NOT NULL DEFAULT 'user',
status ENUM('pending','active','suspended') NOT NULL DEFAULT 'pending',
evotor_user_id VARCHAR(255) NULL,
evotor_meta JSON NULL,
invite_token VARCHAR(255) NULL,
invite_expires DATETIME NULL,
phone_otp VARCHAR(10) NULL,
phone_otp_expires DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT ix_users_email UNIQUE (email),
CONSTRAINT ix_users_phone UNIQUE (phone),
CONSTRAINT ix_users_evotor_user_id UNIQUE (evotor_user_id)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
"""))
# Add new columns if migrating from the Node.js schema (which lacked them)
for col_sql in [
"ALTER TABLE users MODIFY COLUMN password_hash VARCHAR(255) NULL",
"ALTER TABLE users ADD COLUMN IF NOT EXISTS role ENUM('system','admin','user') NOT NULL DEFAULT 'user'",
"ALTER TABLE users ADD COLUMN IF NOT EXISTS status ENUM('pending','active','suspended') NOT NULL DEFAULT 'pending'",
"ALTER TABLE users ADD COLUMN IF NOT EXISTS evotor_user_id VARCHAR(255) NULL",
"ALTER TABLE users ADD COLUMN IF NOT EXISTS evotor_meta JSON NULL",
"ALTER TABLE users ADD COLUMN IF NOT EXISTS invite_token VARCHAR(255) NULL",
"ALTER TABLE users ADD COLUMN IF NOT EXISTS invite_expires DATETIME NULL",
"ALTER TABLE users ADD COLUMN IF NOT EXISTS phone_otp VARCHAR(10) NULL",
"ALTER TABLE users ADD COLUMN IF NOT EXISTS phone_otp_expires DATETIME NULL",
]:
try:
conn.execute(sa.text(col_sql))
except Exception:
pass # column/constraint already exists
# Add unique index on evotor_user_id if missing
try:
conn.execute(sa.text(
"ALTER TABLE users ADD CONSTRAINT ix_users_evotor_user_id UNIQUE (evotor_user_id)"
))
except Exception:
pass
# Add role/status indexes if missing
for idx_sql in [
"CREATE INDEX IF NOT EXISTS ix_users_role ON users (role)",
"CREATE INDEX IF NOT EXISTS ix_users_status ON users (status)",
]:
try:
conn.execute(sa.text(idx_sql))
except Exception:
pass
# ── evotor_connections ───────────────────────────────────────────────────
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS evotor_connections (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NULL,
evotor_user_id VARCHAR(255) NULL,
access_token TEXT NOT NULL,
api_token VARCHAR(255) NULL,
store_id VARCHAR(255) NULL,
store_name VARCHAR(255) NULL,
refresh_token TEXT NULL,
token_expires_at DATETIME NULL,
is_online BOOLEAN NOT NULL DEFAULT FALSE,
last_checked_at DATETIME NULL,
connected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT ix_evotor_connections_user_id UNIQUE (user_id),
CONSTRAINT ix_evotor_connections_evotor_user_id UNIQUE (evotor_user_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
"""))
try:
conn.execute(sa.text(
"ALTER TABLE evotor_connections ADD COLUMN IF NOT EXISTS api_token VARCHAR(255) NULL"
))
except Exception:
pass
# ── vk_connections ───────────────────────────────────────────────────────
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS vk_connections (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
access_token TEXT NOT NULL,
vk_user_id VARCHAR(50) NULL,
first_name VARCHAR(255) NULL,
last_name VARCHAR(255) NULL,
is_online BOOLEAN NOT NULL DEFAULT FALSE,
last_checked_at DATETIME NULL,
connected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT ix_vk_connections_user_id UNIQUE (user_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
"""))
# ── sync_configs ─────────────────────────────────────────────────────────
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS sync_configs (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
is_enabled BOOLEAN NOT NULL DEFAULT FALSE,
confirmed_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT ix_sync_configs_user_id UNIQUE (user_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
"""))
# ── sync_filters ─────────────────────────────────────────────────────────
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS sync_filters (
id INT AUTO_INCREMENT PRIMARY KEY,
sync_config_id INT NOT NULL,
entity_type VARCHAR(20) NOT NULL,
entity_id VARCHAR(255) NOT NULL,
entity_name VARCHAR(255) NULL,
filter_mode VARCHAR(10) NOT NULL,
parent_entity_id VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_sync_filters_config_type_entity UNIQUE (sync_config_id, entity_type, entity_id),
FOREIGN KEY (sync_config_id) REFERENCES sync_configs(id) ON DELETE CASCADE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
"""))
# ── cached_stores ────────────────────────────────────────────────────────
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS cached_stores (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
evotor_id VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
address VARCHAR(500) NULL,
fetched_at DATETIME NOT NULL,
CONSTRAINT uq_cached_stores_user_evotor UNIQUE (user_id, evotor_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
"""))
try:
conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_cached_stores_user_id ON cached_stores (user_id)"))
except Exception:
pass
# ── cached_groups ────────────────────────────────────────────────────────
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS cached_groups (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
evotor_id VARCHAR(255) NOT NULL,
store_evotor_id VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
fetched_at DATETIME NOT NULL,
CONSTRAINT uq_cached_groups_user_evotor UNIQUE (user_id, evotor_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
"""))
try:
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_cached_groups_user_store ON cached_groups (user_id, store_evotor_id)"
))
except Exception:
pass
# ── cached_products ──────────────────────────────────────────────────────
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS cached_products (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
evotor_id VARCHAR(255) NOT NULL,
store_evotor_id VARCHAR(255) NOT NULL,
group_evotor_id VARCHAR(255) NULL,
name VARCHAR(255) NOT NULL,
price DECIMAL(12,2) NULL,
quantity DECIMAL(12,3) NULL,
measure_name VARCHAR(20) NULL,
article_number VARCHAR(100) NULL,
allow_to_sell BOOLEAN NULL,
fetched_at DATETIME NOT NULL,
synced_at DATETIME NULL,
CONSTRAINT uq_cached_products_user_evotor UNIQUE (user_id, evotor_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
"""))
try:
conn.execute(sa.text(
"CREATE INDEX IF NOT EXISTS ix_cached_products_user_store_group "
"ON cached_products (user_id, store_evotor_id, group_evotor_id)"
))
except Exception:
pass
def downgrade() -> None:
conn = op.get_bind()
for table in [
"cached_products", "cached_groups", "cached_stores",
"sync_filters", "sync_configs",
"vk_connections", "evotor_connections",
"users",
]:
conn.execute(sa.text(f"DROP TABLE IF EXISTS {table}"))

View File

@@ -0,0 +1,105 @@
"""RBAC tables with default roles and permissions
Revision ID: 0003
Revises: 0002
Create Date: 2026-04-28
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0003"
down_revision: Union[str, None] = "0002"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
DEFAULT_ROLES = [
("system", "Системный администратор — полный доступ"),
("admin", "Администратор — управление пользователями"),
("user", "Обычный пользователь"),
]
DEFAULT_PERMISSIONS = [
("admin.users.view", "Просмотр списка пользователей"),
("admin.users.edit", "Редактирование пользователей"),
("admin.users.delete", "Удаление пользователей"),
("admin.roles.manage", "Управление ролями и правами"),
]
# system gets all permissions; admin gets view+edit
ROLE_PERMISSION_MAP = {
"system": ["admin.users.view", "admin.users.edit", "admin.users.delete", "admin.roles.manage"],
"admin": ["admin.users.view", "admin.users.edit"],
"user": [],
}
def upgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS roles (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
description VARCHAR(255) NULL,
CONSTRAINT uq_roles_name UNIQUE (name)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
"""))
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS permissions (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
description VARCHAR(255) NULL,
CONSTRAINT uq_permissions_name UNIQUE (name)
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
"""))
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS role_permissions (
role_id INT NOT NULL,
permission_id INT NOT NULL,
PRIMARY KEY (role_id, permission_id),
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
"""))
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS user_roles (
user_id INT NOT NULL,
role_id INT NOT NULL,
PRIMARY KEY (user_id, role_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
"""))
# Seed default roles
for name, description in DEFAULT_ROLES:
conn.execute(sa.text(
"INSERT IGNORE INTO roles (name, description) VALUES (:name, :desc)"
), {"name": name, "desc": description})
# Seed default permissions
for name, description in DEFAULT_PERMISSIONS:
conn.execute(sa.text(
"INSERT IGNORE INTO permissions (name, description) VALUES (:name, :desc)"
), {"name": name, "desc": description})
# Seed role_permissions
for role_name, perm_names in ROLE_PERMISSION_MAP.items():
for perm_name in perm_names:
conn.execute(sa.text("""
INSERT IGNORE INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM roles r, permissions p
WHERE r.name = :role AND p.name = :perm
"""), {"role": role_name, "perm": perm_name})
def downgrade() -> None:
conn = op.get_bind()
for table in ["user_roles", "role_permissions", "permissions", "roles"]:
conn.execute(sa.text(f"DROP TABLE IF EXISTS {table}"))

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

@@ -1,60 +0,0 @@
"""initial
Revision ID: 2c15000e752b
Revises:
Create Date: 2026-03-06 09:07:16.180639
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '2c15000e752b'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("first_name", sa.String(length=100), nullable=False),
sa.Column("last_name", sa.String(length=100), nullable=False),
sa.Column("email", sa.String(length=255), nullable=False),
sa.Column("phone", sa.String(length=20), nullable=False),
sa.Column("password_hash", sa.String(length=255), nullable=False),
sa.Column("is_email_confirmed", sa.Boolean(), nullable=False),
sa.Column("email_confirm_token", sa.String(length=255), nullable=True),
sa.Column("password_reset_token", sa.String(length=255), nullable=True),
sa.Column("password_reset_expires", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
op.create_index(op.f("ix_users_phone"), "users", ["phone"], unique=True)
op.create_table(
"evotor_connections",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("access_token", sa.Text(), nullable=False),
sa.Column("store_id", sa.String(length=255), nullable=True),
sa.Column("store_name", sa.String(length=255), nullable=True),
sa.Column("connected_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id"),
)
def downgrade() -> None:
op.drop_table("evotor_connections")
op.drop_index(op.f("ix_users_phone"), table_name="users")
op.drop_index(op.f("ix_users_email"), table_name="users")
op.drop_table("users")

View File

@@ -1,26 +0,0 @@
"""add is_online and last_checked_at to evotor_connections
Revision ID: a1b2c3d4e5f6
Revises: 2c15000e752b
Create Date: 2026-03-06 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = 'a1b2c3d4e5f6'
down_revision = '2c15000e752b'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('evotor_connections',
sa.Column('is_online', sa.Boolean(), nullable=False, server_default='0'))
op.add_column('evotor_connections',
sa.Column('last_checked_at', sa.DateTime(), nullable=True))
def downgrade() -> None:
op.drop_column('evotor_connections', 'last_checked_at')
op.drop_column('evotor_connections', 'is_online')

View File

@@ -1,37 +0,0 @@
"""add vk_connections table
Revision ID: b2c3d4e5f6a7
Revises: a1b2c3d4e5f6
Create Date: 2026-03-06 00:01:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = 'b2c3d4e5f6a7'
down_revision = 'a1b2c3d4e5f6'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
'vk_connections',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('access_token', sa.Text(), nullable=False),
sa.Column('vk_user_id', sa.String(50), nullable=True),
sa.Column('first_name', sa.String(255), nullable=True),
sa.Column('last_name', sa.String(255), nullable=True),
sa.Column('is_online', sa.Boolean(), nullable=False, server_default='0'),
sa.Column('last_checked_at', sa.DateTime(), nullable=True),
sa.Column('connected_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id'),
)
def downgrade() -> None:
op.drop_table('vk_connections')

View File

@@ -1,48 +0,0 @@
"""add sync_configs and sync_filters tables
Revision ID: c3d4e5f6a7b8
Revises: b2c3d4e5f6a7
Create Date: 2026-03-06 00:02:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = 'c3d4e5f6a7b8'
down_revision = 'b2c3d4e5f6a7'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
'sync_configs',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('is_enabled', sa.Boolean(), nullable=False, server_default='0'),
sa.Column('confirmed_at', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id'),
)
op.create_table(
'sync_filters',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('sync_config_id', sa.Integer(), nullable=False),
sa.Column('entity_type', sa.String(20), nullable=False),
sa.Column('entity_id', sa.String(255), nullable=False),
sa.Column('entity_name', sa.String(255), nullable=True),
sa.Column('filter_mode', sa.String(10), nullable=False),
sa.Column('parent_entity_id', sa.String(255), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(['sync_config_id'], ['sync_configs.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('sync_config_id', 'entity_type', 'entity_id', name='uq_sync_filter'),
)
def downgrade() -> None:
op.drop_table('sync_filters')
op.drop_table('sync_configs')

View File

@@ -1,70 +0,0 @@
"""add catalog cache tables
Revision ID: d4e5f6a7b8c9
Revises: c3d4e5f6a7b8
Create Date: 2026-03-06 00:03:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = 'd4e5f6a7b8c9'
down_revision = 'c3d4e5f6a7b8'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
'cached_stores',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('evotor_id', sa.String(255), nullable=False),
sa.Column('name', sa.String(255), nullable=False),
sa.Column('address', sa.String(500), nullable=True),
sa.Column('fetched_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'evotor_id', name='uq_cached_stores'),
)
op.create_index('ix_cached_stores_user_id', 'cached_stores', ['user_id'])
op.create_table(
'cached_groups',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('evotor_id', sa.String(255), nullable=False),
sa.Column('store_evotor_id', sa.String(255), nullable=False),
sa.Column('name', sa.String(255), nullable=False),
sa.Column('fetched_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'evotor_id', name='uq_cached_groups'),
)
op.create_index('ix_cached_groups_user_store', 'cached_groups', ['user_id', 'store_evotor_id'])
op.create_table(
'cached_products',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('evotor_id', sa.String(255), nullable=False),
sa.Column('store_evotor_id', sa.String(255), nullable=False),
sa.Column('group_evotor_id', sa.String(255), nullable=True),
sa.Column('name', sa.String(255), nullable=False),
sa.Column('price', sa.Numeric(12, 2), nullable=True),
sa.Column('quantity', sa.Numeric(12, 3), nullable=True),
sa.Column('measure_name', sa.String(20), nullable=True),
sa.Column('article_number', sa.String(100), nullable=True),
sa.Column('allow_to_sell', sa.Boolean(), nullable=True),
sa.Column('fetched_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'evotor_id', name='uq_cached_products'),
)
op.create_index('ix_cached_products_user_store_group', 'cached_products', ['user_id', 'store_evotor_id', 'group_evotor_id'])
def downgrade() -> None:
op.drop_table('cached_products')
op.drop_table('cached_groups')
op.drop_table('cached_stores')

View File

@@ -1,24 +0,0 @@
"""add refresh_token and token_expires_at to evotor_connections
Revision ID: e5f6a7b8c9d0
Revises: d4e5f6a7b8c9
Create Date: 2026-03-06 00:04:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = 'e5f6a7b8c9d0'
down_revision = 'd4e5f6a7b8c9'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('evotor_connections', sa.Column('refresh_token', sa.Text(), nullable=True))
op.add_column('evotor_connections', sa.Column('token_expires_at', sa.DateTime(), nullable=True))
def downgrade() -> None:
op.drop_column('evotor_connections', 'token_expires_at')
op.drop_column('evotor_connections', 'refresh_token')

View File

@@ -1,157 +0,0 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, UniqueConstraint, Numeric, Index
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from web.database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, autoincrement=True)
first_name = Column(String(100), nullable=False)
last_name = Column(String(100), nullable=False)
email = Column(String(255), unique=True, nullable=False, index=True)
phone = Column(String(20), unique=True, nullable=False, index=True)
password_hash = Column(String(255), nullable=False)
is_email_confirmed = Column(Boolean, default=False, nullable=False)
email_confirm_token = Column(String(255), nullable=True)
password_reset_token = Column(String(255), nullable=True)
password_reset_expires = Column(DateTime, nullable=True)
created_at = Column(DateTime, server_default=func.now(), nullable=False)
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
evotor_connection = relationship("EvotorConnection", back_populates="user", uselist=False)
vk_connection = relationship("VkConnection", back_populates="user", uselist=False)
sync_config = relationship("SyncConfig", back_populates="user", uselist=False)
cached_stores = relationship("CachedStore", back_populates="user", cascade="all, delete-orphan")
cached_groups = relationship("CachedGroup", back_populates="user", cascade="all, delete-orphan")
cached_products = relationship("CachedProduct", back_populates="user", cascade="all, delete-orphan")
class EvotorConnection(Base):
__tablename__ = "evotor_connections"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False)
access_token = Column(Text, nullable=False)
store_id = Column(String(255), nullable=True)
store_name = Column(String(255), nullable=True)
refresh_token = Column(Text, nullable=True)
token_expires_at = Column(DateTime, nullable=True)
is_online = Column(Boolean, default=False, server_default="0", nullable=False)
last_checked_at = Column(DateTime, nullable=True)
connected_at = Column(DateTime, server_default=func.now(), nullable=False)
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
user = relationship("User", back_populates="evotor_connection")
class VkConnection(Base):
__tablename__ = "vk_connections"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False)
access_token = Column(Text, nullable=False)
vk_user_id = Column(String(50), nullable=True)
first_name = Column(String(255), nullable=True)
last_name = Column(String(255), nullable=True)
is_online = Column(Boolean, default=False, server_default="0", nullable=False)
last_checked_at = Column(DateTime, nullable=True)
connected_at = Column(DateTime, server_default=func.now(), nullable=False)
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
user = relationship("User", back_populates="vk_connection")
class SyncConfig(Base):
__tablename__ = "sync_configs"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False)
is_enabled = Column(Boolean, default=False, nullable=False)
confirmed_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, server_default=func.now(), nullable=False)
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
user = relationship("User", back_populates="sync_config")
filters = relationship("SyncFilter", back_populates="sync_config", cascade="all, delete-orphan")
class SyncFilter(Base):
__tablename__ = "sync_filters"
id = Column(Integer, primary_key=True, autoincrement=True)
sync_config_id = Column(Integer, ForeignKey("sync_configs.id", ondelete="CASCADE"), nullable=False)
entity_type = Column(String(20), nullable=False) # "store", "group", "product"
entity_id = Column(String(255), nullable=False)
entity_name = Column(String(255), nullable=True)
filter_mode = Column(String(10), nullable=False) # "include", "exclude"
parent_entity_id = Column(String(255), nullable=True)
created_at = Column(DateTime, server_default=func.now(), nullable=False)
__table_args__ = (
UniqueConstraint("sync_config_id", "entity_type", "entity_id"),
)
sync_config = relationship("SyncConfig", back_populates="filters")
class CachedStore(Base):
__tablename__ = "cached_stores"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
evotor_id = Column(String(255), nullable=False)
name = Column(String(255), nullable=False)
address = Column(String(500), nullable=True)
fetched_at = Column(DateTime, nullable=False)
__table_args__ = (
UniqueConstraint("user_id", "evotor_id"),
Index("ix_cached_stores_user_id", "user_id"),
)
user = relationship("User", back_populates="cached_stores")
class CachedGroup(Base):
__tablename__ = "cached_groups"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
evotor_id = Column(String(255), nullable=False)
store_evotor_id = Column(String(255), nullable=False)
name = Column(String(255), nullable=False)
fetched_at = Column(DateTime, nullable=False)
__table_args__ = (
UniqueConstraint("user_id", "evotor_id"),
Index("ix_cached_groups_user_store", "user_id", "store_evotor_id"),
)
user = relationship("User", back_populates="cached_groups")
class CachedProduct(Base):
__tablename__ = "cached_products"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
evotor_id = Column(String(255), nullable=False)
store_evotor_id = Column(String(255), nullable=False)
group_evotor_id = Column(String(255), nullable=True)
name = Column(String(255), nullable=False)
price = Column(Numeric(12, 2), nullable=True)
quantity = Column(Numeric(12, 3), nullable=True)
measure_name = Column(String(20), nullable=True)
article_number = Column(String(100), nullable=True)
allow_to_sell = Column(Boolean, nullable=True)
fetched_at = Column(DateTime, nullable=False)
__table_args__ = (
UniqueConstraint("user_id", "evotor_id"),
Index("ix_cached_products_user_store_group", "user_id", "store_evotor_id", "group_evotor_id"),
)
user = relationship("User", back_populates="cached_products")

11
web/models/__init__.py Normal file
View File

@@ -0,0 +1,11 @@
from web.models.user import User, UserRoleEnum, UserStatusEnum # noqa: F401
from web.models.rbac import Role, Permission, role_permissions, UserRole # noqa: F401
from web.models.connections import ( # noqa: F401
EvotorConnection,
VkConnection,
SyncConfig,
SyncFilter,
CachedStore,
CachedGroup,
CachedProduct,
)

203
web/models/connections.py Normal file
View File

@@ -0,0 +1,203 @@
from sqlalchemy import (
Boolean, Column, DateTime, ForeignKey, Index, Integer,
Numeric, String, Text, UniqueConstraint, func,
)
from web.database import Base
class EvotorConnection(Base):
__tablename__ = "evotor_connections"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
evotor_user_id = Column(String(255), nullable=True)
access_token = Column(Text, nullable=False)
api_token = Column(String(255), nullable=True) # token we return to Evotor in webhook responses
store_id = Column(String(255), nullable=True)
store_name = Column(String(255), nullable=True)
refresh_token = Column(Text, nullable=True)
token_expires_at = Column(DateTime, nullable=True)
is_online = Column(Boolean, nullable=False, default=False)
last_checked_at = Column(DateTime, nullable=True)
connected_at = Column(DateTime, nullable=False, server_default=func.now())
updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now())
__table_args__ = (
UniqueConstraint("user_id", name="ix_evotor_connections_user_id"),
UniqueConstraint("evotor_user_id", name="ix_evotor_connections_evotor_user_id"),
)
class VkConnection(Base):
__tablename__ = "vk_connections"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
access_token = Column(Text, nullable=False)
refresh_token = Column(Text, nullable=True)
token_expires_at = Column(DateTime, nullable=True)
vk_user_id = Column(String(50), nullable=True)
first_name = Column(String(255), nullable=True)
last_name = Column(String(255), nullable=True)
is_online = Column(Boolean, nullable=False, default=False)
last_checked_at = Column(DateTime, nullable=True)
connected_at = Column(DateTime, nullable=False, server_default=func.now())
updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now())
__table_args__ = (
UniqueConstraint("user_id", name="ix_vk_connections_user_id"),
)
class SyncConfig(Base):
__tablename__ = "sync_configs"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
is_enabled = Column(Boolean, nullable=False, default=False)
evo_mirror_enabled = Column(Boolean, nullable=False, default=False)
vk_mirror_enabled = Column(Boolean, nullable=False, default=False)
store_filters_seeded = Column(Boolean, nullable=False, default=False)
group_filters_seeded = Column(Boolean, nullable=False, default=False)
confirmed_at = Column(DateTime, nullable=True)
price_multiplier = Column(Numeric(10, 4), nullable=False, default=1.0)
created_at = Column(DateTime, nullable=False, server_default=func.now())
updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now())
__table_args__ = (
UniqueConstraint("user_id", name="ix_sync_configs_user_id"),
)
class SyncFilter(Base):
__tablename__ = "sync_filters"
id = Column(Integer, primary_key=True, autoincrement=True)
sync_config_id = Column(Integer, ForeignKey("sync_configs.id", ondelete="CASCADE"), nullable=False)
entity_type = Column(String(20), nullable=False)
entity_id = Column(String(255), nullable=False)
entity_name = Column(String(255), nullable=True)
filter_mode = Column(String(10), nullable=False)
parent_entity_id = Column(String(255), nullable=True)
created_at = Column(DateTime, nullable=False, server_default=func.now())
__table_args__ = (
UniqueConstraint("sync_config_id", "entity_type", "entity_id",
name="uq_sync_filters_config_type_entity"),
)
class VkCachedAlbum(Base):
__tablename__ = "vk_cached_albums"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
vk_group_id = Column(String(50), nullable=False)
album_id = Column(String(50), nullable=False)
title = Column(String(255), nullable=False)
count = Column(Integer, nullable=True)
fetched_at = Column(DateTime, nullable=False)
__table_args__ = (
UniqueConstraint("user_id", "vk_group_id", "album_id", name="uq_vk_cached_albums"),
Index("ix_vk_cached_albums_user_group", "user_id", "vk_group_id"),
)
class VkCachedProduct(Base):
__tablename__ = "vk_cached_products"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
vk_group_id = Column(String(50), nullable=False)
vk_product_id = Column(String(50), nullable=False)
album_id = Column(String(50), nullable=True)
name = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
price = Column(Numeric(12, 2), nullable=True)
availability = Column(Integer, nullable=True)
thumb_url = Column(String(1024), nullable=True)
fetched_at = Column(DateTime, nullable=False)
__table_args__ = (
UniqueConstraint("user_id", "vk_group_id", "vk_product_id", name="uq_vk_cached_products"),
Index("ix_vk_cached_products_user_group_album", "user_id", "vk_group_id", "album_id"),
)
class CachedStore(Base):
__tablename__ = "cached_stores"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
evotor_id = Column(String(255), nullable=False)
name = Column(String(255), nullable=False)
address = Column(String(500), nullable=True)
fetched_at = Column(DateTime, nullable=False)
__table_args__ = (
UniqueConstraint("user_id", "evotor_id", name="uq_cached_stores_user_evotor"),
Index("ix_cached_stores_user_id", "user_id"),
)
class CachedGroup(Base):
__tablename__ = "cached_groups"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
evotor_id = Column(String(255), nullable=False)
store_evotor_id = Column(String(255), nullable=False)
name = Column(String(255), nullable=False)
fetched_at = Column(DateTime, nullable=False)
__table_args__ = (
UniqueConstraint("user_id", "evotor_id", name="uq_cached_groups_user_evotor"),
Index("ix_cached_groups_user_store", "user_id", "store_evotor_id"),
)
class CachedProduct(Base):
__tablename__ = "cached_products"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
evotor_id = Column(String(255), nullable=False)
store_evotor_id = Column(String(255), nullable=False)
group_evotor_id = Column(String(255), nullable=True)
name = Column(String(255), nullable=False)
price = Column(Numeric(12, 2), nullable=True)
quantity = Column(Numeric(12, 3), nullable=True)
measure_name = Column(String(20), nullable=True)
article_number = Column(String(100), nullable=True)
allow_to_sell = Column(Boolean, nullable=True)
fetched_at = Column(DateTime, nullable=False)
synced_at = Column(DateTime, nullable=True)
vk_product_id = Column(String(50), nullable=True) # VK market item ID after first push
__table_args__ = (
UniqueConstraint("user_id", "evotor_id", name="uq_cached_products_user_evotor"),
Index("ix_cached_products_user_store_group", "user_id", "store_evotor_id", "group_evotor_id"),
)
class ApiLog(Base):
__tablename__ = "api_logs"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
service = Column(String(20), nullable=False) # "evotor" | "vk"
method = Column(String(10), nullable=False) # "GET" | "POST"
url = Column(String(1024), nullable=False)
request_body = Column(Text, nullable=True)
response_status = Column(Integer, nullable=True)
response_body = Column(Text, nullable=True)
duration_ms = Column(Integer, nullable=True)
created_at = Column(DateTime, nullable=False, server_default=func.now())
__table_args__ = (
Index("ix_api_logs_user_service", "user_id", "service"),
Index("ix_api_logs_created_at", "created_at"),
)

34
web/models/rbac.py Normal file
View File

@@ -0,0 +1,34 @@
from sqlalchemy import Column, ForeignKey, Integer, String, Table
from web.database import Base
role_permissions = Table(
"role_permissions",
Base.metadata,
Column("role_id", Integer, ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True),
Column("permission_id", Integer, ForeignKey("permissions.id", ondelete="CASCADE"), primary_key=True),
)
class Role(Base):
__tablename__ = "roles"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(50), nullable=False, unique=True)
description = Column(String(255), nullable=True)
class Permission(Base):
__tablename__ = "permissions"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), nullable=False, unique=True)
description = Column(String(255), nullable=True)
class UserRole(Base):
__tablename__ = "user_roles"
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
role_id = Column(Integer, ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True)

50
web/models/user.py Normal file
View File

@@ -0,0 +1,50 @@
import enum
from sqlalchemy import Boolean, Column, DateTime, Enum, Index, Integer, JSON, String, UniqueConstraint, func
from web.database import Base
class UserRoleEnum(str, enum.Enum):
system = "system"
admin = "admin"
user = "user"
class UserStatusEnum(str, enum.Enum):
pending = "pending"
active = "active"
suspended = "suspended"
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, autoincrement=True)
first_name = Column(String(100), nullable=False)
last_name = Column(String(100), nullable=False)
email = Column(String(255), nullable=False)
phone = Column(String(20), nullable=True)
password_hash = Column(String(255), nullable=True)
is_email_confirmed = Column(Boolean, nullable=False, default=False)
email_confirm_token = Column(String(255), nullable=True)
password_reset_token = Column(String(255), nullable=True)
password_reset_expires = Column(DateTime, nullable=True)
role = Column(Enum(UserRoleEnum), nullable=False, default=UserRoleEnum.user)
status = Column(Enum(UserStatusEnum), nullable=False, default=UserStatusEnum.pending)
evotor_user_id = Column(String(255), nullable=True)
evotor_meta = Column(JSON, nullable=True)
invite_token = Column(String(255), nullable=True)
invite_expires = Column(DateTime, nullable=True)
phone_otp = Column(String(10), nullable=True)
phone_otp_expires = Column(DateTime, nullable=True)
created_at = Column(DateTime, nullable=False, server_default=func.now())
updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now())
__table_args__ = (
UniqueConstraint("email", name="ix_users_email"),
UniqueConstraint("phone", name="ix_users_phone"),
UniqueConstraint("evotor_user_id", name="ix_users_evotor_user_id"),
Index("ix_users_role", "role"),
Index("ix_users_status", "status"),
)

View File

11
web/notifications/base.py Normal file
View File

@@ -0,0 +1,11 @@
from abc import ABC, abstractmethod
class EmailProvider(ABC):
@abstractmethod
def send(self, to: str, subject: str, html_body: str) -> None: ...
class SMSProvider(ABC):
@abstractmethod
def send(self, to: str, text: str) -> None: ...

View File

@@ -0,0 +1,28 @@
import logging
import re
from web.notifications.base import EmailProvider, SMSProvider
logger = logging.getLogger(__name__)
class ConsoleEmailProvider(EmailProvider):
def send(self, to: str, subject: str, html_body: str) -> None:
# Extract plain URLs from HTML for readability in dev logs
urls = re.findall(r'href=["\']([^"\']+)["\']', html_body)
logger.info("=" * 50)
logger.info("EMAIL")
logger.info("Кому: %s", to)
logger.info("Тема: %s", subject)
for url in urls:
logger.info("Ссылка: %s", url)
logger.info("=" * 50)
class ConsoleSMSProvider(SMSProvider):
def send(self, to: str, text: str) -> None:
logger.info("=" * 50)
logger.info("SMS")
logger.info("Номер: %s", to)
logger.info("Текст: %s", text)
logger.info("=" * 50)

View File

@@ -0,0 +1,17 @@
from web.config import settings
from web.notifications.base import EmailProvider, SMSProvider
from web.notifications.console import ConsoleEmailProvider, ConsoleSMSProvider
def get_email_provider() -> EmailProvider:
provider = settings.EMAIL_PROVIDER
if provider == "console":
return ConsoleEmailProvider()
raise ValueError(f"Unknown EMAIL_PROVIDER: {provider!r}")
def get_sms_provider() -> SMSProvider:
provider = settings.SMS_PROVIDER
if provider == "console":
return ConsoleSMSProvider()
raise ValueError(f"Unknown SMS_PROVIDER: {provider!r}")

View File

@@ -0,0 +1,12 @@
from web.tasks.celery_app import celery_app
from web.notifications.registry import get_email_provider, get_sms_provider
@celery_app.task(name="web.notifications.tasks.send_email_task", queue="notifications")
def send_email_task(to: str, subject: str, html_body: str) -> None:
get_email_provider().send(to, subject, html_body)
@celery_app.task(name="web.notifications.tasks.send_sms_task", queue="notifications")
def send_sms_task(to: str, text: str) -> None:
get_sms_provider().send(to, text)

295
web/routes/admin.py Normal file
View File

@@ -0,0 +1,295 @@
import secrets
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from web.auth.password import hash_password
from web.auth.rbac import require_role
from web.auth.session import get_current_user
from web.config import settings
from web.database import get_db
from web.models.rbac import Permission, Role, UserRole, role_permissions
from web.models.user import User, UserRoleEnum, UserStatusEnum
from web.notifications.tasks import send_email_task
from web.templates_env import templates
router = APIRouter(prefix="/admin")
PAGE_SIZE = 25
def _render(request: Request, template: str, ctx: dict) -> HTMLResponse:
ctx["request"] = request
ctx.setdefault("jivosite_widget_id", settings.JIVOSITE_WIDGET_ID)
return templates.TemplateResponse(ctx.pop("request"), template, ctx)
def _admin_user(request: Request, db: Session) -> User:
"""Get current user and verify admin/system role."""
try:
user = get_current_user(request, db)
except Exception:
raise
if user.role not in (UserRoleEnum.admin, UserRoleEnum.system):
from fastapi import HTTPException
raise HTTPException(403, "Недостаточно прав")
return user
# ── User list ─────────────────────────────────────────────────────────────────
@router.get("/users")
async def admin_users(request: Request, db: Session = Depends(get_db)):
try:
admin = _admin_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
q = db.query(User)
search = request.query_params.get("search", "").strip()
status_filter = request.query_params.get("status", "")
role_filter = request.query_params.get("role", "")
page = max(1, int(request.query_params.get("page", 1)))
if search:
q = q.filter(
(User.first_name.ilike(f"%{search}%")) |
(User.last_name.ilike(f"%{search}%")) |
(User.email.ilike(f"%{search}%")) |
(User.phone.ilike(f"%{search}%"))
)
if status_filter:
try:
q = q.filter(User.status == UserStatusEnum(status_filter))
except ValueError:
pass
if role_filter:
try:
q = q.filter(User.role == UserRoleEnum(role_filter))
except ValueError:
pass
total = q.count()
users = q.order_by(User.created_at.desc()).offset((page - 1) * PAGE_SIZE).limit(PAGE_SIZE).all()
total_pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
return _render(request, "admin/users.html", {
"user": admin,
"users": users,
"search": search,
"status_filter": status_filter,
"role_filter": role_filter,
"page": page,
"total_pages": total_pages,
"total": total,
})
# ── User detail ───────────────────────────────────────────────────────────────
@router.get("/users/{user_id}")
async def admin_user_detail(user_id: int, request: Request, db: Session = Depends(get_db)):
try:
admin = _admin_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
target = db.get(User, user_id)
if not target:
return RedirectResponse("/admin/users", 303)
return _render(request, "admin/user_detail.html", {"user": admin, "target": target})
# ── View-as ───────────────────────────────────────────────────────────────────
@router.post("/users/{user_id}/view-as")
async def admin_view_as(user_id: int, request: Request, db: Session = Depends(get_db)):
try:
_admin_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
target = db.get(User, user_id)
if target:
request.session["viewed_user_id"] = user_id
return RedirectResponse("/connections", 303)
@router.post("/view-as/stop")
async def admin_view_as_stop(request: Request, db: Session = Depends(get_db)):
try:
_admin_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
request.session.pop("viewed_user_id", None)
return RedirectResponse("/admin/users", 303)
# ── User actions ──────────────────────────────────────────────────────────────
@router.post("/users/{user_id}/activate")
async def admin_activate(user_id: int, request: Request, db: Session = Depends(get_db)):
try:
_admin_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
user = db.get(User, user_id)
if user:
user.status = UserStatusEnum.active
db.commit()
return RedirectResponse(f"/admin/users/{user_id}", 303)
@router.post("/users/{user_id}/suspend")
async def admin_suspend(user_id: int, request: Request, db: Session = Depends(get_db)):
try:
_admin_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
user = db.get(User, user_id)
if user:
user.status = UserStatusEnum.suspended
db.commit()
return RedirectResponse(f"/admin/users/{user_id}", 303)
@router.post("/users/{user_id}/reset-password")
async def admin_reset_password(user_id: int, request: Request, db: Session = Depends(get_db)):
try:
_admin_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
user = db.get(User, user_id)
if user:
token = secrets.token_urlsafe(32)
user.password_reset_token = token
user.password_reset_expires = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(
minutes=settings.PASSWORD_RESET_EXPIRE_MINUTES
)
db.commit()
reset_url = f"{settings.BASE_URL}/reset-password?token={token}"
html = f'<p>Сброс пароля (запрошен администратором): <a href="{reset_url}">{reset_url}</a></p>'
send_email_task.delay(user.email, "Сброс пароля — ЭВОСИНК", html)
return RedirectResponse(f"/admin/users/{user_id}?success=reset_sent", 303)
@router.post("/users/{user_id}/send-invite")
async def admin_send_invite(user_id: int, request: Request, db: Session = Depends(get_db)):
try:
_admin_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
user = db.get(User, user_id)
if user:
token = secrets.token_urlsafe(32)
user.invite_token = token
user.invite_expires = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(hours=settings.INVITE_EXPIRE_HOURS)
db.commit()
invite_url = f"{settings.BASE_URL}/invite?token={token}"
html = (
f"<p>Вам отправлено приглашение в ЭВОСИНК.</p>"
f'<p><a href="{invite_url}">{invite_url}</a></p>'
f"<p>Ссылка действительна {settings.INVITE_EXPIRE_HOURS} часов.</p>"
)
send_email_task.delay(user.email, "Приглашение в ЭВОСИНК", html)
return RedirectResponse(f"/admin/users/{user_id}?success=invite_sent", 303)
@router.post("/users/{user_id}/edit")
async def admin_edit_user(user_id: int, request: Request, db: Session = Depends(get_db)):
try:
admin = _admin_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
user = db.get(User, user_id)
if not user:
return RedirectResponse("/admin/users", 303)
form = await request.form()
data = {k: str(v).strip() for k, v in form.items()}
errors = []
if not data.get("first_name"):
errors.append("Имя обязательно")
if not data.get("last_name"):
errors.append("Фамилия обязательна")
if errors:
return _render(request, "admin/user_detail.html", {
"user": admin, "target": user, "errors": errors,
})
user.first_name = data["first_name"]
user.last_name = data["last_name"]
if data.get("email"):
user.email = data["email"]
if data.get("phone"):
user.phone = data["phone"]
if data.get("role") and admin.role == UserRoleEnum.system:
try:
user.role = UserRoleEnum(data["role"])
except ValueError:
pass
db.commit()
return RedirectResponse(f"/admin/users/{user_id}?success=saved", 303)
@router.post("/users/{user_id}/delete")
async def admin_delete_user(user_id: int, request: Request, db: Session = Depends(get_db)):
try:
admin = _admin_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
if admin.role != UserRoleEnum.system:
return RedirectResponse(f"/admin/users/{user_id}", 303)
user = db.get(User, user_id)
if user:
db.delete(user)
db.commit()
return RedirectResponse("/admin/users", 303)
# ── Roles ─────────────────────────────────────────────────────────────────────
@router.get("/roles")
async def admin_roles(request: Request, db: Session = Depends(get_db)):
try:
admin = _admin_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
if admin.role != UserRoleEnum.system:
return RedirectResponse("/admin/users", 303)
roles = db.query(Role).order_by(Role.id).all()
permissions = db.query(Permission).order_by(Permission.name).all()
role_perm_ids: dict[int, set[int]] = {}
for role in roles:
rows = db.execute(
role_permissions.select().where(role_permissions.c.role_id == role.id)
).fetchall()
role_perm_ids[role.id] = {r.permission_id for r in rows}
return _render(request, "admin/roles.html", {
"user": admin, "roles": roles, "permissions": permissions,
"role_perm_ids": role_perm_ids,
})
@router.post("/roles/{role_id}/permissions")
async def admin_update_role_permissions(
role_id: int, request: Request, db: Session = Depends(get_db)
):
try:
admin = _admin_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
if admin.role != UserRoleEnum.system:
return RedirectResponse("/admin/roles", 303)
form = await request.form()
selected_ids = {int(v) for k, v in form.items() if k.startswith("perm_")}
# Remove all existing, re-insert selected
db.execute(role_permissions.delete().where(role_permissions.c.role_id == role_id))
for perm_id in selected_ids:
db.execute(role_permissions.insert().values(role_id=role_id, permission_id=perm_id))
db.commit()
return RedirectResponse("/admin/roles", 303)

View File

@@ -1,116 +1,110 @@
import uuid
import secrets
from fastapi import APIRouter, Request, Depends
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from sqlalchemy.orm import Session
from web.auth import hash_password, verify_password, get_current_user
from web.auth.password import verify_password
from web.auth.session import get_session_user_id
from web.config import settings
from web.database import get_db
from web.models import User
from web.schemas import validate_registration, validate_login
from web.models.user import User, UserStatusEnum
from web.notifications.tasks import send_email_task
from web.templates_env import templates
router = APIRouter()
templates = Jinja2Templates(directory="web/templates")
def _render(request: Request, template: str, ctx: dict) -> HTMLResponse:
ctx["request"] = request
ctx.setdefault("jivosite_widget_id", settings.JIVOSITE_WIDGET_ID)
return templates.TemplateResponse(ctx.pop("request"), template, ctx)
@router.get("/register")
def register_form(request: Request, user: User | None = Depends(get_current_user)):
if user:
return RedirectResponse("/profile", 303)
return templates.TemplateResponse("register.html", {"request": request, "user": None})
async def register_get(request: Request):
return Response(status_code=404)
@router.post("/register")
async def register_submit(request: Request, db: Session = Depends(get_db)):
form = await request.form()
data = dict(form)
errors = validate_registration(data)
if not errors:
existing = db.query(User).filter(
(User.email == data["email"].strip()) | (User.phone == data["phone"].strip())
).first()
if existing:
if existing.email == data["email"].strip():
errors.append("Пользователь с таким email уже существует")
else:
errors.append("Пользователь с таким телефоном уже существует")
if errors:
return templates.TemplateResponse("register.html", {
"request": request, "user": None, "errors": errors, "form": data,
})
token = uuid.uuid4().hex
user = User(
first_name=data["first_name"].strip(),
last_name=data["last_name"].strip(),
email=data["email"].strip(),
phone=data["phone"].strip(),
password_hash=hash_password(data["password"]),
email_confirm_token=token,
)
db.add(user)
db.commit()
confirm_url = f"{settings.BASE_URL}/confirm-email?token={token}"
print("=" * 40)
print("ПОДТВЕРЖДЕНИЕ EMAIL")
print(f"Пользователь: {user.email}")
print(f"Ссылка: {confirm_url}")
print("=" * 40)
return templates.TemplateResponse("confirm_email.html", {"request": request, "user": None})
async def register_post(request: Request):
return Response(status_code=404)
@router.get("/confirm-email")
def confirm_email(request: Request, token: str, db: Session = Depends(get_db)):
async def confirm_email(request: Request, db: Session = Depends(get_db)):
token = request.query_params.get("token")
if not token:
return _render(request, "message.html", {
"user": None, "title": "Ошибка", "message": "Неверная или устаревшая ссылка.",
"link": "/login", "link_text": "Войти",
})
user = db.query(User).filter(User.email_confirm_token == token).first()
if not user:
return templates.TemplateResponse("message.html", {
"request": request, "user": None,
"title": "Ошибка", "message": "Неверная или устаревшая ссылка.",
return _render(request, "message.html", {
"user": None, "title": "Ошибка", "message": "Неверная или устаревшая ссылка.",
"link": "/login", "link_text": "Войти",
})
user.is_email_confirmed = True
user.email_confirm_token = None
user.status = UserStatusEnum.active
db.commit()
return _render(request, "email_confirmed.html", {"user": None})
return templates.TemplateResponse("email_confirmed.html", {"request": request, "user": None})
@router.get("/resend-confirm")
async def resend_confirm(request: Request, db: Session = Depends(get_db)):
user_id = get_session_user_id(request)
if not user_id:
return RedirectResponse("/login", 303)
user = db.get(User, user_id)
if not user or user.is_email_confirmed:
return RedirectResponse("/profile", 303)
token = secrets.token_urlsafe(32)
user.email_confirm_token = token
db.commit()
confirm_url = f"{settings.BASE_URL}/confirm-email?token={token}"
html = f'<p>Подтвердите email: <a href="{confirm_url}">{confirm_url}</a></p>'
send_email_task.delay(user.email, "Подтвердите ваш email — ЭВОСИНК", html)
return _render(request, "message.html", {
"user": user,
"title": "Письмо отправлено",
"message": "Проверьте почту и нажмите на ссылку для подтверждения.",
"link": "/profile", "link_text": "Назад",
})
@router.get("/login")
def login_form(request: Request, user: User | None = Depends(get_current_user)):
if user:
async def login_get(request: Request, db: Session = Depends(get_db)):
if get_session_user_id(request):
return RedirectResponse("/profile", 303)
return templates.TemplateResponse("login.html", {"request": request, "user": None})
return _render(request, "login.html", {"user": None})
@router.post("/login")
async def login_submit(request: Request, db: Session = Depends(get_db)):
async def login_post(request: Request, db: Session = Depends(get_db)):
form = await request.form()
data = dict(form)
email = str(form.get("email", "")).strip()
password = str(form.get("password", ""))
errors = []
if not email:
errors.append("Email обязателен")
if not password:
errors.append("Пароль обязателен")
if not errors:
user = db.query(User).filter(User.email == email).first()
if not user or not user.password_hash or not verify_password(password, user.password_hash):
errors.append("Неверный email или пароль")
elif user.status == UserStatusEnum.suspended:
errors.append("Ваш аккаунт заблокирован. Обратитесь к администратору.")
elif not user.is_email_confirmed:
errors.append("Пожалуйста, подтвердите ваш email")
errors = validate_login(data)
if errors:
return templates.TemplateResponse("login.html", {
"request": request, "user": None, "errors": errors, "form": data,
})
user = db.query(User).filter(User.email == data["email"].strip()).first()
if not user or not verify_password(data["password"], user.password_hash):
return templates.TemplateResponse("login.html", {
"request": request, "user": None,
"errors": ["Неверный email или пароль"], "form": data,
})
if not user.is_email_confirmed:
return templates.TemplateResponse("login.html", {
"request": request, "user": None,
"errors": ["Пожалуйста, подтвердите ваш email"], "form": data,
return _render(request, "login.html", {
"user": None, "errors": errors, "form": {"email": email},
})
request.session["user_id"] = user.id
@@ -118,6 +112,12 @@ async def login_submit(request: Request, db: Session = Depends(get_db)):
@router.get("/logout")
def logout(request: Request):
async def logout(request: Request):
request.session.clear()
return RedirectResponse("/login", 303)
async def _hash(plain: str) -> str:
import asyncio
from web.auth.password import hash_password
return await asyncio.get_event_loop().run_in_executor(None, hash_password, plain)

View File

@@ -1,309 +1,265 @@
import csv
import io
from datetime import datetime, timezone
from fastapi import APIRouter, Request, Depends
from fastapi.responses import RedirectResponse, StreamingResponse
from fastapi.templating import Jinja2Templates
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy import func
from sqlalchemy.orm import Session
from web.auth import get_current_user
from web.auth.session import get_current_user, get_viewed_user
from web.config import settings
from web.database import get_db
from web.evotor_api import refresh_catalog_cache
from web.models import User, EvotorConnection, SyncConfig, SyncFilter, CachedStore, CachedGroup, CachedProduct
from web.models.connections import CachedGroup, CachedProduct, CachedStore, SyncConfig, SyncFilter
from web.templates_env import templates
router = APIRouter(prefix="/catalog")
templates = Jinja2Templates(directory="web/templates")
router = APIRouter()
def _get_or_create_sync_config(db: Session, user_id: int) -> SyncConfig:
config = db.query(SyncConfig).filter(SyncConfig.user_id == user_id).first()
if not config:
config = SyncConfig(user_id=user_id, is_enabled=False)
db.add(config)
db.commit()
db.refresh(config)
return config
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 _filter_map(config: SyncConfig) -> dict:
"""Returns {entity_id: filter_mode} for quick lookup."""
return {f.entity_id: f.filter_mode for f in config.filters}
def _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 _filter_label(mode: str | None) -> str:
if mode == "include":
return "include"
if mode == "exclude":
return "exclude"
return "none"
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}
@router.get("")
async def catalog_stores(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
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)
evotor = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
if not evotor:
return templates.TemplateResponse("catalog_stores.html", {
"request": request, "user": user,
"evotor": None, "stores": [], "filter_map": {}, "fetched_at": None,
})
stores = db.query(CachedStore).filter(CachedStore.user_id == user.id).order_by(CachedStore.name).all()
# Auto-refresh if cache is empty
if not stores:
await refresh_catalog_cache(user.id, evotor.access_token, db)
stores = db.query(CachedStore).filter(CachedStore.user_id == user.id).order_by(CachedStore.name).all()
config = _get_or_create_sync_config(db, user.id)
fmap = _filter_map(config)
fetched_at = stores[0].fetched_at if stores else None
return templates.TemplateResponse("catalog_stores.html", {
"request": request,
"user": user,
"evotor": evotor,
stores = (
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,
"filter_map": fmap,
"fetched_at": fetched_at,
"enabled_ids": enabled_ids,
"refresh_interval": settings.CATALOG_REFRESH_INTERVAL_SECONDS,
})
@router.get("/groups")
def catalog_groups(
request: Request,
store_id: str,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
@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 == user.id,
CachedStore.evotor_id == store_id,
).first()
store = (
db.query(CachedStore)
.filter(CachedStore.user_id == viewed_user.id, CachedStore.evotor_id == store_evotor_id)
.first()
)
if not store:
return RedirectResponse("/catalog", 303)
return RedirectResponse("/catalog/stores", 303)
groups = db.query(CachedGroup).filter(
CachedGroup.user_id == user.id,
CachedGroup.store_evotor_id == store_id,
).order_by(CachedGroup.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()
)
enabled_ids = _enabled_group_ids(db, viewed_user.id, store_evotor_id)
# Count products per group
product_counts = {}
for g in groups:
product_counts[g.evotor_id] = db.query(CachedProduct).filter(
CachedProduct.user_id == user.id,
CachedProduct.group_evotor_id == g.evotor_id,
).count()
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}
config = _get_or_create_sync_config(db, user.id)
fmap = _filter_map(config)
return templates.TemplateResponse("catalog_groups.html", {
"request": request,
"user": user,
"store": store,
"groups": groups,
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,
"filter_map": fmap,
})
@router.get("/products")
def catalog_products(
request: Request,
store_id: str,
group_id: str | None = None,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
@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 == user.id,
CachedStore.evotor_id == store_id,
).first()
store = (
db.query(CachedStore)
.filter(CachedStore.user_id == viewed_user.id, CachedStore.evotor_id == store_evotor_id)
.first()
)
if not store:
return RedirectResponse("/catalog", 303)
return RedirectResponse("/catalog/stores", 303)
group = None
query = db.query(CachedProduct).filter(
CachedProduct.user_id == user.id,
CachedProduct.store_evotor_id == store_id,
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:
group = db.query(CachedGroup).filter(
CachedGroup.user_id == user.id,
CachedGroup.evotor_id == group_id,
).first()
query = query.filter(CachedProduct.group_evotor_id == group_id)
q = q.filter(CachedProduct.group_evotor_id == group_id)
products = query.order_by(CachedProduct.name).all()
config = _get_or_create_sync_config(db, user.id)
fmap = _filter_map(config)
return templates.TemplateResponse("catalog_products.html", {
"request": request,
"user": user,
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,
"group": group,
"products": products,
"filter_map": fmap,
"groups": groups,
"group_id": group_id,
"group_map": group_map,
})
@router.post("/filter")
async def catalog_filter(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
@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)
form = await request.form()
entity_type = form.get("entity_type")
entity_id = form.get("entity_id")
entity_name = form.get("entity_name")
filter_mode = form.get("filter_mode") # "include", "exclude", "none"
parent_entity_id = form.get("parent_entity_id") or None
redirect_to = form.get("redirect_to", "/catalog")
cfg = _get_or_create_sync_config(db, user.id)
config = _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}
existing = db.query(SyncFilter).filter(
SyncFilter.sync_config_id == config.id,
SyncFilter.entity_type == entity_type,
SyncFilter.entity_id == entity_id,
).first()
if filter_mode == "none":
if existing:
db.delete(existing)
elif existing:
existing.filter_mode = filter_mode
existing.entity_name = entity_name
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:
db.add(SyncFilter(
sync_config_id=config.id,
entity_type=entity_type,
entity_id=entity_id,
entity_name=entity_name,
filter_mode=filter_mode,
parent_entity_id=parent_entity_id,
))
# 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(redirect_to, 303)
return RedirectResponse("/catalog/stores", 303)
@router.post("/refresh")
async def catalog_refresh(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
@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),
):
if not user:
try:
user = get_current_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
evotor = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
if evotor:
await refresh_catalog_cache(user.id, evotor.access_token, db)
cfg = _get_or_create_sync_config(db, user.id)
return RedirectResponse("/catalog", 303)
existing = db.query(SyncFilter).filter_by(
sync_config_id=cfg.id, entity_type="group", filter_mode="include",
parent_entity_id=store_evotor_id,
).all()
existing_ids = {f.entity_id for f in existing}
if 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
@router.get("/export")
def catalog_export(
request: Request,
type: str,
store_id: str | None = None,
group_id: str | None = None,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
return RedirectResponse("/login", 303)
config = _get_or_create_sync_config(db, user.id)
fmap = _filter_map(config)
def filter_label(eid):
m = fmap.get(eid)
if m == "include":
return "Включено"
if m == "exclude":
return "Исключено"
return "Нет правила"
output = io.StringIO()
output.write("\ufeff") # UTF-8 BOM for Excel
writer = csv.writer(output)
from datetime import date
today = date.today().strftime("%Y%m%d")
if type == "stores":
writer.writerow(["Название", "Адрес", "ID", "Фильтр"])
stores = db.query(CachedStore).filter(CachedStore.user_id == user.id).order_by(CachedStore.name).all()
for s in stores:
writer.writerow([s.name, s.address or "", s.evotor_id, filter_label(s.evotor_id)])
filename = f"stores_{today}.csv"
elif type == "groups":
writer.writerow(["Магазин", "Название", "ID", "Фильтр"])
q = db.query(CachedGroup, CachedStore).join(
CachedStore,
(CachedStore.evotor_id == CachedGroup.store_evotor_id) & (CachedStore.user_id == user.id)
).filter(CachedGroup.user_id == user.id)
if store_id:
q = q.filter(CachedGroup.store_evotor_id == store_id)
for g, s in q.order_by(CachedGroup.name).all():
writer.writerow([s.name, g.name, g.evotor_id, filter_label(g.evotor_id)])
filename = f"groups_{today}.csv"
else: # products
writer.writerow(["Магазин", "Группа", "Название", "Артикул", "Цена", "Количество", "Ед. измерения", "В продаже", "ID", "Фильтр"])
q = db.query(CachedProduct, CachedStore, CachedGroup).join(
CachedStore,
(CachedStore.evotor_id == CachedProduct.store_evotor_id) & (CachedStore.user_id == user.id)
).outerjoin(
CachedGroup,
(CachedGroup.evotor_id == CachedProduct.group_evotor_id) & (CachedGroup.user_id == user.id)
).filter(CachedProduct.user_id == user.id)
if store_id:
q = q.filter(CachedProduct.store_evotor_id == store_id)
if group_id:
q = q.filter(CachedProduct.group_evotor_id == group_id)
for p, s, g in q.order_by(CachedProduct.name).all():
writer.writerow([
s.name,
g.name if g else "",
p.name,
p.article_number or "",
p.price or "",
p.quantity or "",
p.measure_name or "",
"Да" if p.allow_to_sell else ("Нет" if p.allow_to_sell is not None else ""),
p.evotor_id,
filter_label(p.evotor_id),
])
filename = f"products_{today}.csv"
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
db.commit()
return RedirectResponse(f"/catalog/stores/{store_evotor_id}/groups", 303)

View File

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

View File

@@ -1,175 +0,0 @@
import secrets
import logging
import httpx
from fastapi import APIRouter, Request, Depends
logger = logging.getLogger(__name__)
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from web.auth import get_current_user
from web.config import settings
from web.database import get_db
from web.models import User, EvotorConnection
router = APIRouter(prefix="/evotor")
templates = Jinja2Templates(directory="web/templates")
EVOTOR_AUTHORIZE_URL = "https://oauth.evotor.ru/oauth/authorize"
EVOTOR_TOKEN_URL = "https://oauth.evotor.ru/oauth/token"
EVOTOR_STORES_URL = "https://api.evotor.ru/stores"
def _redirect_uri() -> str:
return f"{settings.BASE_URL}/evotor/callback"
@router.get("")
def evotor_page(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
return RedirectResponse("/login", 303)
connection = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
error = request.query_params.get("error")
return templates.TemplateResponse("evotor.html", {
"request": request,
"user": user,
"connection": connection,
"error": error,
})
@router.get("/connect")
def evotor_connect(request: Request, user: User | None = Depends(get_current_user)):
if not user:
return RedirectResponse("/login", 303)
state = secrets.token_urlsafe(32)
request.session["evotor_oauth_state"] = state
params = (
f"?client_id={settings.EVOTOR_CLIENT_ID}"
f"&response_type=code"
f"&redirect_uri={_redirect_uri()}"
f"&scope={settings.EVOTOR_SCOPES.replace(' ', '%20')}"
f"&state={state}"
)
return RedirectResponse(EVOTOR_AUTHORIZE_URL + params, 302)
@router.get("/callback")
async def evotor_callback(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
return RedirectResponse("/login", 303)
code = request.query_params.get("code")
state = request.query_params.get("state")
saved_state = request.session.pop("evotor_oauth_state", None)
if not code or not state or state != saved_state:
return RedirectResponse("/evotor?error=invalid_state", 303)
# Exchange code for access token
try:
async with httpx.AsyncClient() as client:
token_response = await client.post(
EVOTOR_TOKEN_URL,
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": _redirect_uri(),
"client_id": settings.EVOTOR_CLIENT_ID,
"client_secret": settings.EVOTOR_CLIENT_SECRET,
},
timeout=15,
)
token_response.raise_for_status()
token_data = token_response.json()
except httpx.HTTPStatusError as e:
logger.error("Evotor token exchange HTTP error %s: %s", e.response.status_code, e.response.text)
return RedirectResponse("/evotor?error=token_exchange", 303)
except Exception as e:
logger.error("Evotor token exchange failed: %s", e, exc_info=True)
return RedirectResponse("/evotor?error=token_exchange", 303)
access_token = token_data.get("access_token")
refresh_token = token_data.get("refresh_token")
expires_in = token_data.get("expires_in")
if not access_token:
return RedirectResponse("/evotor?error=no_token", 303)
# Fetch first store to save store info
store_id = None
store_name = None
try:
async with httpx.AsyncClient() as client:
stores_response = await client.get(
EVOTOR_STORES_URL,
headers={"Authorization": f"Bearer {access_token}"},
timeout=15,
)
if stores_response.status_code == 200:
stores = stores_response.json()
items = stores.get("items", stores) if isinstance(stores, dict) else stores
if items:
store_id = items[0].get("uuid") or items[0].get("id")
store_name = items[0].get("name")
except Exception:
pass # Store info is optional; token is still saved
# Save or update connection
from datetime import datetime, timedelta
now = datetime.utcnow()
token_expires_at = now + timedelta(seconds=expires_in) if expires_in else None
connection = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
if connection:
connection.access_token = access_token
connection.refresh_token = refresh_token
connection.token_expires_at = token_expires_at
connection.store_id = store_id
connection.store_name = store_name
connection.is_online = True
connection.last_checked_at = now
else:
connection = EvotorConnection(
user_id=user.id,
access_token=access_token,
refresh_token=refresh_token,
token_expires_at=token_expires_at,
store_id=store_id,
store_name=store_name,
is_online=True,
last_checked_at=now,
)
db.add(connection)
db.commit()
return RedirectResponse("/connections", 303)
@router.post("/disconnect")
async def evotor_disconnect(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
return RedirectResponse("/login", 303)
connection = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
if connection:
db.delete(connection)
db.commit()
return RedirectResponse("/connections", 303)

View File

@@ -0,0 +1,269 @@
"""
Evotor webhook endpoints.
POST /user/create — Evotor creates a new subscriber; we create/link a local user and return a token.
POST /user/verify — Evotor verifies credentials for a user trying to log in via the Evotor interface.
POST /user/token — Evotor sends us its own API token for the user.
"""
import json
import logging
import secrets
from datetime import datetime, timezone, timedelta
from typing import Any
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from sqlalchemy import or_
from sqlalchemy.orm import Session
from web.auth.password import verify_password
from web.config import settings
from web.database import get_db
from web.models.connections import EvotorConnection
from web.models.user import User, UserRoleEnum, UserStatusEnum
from web.notifications.tasks import send_email_task
logger = logging.getLogger(__name__)
router = APIRouter()
EVOTOR_STORES_URL = "https://api.evotor.ru/stores"
def _verify_secret(request: Request) -> bool:
secret = settings.EVOTOR_WEBHOOK_SECRET
if not secret:
return True # dev mode: no secret configured
auth = request.headers.get("Authorization", "")
return auth == f"Bearer {secret}"
def _parse_custom_fields(raw: Any) -> dict:
"""Extract known fields from Evotor customField (may be JSON string or dict)."""
if raw is None:
return {}
if isinstance(raw, dict):
return raw
if isinstance(raw, str):
try:
parsed = json.loads(raw)
if isinstance(parsed, dict):
return parsed
except (json.JSONDecodeError, ValueError):
pass
return {}
def _upsert_evotor_connection(
db: Session,
user_id: int | None,
evotor_user_id: str,
access_token: str | None = None,
) -> str:
"""Create or update an evotor_connections row; always regenerates api_token."""
api_token = secrets.token_urlsafe(32)
conn = db.query(EvotorConnection).filter(
EvotorConnection.evotor_user_id == evotor_user_id
).first()
now = datetime.now(timezone.utc).replace(tzinfo=None)
if conn:
conn.api_token = api_token
if user_id is not None:
conn.user_id = user_id
if access_token:
conn.access_token = access_token
conn.updated_at = now
else:
conn = EvotorConnection(
user_id=user_id,
evotor_user_id=evotor_user_id,
access_token=access_token or "",
api_token=api_token,
connected_at=now,
updated_at=now,
)
db.add(conn)
db.flush()
return api_token
@router.post("/user/create")
async def user_create(request: Request, db: Session = Depends(get_db)):
if not _verify_secret(request):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
evotor_user_id: str = body.get("userId", "")
if not evotor_user_id:
return JSONResponse({"error": "userId required"}, status_code=400)
custom = _parse_custom_fields(body.get("customField"))
email = (custom.get("email") or "").strip().lower() or None
phone = (custom.get("phone") or "").strip() or None
first_name = (custom.get("first_name") or custom.get("firstName") or "").strip() or None
last_name = (custom.get("last_name") or custom.get("lastName") or "").strip() or None
# Try to find existing user
user: User | None = None
# 1. By evotor_user_id
user = db.query(User).filter(User.evotor_user_id == evotor_user_id).first()
# 2. By email
if user is None and email:
user = db.query(User).filter(User.email == email).first()
# 3. By phone
if user is None and phone:
user = db.query(User).filter(User.phone == phone).first()
now = datetime.now(timezone.utc).replace(tzinfo=None)
if user:
# Link Evotor to existing user
user.evotor_user_id = evotor_user_id
user.evotor_meta = custom or body
if user.status == UserStatusEnum.pending:
user.status = UserStatusEnum.active
db.flush()
else:
# Create new pending user
user = User(
first_name=first_name or "",
last_name=last_name or "",
email=email or f"{evotor_user_id}@evotor.invalid",
phone=phone or None,
password_hash=None,
role=UserRoleEnum.user,
status=UserStatusEnum.pending,
evotor_user_id=evotor_user_id,
evotor_meta=custom or body,
created_at=now,
updated_at=now,
)
db.add(user)
db.flush() # get user.id
# Generate invite
invite_token = secrets.token_urlsafe(32)
user.invite_token = invite_token
user.invite_expires = now + timedelta(hours=settings.INVITE_EXPIRE_HOURS)
api_token = _upsert_evotor_connection(db, user.id, evotor_user_id)
db.commit()
# Send invite email if we have a real email address
if email:
invite_url = f"{settings.BASE_URL}/invite?token={invite_token}"
html = (
f"<p>Здравствуйте!</p>"
f"<p>Вам открыт доступ к ЭВОСИНК. Завершите регистрацию по ссылке:</p>"
f'<p><a href="{invite_url}">{invite_url}</a></p>'
f"<p>Ссылка действительна {settings.INVITE_EXPIRE_HOURS} часов.</p>"
)
send_email_task.delay(email, "Приглашение в ЭВОСИНК", html)
else:
logger.info("No email for evotor_user_id=%s, invite URL: %s/invite?token=%s",
evotor_user_id, settings.BASE_URL, invite_token)
return JSONResponse({"userId": evotor_user_id, "token": api_token})
@router.post("/user/verify")
async def user_verify(request: Request, db: Session = Depends(get_db)):
if not _verify_secret(request):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
evotor_user_id: str = body.get("userId", "")
username: str = body.get("username", "").strip()
password: str = body.get("password", "")
if not username or not password:
return JSONResponse({"error": "username and password required"}, status_code=400)
# username is email or phone
user = db.query(User).filter(
or_(User.email == username, User.phone == username)
).first()
if not user or not user.password_hash:
return JSONResponse({"error": "Неверные данные"}, status_code=401)
if user.status == UserStatusEnum.suspended:
return JSONResponse({"error": "Аккаунт заблокирован"}, status_code=403)
if not verify_password(password, user.password_hash):
return JSONResponse({"error": "Неверные данные"}, status_code=401)
# Get or create connection to retrieve api_token
conn = db.query(EvotorConnection).filter(
EvotorConnection.evotor_user_id == (user.evotor_user_id or evotor_user_id)
).first()
if not conn:
# Auto-link: create connection with Evotor userId from request
if evotor_user_id and not user.evotor_user_id:
user.evotor_user_id = evotor_user_id
db.flush()
api_token = _upsert_evotor_connection(db, user.id, evotor_user_id or (user.evotor_user_id or ""))
db.commit()
else:
api_token = conn.api_token or secrets.token_urlsafe(32)
if not conn.api_token:
conn.api_token = api_token
db.commit()
return JSONResponse({"userId": user.evotor_user_id or evotor_user_id, "token": api_token})
@router.post("/user/token")
async def user_token(request: Request, db: Session = Depends(get_db)):
if not _verify_secret(request):
return JSONResponse({"error": "Unauthorized"}, status_code=401)
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
evotor_user_id: str = body.get("userId", "")
evotor_token: str = body.get("token", "")
if not evotor_user_id or not evotor_token:
return JSONResponse({"error": "userId and token required"}, status_code=400)
user = db.query(User).filter(User.evotor_user_id == evotor_user_id).first()
if not user:
return JSONResponse({"error": "User not found"}, status_code=404)
conn = db.query(EvotorConnection).filter(
EvotorConnection.evotor_user_id == evotor_user_id
).first()
now = datetime.now(timezone.utc).replace(tzinfo=None)
if conn:
conn.access_token = evotor_token
conn.is_online = True
conn.last_checked_at = now
conn.updated_at = now
else:
conn = EvotorConnection(
user_id=user.id,
evotor_user_id=evotor_user_id,
access_token=evotor_token,
api_token=secrets.token_urlsafe(32),
is_online=True,
last_checked_at=now,
connected_at=now,
updated_at=now,
)
db.add(conn)
db.commit()
return JSONResponse({})

99
web/routes/invite.py Normal file
View File

@@ -0,0 +1,99 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy import or_
from sqlalchemy.orm import Session
from web.auth.password import hash_password
from web.config import settings
from web.database import get_db
from web.models.user import User, UserStatusEnum
from web.templates_env import templates
router = APIRouter()
def _render(request: Request, template: str, ctx: dict) -> HTMLResponse:
ctx["request"] = request
ctx.setdefault("jivosite_widget_id", settings.JIVOSITE_WIDGET_ID)
return templates.TemplateResponse(ctx.pop("request"), template, ctx)
def _bad_token(request: Request) -> HTMLResponse:
return _render(request, "message.html", {
"user": None,
"title": "Ссылка недействительна",
"message": "Ссылка приглашения устарела или недействительна. Обратитесь к администратору.",
"link": "/login", "link_text": "Войти",
})
@router.get("/invite")
async def invite_get(request: Request, db: Session = Depends(get_db)):
token = request.query_params.get("token", "")
user = db.query(User).filter(User.invite_token == token).first()
if not user or not user.invite_expires or user.invite_expires < datetime.now(timezone.utc).replace(tzinfo=None):
return _bad_token(request)
return _render(request, "invite.html", {"user": None, "invite_user": user, "token": token})
@router.post("/invite")
async def invite_post(request: Request, db: Session = Depends(get_db)):
token = request.query_params.get("token", "")
invite_user = db.query(User).filter(User.invite_token == token).first()
if not invite_user or not invite_user.invite_expires or invite_user.invite_expires < datetime.now(timezone.utc).replace(tzinfo=None):
return _bad_token(request)
form = await request.form()
data = {k: str(v).strip() for k, v in form.items()}
errors = []
if not data.get("first_name"):
errors.append("Имя обязательно")
if not data.get("last_name"):
errors.append("Фамилия обязательна")
if not data.get("email"):
errors.append("Email обязателен")
if not data.get("phone"):
errors.append("Телефон обязателен")
if len(data.get("password", "")) < 8:
errors.append("Пароль должен содержать минимум 8 символов")
if data.get("password") != data.get("password_confirm"):
errors.append("Пароли не совпадают")
if not errors:
# Check uniqueness (excluding current invite_user)
dup = db.query(User).filter(
or_(User.email == data["email"], User.phone == data["phone"]),
User.id != invite_user.id,
).first()
if dup:
if dup.email == data["email"]:
errors.append("Пользователь с таким email уже существует")
else:
errors.append("Пользователь с таким телефоном уже существует")
if errors:
return _render(request, "invite.html", {
"user": None, "invite_user": invite_user, "token": token,
"errors": errors, "form": data,
})
invite_user.first_name = data["first_name"]
invite_user.last_name = data["last_name"]
invite_user.email = data["email"]
invite_user.phone = data["phone"]
invite_user.password_hash = hash_password(data["password"])
invite_user.is_email_confirmed = True
invite_user.status = UserStatusEnum.active
invite_user.invite_token = None
invite_user.invite_expires = None
db.commit()
return _render(request, "message.html", {
"user": None,
"title": "Регистрация завершена!",
"message": "Ваш аккаунт активирован. Теперь вы можете войти.",
"link": "/login", "link_text": "Войти",
})

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

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

View File

@@ -1,145 +1,143 @@
from fastapi import APIRouter, Request, Depends
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy import or_
from sqlalchemy.orm import Session
from web.auth import get_current_user, verify_password, hash_password
from web.auth.password import hash_password, verify_password
from web.auth.session import get_current_user
from web.config import settings
from web.database import get_db
from web.models import User
from web.schemas import validate_profile, validate_reset_password
from web.models.user import User
from web.templates_env import templates
router = APIRouter()
templates = Jinja2Templates(directory="web/templates")
# VIEW PROFILE
def _render(request: Request, template: str, ctx: dict) -> HTMLResponse:
ctx["request"] = request
ctx.setdefault("jivosite_widget_id", settings.JIVOSITE_WIDGET_ID)
return templates.TemplateResponse(ctx.pop("request"), template, ctx)
@router.get("/profile")
def profile_view(request: Request, user: User | None = Depends(get_current_user)):
if not user:
async def profile_view(request: Request, db: Session = Depends(get_db)):
try:
user = get_current_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
return templates.TemplateResponse("profile_view.html", {"request": request, "user": user})
return _render(request, "profile_view.html", {"user": user})
# EDIT PROFILE
@router.get("/profile/edit")
def profile_edit_form(request: Request, user: User | None = Depends(get_current_user)):
if not user:
async def profile_edit_get(request: Request, db: Session = Depends(get_db)):
try:
user = get_current_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
return templates.TemplateResponse("profile_edit.html", {"request": request, "user": user})
return _render(request, "profile_edit.html", {"user": user})
@router.post("/profile/edit")
async def profile_edit_submit(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
async def profile_edit_post(request: Request, db: Session = Depends(get_db)):
try:
user = get_current_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
form = await request.form()
data = dict(form)
data = {k: str(v).strip() for k, v in form.items()}
errors = []
errors = validate_profile(data)
if not data.get("first_name"):
errors.append("Имя обязательно")
if not data.get("last_name"):
errors.append("Фамилия обязательна")
if not data.get("phone"):
errors.append("Телефон обязателен")
if not errors:
existing = db.query(User).filter(
User.phone == data["phone"].strip(), User.id != user.id
dup = db.query(User).filter(
User.phone == data["phone"], User.id != user.id
).first()
if existing:
if dup:
errors.append("Пользователь с таким телефоном уже существует")
if errors:
return templates.TemplateResponse("profile_edit.html", {
"request": request, "user": user, "errors": errors, "form": data,
})
return _render(request, "profile_edit.html", {"user": user, "errors": errors, "form": data})
user.first_name = data["first_name"].strip()
user.last_name = data["last_name"].strip()
user.phone = data["phone"].strip()
user.first_name = data["first_name"]
user.last_name = data["last_name"]
user.phone = data["phone"]
db.commit()
return templates.TemplateResponse("profile_edit.html", {
"request": request, "user": user, "success": "Профиль обновлен",
return _render(request, "profile_edit.html", {
"user": user, "success": "Профиль обновлён",
})
# CHANGE PASSWORD
@router.get("/profile/change-password")
def change_password_form(request: Request, user: User | None = Depends(get_current_user)):
if not user:
async def change_pw_get(request: Request, db: Session = Depends(get_db)):
try:
user = get_current_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
return templates.TemplateResponse("profile_change_password.html", {"request": request, "user": user})
return _render(request, "profile_change_password.html", {"user": user})
@router.post("/profile/change-password")
async def change_password_submit(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
async def change_pw_post(request: Request, db: Session = Depends(get_db)):
try:
user = get_current_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
form = await request.form()
data = dict(form)
current = str(form.get("current_password", ""))
new_pw = str(form.get("password", ""))
confirm = str(form.get("password_confirm", ""))
errors = []
current_password = data.get("current_password", "")
if not current_password:
errors.append("Введите текущий пароль")
elif not verify_password(current_password, user.password_hash):
errors.append("Неверный текущий пароль")
password_errors = validate_reset_password(data)
errors.extend(password_errors)
if not user.password_hash or not verify_password(current, user.password_hash):
errors.append("Неверный текущий пароль")
if len(new_pw) < 8:
errors.append("Новый пароль должен содержать минимум 8 символов")
if new_pw != confirm:
errors.append("Пароли не совпадают")
if errors:
return templates.TemplateResponse("profile_change_password.html", {
"request": request, "user": user, "errors": errors,
})
return _render(request, "profile_change_password.html", {"user": user, "errors": errors})
user.password_hash = hash_password(data["password"])
user.password_hash = hash_password(new_pw)
db.commit()
return templates.TemplateResponse("profile_change_password.html", {
"request": request, "user": user, "success": "Пароль изменен",
return _render(request, "profile_change_password.html", {
"user": user, "success": "Пароль изменён",
})
# DELETE ACCOUNT
@router.get("/profile/delete")
def delete_account_form(request: Request, user: User | None = Depends(get_current_user)):
if not user:
async def delete_get(request: Request, db: Session = Depends(get_db)):
try:
user = get_current_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
return templates.TemplateResponse("profile_delete.html", {"request": request, "user": user})
return _render(request, "profile_delete.html", {"user": user})
@router.post("/profile/delete")
async def delete_account_submit(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
async def delete_post(request: Request, db: Session = Depends(get_db)):
try:
user = get_current_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
form = await request.form()
data = dict(form)
password = str(form.get("password", ""))
password = data.get("password", "")
if not password:
return templates.TemplateResponse("profile_delete.html", {
"request": request, "user": user, "errors": ["Введите пароль для подтверждения"],
})
if not verify_password(password, user.password_hash):
return templates.TemplateResponse("profile_delete.html", {
"request": request, "user": user, "errors": ["Неверный пароль"],
if not user.password_hash or not verify_password(password, user.password_hash):
return _render(request, "profile_delete.html", {
"user": user, "errors": ["Неверный пароль"],
})
db.delete(user)
db.commit()
request.session.clear()
return RedirectResponse("/", 303)
return RedirectResponse("/login", 303)

View File

@@ -1,108 +1,101 @@
import uuid
from datetime import datetime, timedelta, timezone
import secrets
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, Request, Depends
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from web.auth import hash_password
from web.auth.password import hash_password
from web.config import settings
from web.database import get_db
from web.models import User
from web.schemas import validate_reset_password
from web.models.user import User
from web.notifications.tasks import send_email_task
from web.templates_env import templates
router = APIRouter()
templates = Jinja2Templates(directory="web/templates")
def _render(request: Request, template: str, ctx: dict) -> HTMLResponse:
ctx["request"] = request
ctx.setdefault("jivosite_widget_id", settings.JIVOSITE_WIDGET_ID)
return templates.TemplateResponse(ctx.pop("request"), template, ctx)
@router.get("/forgot-password")
def forgot_form(request: Request):
return templates.TemplateResponse("forgot_password.html", {"request": request, "user": None})
async def forgot_get(request: Request):
return _render(request, "forgot_password.html", {"user": None})
@router.post("/forgot-password")
async def forgot_submit(request: Request, db: Session = Depends(get_db)):
async def forgot_post(request: Request, db: Session = Depends(get_db)):
form = await request.form()
email = form.get("email", "").strip()
if email:
user = db.query(User).filter(User.email == email).first()
if user:
token = uuid.uuid4().hex
user.password_reset_token = token
user.password_reset_expires = datetime.now(timezone.utc) + timedelta(
minutes=settings.PASSWORD_RESET_EXPIRE_MINUTES
)
db.commit()
reset_url = f"{settings.BASE_URL}/reset-password?token={token}"
print("=" * 40)
print("СБРОС ПАРОЛЯ")
print(f"Пользователь: {user.email}")
print(f"Ссылка: {reset_url}")
print(f"Действительна: {settings.PASSWORD_RESET_EXPIRE_MINUTES} мин.")
print("=" * 40)
return templates.TemplateResponse("message.html", {
"request": request, "user": None,
"title": "Сброс пароля",
"message": "Если аккаунт с таким email существует, мы отправили письмо со ссылкой для сброса пароля.",
email = str(form.get("email", "")).strip()
user = db.query(User).filter(User.email == email).first()
if user:
token = secrets.token_urlsafe(32)
user.password_reset_token = token
user.password_reset_expires = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(
minutes=settings.PASSWORD_RESET_EXPIRE_MINUTES
)
db.commit()
reset_url = f"{settings.BASE_URL}/reset-password?token={token}"
html = f'<p>Сброс пароля: <a href="{reset_url}">{reset_url}</a></p>'
send_email_task.delay(user.email, "Сброс пароля — ЭВОСИНК", html)
# Always show same message to prevent user enumeration
return _render(request, "message.html", {
"user": None,
"title": "Ссылка отправлена",
"message": "Если указанный email зарегистрирован, вы получите ссылку для сброса пароля.",
"link": "/login", "link_text": "Войти",
})
@router.get("/reset-password")
def reset_form(request: Request, token: str, db: Session = Depends(get_db)):
async def reset_get(request: Request, db: Session = Depends(get_db)):
token = request.query_params.get("token", "")
user = db.query(User).filter(User.password_reset_token == token).first()
if not user or not user.password_reset_expires:
return templates.TemplateResponse("message.html", {
"request": request, "user": None,
"title": "Ошибка", "message": "Неверная или устаревшая ссылка.",
if not user or not user.password_reset_expires or user.password_reset_expires < datetime.now(timezone.utc).replace(tzinfo=None):
return _render(request, "message.html", {
"user": None, "title": "Ссылка недействительна",
"message": "Ссылка для сброса пароля устарела или недействительна.",
"link": "/forgot-password", "link_text": "Запросить новую ссылку",
})
if datetime.now(timezone.utc) > user.password_reset_expires.replace(tzinfo=timezone.utc):
return templates.TemplateResponse("message.html", {
"request": request, "user": None,
"title": "Ошибка", "message": "Срок действия ссылки истек.",
})
return templates.TemplateResponse("reset_password.html", {
"request": request, "user": None, "token": token,
})
return _render(request, "reset_password.html", {"user": None, "token": token})
@router.post("/reset-password")
async def reset_submit(request: Request, token: str, db: Session = Depends(get_db)):
user = db.query(User).filter(User.password_reset_token == token).first()
if not user or not user.password_reset_expires:
return templates.TemplateResponse("message.html", {
"request": request, "user": None,
"title": "Ошибка", "message": "Неверная или устаревшая ссылка.",
})
if datetime.now(timezone.utc) > user.password_reset_expires.replace(tzinfo=timezone.utc):
return templates.TemplateResponse("message.html", {
"request": request, "user": None,
"title": "Ошибка", "message": "Срок действия ссылки истек.",
})
async def reset_post(request: Request, db: Session = Depends(get_db)):
token = request.query_params.get("token", "")
form = await request.form()
data = dict(form)
errors = validate_reset_password(data)
password = str(form.get("password", ""))
password_confirm = str(form.get("password_confirm", ""))
errors = []
user = db.query(User).filter(User.password_reset_token == token).first()
if not user or not user.password_reset_expires or user.password_reset_expires < datetime.now(timezone.utc).replace(tzinfo=None):
return _render(request, "message.html", {
"user": None, "title": "Ссылка недействительна",
"message": "Ссылка для сброса пароля устарела.",
"link": "/forgot-password", "link_text": "Запросить новую ссылку",
})
if len(password) < 8:
errors.append("Пароль должен содержать минимум 8 символов")
if password != password_confirm:
errors.append("Пароли не совпадают")
if errors:
return templates.TemplateResponse("reset_password.html", {
"request": request, "user": None, "token": token, "errors": errors,
return _render(request, "reset_password.html", {
"user": None, "token": token, "errors": errors,
})
user.password_hash = hash_password(data["password"])
user.password_hash = hash_password(password)
user.password_reset_token = None
user.password_reset_expires = None
db.commit()
return templates.TemplateResponse("message.html", {
"request": request, "user": None,
"title": "Пароль изменен",
"message": "Ваш пароль успешно изменен. Теперь вы можете войти.",
return _render(request, "message.html", {
"user": None, "title": "Пароль изменён",
"message": "Ваш пароль успешно изменён.",
"link": "/login", "link_text": "Войти",
})

View File

@@ -1,102 +1,77 @@
from datetime import datetime
"""Sync settings page."""
from datetime import datetime, timezone
from fastapi import APIRouter, Request, Depends
from fastapi import APIRouter, Depends, Request
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from web.auth import get_current_user
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 import User, EvotorConnection, VkConnection, SyncConfig, SyncFilter
from web.models.connections import SyncConfig
from web.templates_env import templates
router = APIRouter(prefix="/sync")
templates = Jinja2Templates(directory="web/templates")
router = APIRouter()
def _get_or_create_sync_config(db: Session, user_id: int) -> SyncConfig:
config = db.query(SyncConfig).filter(SyncConfig.user_id == user_id).first()
if not config:
config = SyncConfig(user_id=user_id, is_enabled=False)
db.add(config)
db.commit()
db.refresh(config)
return config
def _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)
def _filter_summary(config: SyncConfig) -> dict:
stores = [f for f in config.filters if f.entity_type == "store"]
groups = [f for f in config.filters if f.entity_type == "group"]
products = [f for f in config.filters if f.entity_type == "product"]
return {
"stores": len(stores),
"groups": len(groups),
"products": len(products),
"total": len(config.filters),
}
@router.get("")
def sync_page(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
@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)
evotor = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
vk = db.query(VkConnection).filter(VkConnection.user_id == user.id).first()
config = _get_or_create_sync_config(db, user.id)
summary = _filter_summary(config)
if config.confirmed_at and config.is_enabled:
status = "active"
elif config.confirmed_at and not config.is_enabled:
status = "paused"
elif summary["total"] > 0:
status = "pending"
else:
status = "unconfigured"
return templates.TemplateResponse("sync.html", {
"request": request,
"user": user,
"evotor": evotor,
"vk": vk,
config = 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,
"summary": summary,
"status": status,
"saved": request.query_params.get("saved"),
})
@router.post("/toggle")
def sync_toggle(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
@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)
config = _get_or_create_sync_config(db, user.id)
config.is_enabled = not config.is_enabled
form = await request.form()
evo_mirror_enabled = form.get("evo_mirror_enabled") == "1"
vk_mirror_enabled = form.get("vk_mirror_enabled") == "1"
sync_enabled = form.get("is_enabled") == "1"
raw_multiplier = str(form.get("price_multiplier", "1")).strip()
try:
multiplier = float(raw_multiplier)
if multiplier <= 0:
raise ValueError
except ValueError:
multiplier = 1.0
config = db.query(SyncConfig).filter_by(user_id=user.id).first()
if config:
config.evo_mirror_enabled = evo_mirror_enabled
config.vk_mirror_enabled = vk_mirror_enabled
config.is_enabled = sync_enabled
config.price_multiplier = multiplier
else:
config = SyncConfig(
user_id=user.id,
evo_mirror_enabled=evo_mirror_enabled,
vk_mirror_enabled=vk_mirror_enabled,
is_enabled=sync_enabled,
price_multiplier=multiplier,
)
db.add(config)
db.commit()
return RedirectResponse("/sync", 303)
@router.post("/confirm")
def sync_confirm(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
return RedirectResponse("/login", 303)
config = _get_or_create_sync_config(db, user.id)
if config.is_enabled and len(config.filters) > 0:
config.confirmed_at = datetime.utcnow()
db.commit()
return RedirectResponse("/sync", 303)
return RedirectResponse("/sync?saved=1", 303)

View File

@@ -1,164 +0,0 @@
import secrets
from datetime import datetime
import httpx
from fastapi import APIRouter, Request, Depends
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from web.auth import get_current_user
from web.config import settings
from web.database import get_db
from web.models import User, VkConnection
router = APIRouter(prefix="/vk")
templates = Jinja2Templates(directory="web/templates")
VK_AUTHORIZE_URL = "https://oauth.vk.com/authorize"
VK_TOKEN_URL = "https://oauth.vk.com/access_token"
VK_API_URL = "https://api.vk.com/method"
def _redirect_uri() -> str:
return f"{settings.BASE_URL}/vk/callback"
@router.get("")
def vk_page(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
return RedirectResponse("/login", 303)
connection = db.query(VkConnection).filter(VkConnection.user_id == user.id).first()
error = request.query_params.get("error")
return templates.TemplateResponse("vk.html", {
"request": request,
"user": user,
"connection": connection,
"error": error,
})
@router.get("/connect")
def vk_connect(request: Request, user: User | None = Depends(get_current_user)):
if not user:
return RedirectResponse("/login", 303)
state = secrets.token_urlsafe(32)
request.session["vk_oauth_state"] = state
params = (
f"?client_id={settings.VK_CLIENT_ID}"
f"&response_type=code"
f"&redirect_uri={_redirect_uri()}"
f"&scope={settings.VK_SCOPES.replace(' ', '%20')}"
f"&state={state}"
f"&display=page"
f"&v={settings.VK_API_VERSION}"
)
return RedirectResponse(VK_AUTHORIZE_URL + params, 302)
@router.get("/callback")
async def vk_callback(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
return RedirectResponse("/login", 303)
code = request.query_params.get("code")
state = request.query_params.get("state")
saved_state = request.session.pop("vk_oauth_state", None)
if not code or not state or state != saved_state:
return RedirectResponse("/vk?error=invalid_state", 303)
# Exchange code for token (VK uses GET with query params)
try:
async with httpx.AsyncClient() as client:
token_response = await client.get(
VK_TOKEN_URL,
params={
"client_id": settings.VK_CLIENT_ID,
"client_secret": settings.VK_CLIENT_SECRET,
"code": code,
"redirect_uri": _redirect_uri(),
},
timeout=15,
)
token_response.raise_for_status()
token_data = token_response.json()
except Exception:
return RedirectResponse("/vk?error=token_exchange", 303)
access_token = token_data.get("access_token")
vk_user_id = str(token_data.get("user_id", "")) or None
if not access_token:
return RedirectResponse("/vk?error=no_token", 303)
# Fetch VK profile info
first_name = None
last_name = None
try:
async with httpx.AsyncClient() as client:
profile_response = await client.get(
f"{VK_API_URL}/users.get",
params={"access_token": access_token, "v": settings.VK_API_VERSION},
timeout=15,
)
if profile_response.status_code == 200:
profile_data = profile_response.json()
items = profile_data.get("response", [])
if items:
first_name = items[0].get("first_name")
last_name = items[0].get("last_name")
except Exception:
pass
# Save or update connection
connection = db.query(VkConnection).filter(VkConnection.user_id == user.id).first()
if connection:
connection.access_token = access_token
connection.vk_user_id = vk_user_id
connection.first_name = first_name
connection.last_name = last_name
connection.is_online = True
connection.last_checked_at = datetime.utcnow()
else:
connection = VkConnection(
user_id=user.id,
access_token=access_token,
vk_user_id=vk_user_id,
first_name=first_name,
last_name=last_name,
is_online=True,
last_checked_at=datetime.utcnow(),
)
db.add(connection)
db.commit()
return RedirectResponse("/connections", 303)
@router.post("/disconnect")
async def vk_disconnect(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
return RedirectResponse("/login", 303)
connection = db.query(VkConnection).filter(VkConnection.user_id == user.id).first()
if connection:
db.delete(connection)
db.commit()
return RedirectResponse("/connections", 303)

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,
})

View File

@@ -1,52 +0,0 @@
import re
def validate_registration(data: dict) -> list[str]:
errors = []
if not data.get("first_name", "").strip():
errors.append("Введите имя")
if not data.get("last_name", "").strip():
errors.append("Введите фамилию")
email = data.get("email", "").strip()
if not email or not re.match(r"^[^@]+@[^@]+\.[^@]+$", email):
errors.append("Введите корректный email")
phone = data.get("phone", "").strip()
if not phone or not re.match(r"^\+7 \(\d{3}\) \d{3}-\d{2}-\d{2}$", phone):
errors.append("Введите телефон в формате +7 (XXX) XXX-XX-XX")
password = data.get("password", "")
if len(password) < 8:
errors.append("Пароль должен быть не менее 8 символов")
if password != data.get("password_confirm", ""):
errors.append("Пароли не совпадают")
return errors
def validate_login(data: dict) -> list[str]:
errors = []
if not data.get("email", "").strip():
errors.append("Введите email")
if not data.get("password", ""):
errors.append("Введите пароль")
return errors
def validate_reset_password(data: dict) -> list[str]:
errors = []
password = data.get("password", "")
if len(password) < 8:
errors.append("Пароль должен быть не менее 8 символов")
if password != data.get("password_confirm", ""):
errors.append("Пароли не совпадают")
return errors
def validate_profile(data: dict) -> list[str]:
errors = []
if not data.get("first_name", "").strip():
errors.append("Введите имя")
if not data.get("last_name", "").strip():
errors.append("Введите фамилию")
phone = data.get("phone", "").strip()
if not phone or not re.match(r"^\+7 \(\d{3}\) \d{3}-\d{2}-\d{2}$", phone):
errors.append("Введите телефон в формате +7 (XXX) XXX-XX-XX")
return errors

View File

@@ -1,39 +1,431 @@
/* Brand overrides */
/* Brand colors */
:root {
--bs-primary: #F05023;
--bs-primary-rgb: 240, 80, 35;
--bs-link-color: #0986E2;
--bs-link-hover-color: #0670c0;
--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;
}
/* 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: 22px;
font-size: 1.3rem;
font-weight: 700;
color: #F05023 !important;
color: var(--brand-primary) !important;
text-decoration: none;
}
.brand-border {
border-color: #F05023 !important;
.nav-links {
flex: 1;
justify-content: flex-end;
}
.btn-primary {
--bs-btn-bg: #F05023;
--bs-btn-border-color: #F05023;
--bs-btn-hover-bg: #d44420;
--bs-btn-hover-border-color: #d44420;
--bs-btn-active-bg: #c03d1c;
--bs-btn-active-border-color: #c03d1c;
.nav-links a {
color: var(--pico-color);
text-decoration: none;
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.btn-secondary {
--bs-btn-bg: #0986E2;
--bs-btn-border-color: #0986E2;
--bs-btn-hover-bg: #0770c0;
--bs-btn-hover-border-color: #0770c0;
--bs-btn-active-bg: #065fa3;
--bs-btn-active-border-color: #065fa3;
.nav-links a:hover {
color: var(--brand-primary);
}
.nav-link:hover {
color: #F05023 !important;
.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; }
.w-100 { width: 100%; }
.h-100 { height: 100%; }
/* Table */
.table-scroll {
overflow-x: auto;
}
table.align-middle td,
table.align-middle th {
vertical-align: middle;
}
/* Breadcrumb */
.breadcrumb {
display: flex;
align-items: center;
gap: 0.25rem;
list-style: none;
padding: 0;
margin: 0 0 1rem;
font-size: 0.9rem;
color: var(--pico-muted-color);
}
.breadcrumb-item + .breadcrumb-item::before {
content: "/";
margin-right: 0.25rem;
color: var(--pico-muted-color);
}
.breadcrumb-item.active { color: var(--pico-color); }
/* Dropdown */
.dropdown {
position: relative;
display: inline-block;
}
.dropdown-menu {
display: none;
position: absolute;
right: 0;
top: calc(100% + 4px);
background: var(--pico-background-color);
border: 1px solid var(--pico-border-color);
border-radius: var(--pico-border-radius);
box-shadow: 0 4px 12px rgba(0,0,0,0.12);
z-index: 200;
min-width: 220px;
padding: 0.25rem 0;
list-style: none;
margin: 0;
}
.dropdown.open .dropdown-menu { display: block; }
.dropdown-item {
display: block;
width: 100%;
padding: 0.45rem 1rem;
background: none;
border: none;
text-align: left;
cursor: pointer;
color: var(--pico-color);
font-size: 0.9rem;
text-decoration: none;
}
.dropdown-item:hover {
background: var(--pico-muted-background-color);
}
.dropdown-item.muted { color: var(--pico-muted-color); }
.dropdown-divider {
border: none;
border-top: 1px solid var(--pico-border-color);
margin: 0.25rem 0;
}
/* Spinner */
.spinner {
display: inline-block;
width: 2rem;
height: 2rem;
border: 3px solid var(--pico-muted-background-color);
border-top-color: var(--brand-primary);
border-radius: 50%;
animation: spin 0.75s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Input group */
.input-group {
display: flex;
gap: 0;
}
.input-group input {
border-radius: var(--pico-border-radius) 0 0 var(--pico-border-radius);
margin: 0;
flex: 1;
}
.input-group button {
border-radius: 0 var(--pico-border-radius) var(--pico-border-radius) 0;
margin: 0;
white-space: nowrap;
}
/* Empty state */
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: var(--pico-muted-color);
}
.empty-state .empty-icon {
font-size: 3rem;
display: block;
margin-bottom: 0.75rem;
}

0
web/tasks/__init__.py Normal file
View File

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

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

61
web/tasks/celery_app.py Normal file
View File

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

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

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

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

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

View File

@@ -0,0 +1,147 @@
{% extends "base.html" %}
{% block title %}API Логи — ЭВОСИНК{% endblock %}
{% block content %}
<div class="d-flex justify-between align-center mb-3">
<h1 style="font-size:1.3rem; margin:0;"><i class="bi bi-journal-text me-2"></i>API Логи</h1>
<span class="text-muted small">Найдено: {{ total }}</span>
</div>
{# ── filters ── #}
<form method="get" action="/admin/logs" class="mb-3" style="display:flex; flex-wrap:wrap; gap:0.5rem; align-items:center;">
<select name="service" style="width:auto;">
<option value="" {% if not filter_service %}selected{% endif %}>Все сервисы</option>
<option value="evotor" {% if filter_service == 'evotor' %}selected{% endif %}>Эвотор</option>
<option value="vk" {% if filter_service == 'vk' %}selected{% endif %}>ВКонтакте</option>
<option value="other" {% if filter_service == 'other' %}selected{% endif %}>Другое</option>
</select>
<select name="method" style="width:auto;">
<option value="" {% if not filter_method %}selected{% endif %}>Все методы</option>
<option value="GET" {% if filter_method == 'GET' %}selected{% endif %}>GET</option>
<option value="POST" {% if filter_method == 'POST' %}selected{% endif %}>POST</option>
</select>
<select name="status" style="width:auto;">
<option value="" {% if not filter_status %}selected{% endif %}>Любой статус</option>
<option value="ok" {% if filter_status == 'ok' %}selected{% endif %}>2xx / 3xx</option>
<option value="error" {% if filter_status == 'error' %}selected{% endif %}>4xx / 5xx</option>
<option value="200" {% if filter_status == '200' %}selected{% endif %}>200</option>
<option value="401" {% if filter_status == '401' %}selected{% endif %}>401</option>
<option value="403" {% if filter_status == '403' %}selected{% endif %}>403</option>
<option value="429" {% if filter_status == '429' %}selected{% endif %}>429</option>
<option value="500" {% if filter_status == '500' %}selected{% endif %}>500</option>
</select>
<select name="hours" style="width:auto;">
<option value="1" {% if filter_hours == 1 %}selected{% endif %}>Последний час</option>
<option value="6" {% if filter_hours == 6 %}selected{% endif %}>6 часов</option>
<option value="24" {% if filter_hours == 24 %}selected{% endif %}>24 часа</option>
<option value="168" {% if filter_hours == 168 or (not filter_hours) %}selected{% endif %}>7 дней</option>
<option value="720" {% if filter_hours == 720 %}selected{% endif %}>30 дней</option>
</select>
<input type="search" name="q" value="{{ filter_q }}" placeholder="URL или тело ответа…" style="flex:1; min-width:160px;">
<button type="submit">Применить</button>
{% if filter_service or filter_method or filter_status or filter_q or filter_hours != 24 %}
<a href="/admin/logs" role="button" class="outline secondary">Сбросить</a>
{% endif %}
</form>
<article class="card" style="padding:0;">
{% if logs %}
<div class="table-scroll">
<table class="align-middle" style="font-size:0.82rem;">
<thead>
<tr>
<th style="width:140px;">Время</th>
<th style="width:60px;">Сервис</th>
<th style="width:40px;">Метод</th>
<th style="width:50px;">Статус</th>
<th style="width:60px;">Мс</th>
<th>URL</th>
<th style="width:30px;"></th>
</tr>
</thead>
<tbody>
{% for log in logs %}
{% set is_error = log.response_status and log.response_status >= 400 %}
<tr class="{{ 'text-danger' if is_error else '' }}" style="cursor:pointer;" onclick="toggleDetail({{ log.id }})">
<td class="text-muted">{{ log.created_at | datefmt }}</td>
<td>
<span class="badge {{ 'badge-evotor' if log.service == 'evotor' else 'badge-vk' if log.service == 'vk' else '' }}">
{{ log.service }}
</span>
</td>
<td><code>{{ log.method }}</code></td>
<td>
{% if log.response_status %}
<span class="{{ 'text-danger' if is_error else 'text-muted' }}">{{ log.response_status }}</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td class="text-muted">{{ log.duration_ms if log.duration_ms is not none else '—' }}</td>
<td style="max-width:400px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
<span title="{{ log.url }}">{{ log.url }}</span>
</td>
<td class="text-muted"><i class="bi bi-chevron-down"></i></td>
</tr>
<tr id="detail-{{ log.id }}" style="display:none; background:var(--pico-card-background-color);">
<td colspan="7" style="padding:0.75rem 1rem;">
<div style="display:grid; grid-template-columns:1fr 1fr; gap:1rem;">
<div>
<div class="text-muted small mb-1"><strong>URL</strong></div>
<code style="word-break:break-all; font-size:0.78rem;">{{ log.url }}</code>
{% if log.request_body %}
<div class="text-muted small mt-2 mb-1"><strong>Request body</strong></div>
<pre style="font-size:0.75rem; max-height:200px; overflow:auto; margin:0; background:var(--pico-code-background-color); padding:0.5rem; border-radius:4px;">{{ log.request_body }}</pre>
{% endif %}
</div>
<div>
<div class="text-muted small mb-1"><strong>Response ({{ log.response_status }})</strong></div>
{% if log.response_body %}
<pre style="font-size:0.75rem; max-height:200px; overflow:auto; margin:0; background:var(--pico-code-background-color); padding:0.5rem; border-radius:4px;">{{ log.response_body }}</pre>
{% else %}
<span class="text-muted"></span>
{% endif %}
</div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{# ── pagination ── #}
{% if total_pages > 1 %}
<div style="display:flex; justify-content:center; gap:0.5rem; padding:1rem;">
{% if page > 1 %}
<a href="?service={{ filter_service }}&method={{ filter_method }}&status={{ filter_status }}&q={{ filter_q }}&hours={{ filter_hours }}&page={{ page - 1 }}" role="button" class="outline secondary sm">← Назад</a>
{% endif %}
<span class="text-muted" style="line-height:2.2rem;">Стр. {{ page }} / {{ total_pages }}</span>
{% if page < total_pages %}
<a href="?service={{ filter_service }}&method={{ filter_method }}&status={{ filter_status }}&q={{ filter_q }}&hours={{ filter_hours }}&page={{ page + 1 }}" role="button" class="outline secondary sm">Вперёд →</a>
{% endif %}
</div>
{% endif %}
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi bi-journal-x" style="font-size:2rem;"></i>
<p class="mt-2">Записей не найдено за выбранный период.</p>
</div>
{% endif %}
</article>
<style>
.badge { display:inline-block; padding:0.1rem 0.4rem; border-radius:4px; font-size:0.75rem; font-weight:600; }
.badge-evotor { background:#e8f4fd; color:#0986E2; }
.badge-vk { background:#e8f0fe; color:#3b5998; }
.text-danger { color:#dc3545; }
</style>
<script>
function toggleDetail(id) {
const row = document.getElementById('detail-' + id);
row.style.display = row.style.display === 'none' ? 'table-row' : 'none';
}
</script>
{% endblock %}

View File

@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}Роли и права — ЭВОСИНК{% endblock %}
{% block content %}
<nav class="breadcrumb">
<li class="breadcrumb-item"><a href="/admin/users">Пользователи</a></li>
<li class="breadcrumb-item active">Роли и права</li>
</nav>
<h1 style="font-size:1.3rem;" class="mb-3"><i class="bi bi-shield-lock me-2"></i>Роли и права</h1>
{% for role in roles %}
<article class="card mb-3">
<header>
<h2 style="font-size:1rem;">{{ role.name }}
<span class="text-muted small fw-normal">— {{ role.description or '' }}</span>
</h2>
</header>
<div class="card-body">
<form method="post" action="/admin/roles/{{ role.id }}/permissions">
<div class="row gap-2 flex-wrap">
{% for perm in permissions %}
<div class="col-auto">
<label style="display:flex; align-items:center; gap:0.4rem; margin:0;">
<input type="checkbox" name="perm_{{ perm.id }}" value="{{ perm.id }}"
{% if perm.id in role_perm_ids[role.id] %}checked{% endif %}>
{{ perm.name }}
{% if perm.description %}
<span class="text-muted small">({{ perm.description }})</span>
{% endif %}
</label>
</div>
{% endfor %}
</div>
<button type="submit" class="sm mt-3">Сохранить права для «{{ role.name }}»</button>
</form>
</div>
</article>
{% endfor %}
{% endblock %}

View File

@@ -0,0 +1,152 @@
{% extends "base.html" %}
{% block title %}{{ target.first_name }} {{ target.last_name }} — Админ — ЭВОСИНК{% endblock %}
{% block content %}
<nav class="breadcrumb">
<li class="breadcrumb-item"><a href="/admin/users">Пользователи</a></li>
<li class="breadcrumb-item active">{{ target.first_name }} {{ target.last_name }}</li>
</nav>
{% if request.query_params.get('success') == 'reset_sent' %}
<div class="alert alert-success mb-3"><p>Ссылка для сброса пароля отправлена.</p></div>
{% elif request.query_params.get('success') == 'invite_sent' %}
<div class="alert alert-success mb-3"><p>Приглашение отправлено.</p></div>
{% elif request.query_params.get('success') == 'saved' %}
<div class="alert alert-success mb-3"><p>Данные сохранены.</p></div>
{% endif %}
<div class="row gap-3 align-start">
<div class="col-lg-6">
<article class="card">
<header><h2>Профиль</h2></header>
<ul class="list-group">
<li class="list-group-item"><span class="text-muted small">ID</span><span>{{ target.id }}</span></li>
<li class="list-group-item"><span class="text-muted small">Имя</span><span>{{ target.first_name }} {{ target.last_name }}</span></li>
<li class="list-group-item"><span class="text-muted small">Email</span>
<span>{{ target.email }}
{% if target.is_email_confirmed %}
<span class="badge badge-success ms-1">подтверждён</span>
{% else %}
<span class="badge badge-warning ms-1">не подтверждён</span>
{% endif %}
</span>
</li>
<li class="list-group-item"><span class="text-muted small">Телефон</span><span>{{ target.phone }}</span></li>
<li class="list-group-item"><span class="text-muted small">Роль</span>
<span>
{% if target.role == 'system' %}<span class="badge badge-danger">Системный</span>
{% elif target.role == 'admin' %}<span class="badge badge-warning">Администратор</span>
{% else %}<span class="badge badge-secondary">Пользователь</span>
{% endif %}
</span>
</li>
<li class="list-group-item"><span class="text-muted small">Статус</span>
<span>
{% if target.status == 'active' %}<span class="badge badge-success">Активен</span>
{% elif target.status == 'pending' %}<span class="badge badge-warning">Ожидает</span>
{% else %}<span class="badge badge-danger">Заблокирован</span>
{% endif %}
</span>
</li>
<li class="list-group-item"><span class="text-muted small">Регистрация</span><span>{{ target.created_at | datefmt }}</span></li>
{% if target.evotor_user_id %}
<li class="list-group-item"><span class="text-muted small">Эвотор ID</span><span class="font-monospace small">{{ target.evotor_user_id }}</span></li>
{% endif %}
{% if target.invite_token %}
<li class="list-group-item"><span class="text-muted small">Приглашение до</span><span>{{ target.invite_expires | datefmt }}</span></li>
{% endif %}
</ul>
</article>
{% if target.evotor_meta %}
<article class="card mt-3">
<header><h2>Данные Эвотор</h2></header>
<div class="card-body">
<pre class="font-monospace small" style="overflow-x:auto; white-space:pre-wrap; margin:0;">{{ target.evotor_meta | tojson(indent=2) }}</pre>
</div>
</article>
{% endif %}
</div>
<div class="col-lg-6">
<article class="card">
<header><h2>Действия</h2></header>
<div class="card-body d-grid gap-2">
{% if target.status != 'active' %}
<form method="post" action="/admin/users/{{ target.id }}/activate">
<button type="submit" class="w-100">
<i class="bi bi-check-circle me-1"></i>Активировать
</button>
</form>
{% endif %}
{% if target.status != 'suspended' %}
<form method="post" action="/admin/users/{{ target.id }}/suspend">
<button type="submit" class="w-100 outline danger">
<i class="bi bi-slash-circle me-1"></i>Заблокировать
</button>
</form>
{% endif %}
<form method="post" action="/admin/users/{{ target.id }}/reset-password">
<button type="submit" class="w-100 outline secondary">
<i class="bi bi-key me-1"></i>Сбросить пароль
</button>
</form>
<form method="post" action="/admin/users/{{ target.id }}/send-invite">
<button type="submit" class="w-100 outline secondary">
<i class="bi bi-envelope me-1"></i>Отправить приглашение
</button>
</form>
<form method="post" action="/admin/users/{{ target.id }}/view-as">
<button type="submit" class="w-100 outline">
<i class="bi bi-eye me-1"></i>Просмотр от имени пользователя
</button>
</form>
{% if user.role == 'system' and target.id != user.id %}
<form method="post" action="/admin/users/{{ target.id }}/delete"
onsubmit="return confirm('Удалить пользователя {{ target.email }}? Это действие необратимо.')">
<button type="submit" class="w-100 danger sm">
<i class="bi bi-trash me-1"></i>Удалить
</button>
</form>
{% endif %}
</div>
</article>
<article class="card mt-3">
<header><h2>Редактировать</h2></header>
<div class="card-body">
<form method="post" action="/admin/users/{{ target.id }}/edit">
<div class="row gap-2 mb-2">
<div class="col">
<label for="first_name">Имя
<input type="text" id="first_name" name="first_name" value="{{ target.first_name }}" required>
</label>
</div>
<div class="col">
<label for="last_name">Фамилия
<input type="text" id="last_name" name="last_name" value="{{ target.last_name }}" required>
</label>
</div>
</div>
<label for="email">Email
<input type="email" id="email" name="email" value="{{ target.email }}">
</label>
<label for="phone">Телефон
<input type="tel" id="phone" name="phone" value="{{ target.phone }}">
</label>
{% if user.role == 'system' %}
<label for="role">Роль
<select id="role" name="role">
<option value="user" {% if target.role == 'user' %}selected{% endif %}>Пользователь</option>
<option value="admin" {% if target.role == 'admin' %}selected{% endif %}>Администратор</option>
<option value="system" {% if target.role == 'system' %}selected{% endif %}>Системный</option>
</select>
</label>
{% endif %}
<button type="submit">Сохранить</button>
</form>
</div>
</article>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,118 @@
{% extends "base.html" %}
{% block title %}Пользователи — Администрирование — ЭВОСИНК{% endblock %}
{% block content %}
<div class="d-flex justify-between align-center mb-3">
<h1 style="font-size:1.3rem; margin:0;"><i class="bi bi-people me-2"></i>Пользователи</h1>
<span class="text-muted small">Всего: {{ total }}</span>
</div>
<article class="card mb-3">
<div class="card-body">
<form method="get" action="/admin/users" class="d-flex gap-2 flex-wrap align-center">
<input type="text" name="search" value="{{ search }}" placeholder="Поиск по имени, email, телефону" style="flex:1; min-width:200px; margin:0;">
<select name="status" style="width:auto; margin:0;">
<option value="">Все статусы</option>
<option value="pending" {% if status_filter == 'pending' %}selected{% endif %}>Ожидает</option>
<option value="active" {% if status_filter == 'active' %}selected{% endif %}>Активен</option>
<option value="suspended" {% if status_filter == 'suspended' %}selected{% endif %}>Заблокирован</option>
</select>
<select name="role" style="width:auto; margin:0;">
<option value="">Все роли</option>
<option value="user" {% if role_filter == 'user' %}selected{% endif %}>Пользователь</option>
<option value="admin" {% if role_filter == 'admin' %}selected{% endif %}>Администратор</option>
<option value="system" {% if role_filter == 'system' %}selected{% endif %}>Системный</option>
</select>
<button type="submit" class="sm">Найти</button>
{% if search or status_filter or role_filter %}
<a href="/admin/users" role="button" class="outline secondary sm">Сбросить</a>
{% endif %}
</form>
</div>
</article>
<article class="card">
<div class="table-scroll">
<table class="align-middle">
<thead>
<tr>
<th>ID</th>
<th>Имя</th>
<th>Email</th>
<th>Телефон</th>
<th>Роль</th>
<th>Статус</th>
<th>Эвотор</th>
<th>Регистрация</th>
<th></th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td class="text-muted small">{{ u.id }}</td>
<td>{{ u.first_name }} {{ u.last_name }}</td>
<td>
{{ u.email }}
{% if not u.is_email_confirmed %}
<span class="badge badge-warning ms-1" title="Email не подтверждён"><i class="bi bi-exclamation-circle"></i></span>
{% endif %}
</td>
<td>{{ u.phone }}</td>
<td>
{% if u.role == 'system' %}<span class="badge badge-danger">Системный</span>
{% elif u.role == 'admin' %}<span class="badge badge-warning">Админ</span>
{% else %}<span class="badge badge-secondary">Польз.</span>
{% endif %}
</td>
<td>
{% if u.status == 'active' %}<span class="badge badge-success">Активен</span>
{% elif u.status == 'pending' %}<span class="badge badge-warning">Ожидает</span>
{% else %}<span class="badge badge-danger">Заблок.</span>
{% endif %}
</td>
<td>
{% if u.evotor_user_id %}
<i class="bi bi-check-circle text-success" title="{{ u.evotor_user_id }}"></i>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td class="text-muted small">{{ u.created_at | datefmt }}</td>
<td>
<a href="/admin/users/{{ u.id }}" role="button" class="outline sm">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="9" class="text-center text-muted py-4">Пользователи не найдены</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if total_pages > 1 %}
<footer>
<div class="d-flex gap-2 justify-center align-center">
{% if page > 1 %}
<a href="?page={{ page - 1 }}&search={{ search }}&status={{ status_filter }}&role={{ role_filter }}" role="button" class="outline sm">«</a>
{% endif %}
<span class="text-muted small">Стр. {{ page }} из {{ total_pages }}</span>
{% if page < total_pages %}
<a href="?page={{ page + 1 }}&search={{ search }}&status={{ status_filter }}&role={{ role_filter }}" role="button" class="outline sm">»</a>
{% endif %}
</div>
</footer>
{% endif %}
</article>
{% if user.role == 'system' %}
<div class="mt-3 text-end">
<a href="/admin/roles" role="button" class="outline secondary sm">
<i class="bi bi-shield-lock me-1"></i>Управление ролями
</a>
</div>
{% endif %}
{% endblock %}

View File

@@ -1,70 +1,96 @@
<!DOCTYPE html>
<html lang="ru">
<html lang="ru" data-theme="light">
<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/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<nav class="navbar navbar-expand-lg bg-white border-bottom border-2 brand-border">
<div class="container">
<a href="/" class="navbar-brand brand-logo">ЭВОСИНК</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
{% if user %}
<li class="nav-item">
<a href="/connections" class="nav-link">Подключения</a>
</li>
<li class="nav-item">
<a href="/catalog" class="nav-link">Каталог</a>
</li>
<li class="nav-item">
<a href="/sync" class="nav-link">Синхронизация</a>
</li>
<li class="nav-item">
<a href="/profile" class="nav-link"><i class="bi bi-person-circle me-1"></i>Личный кабинет</a>
</li>
<li class="nav-item">
<a href="/logout" class="nav-link text-muted">Выход</a>
</li>
{% else %}
<li class="nav-item">
<a href="/login" class="nav-link">Вход</a>
</li>
<li class="nav-item">
<a href="/register" class="nav-link">Регистрация</a>
</li>
<header class="site-header">
<nav class="container">
<ul>
<li><a href="/" class="brand-logo">ЭВОСИНК</a></li>
</ul>
<ul class="nav-links">
{% if user %}
{% if user.role not in ('admin', 'system') or viewed_user %}
<li><a href="/connections">Подключения</a></li>
<li><a href="/catalog">Каталог Эвотор</a></li>
<li><a href="/vk-catalog/albums">Каталог ВК</a></li>
<li><a href="/sync">Синхронизация</a></li>
{% endif %}
{% if user.role in ('admin', 'system') %}
<li><a href="/admin/users"><i class="bi bi-shield-lock"></i> Админ</a></li>
<li><a href="/admin/logs"><i class="bi bi-journal-text"></i> Логи</a></li>
{% endif %}
<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>
{% endif %}
</ul>
{% if user %}
<details class="mobile-menu">
<summary role="button" class="outline secondary icon-btn"><i class="bi bi-list"></i></summary>
<ul>
{% if user.role not in ('admin', 'system') or viewed_user %}
<li><a href="/connections">Подключения</a></li>
<li><a href="/catalog">Каталог Эвотор</a></li>
<li><a href="/vk-catalog/albums">Каталог ВК</a></li>
<li><a href="/sync">Синхронизация</a></li>
{% endif %}
{% if user.role in ('admin', 'system') %}
<li><a href="/admin/users">Админ</a></li>
<li><a href="/admin/logs">Логи</a></li>
{% endif %}
<li><a href="/profile">Личный кабинет</a></li>
<li><a href="/logout">Выход</a></li>
</ul>
</div>
</div>
</nav>
</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>
</ul>
</details>
{% endif %}
</nav>
</header>
{% if viewed_user %}
<div style="background:#e65c00;color:#fff;text-align:center;padding:0.4rem 1rem;font-size:0.9rem;">
<i class="bi bi-eye me-1"></i>Просмотр от имени: <strong>{{ viewed_user.first_name }} {{ viewed_user.last_name }}</strong> ({{ viewed_user.email }})
<form method="post" action="/admin/view-as/stop" style="display:inline;margin-left:1rem;">
<button type="submit" style="background:none;border:1px solid #fff;color:#fff;padding:0.1rem 0.6rem;font-size:0.85rem;cursor:pointer;border-radius:4px;">Выйти</button>
</form>
</div>
{% endif %}
<main class="container py-4">
{% if errors %}
<div class="alert alert-danger">
<div role="alert" class="alert alert-danger">
{% for error in errors %}
<p class="mb-1">{{ error }}</p>
<p>{{ error }}</p>
{% endfor %}
</div>
{% endif %}
{% if success %}
<div class="alert alert-success">
<p class="mb-0">{{ success }}</p>
<div role="alert" class="alert alert-success">
<p>{{ success }}</p>
</div>
{% endif %}
{% block content %}{% endblock %}
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
{% 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() {
@@ -90,5 +116,6 @@
if (e.target.required) e.target.setCustomValidity('');
}, true);
</script>
{% block scripts %}{% endblock %}
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More