23 Commits

Author SHA1 Message Date
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
25 changed files with 435 additions and 299 deletions

View File

@@ -2,6 +2,9 @@ DATABASE_URL=mysql+pymysql://evosync:evosync@db:3306/evosync
SECRET_KEY=your-random-secret-key-here SECRET_KEY=your-random-secret-key-here
BASE_URL=http://localhost:8000 BASE_URL=http://localhost:8000
EVOTOR_APP_ID=your-evotor-app-id
EVOTOR_WEBHOOK_SECRET=your-webhook-secret
DB_ROOT_PASSWORD=rootpass DB_ROOT_PASSWORD=rootpass
DB_NAME=evosync DB_NAME=evosync
DB_USER=evosync DB_USER=evosync

1
.gitignore vendored
View File

@@ -16,3 +16,4 @@ passwords.txt
.env .env
__pycache__/ __pycache__/
*.pyc *.pyc
certbot

View File

@@ -5,47 +5,46 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.8.0] - 2026-03-06 ## [1.8.2] - 2026-03-06
### Added ### Miscellaneous
- Connections dashboard (`/connections`) — unified page for all service integrations with online/offline status indicators - Replace EvoSync with ЭВОСИНК throughout UI
- 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 ## [1.8.1] - 2026-03-06
- Navbar: replaced "Эвотор" link with "Подключения", added "Каталог" and "Синхронизация" links ### Documentation
- Evotor and VK connect/disconnect flows now redirect to `/connections`
- Back links on `/evotor` and `/vk` pages updated to "Вернуться к подключениям" - Update changelog for v1.7.3
- VK connection card icon changed to `bi-bag` (shopping bag) to reflect VK Market use case - Fix changelog order — 1.8.0 before 1.7.3
- Password reset and email confirmation pages: replaced dev-mode console instructions with user-facing copy
### Other
- V1.8.1
## [1.7.3] - 2026-03-06 ## [1.7.3] - 2026-03-06
### Added ### Added
- Nginx reverse proxy configuration — SSL termination, HTTP→HTTPS redirect, proxy to uvicorn - Add nginx reverse proxy and Let's Encrypt TLS setup
- `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 ### Other
- Evotor OAuth token exchange — move `client_id` and `client_secret` from HTTP Basic Auth to form body fields (resolves `invalid_client` errors) - V1.7.3
- Docker environment — pass `EVOTOR_CLIENT_ID` and `EVOTOR_CLIENT_SECRET` to web container via docker-compose
### Changed ## [1.8.0] - 2026-03-06
- Default `BASE_URL` changed to `https://evosync.ru` for production deployment ### Added
- `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 - Add Evotor OAuth connection feature with formatted phone input
- Add Alembic database migrations
- Add connections dashboard with background health checks
- Add VK OAuth connection with health checks
- Release v1.8.0 — connections dashboard, VK OAuth, sync config, catalog browser
### Miscellaneous
- Add semantic versioning and automatic changelog generation
## [1.7.2] - 2026-03-05 ## [1.7.2] - 2026-03-05

View File

@@ -5,14 +5,14 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile.web dockerfile: Dockerfile.web
expose: ports:
- "8000" - "8080:8000"
environment: environment:
- DATABASE_URL=mysql+pymysql://${DB_USER}:${DB_PASSWORD}@172.25.0.1:3306/${DB_NAME} - DATABASE_URL=mysql+pymysql://${DB_USER}:${DB_PASSWORD}@host.docker.internal:3306/${DB_NAME}
- SECRET_KEY=${SECRET_KEY:-change-me-in-production} - SECRET_KEY=${SECRET_KEY:-change-me-in-production}
- BASE_URL=${BASE_URL:-https://evosync.ru} - BASE_URL=${BASE_URL:-https://evosync.ru}
- EVOTOR_CLIENT_ID=${EVOTOR_CLIENT_ID} - EVOTOR_APP_ID=${EVOTOR_APP_ID}
- EVOTOR_CLIENT_SECRET=${EVOTOR_CLIENT_SECRET} - EVOTOR_WEBHOOK_SECRET=${EVOTOR_WEBHOOK_SECRET}
- VK_CLIENT_ID=${VK_CLIENT_ID} - VK_CLIENT_ID=${VK_CLIENT_ID}
- VK_CLIENT_SECRET=${VK_CLIENT_SECRET} - VK_CLIENT_SECRET=${VK_CLIENT_SECRET}
volumes: volumes:
@@ -20,6 +20,8 @@ services:
- ./alembic.ini:/app/alembic.ini - ./alembic.ini:/app/alembic.ini
- ./docker-entrypoint.sh:/app/docker-entrypoint.sh - ./docker-entrypoint.sh:/app/docker-entrypoint.sh
restart: unless-stopped restart: unless-stopped
extra_hosts:
- "host.docker.internal:host-gateway"
# sync: # sync:
# build: # build:

View File

@@ -1,3 +1,7 @@
upstream web {
server 127.0.0.1:8080;
}
server { server {
listen 80; listen 80;
server_name evosync.ru www.evosync.ru; server_name evosync.ru www.evosync.ru;
@@ -23,7 +27,7 @@ server {
ssl_prefer_server_ciphers on; ssl_prefer_server_ciphers on;
location / { location / {
proxy_pass http://web:8000; proxy_pass http://web;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

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,46 @@
#!/bin/bash #!/bin/bash
# Obtain TLS certificates from Let's Encrypt for evosync.ru # Obtain TLS certificates from Let's Encrypt for evosync.ru
# Run once on first deploy: sudo ./scripts/init-letsencrypt.sh # Run once on first deploy: sudo ./scripts/init-letsencrypt.sh
# Requires nginx running on the host with acme-challenge location configured
set -euo pipefail set -euo pipefail
DOMAIN="evosync.ru" DOMAIN="evosync.ru"
EMAIL="${LETSENCRYPT_EMAIL:-admin@evosync.ru}" EMAIL="${LETSENCRYPT_EMAIL:-admin@evosync.ru}"
COMPOSE="docker compose"
CERTBOT_DIR="./certbot" CERTBOT_DIR="./certbot"
ACME_DIR="/var/www/certbot"
echo "==> Creating certbot directories..." echo "==> Creating certbot directories..."
mkdir -p "$CERTBOT_DIR/conf" "$CERTBOT_DIR/www" mkdir -p "$CERTBOT_DIR/conf" "$CERTBOT_DIR/www"
echo "==> Starting nginx (HTTP only, for ACME challenge)..." echo "==> Ensuring acme-challenge directory exists on host..."
# Temporarily use a basic config that doesn't require certs sudo mkdir -p "$ACME_DIR"
cat > nginx/nginx-temp.conf <<'TMPCONF' sudo chmod 755 "$ACME_DIR"
server {
listen 80;
server_name evosync.ru www.evosync.ru;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 200 'Setting up TLS...';
add_header Content-Type text/plain;
}
}
TMPCONF
$COMPOSE up -d nginx
echo "==> Requesting certificate from Let's Encrypt..." echo "==> Requesting certificate from Let's Encrypt..."
docker run --rm \ sudo certbot certonly \
-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 \
--webroot \ --webroot \
--webroot-path=/var/www/certbot \ --webroot-path="$ACME_DIR" \
--email "$EMAIL" \ --email "$EMAIL" \
--agree-tos \ --agree-tos \
--no-eff-email \ --no-eff-email \
-d "$DOMAIN" \ -d "$DOMAIN" \
-d "www.$DOMAIN" -d "www.$DOMAIN"
echo "==> Restoring production nginx config..." echo "==> Copying certificates to project directory..."
rm -f nginx/nginx-temp.conf sudo cp "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" "$CERTBOT_DIR/conf/fullchain.pem"
sudo cp "/etc/letsencrypt/live/$DOMAIN/privkey.pem" "$CERTBOT_DIR/conf/privkey.pem"
echo "==> Restarting nginx with TLS..." sudo chown "$(whoami):$(whoami)" "$CERTBOT_DIR/conf"/*.pem
$COMPOSE restart nginx
echo "==> Done! TLS certificate installed for $DOMAIN" echo "==> Done! TLS certificate installed for $DOMAIN"
echo " Set up auto-renewal with: sudo crontab -e" echo ""
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 "Certificate files:"
echo " - $CERTBOT_DIR/conf/fullchain.pem"
echo " - $CERTBOT_DIR/conf/privkey.pem"
echo ""
echo "Configure nginx:"
echo " ssl_certificate $CERTBOT_DIR/conf/fullchain.pem;"
echo " ssl_certificate_key $CERTBOT_DIR/conf/privkey.pem;"
echo ""
echo "Set up auto-renewal with: sudo crontab -e"
echo "Add: 0 3 * * * certbot renew --quiet && systemctl reload nginx"

View File

@@ -4,18 +4,17 @@ from pydantic_settings import BaseSettings
class Settings(BaseSettings): class Settings(BaseSettings):
DATABASE_URL: str = "mysql+pymysql://evosync:evosync@localhost:3306/evosync" DATABASE_URL: str = "mysql+pymysql://evosync:evosync@localhost:3306/evosync"
SECRET_KEY: str = "change-me-in-production" SECRET_KEY: str = "change-me-in-production"
BASE_URL: str = "http://localhost:8000" BASE_URL: str = "http://localhost:8080"
PASSWORD_RESET_EXPIRE_MINUTES: int = 60 PASSWORD_RESET_EXPIRE_MINUTES: int = 60
EVOTOR_CLIENT_ID: str = "" EVOTOR_APP_ID: str = ""
EVOTOR_CLIENT_SECRET: str = "" EVOTOR_WEBHOOK_SECRET: str = ""
EVOTOR_SCOPES: str = "store:read product:read"
JIVOSITE_WIDGET_ID: str = ""
HEALTH_CHECK_INTERVAL_SECONDS: int = 600 HEALTH_CHECK_INTERVAL_SECONDS: int = 600
CATALOG_REFRESH_INTERVAL_SECONDS: int = 3600
VK_CLIENT_ID: str = ""
VK_CLIENT_SECRET: str = ""
VK_SCOPES: str = "market groups offline"
VK_API_VERSION: str = "5.131" VK_API_VERSION: str = "5.131"
# Docker compose vars (ignored in app, kept for env compatibility) # Docker compose vars (ignored in app, kept for env compatibility)

View File

@@ -33,6 +33,8 @@ async def fetch_groups(access_token: str, store_id: str) -> list[dict]:
headers={"Authorization": f"Bearer {access_token}"}, headers={"Authorization": f"Bearer {access_token}"},
timeout=15, timeout=15,
) )
if resp.status_code == 402:
return []
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
items = data.get("items", data) if isinstance(data, dict) else data items = data.get("items", data) if isinstance(data, dict) else data
@@ -46,6 +48,8 @@ async def fetch_products(access_token: str, store_id: str) -> list[dict]:
headers={"Authorization": f"Bearer {access_token}"}, headers={"Authorization": f"Bearer {access_token}"},
timeout=15, timeout=15,
) )
if resp.status_code == 402:
return []
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
items = data.get("items", data) if isinstance(data, dict) else data items = data.get("items", data) if isinstance(data, dict) else data

View File

@@ -5,13 +5,13 @@ from datetime import datetime, timedelta
import httpx import httpx
from web.database import SessionLocal from web.database import SessionLocal
from web.models import EvotorConnection, VkConnection from web.models import EvotorConnection, VkConnection, CachedStore
logger = logging.getLogger("uvicorn.error") logger = logging.getLogger("uvicorn.error")
EVOTOR_STORES_URL = "https://api.evotor.ru/stores" EVOTOR_STORES_URL = "https://api.evotor.ru/stores"
EVOTOR_TOKEN_URL = "https://oauth.evotor.ru/oauth/token" EVOTOR_TOKEN_URL = "https://oauth.evotor.ru/oauth/token"
VK_USERS_GET_URL = "https://api.vk.com/method/users.get" VK_GROUPS_GET_URL = "https://api.vk.com/method/groups.getById"
VK_API_VERSION = "5.131" VK_API_VERSION = "5.131"
# Refresh Evotor token if it expires within this window # Refresh Evotor token if it expires within this window
@@ -59,7 +59,7 @@ async def check_vk_connection(access_token: str) -> bool:
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
resp = await client.get( resp = await client.get(
VK_USERS_GET_URL, VK_GROUPS_GET_URL,
params={"access_token": access_token, "v": VK_API_VERSION}, params={"access_token": access_token, "v": VK_API_VERSION},
timeout=10, timeout=10,
) )
@@ -116,10 +116,28 @@ async def run_health_checks() -> None:
conn.last_checked_at = now conn.last_checked_at = now
db.commit() db.commit()
# Refresh catalog cache for online Evotor connections
from web.config import settings
refreshed_catalog = 0
for conn in evotor_connections:
if not conn.is_online:
continue
cached = db.query(CachedStore).filter(CachedStore.user_id == conn.user_id).first()
cache_age = (now - cached.fetched_at).total_seconds() if cached else None
if cached is None or cache_age >= settings.CATALOG_REFRESH_INTERVAL_SECONDS:
try:
from web.evotor_api import refresh_catalog_cache
await refresh_catalog_cache(conn.user_id, conn.access_token, db)
refreshed_catalog += 1
except Exception:
logger.exception("Failed to refresh catalog cache for user_id=%d", conn.user_id)
logger.info( logger.info(
"Health checks completed: %d Evotor, %d VK", "Health checks completed: %d Evotor, %d VK, %d catalogs refreshed",
len(evotor_connections), len(evotor_connections),
len(vk_connections), len(vk_connections),
refreshed_catalog,
) )
except Exception: except Exception:
logger.exception("Error during health checks") logger.exception("Error during health checks")

View File

@@ -0,0 +1,53 @@
"""evotor webhook token flow: add evotor_user_id, make user_id nullable
Revision ID: f6a7b8c9d0e1
Revises: e5f6a7b8c9d0
Branch Labels: None
Depends On: None
"""
from alembic import op
import sqlalchemy as sa
revision = 'f6a7b8c9d0e1'
down_revision = 'e5f6a7b8c9d0'
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
# Check existing columns
columns = [row[0] for row in conn.execute(sa.text(
"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS "
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'evotor_connections'"
))]
if 'evotor_user_id' not in columns:
op.add_column('evotor_connections',
sa.Column('evotor_user_id', sa.String(255), nullable=True))
# Check existing indexes
indexes = [row[2] for row in conn.execute(sa.text(
"SHOW INDEX FROM evotor_connections"
))]
if 'uq_evotor_connections_evotor_user_id' not in indexes:
op.create_unique_constraint('uq_evotor_connections_evotor_user_id',
'evotor_connections', ['evotor_user_id'])
if 'ix_evotor_connections_evotor_user_id' not in indexes:
op.create_index('ix_evotor_connections_evotor_user_id',
'evotor_connections', ['evotor_user_id'])
op.alter_column('evotor_connections', 'user_id',
existing_type=sa.Integer(), nullable=True)
def downgrade() -> None:
op.alter_column('evotor_connections', 'user_id',
existing_type=sa.Integer(), nullable=False)
op.drop_index('ix_evotor_connections_evotor_user_id', 'evotor_connections')
op.drop_constraint('uq_evotor_connections_evotor_user_id', 'evotor_connections')
op.drop_column('evotor_connections', 'evotor_user_id')

View File

@@ -33,7 +33,8 @@ class EvotorConnection(Base):
__tablename__ = "evotor_connections" __tablename__ = "evotor_connections"
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False) user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=True)
evotor_user_id = Column(String(255), unique=True, nullable=True, index=True)
access_token = Column(Text, nullable=False) access_token = Column(Text, nullable=False)
store_id = Column(String(255), nullable=True) store_id = Column(String(255), nullable=True)
store_name = Column(String(255), nullable=True) store_name = Column(String(255), nullable=True)

View File

@@ -2,7 +2,7 @@ import uuid
from fastapi import APIRouter, Request, Depends from fastapi import APIRouter, Request, Depends
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates from web.templates_env import templates
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from web.auth import hash_password, verify_password, get_current_user from web.auth import hash_password, verify_password, get_current_user
@@ -12,7 +12,6 @@ from web.models import User
from web.schemas import validate_registration, validate_login from web.schemas import validate_registration, validate_login
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="web/templates")
@router.get("/register") @router.get("/register")

View File

@@ -3,7 +3,7 @@ import io
from fastapi import APIRouter, Request, Depends from fastapi import APIRouter, Request, Depends
from fastapi.responses import RedirectResponse, StreamingResponse from fastapi.responses import RedirectResponse, StreamingResponse
from fastapi.templating import Jinja2Templates from web.templates_env import templates
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from web.auth import get_current_user from web.auth import get_current_user
@@ -12,7 +12,6 @@ from web.evotor_api import refresh_catalog_cache
from web.models import User, EvotorConnection, SyncConfig, SyncFilter, CachedStore, CachedGroup, CachedProduct from web.models import User, EvotorConnection, SyncConfig, SyncFilter, CachedStore, CachedGroup, CachedProduct
router = APIRouter(prefix="/catalog") router = APIRouter(prefix="/catalog")
templates = Jinja2Templates(directory="web/templates")
def _get_or_create_sync_config(db: Session, user_id: int) -> SyncConfig: def _get_or_create_sync_config(db: Session, user_id: int) -> SyncConfig:

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter, Request, Depends from fastapi import APIRouter, Request, Depends
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates from web.templates_env import templates
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from web.auth import get_current_user from web.auth import get_current_user
@@ -8,7 +8,6 @@ from web.database import get_db
from web.models import User, EvotorConnection, VkConnection from web.models import User, EvotorConnection, VkConnection
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="web/templates")
SERVICE_TYPES = [ SERVICE_TYPES = [
{ {
@@ -17,7 +16,7 @@ SERVICE_TYPES = [
"icon": "bi-shop", "icon": "bi-shop",
"description": "Подключите кассу Эвотор для синхронизации каталога товаров.", "description": "Подключите кассу Эвотор для синхронизации каталога товаров.",
"configure_url": "/evotor", "configure_url": "/evotor",
"connect_url": "/evotor/connect", "connect_url": "/evotor",
}, },
{ {
"type": "vk", "type": "vk",

View File

@@ -1,12 +1,11 @@
import secrets
import logging import logging
import httpx import httpx
from fastapi import APIRouter, Request, Depends from datetime import datetime
from fastapi import APIRouter, Request, Depends, HTTPException
logger = logging.getLogger(__name__) from fastapi.responses import RedirectResponse, JSONResponse
from fastapi.responses import RedirectResponse from web.templates_env import templates
from fastapi.templating import Jinja2Templates from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from web.auth import get_current_user from web.auth import get_current_user
@@ -14,18 +13,14 @@ from web.config import settings
from web.database import get_db from web.database import get_db
from web.models import User, EvotorConnection from web.models import User, EvotorConnection
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/evotor") router = APIRouter(prefix="/evotor")
templates = Jinja2Templates(directory="web/templates")
EVOTOR_AUTHORIZE_URL = "https://oauth.evotor.ru/oauth/authorize" EVOTOR_APP_URL = "https://market.evotor.ru/store/apps/{app_id}"
EVOTOR_TOKEN_URL = "https://oauth.evotor.ru/oauth/token"
EVOTOR_STORES_URL = "https://api.evotor.ru/stores" EVOTOR_STORES_URL = "https://api.evotor.ru/stores"
def _redirect_uri() -> str:
return f"{settings.BASE_URL}/evotor/callback"
@router.get("") @router.get("")
def evotor_page( def evotor_page(
request: Request, request: Request,
@@ -37,85 +32,49 @@ def evotor_page(
connection = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first() connection = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
error = request.query_params.get("error") error = request.query_params.get("error")
app_url = EVOTOR_APP_URL.format(app_id=settings.EVOTOR_APP_ID) if settings.EVOTOR_APP_ID else None
return templates.TemplateResponse("evotor.html", { return templates.TemplateResponse("evotor.html", {
"request": request, "request": request,
"user": user, "user": user,
"connection": connection, "connection": connection,
"error": error, "error": error,
"app_url": app_url,
}) })
@router.get("/connect") class EvotorTokenPayload(BaseModel):
def evotor_connect(request: Request, user: User | None = Depends(get_current_user)): userId: str
if not user: token: str
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") @router.post("/callback")
async def evotor_callback( async def evotor_callback(
request: Request, request: Request,
payload: EvotorTokenPayload,
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
): ):
if not user: """
return RedirectResponse("/login", 303) Webhook endpoint: Evotor POSTs {"userId": "...", "token": "..."} here
after the user authorizes the app in their Evotor account.
"""
# Verify the Authorization header matches our configured webhook secret
if settings.EVOTOR_WEBHOOK_SECRET:
auth_header = request.headers.get("Authorization", "")
expected = f"Bearer {settings.EVOTOR_WEBHOOK_SECRET}"
if auth_header != expected:
logger.warning("Evotor webhook: invalid Authorization header")
raise HTTPException(status_code=401, detail="Unauthorized")
code = request.query_params.get("code") now = datetime.utcnow()
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: # Fetch store info using the received token
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_id = None
store_name = None store_name = None
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
stores_response = await client.get( stores_response = await client.get(
EVOTOR_STORES_URL, EVOTOR_STORES_URL,
headers={"Authorization": f"Bearer {access_token}"}, headers={"Authorization": f"Bearer {payload.token}"},
timeout=15, timeout=15,
) )
if stores_response.status_code == 200: if stores_response.status_code == 200:
@@ -125,28 +84,87 @@ async def evotor_callback(
store_id = items[0].get("uuid") or items[0].get("id") store_id = items[0].get("uuid") or items[0].get("id")
store_name = items[0].get("name") store_name = items[0].get("name")
except Exception: except Exception:
pass # Store info is optional; token is still saved pass # Store info is optional
# Save or update connection # Upsert by evotor_user_id (user_id stays NULL until /evotor/link is called)
from datetime import datetime, timedelta connection = db.query(EvotorConnection).filter(
now = datetime.utcnow() EvotorConnection.evotor_user_id == payload.userId
token_expires_at = now + timedelta(seconds=expires_in) if expires_in else None ).first()
connection = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
if connection: if connection:
connection.access_token = access_token connection.access_token = payload.token
connection.refresh_token = refresh_token
connection.token_expires_at = token_expires_at
connection.store_id = store_id connection.store_id = store_id
connection.store_name = store_name connection.store_name = store_name
connection.is_online = True connection.is_online = True
connection.last_checked_at = now connection.last_checked_at = now
connection.updated_at = now
else:
connection = EvotorConnection(
evotor_user_id=payload.userId,
access_token=payload.token,
store_id=store_id,
store_name=store_name,
is_online=True,
last_checked_at=now,
)
db.add(connection)
db.commit()
logger.info("Evotor webhook: saved token for evotor_user_id=%s", payload.userId)
return JSONResponse({"status": "ok"})
@router.post("/token")
async def evotor_token_manual(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
"""Allow user to manually paste their Evotor token."""
if not user:
return RedirectResponse("/login", 303)
form = await request.form()
token = (form.get("token") or "").strip()
if not token:
return RedirectResponse("/evotor?error=empty_token", 303)
now = datetime.utcnow()
# Fetch store info
store_id = None
store_name = None
try:
async with httpx.AsyncClient() as client:
stores_response = await client.get(
EVOTOR_STORES_URL,
headers={"Authorization": f"Bearer {token}"},
timeout=15,
)
if stores_response.status_code == 200:
stores = stores_response.json()
items = stores.get("items", stores) if isinstance(stores, dict) else stores
if items:
store_id = items[0].get("uuid") or items[0].get("id")
store_name = items[0].get("name")
elif stores_response.status_code == 401:
return RedirectResponse("/evotor?error=invalid_token", 303)
except Exception:
pass
connection = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
if connection:
connection.access_token = token
connection.store_id = store_id
connection.store_name = store_name
connection.is_online = True
connection.last_checked_at = now
connection.updated_at = now
else: else:
connection = EvotorConnection( connection = EvotorConnection(
user_id=user.id, user_id=user.id,
access_token=access_token, access_token=token,
refresh_token=refresh_token,
token_expires_at=token_expires_at,
store_id=store_id, store_id=store_id,
store_name=store_name, store_name=store_name,
is_online=True, is_online=True,

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter, Request, Depends from fastapi import APIRouter, Request, Depends
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates from web.templates_env import templates
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from web.auth import get_current_user, verify_password, hash_password from web.auth import get_current_user, verify_password, hash_password
@@ -9,7 +9,6 @@ from web.models import User
from web.schemas import validate_profile, validate_reset_password from web.schemas import validate_profile, validate_reset_password
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="web/templates")
# VIEW PROFILE # VIEW PROFILE

View File

@@ -3,7 +3,7 @@ from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Request, Depends from fastapi import APIRouter, Request, Depends
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates from web.templates_env import templates
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from web.auth import hash_password from web.auth import hash_password
@@ -13,7 +13,6 @@ from web.models import User
from web.schemas import validate_reset_password from web.schemas import validate_reset_password
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="web/templates")
@router.get("/forgot-password") @router.get("/forgot-password")

View File

@@ -2,7 +2,7 @@ from datetime import datetime
from fastapi import APIRouter, Request, Depends from fastapi import APIRouter, Request, Depends
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates from web.templates_env import templates
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from web.auth import get_current_user from web.auth import get_current_user
@@ -10,7 +10,6 @@ from web.database import get_db
from web.models import User, EvotorConnection, VkConnection, SyncConfig, SyncFilter from web.models import User, EvotorConnection, VkConnection, SyncConfig, SyncFilter
router = APIRouter(prefix="/sync") router = APIRouter(prefix="/sync")
templates = Jinja2Templates(directory="web/templates")
def _get_or_create_sync_config(db: Session, user_id: int) -> SyncConfig: def _get_or_create_sync_config(db: Session, user_id: int) -> SyncConfig:

View File

@@ -1,11 +1,10 @@
import secrets
from datetime import datetime from datetime import datetime
import httpx import httpx
from fastapi import APIRouter, Request, Depends from fastapi import APIRouter, Request, Depends
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates from web.templates_env import templates
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from web.auth import get_current_user from web.auth import get_current_user
@@ -14,17 +13,10 @@ from web.database import get_db
from web.models import User, VkConnection from web.models import User, VkConnection
router = APIRouter(prefix="/vk") 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" VK_API_URL = "https://api.vk.com/method"
def _redirect_uri() -> str:
return f"{settings.BASE_URL}/vk/callback"
@router.get("") @router.get("")
def vk_page( def vk_page(
request: Request, request: Request,
@@ -44,102 +36,62 @@ def vk_page(
}) })
@router.get("/connect") @router.post("/token")
def vk_connect(request: Request, user: User | None = Depends(get_current_user)): async def vk_token(
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, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: User | None = Depends(get_current_user), user: User | None = Depends(get_current_user),
): ):
"""Save a manually entered VK community access token."""
if not user: if not user:
return RedirectResponse("/login", 303) return RedirectResponse("/login", 303)
code = request.query_params.get("code") form = await request.form()
state = request.query_params.get("state") token = (form.get("token") or "").strip()
saved_state = request.session.pop("vk_oauth_state", None) if not token:
return RedirectResponse("/vk?error=empty_token", 303)
if not code or not state or state != saved_state: # Fetch community info to validate the token and get group name/id
return RedirectResponse("/vk?error=invalid_state", 303) group_id = None
group_name = None
# Exchange code for token (VK uses GET with query params)
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
token_response = await client.get( resp = await client.get(
VK_TOKEN_URL, f"{VK_API_URL}/groups.getById",
params={ params={"access_token": token, "v": settings.VK_API_VERSION},
"client_id": settings.VK_CLIENT_ID,
"client_secret": settings.VK_CLIENT_SECRET,
"code": code,
"redirect_uri": _redirect_uri(),
},
timeout=15, timeout=15,
) )
token_response.raise_for_status() if resp.status_code == 200:
token_data = token_response.json() data = resp.json()
except Exception: if "error" in data:
return RedirectResponse("/vk?error=token_exchange", 303) return RedirectResponse("/vk?error=invalid_token", 303)
groups = data.get("response", {}).get("groups", [])
access_token = token_data.get("access_token") if groups:
vk_user_id = str(token_data.get("user_id", "")) or None group_id = str(groups[0].get("id", ""))
if not access_token: group_name = groups[0].get("name")
return RedirectResponse("/vk?error=no_token", 303) elif resp.status_code == 401:
return RedirectResponse("/vk?error=invalid_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: except Exception:
pass pass
# Save or update connection
connection = db.query(VkConnection).filter(VkConnection.user_id == user.id).first() connection = db.query(VkConnection).filter(VkConnection.user_id == user.id).first()
now = datetime.utcnow()
if connection: if connection:
connection.access_token = access_token connection.access_token = token
connection.vk_user_id = vk_user_id connection.vk_user_id = group_id
connection.first_name = first_name connection.first_name = group_name
connection.last_name = last_name connection.last_name = None
connection.is_online = True connection.is_online = True
connection.last_checked_at = datetime.utcnow() connection.last_checked_at = now
else: else:
connection = VkConnection( connection = VkConnection(
user_id=user.id, user_id=user.id,
access_token=access_token, access_token=token,
vk_user_id=vk_user_id, vk_user_id=group_id,
first_name=first_name, first_name=group_name,
last_name=last_name, last_name=None,
is_online=True, is_online=True,
last_checked_at=datetime.utcnow(), last_checked_at=now,
) )
db.add(connection) db.add(connection)
db.commit() db.commit()

View File

@@ -64,6 +64,9 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
{% if jivosite_widget_id %}
<script src="//code.jivosite.com/widget/{{ jivosite_widget_id }}" async></script>
{% endif %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/inputmask@5.0.9/dist/inputmask.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/inputmask@5.0.9/dist/inputmask.min.js"></script>
<script> <script>

View File

@@ -73,7 +73,7 @@
</td> </td>
<td> <td>
<div class="dropdown"> <div class="dropdown">
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown"> <button class="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown" data-bs-boundary="document" data-bs-display="dynamic" data-bs-strategy="fixed">
<i class="bi bi-funnel"></i> <i class="bi bi-funnel"></i>
</button> </button>
<ul class="dropdown-menu dropdown-menu-end"> <ul class="dropdown-menu dropdown-menu-end">

View File

@@ -7,12 +7,10 @@
{% if error %} {% if error %}
<div class="alert alert-danger mt-4"> <div class="alert alert-danger mt-4">
{% if error == "invalid_state" %} {% if error == "invalid_token" %}
<i class="bi bi-exclamation-triangle me-2"></i>Ошибка безопасности. Попробуйте подключить аккаунт заново. <i class="bi bi-exclamation-triangle me-2"></i>Токен недействителен. Проверьте правильность и попробуйте снова.
{% elif error == "token_exchange" %} {% elif error == "empty_token" %}
<i class="bi bi-exclamation-triangle me-2"></i>Не удалось получить токен доступа от Эвотор. Попробуйте позже. <i class="bi bi-exclamation-triangle me-2"></i>Введите токен.
{% elif error == "no_token" %}
<i class="bi bi-exclamation-triangle me-2"></i>Эвотор не вернул токен доступа. Попробуйте позже.
{% else %} {% else %}
<i class="bi bi-exclamation-triangle me-2"></i>Произошла ошибка при подключении: {{ error }} <i class="bi bi-exclamation-triangle me-2"></i>Произошла ошибка при подключении: {{ error }}
{% endif %} {% endif %}
@@ -48,8 +46,16 @@
<span class="small">{{ connection.connected_at.strftime("%d.%m.%Y %H:%M") }}</span> <span class="small">{{ connection.connected_at.strftime("%d.%m.%Y %H:%M") }}</span>
</li> </li>
</ul> </ul>
<div class="card-body d-grid gap-2"> <div class="card-footer">
<a href="/evotor/connect" class="btn btn-primary">Переподключить</a> <p class="text-muted small mb-2">Обновить токен (Личный кабинет Эвотор → <strong>Приложения → ЭвоСинк → Настройки</strong>):</p>
<form method="post" action="/evotor/token">
<div class="input-group input-group-sm">
<input type="text" name="token" class="form-control font-monospace" placeholder="Новый токен" required>
<button type="submit" class="btn btn-outline-secondary">Обновить</button>
</div>
</form>
</div>
<div class="card-body d-grid">
<form method="post" action="/evotor/disconnect"> <form method="post" action="/evotor/disconnect">
<button type="submit" class="btn btn-outline-danger w-100">Отключить аккаунт Эвотор</button> <button type="submit" class="btn btn-outline-danger w-100">Отключить аккаунт Эвотор</button>
</form> </form>
@@ -59,17 +65,29 @@
{# ── NOT CONNECTED STATE ── #} {# ── NOT CONNECTED STATE ── #}
<div class="card-body"> <div class="card-body">
<p class="text-muted mb-3"> <p class="text-muted mb-3">
Подключите ваш аккаунт Эвотор, чтобы система могла автоматически синхронизировать Для подключения вам нужно установить приложение <strong>ЭвоСинк</strong> в личном кабинете Эвотор
каталог товаров из вашей кассы в ВКонтакте. и скопировать токен доступа из его настроек.
</p> </p>
<ul class="text-muted small mb-4">
<li>Вы будете перенаправлены на сайт Эвотор для авторизации</li> <ol class="text-muted small mb-4">
<li>После подтверждения доступа синхронизация будет настроена автоматически</li> {% if app_url %}
<li>Вы можете отключить доступ в любой момент</li> <li class="mb-1">Откройте <a href="{{ app_url }}" target="_blank" rel="noopener">приложение ЭвоСинк в магазине Эвотор <i class="bi bi-box-arrow-up-right small"></i></a> и установите его.</li>
</ul> {% else %}
<div class="d-grid"> <li class="mb-1">Найдите приложение <strong>ЭвоСинк</strong> в магазине Эвотор и установите его.</li>
<a href="/evotor/connect" class="btn btn-primary btn-lg">Подключить Эвотор</a> {% endif %}
<li class="mb-1">Перейдите в раздел <strong>Приложения → ЭвоСинк → Настройки</strong>.</li>
<li class="mb-1">Скопируйте токен доступа и вставьте его в поле ниже.</li>
</ol>
<form method="post" action="/evotor/token">
<div class="mb-3">
<label class="form-label small text-muted">Токен доступа</label>
<input type="text" name="token" class="form-control font-monospace" placeholder="Вставьте токен Эвотор" required autofocus>
</div> </div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">Подключить</button>
</div>
</form>
</div> </div>
{% endif %} {% endif %}

View File

@@ -7,12 +7,10 @@
{% if error %} {% if error %}
<div class="alert alert-danger mt-4"> <div class="alert alert-danger mt-4">
{% if error == "invalid_state" %} {% if error == "invalid_token" %}
<i class="bi bi-exclamation-triangle me-2"></i>Ошибка безопасности. Попробуйте подключить аккаунт заново. <i class="bi bi-exclamation-triangle me-2"></i>Токен недействителен. Убедитесь, что скопировали ключ доступа сообщества правильно.
{% elif error == "token_exchange" %} {% elif error == "empty_token" %}
<i class="bi bi-exclamation-triangle me-2"></i>Не удалось получить токен доступа от ВКонтакте. Попробуйте позже. <i class="bi bi-exclamation-triangle me-2"></i>Введите ключ доступа.
{% elif error == "no_token" %}
<i class="bi bi-exclamation-triangle me-2"></i>ВКонтакте не вернул токен доступа. Попробуйте позже.
{% else %} {% else %}
<i class="bi bi-exclamation-triangle me-2"></i>Произошла ошибка при подключении: {{ error }} <i class="bi bi-exclamation-triangle me-2"></i>Произошла ошибка при подключении: {{ error }}
{% endif %} {% endif %}
@@ -31,15 +29,15 @@
<span class="text-muted small">Статус</span> <span class="text-muted small">Статус</span>
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Подключено</span> <span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Подключено</span>
</li> </li>
{% if connection.first_name or connection.last_name %} {% if connection.first_name %}
<li class="list-group-item d-flex justify-content-between align-items-center"> <li class="list-group-item d-flex justify-content-between align-items-center">
<span class="text-muted small">Профиль</span> <span class="text-muted small">Сообщество</span>
<span>{{ connection.first_name }} {{ connection.last_name }}</span> <span>{{ connection.first_name }}</span>
</li> </li>
{% endif %} {% endif %}
{% if connection.vk_user_id %} {% if connection.vk_user_id %}
<li class="list-group-item d-flex justify-content-between align-items-center"> <li class="list-group-item d-flex justify-content-between align-items-center">
<span class="text-muted small">ID пользователя</span> <span class="text-muted small">ID сообщества</span>
<span class="font-monospace small text-muted">{{ connection.vk_user_id }}</span> <span class="font-monospace small text-muted">{{ connection.vk_user_id }}</span>
</li> </li>
{% endif %} {% endif %}
@@ -48,10 +46,18 @@
<span class="small">{{ connection.connected_at.strftime("%d.%m.%Y %H:%M") }}</span> <span class="small">{{ connection.connected_at.strftime("%d.%m.%Y %H:%M") }}</span>
</li> </li>
</ul> </ul>
<div class="card-body d-grid gap-2"> <div class="card-footer">
<a href="/vk/connect" class="btn btn-primary">Переподключить</a> <p class="text-muted small mb-2">Обновить ключ доступа:</p>
<form method="post" action="/vk/token">
<div class="input-group input-group-sm">
<input type="text" name="token" class="form-control font-monospace" placeholder="Новый ключ доступа" required>
<button type="submit" class="btn btn-outline-secondary">Обновить</button>
</div>
</form>
</div>
<div class="card-body d-grid">
<form method="post" action="/vk/disconnect"> <form method="post" action="/vk/disconnect">
<button type="submit" class="btn btn-outline-danger w-100">Отключить аккаунт ВКонтакте</button> <button type="submit" class="btn btn-outline-danger w-100">Отключить ВКонтакте</button>
</form> </form>
</div> </div>
@@ -59,17 +65,26 @@
{# ── NOT CONNECTED STATE ── #} {# ── NOT CONNECTED STATE ── #}
<div class="card-body"> <div class="card-body">
<p class="text-muted mb-3"> <p class="text-muted mb-3">
Подключите ваш аккаунт ВКонтакте, чтобы система могла автоматически синхронизировать Для подключения вам нужен <strong>ключ доступа сообщества</strong> ВКонтакте.
каталог товаров из Эвотор в вашу группу ВКонтакте. Синхронизация товаров работает только через сообщество.
</p> </p>
<ul class="text-muted small mb-4">
<li>Вы будете перенаправлены на сайт ВКонтакте для авторизации</li> <ol class="text-muted small mb-4">
<li>После подтверждения доступа синхронизация будет настроена автоматически</li> <li class="mb-1">Откройте <a href="https://vk.com" target="_blank" rel="noopener">vk.com <i class="bi bi-box-arrow-up-right small"></i></a> и перейдите в управление вашим сообществом.</li>
<li>Вы можете отключить доступ в любой момент</li> <li class="mb-1">Перейдите в раздел <strong>Настройки → Работа с API</strong>.</li>
</ul> <li class="mb-1">Создайте ключ доступа с правами <strong>Управление товарами</strong> и <strong>Управление сообществом</strong>.</li>
<div class="d-grid"> <li class="mb-1">Скопируйте ключ и вставьте его в поле ниже.</li>
<a href="/vk/connect" class="btn btn-primary btn-lg">Подключить ВКонтакте</a> </ol>
<form method="post" action="/vk/token">
<div class="mb-3">
<label class="form-label small text-muted">Ключ доступа сообщества</label>
<input type="text" name="token" class="form-control font-monospace" placeholder="Вставьте ключ доступа" required autofocus>
</div> </div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">Подключить</button>
</div>
</form>
</div> </div>
{% endif %} {% endif %}

6
web/templates_env.py Normal file
View File

@@ -0,0 +1,6 @@
from fastapi.templating import Jinja2Templates
from web.config import settings
templates = Jinja2Templates(directory="web/templates")
templates.env.globals["jivosite_widget_id"] = settings.JIVOSITE_WIDGET_ID