From cfc7229daf8a9526aceb4e15e758a3e33fc9196e Mon Sep 17 00:00:00 2001 From: mguschin Date: Fri, 6 Mar 2026 15:29:42 +0300 Subject: [PATCH] feat: add VK OAuth connection with health checks - Add VkConnection model with is_online/last_checked_at fields - Add /vk OAuth flow (connect/callback/disconnect/page) - Add VK entry to connections dashboard - Extend background health checker to check VK tokens via users.get - Add Alembic migration for vk_connections table - Add VK_CLIENT_ID/SECRET/SCOPES/API_VERSION config settings Co-Authored-By: Claude Sonnet 4.6 --- web/config.py | 5 + web/health_checker.py | 39 ++++- web/main.py | 3 +- .../b2c3d4e5f6a7_add_vk_connections_table.py | 37 ++++ web/models.py | 18 ++ web/routes/connections.py | 15 +- web/routes/vk.py | 164 ++++++++++++++++++ web/templates/vk.html | 86 +++++++++ 8 files changed, 358 insertions(+), 9 deletions(-) create mode 100644 web/migrations/versions/b2c3d4e5f6a7_add_vk_connections_table.py create mode 100644 web/routes/vk.py create mode 100644 web/templates/vk.html diff --git a/web/config.py b/web/config.py index f122e49..cd9c71f 100644 --- a/web/config.py +++ b/web/config.py @@ -13,6 +13,11 @@ class Settings(BaseSettings): HEALTH_CHECK_INTERVAL_SECONDS: int = 600 + VK_CLIENT_ID: str = "" + VK_CLIENT_SECRET: str = "" + VK_SCOPES: str = "market groups offline" + VK_API_VERSION: str = "5.131" + # Docker compose vars (ignored in app, kept for env compatibility) DB_ROOT_PASSWORD: str = "" DB_NAME: str = "" diff --git a/web/health_checker.py b/web/health_checker.py index 371b561..a0b6b9c 100644 --- a/web/health_checker.py +++ b/web/health_checker.py @@ -5,11 +5,13 @@ from datetime import datetime import httpx from web.database import SessionLocal -from web.models import EvotorConnection +from web.models import EvotorConnection, VkConnection logger = logging.getLogger("uvicorn.error") EVOTOR_STORES_URL = "https://api.evotor.ru/stores" +VK_USERS_GET_URL = "https://api.vk.com/method/users.get" +VK_API_VERSION = "5.131" async def check_evotor_connection(access_token: str) -> bool: @@ -25,16 +27,41 @@ async def check_evotor_connection(access_token: str) -> bool: return False +async def check_vk_connection(access_token: str) -> bool: + try: + async with httpx.AsyncClient() as client: + resp = await client.get( + VK_USERS_GET_URL, + params={"access_token": access_token, "v": VK_API_VERSION}, + timeout=10, + ) + if resp.status_code != 200: + return False + data = resp.json() + return "error" not in data + except Exception: + return False + + async def run_health_checks() -> None: db = SessionLocal() try: - connections = db.query(EvotorConnection).all() - for conn in connections: - is_online = await check_evotor_connection(conn.access_token) - conn.is_online = is_online + evotor_connections = db.query(EvotorConnection).all() + for conn in evotor_connections: + conn.is_online = await check_evotor_connection(conn.access_token) conn.last_checked_at = datetime.utcnow() + + vk_connections = db.query(VkConnection).all() + for conn in vk_connections: + conn.is_online = await check_vk_connection(conn.access_token) + conn.last_checked_at = datetime.utcnow() + db.commit() - logger.info("Health checks completed for %d connection(s)", len(connections)) + logger.info( + "Health checks completed: %d Evotor, %d VK", + len(evotor_connections), + len(vk_connections), + ) except Exception: logger.exception("Error during health checks") db.rollback() diff --git a/web/main.py b/web/main.py index 85db4f9..545e9a0 100644 --- a/web/main.py +++ b/web/main.py @@ -10,7 +10,7 @@ from web.auth import get_current_user from web.config import settings from web.health_checker import health_check_loop from web.models import User -from web.routes import auth, profile, reset, evotor +from web.routes import auth, profile, reset, evotor, vk from web.routes import connections @@ -35,6 +35,7 @@ app.include_router(profile.router) app.include_router(reset.router) app.include_router(evotor.router) app.include_router(connections.router) +app.include_router(vk.router) @app.get("/") diff --git a/web/migrations/versions/b2c3d4e5f6a7_add_vk_connections_table.py b/web/migrations/versions/b2c3d4e5f6a7_add_vk_connections_table.py new file mode 100644 index 0000000..c4d8bda --- /dev/null +++ b/web/migrations/versions/b2c3d4e5f6a7_add_vk_connections_table.py @@ -0,0 +1,37 @@ +"""add vk_connections table + +Revision ID: b2c3d4e5f6a7 +Revises: a1b2c3d4e5f6 +Create Date: 2026-03-06 00:01:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +revision = 'b2c3d4e5f6a7' +down_revision = 'a1b2c3d4e5f6' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'vk_connections', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('access_token', sa.Text(), nullable=False), + sa.Column('vk_user_id', sa.String(50), nullable=True), + sa.Column('first_name', sa.String(255), nullable=True), + sa.Column('last_name', sa.String(255), nullable=True), + sa.Column('is_online', sa.Boolean(), nullable=False, server_default='0'), + sa.Column('last_checked_at', sa.DateTime(), nullable=True), + sa.Column('connected_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id'), + ) + + +def downgrade() -> None: + op.drop_table('vk_connections') diff --git a/web/models.py b/web/models.py index 5380ad4..544d99c 100644 --- a/web/models.py +++ b/web/models.py @@ -22,6 +22,7 @@ class User(Base): updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False) evotor_connection = relationship("EvotorConnection", back_populates="user", uselist=False) + vk_connection = relationship("VkConnection", back_populates="user", uselist=False) class EvotorConnection(Base): @@ -38,3 +39,20 @@ class EvotorConnection(Base): updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False) user = relationship("User", back_populates="evotor_connection") + + +class VkConnection(Base): + __tablename__ = "vk_connections" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False) + access_token = Column(Text, nullable=False) + vk_user_id = Column(String(50), nullable=True) + first_name = Column(String(255), nullable=True) + last_name = Column(String(255), nullable=True) + is_online = Column(Boolean, default=False, server_default="0", nullable=False) + last_checked_at = Column(DateTime, nullable=True) + connected_at = Column(DateTime, server_default=func.now(), nullable=False) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False) + + user = relationship("User", back_populates="vk_connection") diff --git a/web/routes/connections.py b/web/routes/connections.py index c17127f..57cbe7e 100644 --- a/web/routes/connections.py +++ b/web/routes/connections.py @@ -5,7 +5,7 @@ from sqlalchemy.orm import Session from web.auth import get_current_user from web.database import get_db -from web.models import User, EvotorConnection +from web.models import User, EvotorConnection, VkConnection router = APIRouter() templates = Jinja2Templates(directory="web/templates") @@ -21,6 +21,7 @@ def connections_page( return RedirectResponse("/login", 303) evotor = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first() + vk = db.query(VkConnection).filter(VkConnection.user_id == user.id).first() connections = [ { @@ -32,7 +33,17 @@ def connections_page( "details": evotor.store_name if evotor else None, "connect_url": "/evotor/connect", "disconnect_url": "/evotor/disconnect", - } + }, + { + "name": "ВКонтакте", + "icon": "bi-chat-dots", + "connected": vk is not None, + "is_online": vk.is_online if vk else False, + "last_checked_at": vk.last_checked_at if vk else None, + "details": f"{vk.first_name} {vk.last_name}".strip() if vk and vk.first_name else None, + "connect_url": "/vk", + "disconnect_url": "/vk/disconnect", + }, ] return templates.TemplateResponse("connections.html", { diff --git a/web/routes/vk.py b/web/routes/vk.py new file mode 100644 index 0000000..2a473ac --- /dev/null +++ b/web/routes/vk.py @@ -0,0 +1,164 @@ +import secrets +from datetime import datetime + +import httpx + +from fastapi import APIRouter, Request, Depends +from fastapi.responses import RedirectResponse +from fastapi.templating import Jinja2Templates +from sqlalchemy.orm import Session + +from web.auth import get_current_user +from web.config import settings +from web.database import get_db +from web.models import User, VkConnection + +router = APIRouter(prefix="/vk") +templates = Jinja2Templates(directory="web/templates") + +VK_AUTHORIZE_URL = "https://oauth.vk.com/authorize" +VK_TOKEN_URL = "https://oauth.vk.com/access_token" +VK_API_URL = "https://api.vk.com/method" + + +def _redirect_uri() -> str: + return f"{settings.BASE_URL}/vk/callback" + + +@router.get("") +def vk_page( + request: Request, + db: Session = Depends(get_db), + user: User | None = Depends(get_current_user), +): + if not user: + return RedirectResponse("/login", 303) + + connection = db.query(VkConnection).filter(VkConnection.user_id == user.id).first() + error = request.query_params.get("error") + return templates.TemplateResponse("vk.html", { + "request": request, + "user": user, + "connection": connection, + "error": error, + }) + + +@router.get("/connect") +def vk_connect(request: Request, user: User | None = Depends(get_current_user)): + if not user: + return RedirectResponse("/login", 303) + + state = secrets.token_urlsafe(32) + request.session["vk_oauth_state"] = state + + params = ( + f"?client_id={settings.VK_CLIENT_ID}" + f"&response_type=code" + f"&redirect_uri={_redirect_uri()}" + f"&scope={settings.VK_SCOPES.replace(' ', '%20')}" + f"&state={state}" + f"&display=page" + f"&v={settings.VK_API_VERSION}" + ) + return RedirectResponse(VK_AUTHORIZE_URL + params, 302) + + +@router.get("/callback") +async def vk_callback( + request: Request, + db: Session = Depends(get_db), + user: User | None = Depends(get_current_user), +): + if not user: + return RedirectResponse("/login", 303) + + code = request.query_params.get("code") + state = request.query_params.get("state") + saved_state = request.session.pop("vk_oauth_state", None) + + if not code or not state or state != saved_state: + return RedirectResponse("/vk?error=invalid_state", 303) + + # Exchange code for token (VK uses GET with query params) + try: + async with httpx.AsyncClient() as client: + token_response = await client.get( + VK_TOKEN_URL, + params={ + "client_id": settings.VK_CLIENT_ID, + "client_secret": settings.VK_CLIENT_SECRET, + "code": code, + "redirect_uri": _redirect_uri(), + }, + timeout=15, + ) + token_response.raise_for_status() + token_data = token_response.json() + except Exception: + return RedirectResponse("/vk?error=token_exchange", 303) + + access_token = token_data.get("access_token") + vk_user_id = str(token_data.get("user_id", "")) or None + if not access_token: + return RedirectResponse("/vk?error=no_token", 303) + + # Fetch VK profile info + first_name = None + last_name = None + try: + async with httpx.AsyncClient() as client: + profile_response = await client.get( + f"{VK_API_URL}/users.get", + params={"access_token": access_token, "v": settings.VK_API_VERSION}, + timeout=15, + ) + if profile_response.status_code == 200: + profile_data = profile_response.json() + items = profile_data.get("response", []) + if items: + first_name = items[0].get("first_name") + last_name = items[0].get("last_name") + except Exception: + pass + + # Save or update connection + connection = db.query(VkConnection).filter(VkConnection.user_id == user.id).first() + if connection: + connection.access_token = access_token + connection.vk_user_id = vk_user_id + connection.first_name = first_name + connection.last_name = last_name + connection.is_online = True + connection.last_checked_at = datetime.utcnow() + else: + connection = VkConnection( + user_id=user.id, + access_token=access_token, + vk_user_id=vk_user_id, + first_name=first_name, + last_name=last_name, + is_online=True, + last_checked_at=datetime.utcnow(), + ) + db.add(connection) + db.commit() + + return RedirectResponse("/connections", 303) + + +@router.post("/disconnect") +async def vk_disconnect( + request: Request, + db: Session = Depends(get_db), + user: User | None = Depends(get_current_user), +): + if not user: + return RedirectResponse("/login", 303) + + connection = db.query(VkConnection).filter(VkConnection.user_id == user.id).first() + if connection: + db.delete(connection) + db.commit() + + return RedirectResponse("/connections", 303) diff --git a/web/templates/vk.html b/web/templates/vk.html new file mode 100644 index 0000000..c37d246 --- /dev/null +++ b/web/templates/vk.html @@ -0,0 +1,86 @@ +{% extends "base.html" %} +{% block title %}Подключение ВКонтакте — EvoSync{% endblock %} + +{% block content %} +
+
+ + {% if error %} +
+ {% if error == "invalid_state" %} + Ошибка безопасности. Попробуйте подключить аккаунт заново. + {% elif error == "token_exchange" %} + Не удалось получить токен доступа от ВКонтакте. Попробуйте позже. + {% elif error == "no_token" %} + ВКонтакте не вернул токен доступа. Попробуйте позже. + {% else %} + Произошла ошибка при подключении: {{ error }} + {% endif %} +
+ {% endif %} + +
+
+

Подключение ВКонтакте

+
+ + {% if connection %} + {# ── CONNECTED STATE ── #} +
    +
  • + Статус + Подключено +
  • + {% if connection.first_name or connection.last_name %} +
  • + Профиль + {{ connection.first_name }} {{ connection.last_name }} +
  • + {% endif %} + {% if connection.vk_user_id %} +
  • + ID пользователя + {{ connection.vk_user_id }} +
  • + {% endif %} +
  • + Подключено + {{ connection.connected_at.strftime("%d.%m.%Y %H:%M") }} +
  • +
+
+ Переподключить +
+ +
+
+ + {% else %} + {# ── NOT CONNECTED STATE ── #} +
+

+ Подключите ваш аккаунт ВКонтакте, чтобы система могла автоматически синхронизировать + каталог товаров из Эвотор в вашу группу ВКонтакте. +

+
    +
  • Вы будете перенаправлены на сайт ВКонтакте для авторизации
  • +
  • После подтверждения доступа синхронизация будет настроена автоматически
  • +
  • Вы можете отключить доступ в любой момент
  • +
+ +
+ {% endif %} + +
+ + + +
+
+{% endblock %}