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 %} +
+ Подключите ваш аккаунт ВКонтакте, чтобы система могла автоматически синхронизировать + каталог товаров из Эвотор в вашу группу ВКонтакте. +
+