Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aaeaa4f658 | ||
|
|
aea28ead9c | ||
|
|
cde2069d74 | ||
|
|
debb2efb3d | ||
|
|
4d4d5b0118 | ||
|
|
00b74b8aa9 | ||
|
|
577c5de200 | ||
|
|
0926757b7a | ||
|
|
13c32e9181 | ||
|
|
6b9eb562ba | ||
|
|
9558333c94 | ||
|
|
40e7abd012 | ||
|
|
3d7a456299 | ||
|
|
5acf597944 | ||
|
|
3f4bbcbb0d | ||
|
|
c8beeaf1b1 | ||
|
|
5ee8419c7c | ||
|
|
e376c86fbe | ||
|
|
69e21a18c9 | ||
|
|
90a2f7be1f | ||
|
|
d4633a0f46 | ||
|
|
b72b0e78b0 | ||
|
|
2a04099f95 | ||
|
|
58f9b74a1c | ||
|
|
8c9c328302 | ||
|
|
784bb27958 | ||
|
|
1add9fa299 | ||
|
|
b8696793f4 | ||
|
|
37e2df1fef | ||
|
|
48da26c270 | ||
|
|
bacfd8fe54 |
@@ -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
1
.gitignore
vendored
@@ -16,3 +16,4 @@ passwords.txt
|
|||||||
.env
|
.env
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
certbot
|
||||||
|
|||||||
50
CHANGELOG.md
50
CHANGELOG.md
@@ -5,28 +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.2] - 2026-03-06
|
||||||
|
|
||||||
|
### Miscellaneous
|
||||||
|
|
||||||
|
- Replace EvoSync with ЭВОСИНК throughout UI
|
||||||
|
|
||||||
|
## [1.8.1] - 2026-03-06
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- Update changelog for v1.7.3
|
||||||
|
- Fix changelog order — 1.8.0 before 1.7.3
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
- V1.8.1
|
||||||
|
|
||||||
|
## [1.7.3] - 2026-03-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Add nginx reverse proxy and Let's Encrypt TLS setup
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
- V1.7.3
|
||||||
|
|
||||||
## [1.8.0] - 2026-03-06
|
## [1.8.0] - 2026-03-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Connections dashboard (`/connections`) — unified page for all service integrations with online/offline status indicators
|
- Add Evotor OAuth connection feature with formatted phone input
|
||||||
- Add-connection page (`/connections/add`) — shows only unconnected services; "all connected" state when none remain
|
- Add Alembic database migrations
|
||||||
- VK OAuth connection — connect VK account via OAuth, store access token and profile info
|
- Add connections dashboard with background health checks
|
||||||
- Background health checker — runs every 10 minutes, checks Evotor and VK tokens, updates `is_online` / `last_checked_at`
|
- Add VK OAuth connection with health checks
|
||||||
- Sync configuration page (`/sync`) — master enable/disable toggle, confirm-and-start button, filter summary, warnings for missing connections
|
- Release v1.8.0 — connections dashboard, VK OAuth, sync config, catalog browser
|
||||||
- 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
|
### Miscellaneous
|
||||||
|
|
||||||
- Navbar: replaced "Эвотор" link with "Подключения", added "Каталог" and "Синхронизация" links
|
- Add semantic versioning and automatic changelog generation
|
||||||
- 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.2] - 2026-03-05
|
## [1.7.2] - 2026-03-05
|
||||||
|
|
||||||
|
|||||||
@@ -8,13 +8,20 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8080: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:-http://localhost:8080}
|
- BASE_URL=${BASE_URL:-https://evosync.ru}
|
||||||
|
- EVOTOR_APP_ID=${EVOTOR_APP_ID}
|
||||||
|
- EVOTOR_WEBHOOK_SECRET=${EVOTOR_WEBHOOK_SECRET}
|
||||||
|
- VK_CLIENT_ID=${VK_CLIENT_ID}
|
||||||
|
- VK_CLIENT_SECRET=${VK_CLIENT_SECRET}
|
||||||
volumes:
|
volumes:
|
||||||
- ./web:/app/web
|
- ./web:/app/web
|
||||||
- ./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
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
|
||||||
# sync:
|
# sync:
|
||||||
# build:
|
# build:
|
||||||
|
|||||||
36
nginx/nginx.conf
Normal file
36
nginx/nginx.conf
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
upstream web {
|
||||||
|
server 127.0.0.1:8080;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name evosync.ru www.evosync.ru;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name evosync.ru www.evosync.ru;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/evosync.ru/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/evosync.ru/privkey.pem;
|
||||||
|
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
ssl_prefer_server_ciphers on;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://web;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
57
scripts/evotor-get-token.sh
Normal file
57
scripts/evotor-get-token.sh
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Obtain an Evotor developer access token via password grant (no browser required).
|
||||||
|
# Uses dev.evotor.ru credentials (your Evotor developer account).
|
||||||
|
#
|
||||||
|
# Usage: ./scripts/evotor-get-token.sh
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Load .env if present
|
||||||
|
if [[ -f .env ]]; then
|
||||||
|
set -a; source .env; set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
EVOTOR_TOKEN_URL="https://dev.evotor.ru/oauth/token"
|
||||||
|
|
||||||
|
# Prompt for credentials
|
||||||
|
read -rp "Evotor developer login (email): " EVOTOR_LOGIN
|
||||||
|
read -rsp "Evotor developer password: " EVOTOR_PASSWORD
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "A 2FA code will be sent to your email if this IP is not recognized."
|
||||||
|
read -rp "2FA code (leave blank if not required): " EVOTOR_2FA
|
||||||
|
|
||||||
|
# Build request body
|
||||||
|
BODY="type=LOGIN&grant_type=password&client_id=Evo-UI&username=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$EVOTOR_LOGIN")&password=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "$EVOTOR_PASSWORD")"
|
||||||
|
|
||||||
|
EXTRA_HEADERS=()
|
||||||
|
if [[ -n "$EVOTOR_2FA" ]]; then
|
||||||
|
EXTRA_HEADERS+=(-H "2fa_confirmation: $EVOTOR_2FA")
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Requesting token..."
|
||||||
|
RESPONSE=$(curl -s -X POST "$EVOTOR_TOKEN_URL" \
|
||||||
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||||
|
"${EXTRA_HEADERS[@]}" \
|
||||||
|
-d "$BODY")
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Response:"
|
||||||
|
echo "$RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$RESPONSE"
|
||||||
|
|
||||||
|
ACCESS_TOKEN=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('access_token',''))" 2>/dev/null || true)
|
||||||
|
|
||||||
|
if [[ -z "$ACCESS_TOKEN" ]]; then
|
||||||
|
echo
|
||||||
|
echo "ERROR: No access_token in response." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Access token:"
|
||||||
|
echo "$ACCESS_TOKEN"
|
||||||
|
echo
|
||||||
|
echo "To save this token to .env, add or update:"
|
||||||
|
echo " EVOTOR_ACCESS_TOKEN=$ACCESS_TOKEN"
|
||||||
46
scripts/init-letsencrypt.sh
Executable file
46
scripts/init-letsencrypt.sh
Executable file
@@ -0,0 +1,46 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Obtain TLS certificates from Let's Encrypt for evosync.ru
|
||||||
|
# Run once on first deploy: sudo ./scripts/init-letsencrypt.sh
|
||||||
|
# Requires nginx running on the host with acme-challenge location configured
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DOMAIN="evosync.ru"
|
||||||
|
EMAIL="${LETSENCRYPT_EMAIL:-admin@evosync.ru}"
|
||||||
|
CERTBOT_DIR="./certbot"
|
||||||
|
ACME_DIR="/var/www/certbot"
|
||||||
|
|
||||||
|
echo "==> Creating certbot directories..."
|
||||||
|
mkdir -p "$CERTBOT_DIR/conf" "$CERTBOT_DIR/www"
|
||||||
|
|
||||||
|
echo "==> Ensuring acme-challenge directory exists on host..."
|
||||||
|
sudo mkdir -p "$ACME_DIR"
|
||||||
|
sudo chmod 755 "$ACME_DIR"
|
||||||
|
|
||||||
|
echo "==> Requesting certificate from Let's Encrypt..."
|
||||||
|
sudo certbot certonly \
|
||||||
|
--webroot \
|
||||||
|
--webroot-path="$ACME_DIR" \
|
||||||
|
--email "$EMAIL" \
|
||||||
|
--agree-tos \
|
||||||
|
--no-eff-email \
|
||||||
|
-d "$DOMAIN" \
|
||||||
|
-d "www.$DOMAIN"
|
||||||
|
|
||||||
|
echo "==> Copying certificates to project directory..."
|
||||||
|
sudo cp "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" "$CERTBOT_DIR/conf/fullchain.pem"
|
||||||
|
sudo cp "/etc/letsencrypt/live/$DOMAIN/privkey.pem" "$CERTBOT_DIR/conf/privkey.pem"
|
||||||
|
sudo chown "$(whoami):$(whoami)" "$CERTBOT_DIR/conf"/*.pem
|
||||||
|
|
||||||
|
echo "==> Done! TLS certificate installed for $DOMAIN"
|
||||||
|
echo ""
|
||||||
|
echo "Certificate files:"
|
||||||
|
echo " - $CERTBOT_DIR/conf/fullchain.pem"
|
||||||
|
echo " - $CERTBOT_DIR/conf/privkey.pem"
|
||||||
|
echo ""
|
||||||
|
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"
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,18 +1,46 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
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"
|
||||||
VK_USERS_GET_URL = "https://api.vk.com/method/users.get"
|
EVOTOR_TOKEN_URL = "https://oauth.evotor.ru/oauth/token"
|
||||||
|
VK_GROUPS_GET_URL = "https://api.vk.com/method/groups.getById"
|
||||||
VK_API_VERSION = "5.131"
|
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:
|
async def check_evotor_connection(access_token: str) -> bool:
|
||||||
try:
|
try:
|
||||||
@@ -31,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,
|
||||||
)
|
)
|
||||||
@@ -46,21 +74,70 @@ async def check_vk_connection(access_token: str) -> bool:
|
|||||||
async def run_health_checks() -> None:
|
async def run_health_checks() -> None:
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
evotor_connections = db.query(EvotorConnection).all()
|
evotor_connections = db.query(EvotorConnection).all()
|
||||||
for conn in evotor_connections:
|
for conn in evotor_connections:
|
||||||
conn.is_online = await check_evotor_connection(conn.access_token)
|
# Proactively refresh if token expires soon
|
||||||
conn.last_checked_at = datetime.utcnow()
|
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()
|
vk_connections = db.query(VkConnection).all()
|
||||||
for conn in vk_connections:
|
for conn in vk_connections:
|
||||||
conn.is_online = await check_vk_connection(conn.access_token)
|
conn.is_online = await check_vk_connection(conn.access_token)
|
||||||
conn.last_checked_at = datetime.utcnow()
|
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")
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ async def lifespan(app: FastAPI):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="EvoSync — Личный кабинет", lifespan=lifespan)
|
app = FastAPI(title="ЭВОСИНК — Личный кабинет", lifespan=lifespan)
|
||||||
|
|
||||||
app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY)
|
app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY)
|
||||||
app.mount("/static", StaticFiles(directory="web/static"), name="static")
|
app.mount("/static", StaticFiles(directory="web/static"), name="static")
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""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')
|
||||||
@@ -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')
|
||||||
@@ -33,10 +33,13 @@ 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)
|
||||||
|
refresh_token = Column(Text, nullable=True)
|
||||||
|
token_expires_at = Column(DateTime, nullable=True)
|
||||||
is_online = Column(Boolean, default=False, server_default="0", nullable=False)
|
is_online = Column(Boolean, default=False, server_default="0", nullable=False)
|
||||||
last_checked_at = Column(DateTime, nullable=True)
|
last_checked_at = Column(DateTime, nullable=True)
|
||||||
connected_at = Column(DateTime, server_default=func.now(), nullable=False)
|
connected_at = Column(DateTime, server_default=func.now(), nullable=False)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -25,7 +24,7 @@ SERVICE_TYPES = [
|
|||||||
"icon": "bi-bag",
|
"icon": "bi-bag",
|
||||||
"description": "Подключите аккаунт ВКонтакте для публикации товаров в вашу группу.",
|
"description": "Подключите аккаунт ВКонтакте для публикации товаров в вашу группу.",
|
||||||
"configure_url": "/vk",
|
"configure_url": "/vk",
|
||||||
"connect_url": "/vk/connect",
|
"connect_url": "/vk",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import secrets
|
import logging
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, Depends
|
from datetime import datetime
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi import APIRouter, Request, Depends, HTTPException
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.responses import RedirectResponse, JSONResponse
|
||||||
|
from web.templates_env import templates
|
||||||
|
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
|
||||||
@@ -11,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,
|
||||||
@@ -34,78 +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(),
|
|
||||||
},
|
|
||||||
auth=(settings.EVOTOR_CLIENT_ID, settings.EVOTOR_CLIENT_SECRET),
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
token_response.raise_for_status()
|
|
||||||
token_data = token_response.json()
|
|
||||||
except Exception:
|
|
||||||
return RedirectResponse("/evotor?error=token_exchange", 303)
|
|
||||||
|
|
||||||
access_token = token_data.get("access_token")
|
|
||||||
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:
|
||||||
@@ -115,25 +84,91 @@ 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
|
||||||
|
|
||||||
|
# Upsert by evotor_user_id (user_id stays NULL until /evotor/link is called)
|
||||||
|
connection = db.query(EvotorConnection).filter(
|
||||||
|
EvotorConnection.evotor_user_id == payload.userId
|
||||||
|
).first()
|
||||||
|
|
||||||
# Save or update connection
|
|
||||||
from datetime import datetime
|
|
||||||
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.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 = datetime.utcnow()
|
connection.last_checked_at = now
|
||||||
|
connection.updated_at = now
|
||||||
else:
|
else:
|
||||||
connection = EvotorConnection(
|
connection = EvotorConnection(
|
||||||
user_id=user.id,
|
evotor_user_id=payload.userId,
|
||||||
access_token=access_token,
|
access_token=payload.token,
|
||||||
store_id=store_id,
|
store_id=store_id,
|
||||||
store_name=store_name,
|
store_name=store_name,
|
||||||
is_online=True,
|
is_online=True,
|
||||||
last_checked_at=datetime.utcnow(),
|
last_checked_at=now,
|
||||||
|
)
|
||||||
|
db.add(connection)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
logger.info("Evotor webhook: saved token for evotor_user_id=%s", payload.userId)
|
||||||
|
|
||||||
|
return JSONResponse({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/token")
|
||||||
|
async def evotor_token_manual(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User | None = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Allow user to manually paste their Evotor token."""
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
|
form = await request.form()
|
||||||
|
token = (form.get("token") or "").strip()
|
||||||
|
if not token:
|
||||||
|
return RedirectResponse("/evotor?error=empty_token", 303)
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
# Fetch store info
|
||||||
|
store_id = None
|
||||||
|
store_name = None
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
stores_response = await client.get(
|
||||||
|
EVOTOR_STORES_URL,
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
if stores_response.status_code == 200:
|
||||||
|
stores = stores_response.json()
|
||||||
|
items = stores.get("items", stores) if isinstance(stores, dict) else stores
|
||||||
|
if items:
|
||||||
|
store_id = items[0].get("uuid") or items[0].get("id")
|
||||||
|
store_name = items[0].get("name")
|
||||||
|
elif stores_response.status_code == 401:
|
||||||
|
return RedirectResponse("/evotor?error=invalid_token", 303)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
connection = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
|
||||||
|
if connection:
|
||||||
|
connection.access_token = token
|
||||||
|
connection.store_id = store_id
|
||||||
|
connection.store_name = store_name
|
||||||
|
connection.is_online = True
|
||||||
|
connection.last_checked_at = now
|
||||||
|
connection.updated_at = now
|
||||||
|
else:
|
||||||
|
connection = EvotorConnection(
|
||||||
|
user_id=user.id,
|
||||||
|
access_token=token,
|
||||||
|
store_id=store_id,
|
||||||
|
store_name=store_name,
|
||||||
|
is_online=True,
|
||||||
|
last_checked_at=now,
|
||||||
)
|
)
|
||||||
db.add(connection)
|
db.add(connection)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
118
web/routes/vk.py
118
web/routes/vk.py
@@ -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()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% block title %}EvoSync{% endblock %}</title>
|
<title>{% block title %}ЭВОСИНК{% endblock %}</title>
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<nav class="navbar navbar-expand-lg bg-white border-bottom border-2 brand-border">
|
<nav class="navbar navbar-expand-lg bg-white border-bottom border-2 brand-border">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<a href="/" class="navbar-brand brand-logo">EvoSync</a>
|
<a href="/" class="navbar-brand brand-logo">ЭВОСИНК</a>
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Группы — {{ store.name }} — EvoSync{% endblock %}
|
{% block title %}Группы — {{ store.name }} — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<nav aria-label="breadcrumb" class="mb-3">
|
<nav aria-label="breadcrumb" class="mb-3">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Товары — EvoSync{% endblock %}
|
{% block title %}Товары — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<nav aria-label="breadcrumb" class="mb-3">
|
<nav aria-label="breadcrumb" class="mb-3">
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
<p>Товары не найдены.</p>
|
<p>Товары не найдены.</p>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="table-responsive">
|
<div class="table-responsive" style="overflow: visible;">
|
||||||
<table class="table table-striped table-hover align-middle small">
|
<table class="table table-striped table-hover align-middle small">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -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-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">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Каталог — EvoSync{% endblock %}
|
{% block title %}Каталог — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Подтверждение email — EvoSync{% endblock %}
|
{% block title %}Подтверждение email — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Подключения — EvoSync{% endblock %}
|
{% block title %}Подключения — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Добавить подключение — EvoSync{% endblock %}
|
{% block title %}Добавить подключение — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex align-items-center mb-4">
|
<div class="d-flex align-items-center mb-4">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Email подтвержден — EvoSync{% endblock %}
|
{% block title %}Email подтвержден — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Подключение Эвотор — EvoSync{% endblock %}
|
{% block title %}Подключение Эвотор — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
@@ -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 %}
|
||||||
</div>
|
<li class="mb-1">Перейдите в раздел <strong>Приложения → ЭвоСинк → Настройки</strong>.</li>
|
||||||
|
<li class="mb-1">Скопируйте токен доступа и вставьте его в поле ниже.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<form method="post" action="/evotor/token">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small text-muted">Токен доступа</label>
|
||||||
|
<input type="text" name="token" class="form-control font-monospace" placeholder="Вставьте токен Эвотор" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="d-grid">
|
||||||
|
<button type="submit" class="btn btn-primary">Подключить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Забыли пароль — EvoSync{% endblock %}
|
{% block title %}Забыли пароль — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Вход — EvoSync{% endblock %}
|
{% block title %}Вход — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}{{ title }} — EvoSync{% endblock %}
|
{% block title %}{{ title }} — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Изменить пароль — EvoSync{% endblock %}
|
{% block title %}Изменить пароль — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Удалить аккаунт — EvoSync{% endblock %}
|
{% block title %}Удалить аккаунт — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Редактировать профиль — EvoSync{% endblock %}
|
{% block title %}Редактировать профиль — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Личный кабинет — EvoSync{% endblock %}
|
{% block title %}Личный кабинет — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Регистрация — EvoSync{% endblock %}
|
{% block title %}Регистрация — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Новый пароль — EvoSync{% endblock %}
|
{% block title %}Новый пароль — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Синхронизация — EvoSync{% endblock %}
|
{% block title %}Синхронизация — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1 class="h4 mb-4">Синхронизация</h1>
|
<h1 class="h4 mb-4">Синхронизация</h1>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Подключение ВКонтакте — EvoSync{% endblock %}
|
{% block title %}Подключение ВКонтакте — ЭВОСИНК{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
@@ -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>
|
||||||
</div>
|
|
||||||
|
<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 class="d-grid">
|
||||||
|
<button type="submit" class="btn btn-primary">Подключить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
6
web/templates_env.py
Normal file
6
web/templates_env.py
Normal 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
|
||||||
Reference in New Issue
Block a user