Compare commits
11 Commits
background
...
v1.9.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db0c1cbed3 | ||
|
|
fd7d0022ea | ||
|
|
1bf82adbfc | ||
|
|
9a68c083e3 | ||
|
|
aaeaa4f658 | ||
|
|
aea28ead9c | ||
|
|
cde2069d74 | ||
|
|
debb2efb3d | ||
|
|
4d4d5b0118 | ||
|
|
00b74b8aa9 | ||
|
|
577c5de200 |
@@ -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
161
README.md
@@ -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
|
||||||
|
```
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
18
web/main.py
18
web/main.py
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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 = [
|
||||||
{
|
{
|
||||||
@@ -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,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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
184
web/routes/vk.py
184
web/routes/vk.py
@@ -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
485
web/sync_engine.py
Normal 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)
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|
||||||
|
|||||||
47
web/templates/vk_callback.html
Normal file
47
web/templates/vk_callback.html
Normal 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
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