11 Commits

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 12:53:44 +03:00
24 changed files with 957 additions and 250 deletions

View File

@@ -5,9 +5,6 @@ BASE_URL=http://localhost:8000
EVOTOR_APP_ID=your-evotor-app-id EVOTOR_APP_ID=your-evotor-app-id
EVOTOR_WEBHOOK_SECRET=your-webhook-secret EVOTOR_WEBHOOK_SECRET=your-webhook-secret
VK_CLIENT_ID=your-vk-client-id
VK_CLIENT_SECRET=your-vk-client-secret
DB_ROOT_PASSWORD=rootpass DB_ROOT_PASSWORD=rootpass
DB_NAME=evosync DB_NAME=evosync
DB_USER=evosync DB_USER=evosync

161
README.md
View File

@@ -1,3 +1,160 @@
# evo-sync # ЭВОСИНК (EvoSync)
evo-sync is a command-line synchronization tool that fetches product, group, and store data from the Evo platform and syncs it with VK (VKontakte). Сервис автоматической синхронизации товарного каталога из [Эвотор](https://evotor.ru) в [ВКонтакте](https://vk.com).
## Возможности
- Подключение аккаунта Эвотор через OAuth
- Подключение сообщества ВКонтакте через токен
- Фильтрация по магазинам, группам и товарам (включить/исключить)
- Автоматическая фоновая синхронизация по расписанию
- Создание, обновление и удаление товаров в ВК-магазине
- Личный кабинет с веб-интерфейсом
- Просмотр закешированного каталога товаров
## Стек технологий
- **Backend**: FastAPI, SQLAlchemy ORM, Alembic
- **База данных**: MariaDB (драйвер pymysql)
- **Шаблоны**: Jinja2 (интерфейс на русском языке)
- **Аутентификация**: Cookie-сессии, bcrypt
- **Деплой**: Docker Compose
## Быстрый старт
### Требования
- Docker и Docker Compose
- MariaDB/MySQL (можно использовать хост-машину или отдельный контейнер)
### Настройка
1. Скопируйте файл переменных окружения:
```bash
cp .env.example .env
```
2. Отредактируйте `.env`:
```env
# База данных
DATABASE_URL=mysql+pymysql://evosync:evosync@db:3306/evosync
# Безопасность (обязательно измените в production)
SECRET_KEY=your-random-secret-key-here
# URL приложения (используется в OAuth-редиректах)
BASE_URL=https://your-domain.ru
# Эвотор
EVOTOR_APP_ID=your-evotor-app-id
EVOTOR_WEBHOOK_SECRET=your-webhook-secret
# Для отдельного MySQL-контейнера (опционально)
DB_ROOT_PASSWORD=rootpass
DB_NAME=evosync
DB_USER=evosync
DB_PASSWORD=evosync
```
3. Запустите сервис:
```bash
docker compose up -d
```
Приложение будет доступно по адресу `http://localhost:8080`.
При старте контейнер автоматически применяет миграции базы данных (`alembic upgrade head`).
## Переменные окружения
| Переменная | Обязательно | По умолчанию | Описание |
|---|---|---|---|
| `DATABASE_URL` | Да | — | MySQL connection string (pymysql) |
| `SECRET_KEY` | Да | `change-me-in-production` | Ключ для подписи сессий |
| `BASE_URL` | Да | `http://localhost:8080` | Публичный URL (для OAuth-callback) |
| `EVOTOR_APP_ID` | Да | — | ID приложения Эвотор |
| `EVOTOR_WEBHOOK_SECRET` | Да | — | Секрет для верификации вебхуков Эвотор |
| `VK_API_VERSION` | Нет | `5.131` | Версия VK API |
| `VK_DEFAULT_PHOTO_PATH` | Нет | `/app/default_product.png` | Путь к изображению товара по умолчанию |
| `JIVOSITE_WIDGET_ID` | Нет | — | ID виджета Jivosite (онлайн-чат) |
| `SYNC_INTERVAL_SECONDS` | Нет | `3600` | Интервал синхронизации (сек) |
| `CATALOG_REFRESH_INTERVAL_SECONDS` | Нет | `3600` | Интервал обновления каталога (сек) |
| `HEALTH_CHECK_INTERVAL_SECONDS` | Нет | `600` | Интервал проверки соединений (сек) |
## Структура проекта
```
evo-sync/
├── web/
│ ├── main.py # FastAPI-приложение, регистрация роутеров, фоновые задачи
│ ├── config.py # Настройки из переменных окружения (pydantic-settings)
│ ├── models.py # SQLAlchemy-модели (User, EvotorConnection, VkConnection, ...)
│ ├── database.py # Движок и сессия SQLAlchemy
│ ├── auth.py # Хеширование паролей, работа с сессиями
│ ├── schemas.py # Pydantic-схемы для форм
│ ├── sync_engine.py # Фоновый движок синхронизации (Эвотор → ВК)
│ ├── routes/
│ │ ├── auth.py # Регистрация, вход, выход, подтверждение email
│ │ ├── profile.py # Профиль пользователя
│ │ ├── reset.py # Сброс пароля
│ │ ├── evotor.py # OAuth-подключение Эвотор
│ │ ├── vk.py # Подключение ВКонтакте (ввод токена)
│ │ ├── connections.py # Обзор всех подключений
│ │ ├── sync.py # Настройка и управление синхронизацией
│ │ └── catalog.py # Просмотр закешированного каталога
│ ├── templates/ # Jinja2-шаблоны (русский интерфейс)
│ └── migrations/ # Alembic-миграции
├── Dockerfile.web # Docker-образ веб-приложения (Python 3.12-slim)
├── docker-compose.yml # Оркестрация сервисов
├── docker-entrypoint.sh # Скрипт запуска контейнера
├── alembic.ini # Конфигурация миграций
├── requirements.txt # Python-зависимости
└── .env.example # Шаблон переменных окружения
```
## Модели базы данных
| Модель | Таблица | Назначение |
|---|---|---|
| `User` | `users` | Аккаунт пользователя |
| `EvotorConnection` | `evotor_connections` | OAuth-токен Эвотор |
| `VkConnection` | `vk_connections` | Токен сообщества ВКонтакте |
| `SyncConfig` | `sync_configs` | Настройки синхронизации пользователя |
| `SyncFilter` | `sync_filters` | Фильтры по магазинам/группам/товарам |
| `CachedStore` | `cached_stores` | Кеш магазинов Эвотор |
| `CachedGroup` | `cached_groups` | Кеш групп товаров Эвотор |
| `CachedProduct` | `cached_products` | Кеш товаров Эвотор + статус синхронизации |
## Как работает синхронизация
1. По расписанию (каждые `SYNC_INTERVAL_SECONDS` секунд) запускается `sync_engine.run_sync()`
2. Для каждого пользователя с активным `SyncConfig` и подключёнными Эвотор и ВК:
- Загружаются товары и группы из Эвотор API
- Применяются фильтры (включить/исключить магазины, группы, товары)
- Загружаются текущие товары и альбомы из ВК
- Создаются/обновляются/удаляются альбомы и товары в ВК-магазине
- В БД обновляется поле `synced_at` у синхронизированных товаров
**Особенности:**
- Товары на развес (единицы измерения в граммах) получают скорректированную цену (×10)
- Совпадение товаров ВК и Эвотор происходит по нормализованному названию
- Товары с `allow_to_sell=false` не синхронизируются в ВК
## Разработка
Для локальной разработки с горячей перезагрузкой кода папка `./web` монтируется в контейнер как volume. После изменения Python-файлов uvicorn перезапустится автоматически.
Создание новой миграции:
```bash
docker compose exec web alembic revision --autogenerate -m "описание изменений"
```
Применение миграций вручную:
```bash
docker compose exec web alembic upgrade head
```

View File

@@ -19,6 +19,7 @@ services:
- ./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
- ./5393364294319597854.png:/app/default_product.png:ro
restart: unless-stopped restart: unless-stopped
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"

View File

@@ -1 +1 @@
1.8.1 1.9.0

View File

@@ -10,11 +10,16 @@ class Settings(BaseSettings):
EVOTOR_APP_ID: str = "" EVOTOR_APP_ID: str = ""
EVOTOR_WEBHOOK_SECRET: str = "" EVOTOR_WEBHOOK_SECRET: str = ""
JIVOSITE_WIDGET_ID: str = ""
HEALTH_CHECK_INTERVAL_SECONDS: int = 600 HEALTH_CHECK_INTERVAL_SECONDS: int = 600
CATALOG_REFRESH_INTERVAL_SECONDS: int = 3600
SYNC_INTERVAL_SECONDS: int = 3600
VK_DEFAULT_PHOTO_PATH: str = "/app/default_product.png"
VK_CLIENT_ID: str = "" VK_CLIENT_ID: str = ""
VK_CLIENT_SECRET: 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

@@ -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

@@ -9,6 +9,7 @@ from starlette.middleware.sessions import SessionMiddleware
from web.auth import get_current_user from web.auth import get_current_user
from web.config import settings from web.config import settings
from web.health_checker import health_check_loop from web.health_checker import health_check_loop
from web.sync_engine import sync_loop
from web.models import User from web.models import User
from web.routes import auth, profile, reset, evotor, vk, sync, catalog from web.routes import auth, profile, reset, evotor, vk, sync, catalog
from web.routes import connections from web.routes import connections
@@ -16,13 +17,18 @@ from web.routes import connections
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
task = asyncio.create_task(health_check_loop(settings.HEALTH_CHECK_INTERVAL_SECONDS)) tasks = [
asyncio.create_task(health_check_loop(settings.HEALTH_CHECK_INTERVAL_SECONDS)),
asyncio.create_task(sync_loop(settings.SYNC_INTERVAL_SECONDS)),
]
yield yield
task.cancel() for t in tasks:
try: t.cancel()
await task for t in tasks:
except asyncio.CancelledError: try:
pass await t
except asyncio.CancelledError:
pass
app = FastAPI(title="ЭВОСИНК — Личный кабинет", lifespan=lifespan) app = FastAPI(title="ЭВОСИНК — Личный кабинет", lifespan=lifespan)

View File

@@ -0,0 +1,26 @@
"""add synced_at to cached_products
Revision ID: a7b8c9d0e1f2
Revises: f6a7b8c9d0e1
Branch Labels: None
Depends On: None
"""
from alembic import op
import sqlalchemy as sa
revision = "a7b8c9d0e1f2"
down_revision = "f6a7b8c9d0e1"
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
"cached_products",
sa.Column("synced_at", sa.DateTime(), nullable=True),
)
def downgrade():
op.drop_column("cached_products", "synced_at")

View File

@@ -149,6 +149,7 @@ class CachedProduct(Base):
article_number = Column(String(100), nullable=True) article_number = Column(String(100), nullable=True)
allow_to_sell = Column(Boolean, nullable=True) allow_to_sell = Column(Boolean, nullable=True)
fetched_at = Column(DateTime, nullable=False) fetched_at = Column(DateTime, nullable=False)
synced_at = Column(DateTime, nullable=True)
__table_args__ = ( __table_args__ = (
UniqueConstraint("user_id", "evotor_id"), UniqueConstraint("user_id", "evotor_id"),

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 = [
{ {
@@ -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",
}, },
] ]

View File

@@ -1,10 +1,10 @@
import logging import logging
import httpx import httpx
from datetime import datetime, timedelta from datetime import datetime
from fastapi import APIRouter, Request, Depends, HTTPException from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi.responses import RedirectResponse, JSONResponse from fastapi.responses import RedirectResponse, JSONResponse
from fastapi.templating import Jinja2Templates from web.templates_env import templates
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -16,14 +16,10 @@ from web.models import User, EvotorConnection
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/evotor") router = APIRouter(prefix="/evotor")
templates = Jinja2Templates(directory="web/templates")
EVOTOR_APP_URL = "https://market.evotor.ru/store/apps/{app_id}" EVOTOR_APP_URL = "https://market.evotor.ru/store/apps/{app_id}"
EVOTOR_STORES_URL = "https://api.evotor.ru/stores" EVOTOR_STORES_URL = "https://api.evotor.ru/stores"
# Pending connections older than this are ignored during linking
PENDING_LINK_WINDOW_SECONDS = 300
@router.get("") @router.get("")
def evotor_page( def evotor_page(
@@ -36,27 +32,16 @@ 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")
def evotor_connect(request: Request, user: User | None = Depends(get_current_user)):
if not user:
return RedirectResponse("/login", 303)
# Record when this user initiated a connect so we can link the incoming webhook
request.session["evotor_connect_user_id"] = user.id
request.session["evotor_connect_at"] = datetime.utcnow().isoformat()
url = EVOTOR_APP_URL.format(app_id=settings.EVOTOR_APP_ID)
return RedirectResponse(url, 302)
class EvotorTokenPayload(BaseModel): class EvotorTokenPayload(BaseModel):
userId: str userId: str
token: str token: str
@@ -130,62 +115,6 @@ async def evotor_callback(
return JSONResponse({"status": "ok"}) return JSONResponse({"status": "ok"})
@router.get("/link")
def evotor_link(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
"""
Called when the user returns to our app after authorizing on Evotor.
Links the most recently received unlinked token to this user.
"""
if not user:
return RedirectResponse("/login", 303)
connect_user_id = request.session.pop("evotor_connect_user_id", None)
connect_at_str = request.session.pop("evotor_connect_at", None)
if not connect_user_id or connect_user_id != user.id or not connect_at_str:
return RedirectResponse("/evotor?error=session_expired", 303)
try:
connect_at = datetime.fromisoformat(connect_at_str)
except ValueError:
return RedirectResponse("/evotor?error=session_expired", 303)
cutoff = connect_at - timedelta(seconds=10) # allow slight clock drift
now = datetime.utcnow()
if (now - connect_at).total_seconds() > PENDING_LINK_WINDOW_SECONDS:
return RedirectResponse("/evotor?error=link_timeout", 303)
# Find an unlinked connection received after the user clicked "Connect"
pending = (
db.query(EvotorConnection)
.filter(
EvotorConnection.user_id.is_(None),
EvotorConnection.connected_at >= cutoff,
)
.order_by(EvotorConnection.connected_at.desc())
.first()
)
if not pending:
return RedirectResponse("/evotor?error=token_not_received", 303)
# Detach any existing connection for this user
existing = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
if existing:
db.delete(existing)
db.flush()
pending.user_id = user.id
db.commit()
return RedirectResponse("/connections", 303)
@router.post("/token") @router.post("/token")
async def evotor_token_manual( async def evotor_token_manual(
request: Request, request: Request,

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,11 @@
import secrets
from datetime import datetime from datetime import datetime
from urllib.parse import urlencode
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,15 +14,59 @@ 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"
VK_OAUTH_URL = "https://oauth.vk.com/authorize"
def _redirect_uri() -> str: async def _fetch_group_info(token: str) -> tuple[str | None, str | None]:
return f"{settings.BASE_URL}/vk/callback" """Returns (group_id, group_name) for the first admin group, or (None, None)."""
try:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{VK_API_URL}/groups.get",
params={
"access_token": token,
"v": settings.VK_API_VERSION,
"filter": "admin",
"extended": 1,
"count": 1,
},
timeout=15,
)
if resp.status_code == 200:
data = resp.json()
if "error" not in data:
items = data.get("response", {}).get("items", [])
if items:
return str(items[0].get("id", "")), items[0].get("name")
except Exception:
pass
return None, None
def _save_connection(db: Session, user_id: int, token: str,
group_id: str | None, group_name: str | None) -> None:
now = datetime.utcnow()
connection = db.query(VkConnection).filter(VkConnection.user_id == user_id).first()
if connection:
connection.access_token = token
connection.vk_user_id = group_id
connection.first_name = group_name
connection.last_name = None
connection.is_online = True
connection.last_checked_at = now
else:
db.add(VkConnection(
user_id=user_id,
access_token=token,
vk_user_id=group_id,
first_name=group_name,
last_name=None,
is_online=True,
last_checked_at=now,
))
db.commit()
@router.get("") @router.get("")
@@ -41,109 +85,69 @@ def vk_page(
"user": user, "user": user,
"connection": connection, "connection": connection,
"error": error, "error": error,
"vk_client_id": settings.VK_CLIENT_ID,
"callback_url": f"{settings.BASE_URL}/vk/callback",
}) })
@router.get("/connect") @router.get("/connect")
def vk_connect(request: Request, user: User | None = Depends(get_current_user)): def vk_connect(
request: Request,
user: User | None = Depends(get_current_user),
):
"""Redirect to VK OAuth authorization page."""
if not user: if not user:
return RedirectResponse("/login", 303) return RedirectResponse("/login", 303)
state = secrets.token_urlsafe(32) if not settings.VK_CLIENT_ID:
request.session["vk_oauth_state"] = state return RedirectResponse("/vk?error=no_client_id", 303)
params = ( params = urlencode({
f"?client_id={settings.VK_CLIENT_ID}" "client_id": settings.VK_CLIENT_ID,
f"&response_type=code" "scope": "market,groups",
f"&redirect_uri={_redirect_uri()}" "redirect_uri": f"{settings.BASE_URL}/vk/callback",
f"&scope={settings.VK_SCOPES.replace(' ', '%20')}" "display": "page",
f"&state={state}" "response_type": "token",
f"&display=page" "v": settings.VK_API_VERSION,
f"&v={settings.VK_API_VERSION}" })
) return RedirectResponse(f"{VK_OAUTH_URL}?{params}", 302)
return RedirectResponse(VK_AUTHORIZE_URL + params, 302)
@router.get("/callback") @router.get("/callback")
async def vk_callback( def vk_callback(
request: Request,
user: User | None = Depends(get_current_user),
):
"""Landing page after VK OAuth. JS reads the token from the URL fragment and POSTs it."""
if not user:
return RedirectResponse("/login", 303)
return templates.TemplateResponse("vk_callback.html", {
"request": request,
"user": user,
})
@router.post("/token")
async def vk_token(
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 VK user access token (from manual entry or OAuth callback)."""
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: group_id, group_name = await _fetch_group_info(token)
return RedirectResponse("/vk?error=invalid_state", 303) if not group_id:
return RedirectResponse("/vk?error=invalid_token", 303)
# Exchange code for token (VK uses GET with query params)
try:
async with httpx.AsyncClient() as client:
token_response = await client.get(
VK_TOKEN_URL,
params={
"client_id": settings.VK_CLIENT_ID,
"client_secret": settings.VK_CLIENT_SECRET,
"code": code,
"redirect_uri": _redirect_uri(),
},
timeout=15,
)
token_response.raise_for_status()
token_data = token_response.json()
except Exception:
return RedirectResponse("/vk?error=token_exchange", 303)
access_token = token_data.get("access_token")
vk_user_id = str(token_data.get("user_id", "")) or None
if not access_token:
return RedirectResponse("/vk?error=no_token", 303)
# Fetch VK profile info
first_name = None
last_name = None
try:
async with httpx.AsyncClient() as client:
profile_response = await client.get(
f"{VK_API_URL}/users.get",
params={"access_token": access_token, "v": settings.VK_API_VERSION},
timeout=15,
)
if profile_response.status_code == 200:
profile_data = profile_response.json()
items = profile_data.get("response", [])
if items:
first_name = items[0].get("first_name")
last_name = items[0].get("last_name")
except Exception:
pass
# Save or update connection
connection = db.query(VkConnection).filter(VkConnection.user_id == user.id).first()
if connection:
connection.access_token = access_token
connection.vk_user_id = vk_user_id
connection.first_name = first_name
connection.last_name = last_name
connection.is_online = True
connection.last_checked_at = datetime.utcnow()
else:
connection = VkConnection(
user_id=user.id,
access_token=access_token,
vk_user_id=vk_user_id,
first_name=first_name,
last_name=last_name,
is_online=True,
last_checked_at=datetime.utcnow(),
)
db.add(connection)
db.commit()
_save_connection(db, user.id, token, group_id, group_name)
return RedirectResponse("/connections", 303) return RedirectResponse("/connections", 303)

485
web/sync_engine.py Normal file
View File

@@ -0,0 +1,485 @@
"""
Sync engine: syncs Evotor products to VK market for all enabled users.
Runs as a background asyncio loop inside the web app.
"""
import asyncio
import logging
from datetime import datetime
import httpx
from sqlalchemy.orm import Session
from web.database import SessionLocal
from web.models import CachedProduct, EvotorConnection, VkConnection, SyncConfig, SyncFilter
logger = logging.getLogger("uvicorn.error")
VK_API_HOST = "https://api.vk.ru/method"
VK_API_VERSION = "5.199"
EVOTOR_API_BASE = "https://api.evotor.ru"
VK_CATEGORY_ID = 40932
VK_STOCK_AMOUNT = 1000
WEIGHT_PRICE_MULTIPLIER = 10
WEIGHT_MEASURES = {"г", "г.", "грамм", "граммов", "гр", "гр."}
def _is_weight_measure(measure: str | None) -> bool:
if not measure:
return False
return measure.strip().lower() in WEIGHT_MEASURES
def _normalize_name(name: str) -> str:
return name.strip().replace(";", ",")
def _calc_price(price_kopecks, measure: str | None) -> tuple[int, str]:
"""Returns (price_in_kopecks_for_vk, price_info_label)."""
base = int(price_kopecks or 0)
if _is_weight_measure(measure):
return base * WEIGHT_PRICE_MULTIPLIER, f"{WEIGHT_PRICE_MULTIPLIER}{measure}"
return base, measure or ""
def _build_description(name: str, price_info: str, extra_desc: str | None) -> str:
desc = f"{name} (цена за {price_info}.)\n\n"
if extra_desc:
desc += extra_desc
return desc
def _get_included_store_ids(filters: list) -> list[str]:
return [f.entity_id for f in filters if f.entity_type == "store" and f.filter_mode == "include"]
def _is_group_included(group_id: str | None, filters: list) -> bool:
"""Returns True if the group should be synced based on filters."""
group_filters = {f.entity_id: f.filter_mode for f in filters if f.entity_type == "group"}
if not group_filters:
return True # no group filters → include all
mode = group_filters.get(group_id)
if mode == "exclude":
return False
if mode == "include":
return True
# Not mentioned — include if there are only excludes, exclude if there are only includes
has_includes = any(v == "include" for v in group_filters.values())
return not has_includes
def _is_product_included(product_id: str, filters: list) -> bool:
product_filters = {f.entity_id: f.filter_mode for f in filters if f.entity_type == "product"}
if not product_filters:
return True
mode = product_filters.get(product_id)
if mode == "exclude":
return False
if mode == "include":
return True
has_includes = any(v == "include" for v in product_filters.values())
return not has_includes
# ---------------------------------------------------------------------------
# Evotor API helpers
# ---------------------------------------------------------------------------
async def _evo_fetch_products(token: str, store_id: str) -> list[dict]:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{EVOTOR_API_BASE}/stores/{store_id}/products",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
if resp.status_code in (402, 404):
return []
resp.raise_for_status()
data = resp.json()
return data.get("items", data) if isinstance(data, dict) else data
async def _evo_fetch_groups(token: str, store_id: str) -> list[dict]:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{EVOTOR_API_BASE}/stores/{store_id}/product-groups",
headers={"Authorization": f"Bearer {token}"},
timeout=30,
)
if resp.status_code in (402, 404):
return []
resp.raise_for_status()
data = resp.json()
return data.get("items", data) if isinstance(data, dict) else data
# ---------------------------------------------------------------------------
# VK API helpers
# ---------------------------------------------------------------------------
def _vk_params(token: str, **extra) -> dict:
return {"access_token": token, "v": VK_API_VERSION, **extra}
async def _vk_get_albums(token: str, owner_id: str) -> list[dict]:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{VK_API_HOST}/market.getAlbums",
params=_vk_params(token, owner_id=owner_id, count=200),
timeout=30,
)
resp.raise_for_status()
data = resp.json()
return data.get("response", {}).get("items", [])
async def _vk_create_album(token: str, owner_id: str, title: str) -> int | None:
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{VK_API_HOST}/market.addAlbum",
data=_vk_params(token, owner_id=owner_id, title=title),
timeout=30,
)
resp.raise_for_status()
data = resp.json()
if "error" in data:
logger.error("VK create album error: %s", data["error"])
return None
return data.get("response", {}).get("market_album_id")
async def _vk_get_products(token: str, owner_id: str) -> list[dict]:
"""Fetch all VK market items (handles pagination)."""
items = []
offset = 0
count = 200
async with httpx.AsyncClient() as client:
while True:
resp = await client.get(
f"{VK_API_HOST}/market.get",
params=_vk_params(token, owner_id=owner_id, extended=1,
with_disabled=1, count=count, offset=offset),
timeout=30,
)
resp.raise_for_status()
data = resp.json()
batch = data.get("response", {}).get("items", [])
items.extend(batch)
if len(batch) < count:
break
offset += count
return items
async def _vk_upload_photo(token: str, group_id: str, photo_path: str) -> str | None:
"""Upload a photo and return photo_id."""
async with httpx.AsyncClient() as client:
# Get upload URL
resp = await client.get(
f"{VK_API_HOST}/market.getProductPhotoUploadServer",
params=_vk_params(token, group_id=group_id),
timeout=30,
)
resp.raise_for_status()
data = resp.json()
if "error" in data:
logger.error("VK get upload URL error: %s", data["error"])
return None
upload_url = data.get("response", {}).get("upload_url")
if not upload_url:
return None
# Upload photo
with open(photo_path, "rb") as f:
upload_resp = await client.post(upload_url, files={"file": f}, timeout=60)
upload_resp.raise_for_status()
upload_obj = upload_resp.json()
# Save photo
save_resp = await client.post(
f"{VK_API_HOST}/market.saveProductPhoto",
data=_vk_params(token, upload_response=upload_resp.text),
timeout=30,
)
save_resp.raise_for_status()
save_data = save_resp.json()
if "error" in save_data:
logger.error("VK save photo error: %s", save_data["error"])
return None
return save_data.get("response", {}).get("photo_id")
async def _vk_create_product(
token: str, owner_id: str, name: str, description: str,
price: int, stock_amount: int, photo_id: str, album_id: int | None,
) -> int | None:
params = _vk_params(
token,
owner_id=owner_id,
name=name,
description=description,
category_id=VK_CATEGORY_ID,
price=price,
main_photo_id=photo_id,
stock_amount=stock_amount,
)
async with httpx.AsyncClient() as client:
resp = await client.post(f"{VK_API_HOST}/market.add", data=params, timeout=30)
resp.raise_for_status()
data = resp.json()
if "error" in data:
logger.error("VK create product error: %s", data["error"])
return None
product_id = data.get("response", {}).get("market_item_id")
if product_id and album_id:
await client.get(
f"{VK_API_HOST}/market.addToAlbum",
params=_vk_params(token, owner_id=owner_id,
item_ids=product_id, album_ids=album_id),
timeout=30,
)
return product_id
async def _vk_edit_product(
token: str, owner_id: str, item_id: int, name: str,
description: str, price: int, stock_amount: int,
) -> None:
params = _vk_params(
token,
owner_id=owner_id,
item_id=item_id,
name=name,
description=description,
category_id=VK_CATEGORY_ID,
price=price,
stock_amount=stock_amount,
)
async with httpx.AsyncClient() as client:
resp = await client.post(f"{VK_API_HOST}/market.edit", data=params, timeout=30)
resp.raise_for_status()
data = resp.json()
if "error" in data:
logger.error("VK edit product error: %s", data["error"])
async def _vk_delete_product(token: str, owner_id: str, item_id: int) -> None:
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{VK_API_HOST}/market.delete",
data=_vk_params(token, owner_id=owner_id, item_id=item_id),
timeout=30,
)
resp.raise_for_status()
# ---------------------------------------------------------------------------
# Main sync logic per user
# ---------------------------------------------------------------------------
def _stamp_synced(db: Session, user_id: int, evo_id: str, now: datetime) -> None:
db.query(CachedProduct).filter(
CachedProduct.user_id == user_id,
CachedProduct.evotor_id == evo_id,
).update({"synced_at": now})
db.commit()
async def sync_user(
user_id: int,
evo_token: str,
vk_token: str,
vk_group_id: str,
filters: list,
photo_path: str,
db: Session,
) -> None:
owner_id = f"-{vk_group_id}"
now = datetime.utcnow()
logger.info("Sync start: user_id=%d vk_group=%s", user_id, vk_group_id)
store_ids = _get_included_store_ids(filters)
if not store_ids:
logger.info("Sync skip: user_id=%d — no stores included in filters", user_id)
return
# Collect all Evotor products and groups across included stores
evo_products: list[dict] = []
groups_by_id: dict[str, dict] = {}
for store_id in store_ids:
raw_groups = await _evo_fetch_groups(evo_token, store_id)
for g in raw_groups:
gid = g.get("uuid") or g.get("id")
if gid:
groups_by_id[gid] = g
raw_products = await _evo_fetch_products(evo_token, store_id)
for p in raw_products:
pid = p.get("uuid") or p.get("id")
gid = p.get("parentUuid") or p.get("parent_id")
if not _is_group_included(gid, filters):
continue
if not _is_product_included(pid, filters):
continue
evo_products.append(p)
# Build evo product lookup by normalized name
# {normalized_name: product_dict}
evo_by_name: dict[str, dict] = {}
for p in evo_products:
raw_name = (p.get("name") or "").strip()
norm = _normalize_name(raw_name)
evo_by_name[norm] = p
# Fetch VK state
vk_products = await _vk_get_products(vk_token, owner_id)
vk_albums = await _vk_get_albums(vk_token, owner_id)
vk_album_by_title: dict[str, dict] = {a["title"]: a for a in vk_albums}
# Ensure albums exist for all included groups
for gid, group in groups_by_id.items():
if not _is_group_included(gid, filters):
continue
title = group.get("name", "")
if title and title not in vk_album_by_title:
new_album_id = await _vk_create_album(vk_token, owner_id, title)
if new_album_id:
vk_album_by_title[title] = {"id": new_album_id, "title": title}
logger.info("Created VK album '%s' for user_id=%d", title, user_id)
# Build VK product lookup by normalized name
# {normalized_name: [vk_item, ...]}
vk_by_name: dict[str, list[dict]] = {}
for item in vk_products:
norm = _normalize_name(item.get("title", ""))
vk_by_name.setdefault(norm, []).append(item)
# --- UPDATE / CREATE ---
for norm_name, evo_p in evo_by_name.items():
evo_id = evo_p.get("uuid") or evo_p.get("id")
raw_name = (evo_p.get("name") or "").strip()
name_for_vk = _normalize_name(raw_name)
measure = evo_p.get("measureName") or evo_p.get("measure_name")
raw_price = evo_p.get("price") or 0
price, price_info = _calc_price(raw_price, measure)
allow_to_sell = evo_p.get("allowToSell") if evo_p.get("allowToSell") is not None else evo_p.get("allow_to_sell")
stock_amount = VK_STOCK_AMOUNT if allow_to_sell else 0
extra_desc = evo_p.get("description") or ""
description = _build_description(raw_name, price_info, extra_desc).strip()
gid = evo_p.get("parentUuid") or evo_p.get("parent_id")
group_name = groups_by_id.get(gid, {}).get("name") if gid else None
album = vk_album_by_title.get(group_name) if group_name else None
album_id = album["id"] if album else None
if norm_name in vk_by_name:
# Update existing (use first match)
vk_item = vk_by_name[norm_name][0]
vk_id = vk_item["id"]
orig_price = vk_item.get("price", {}).get("amount", 0)
orig_price_int = int(orig_price) if orig_price else 0
orig_desc = (vk_item.get("description") or "").strip()
orig_stock = vk_item.get("stock_amount", 0)
price_changed = price != orig_price_int
desc_changed = description != orig_desc
stock_changed = stock_amount != orig_stock
if price_changed or desc_changed or stock_changed:
logger.info(
"Updating VK product '%s' user_id=%d (price=%s desc=%s stock=%s)",
name_for_vk, user_id, price_changed, desc_changed, stock_changed,
)
await _vk_edit_product(
vk_token, owner_id, vk_id, name_for_vk, description, price, stock_amount,
)
_stamp_synced(db, user_id, evo_id, now)
else:
# Create new (only if allow_to_sell)
if not allow_to_sell:
continue
photo_id = await _vk_upload_photo(vk_token, vk_group_id, photo_path)
if not photo_id:
logger.error("Skipping product '%s' — photo upload failed", name_for_vk)
continue
logger.info("Creating VK product '%s' user_id=%d", name_for_vk, user_id)
created = await _vk_create_product(
vk_token, owner_id, name_for_vk, description,
price, stock_amount, photo_id, album_id,
)
if created:
_stamp_synced(db, user_id, evo_id, now)
# --- DELETE products in VK that are no longer in Evo ---
for norm_name, vk_items in vk_by_name.items():
if norm_name in evo_by_name:
# Delete duplicates (keep first)
for dup in vk_items[1:]:
logger.info("Deleting duplicate VK product '%s' id=%d user_id=%d",
norm_name, dup["id"], user_id)
await _vk_delete_product(vk_token, owner_id, dup["id"])
else:
# Delete all — product removed from Evo
for item in vk_items:
logger.info("Deleting removed product '%s' id=%d user_id=%d",
norm_name, item["id"], user_id)
await _vk_delete_product(vk_token, owner_id, item["id"])
logger.info("Sync complete: user_id=%d", user_id)
# ---------------------------------------------------------------------------
# Background loop
# ---------------------------------------------------------------------------
async def run_sync() -> None:
from web.config import settings
db = SessionLocal()
try:
configs = db.query(SyncConfig).filter(
SyncConfig.is_enabled == True,
SyncConfig.confirmed_at != None,
).all()
for config in configs:
user_id = config.user_id
evo = db.query(EvotorConnection).filter(
EvotorConnection.user_id == user_id
).first()
vk = db.query(VkConnection).filter(
VkConnection.user_id == user_id
).first()
if not evo or not vk:
continue
if not evo.access_token or not vk.access_token:
continue
if not vk.vk_user_id:
continue
try:
await sync_user(
user_id=user_id,
evo_token=evo.access_token,
vk_token=vk.access_token,
vk_group_id=vk.vk_user_id,
filters=config.filters,
photo_path=settings.VK_DEFAULT_PHOTO_PATH,
db=db,
)
except Exception:
logger.exception("Sync failed for user_id=%d", user_id)
except Exception:
logger.exception("Error in sync runner")
db.rollback()
finally:
db.close()
async def sync_loop(interval: int) -> None:
while True:
await run_sync()
await asyncio.sleep(interval)

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

@@ -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>
@@ -40,6 +40,7 @@
<th>Кол-во</th> <th>Кол-во</th>
<th>Ед. изм.</th> <th>Ед. изм.</th>
<th>В продаже</th> <th>В продаже</th>
<th>Синхронизирован</th>
<th>Фильтр</th> <th>Фильтр</th>
<th></th> <th></th>
</tr> </tr>
@@ -62,6 +63,15 @@
<i class="bi bi-x-circle-fill text-danger"></i> <i class="bi bi-x-circle-fill text-danger"></i>
{% endif %} {% endif %}
</td> </td>
<td>
{% if product.synced_at %}
<span title="{{ product.synced_at.strftime('%d.%m.%Y %H:%M') }}">
<i class="bi bi-check-circle-fill text-success"></i>
</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td> <td>
{% if mode == "include" %} {% if mode == "include" %}
<span class="badge bg-success">✓ Включено</span> <span class="badge bg-success">✓ Включено</span>
@@ -73,7 +83,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" data-bs-boundary="document" data-bs-display="dynamic" data-bs-strategy="fixed"> <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">

View File

@@ -7,13 +7,7 @@
{% if error %} {% if error %}
<div class="alert alert-danger mt-4"> <div class="alert alert-danger mt-4">
{% if error == "token_not_received" %} {% if error == "invalid_token" %}
<i class="bi bi-exclamation-triangle me-2"></i>Токен от Эвотор ещё не получен. Убедитесь, что авторизация прошла успешно, и попробуйте снова.
{% elif error == "link_timeout" %}
<i class="bi bi-exclamation-triangle me-2"></i>Время ожидания истекло. Попробуйте подключить аккаунт заново.
{% elif error == "session_expired" %}
<i class="bi bi-exclamation-triangle me-2"></i>Сессия устарела. Попробуйте подключить аккаунт заново.
{% elif error == "invalid_token" %}
<i class="bi bi-exclamation-triangle me-2"></i>Токен недействителен. Проверьте правильность и попробуйте снова. <i class="bi bi-exclamation-triangle me-2"></i>Токен недействителен. Проверьте правильность и попробуйте снова.
{% elif error == "empty_token" %} {% elif error == "empty_token" %}
<i class="bi bi-exclamation-triangle me-2"></i>Введите токен. <i class="bi bi-exclamation-triangle me-2"></i>Введите токен.
@@ -52,14 +46,8 @@
<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">
<a href="/evotor/connect" class="btn btn-primary">Переподключить</a>
<form method="post" action="/evotor/disconnect">
<button type="submit" class="btn btn-outline-danger w-100">Отключить аккаунт Эвотор</button>
</form>
</div>
<div class="card-footer"> <div class="card-footer">
<p class="text-muted small mb-2">Обновить токен вручную (Личный кабинет Эвотор → <strong>Приложения → ЭвоСинк → Настройки</strong>):</p> <p class="text-muted small mb-2">Обновить токен (Личный кабинет Эвотор → <strong>Приложения → ЭвоСинк → Настройки</strong>):</p>
<form method="post" action="/evotor/token"> <form method="post" action="/evotor/token">
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<input type="text" name="token" class="form-control font-monospace" placeholder="Новый токен" required> <input type="text" name="token" class="form-control font-monospace" placeholder="Новый токен" required>
@@ -67,30 +55,37 @@
</div> </div>
</form> </form>
</div> </div>
<div class="card-body d-grid">
<form method="post" action="/evotor/disconnect">
<button type="submit" class="btn btn-outline-danger w-100">Отключить аккаунт Эвотор</button>
</form>
</div>
{% else %} {% else %}
{# ── 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>
<li>После подтверждения доступа синхронизация будет настроена автоматически</li>
<li>Вы можете отключить доступ в любой момент</li>
</ul>
<div class="d-grid gap-2">
<a href="/evotor/connect" class="btn btn-primary btn-lg">Подключить Эвотор</a>
<a href="/evotor/link" class="btn btn-outline-secondary">Уже авторизовался — подтвердить подключение</a>
</div>
<hr class="my-4"> <ol class="text-muted small mb-4">
<p class="text-muted small mb-2">Если приложение уже установлено, введите токен вручную. Его можно найти в личном кабинете Эвотор: <strong>Приложения → ЭвоСинк → Настройки</strong>.</p> {% if app_url %}
<li class="mb-1">Откройте <a href="{{ app_url }}" target="_blank" rel="noopener">приложение ЭвоСинк в магазине Эвотор <i class="bi bi-box-arrow-up-right small"></i></a> и установите его.</li>
{% else %}
<li class="mb-1">Найдите приложение <strong>ЭвоСинк</strong> в магазине Эвотор и установите его.</li>
{% endif %}
<li class="mb-1">Перейдите в раздел <strong>Приложения → ЭвоСинк → Настройки</strong>.</li>
<li class="mb-1">Скопируйте токен доступа и вставьте его в поле ниже.</li>
</ol>
<form method="post" action="/evotor/token"> <form method="post" action="/evotor/token">
<div class="input-group"> <div class="mb-3">
<input type="text" name="token" class="form-control font-monospace" placeholder="Введите токен Эвотор" required> <label class="form-label small text-muted">Токен доступа</label>
<button type="submit" class="btn btn-outline-primary">Сохранить</button> <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> </div>
</form> </form>
</div> </div>

View File

@@ -7,12 +7,12 @@
{% 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" %} {% elif error == "no_client_id" %}
<i class="bi bi-exclamation-triangle me-2"></i>ВКонтакте не вернул токен доступа. Попробуйте позже. <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 +31,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,28 +48,51 @@
<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>
{% else %} {% else %}
{# ── NOT CONNECTED STATE ── #} {# ── NOT CONNECTED STATE ── #}
<div class="card-body"> <div class="card-body">
<p class="text-muted mb-3"> {% if vk_client_id %}
Подключите ваш аккаунт ВКонтакте, чтобы система могла автоматически синхронизировать <p class="text-muted mb-4">
каталог товаров из Эвотор в вашу группу ВКонтакте. Нажмите кнопку ниже, чтобы авторизоваться через ВКонтакте и выдать доступ к управлению товарами вашего сообщества.
</p> </p>
<ul class="text-muted small mb-4"> <div class="d-grid mb-3">
<li>Вы будете перенаправлены на сайт ВКонтакте для авторизации</li> <a href="/vk/connect" class="btn btn-primary btn-lg">
<li>После подтверждения доступа синхронизация будет настроена автоматически</li> <i class="bi bi-box-arrow-in-right me-2"></i>Подключить ВКонтакте
<li>Вы можете отключить доступ в любой момент</li> </a>
</ul>
<div class="d-grid">
<a href="/vk/connect" class="btn btn-primary btn-lg">Подключить ВКонтакте</a>
</div> </div>
<hr class="my-4">
<p class="text-muted small mb-2">Или введите токен вручную:</p>
{% else %}
<p class="text-muted mb-3">
Для синхронизации товаров необходим <strong>токен пользователя</strong> ВКонтакте
с правами на управление товарами сообщества.
</p>
{% endif %}
<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 {% if vk_client_id %}{% else %}autofocus{% endif %}>
</div>
<div class="d-grid">
<button type="submit" class="btn {% if vk_client_id %}btn-outline-secondary{% else %}btn-primary{% endif %}">Подключить</button>
</div>
</form>
</div> </div>
{% endif %} {% endif %}

View File

@@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block title %}Подключение ВКонтакте — ЭВОСИНК{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-sm-10 col-md-7 col-lg-6">
<div class="card shadow-sm mt-4 text-center">
<div class="card-body py-5">
<div id="state-loading">
<div class="spinner-border text-primary mb-3" role="status"></div>
<p class="text-muted mb-0">Подключение ВКонтакте…</p>
</div>
<div id="state-error" class="d-none">
<i class="bi bi-exclamation-triangle-fill text-danger fs-1 mb-3 d-block"></i>
<p class="text-muted mb-3" id="error-message">Не удалось получить токен от ВКонтакте.</p>
<a href="/vk" class="btn btn-outline-secondary">Попробовать снова</a>
</div>
</div>
</div>
</div>
</div>
<form id="token-form" method="post" action="/vk/token" class="d-none">
<input type="hidden" name="token" id="token-input">
</form>
<script>
(function () {
var hash = window.location.hash.slice(1);
var params = {};
hash.split("&").forEach(function (part) {
var kv = part.split("=");
params[decodeURIComponent(kv[0])] = decodeURIComponent(kv[1] || "");
});
if (params.access_token) {
document.getElementById("token-input").value = params.access_token;
document.getElementById("token-form").submit();
} else {
var msg = params.error_description || params.error || "Авторизация отклонена.";
document.getElementById("error-message").textContent = msg;
document.getElementById("state-loading").classList.add("d-none");
document.getElementById("state-error").classList.remove("d-none");
}
})();
</script>
{% endblock %}

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