From 865798967a614f2c9b0c1a2125700dcd4b23c4a5 Mon Sep 17 00:00:00 2001 From: mguschin Date: Thu, 5 Mar 2026 21:33:41 +0300 Subject: [PATCH] feat: add Evotor OAuth connection feature with formatted phone input - Add EvotorConnection model to store user's Evotor access tokens - Implement OAuth 2.0 flow: /evotor (view), /evotor/connect, /evotor/callback, /evotor/disconnect - Add Evotor connection page with connected/disconnected states - Implement phone input masking (+7 (XXX) XXX-XX-XX) using Inputmask - Add Russian validation messages for form fields - Update phone validator to match masked format - Add httpx dependency for async OAuth token exchange - Add Evotor settings to config: CLIENT_ID, CLIENT_SECRET, SCOPES Co-Authored-By: Claude Haiku 4.5 --- requirements.txt | 1 + web/config.py | 10 +++ web/main.py | 5 +- web/models.py | 19 ++++- web/routes/evotor.py | 153 ++++++++++++++++++++++++++++++++++++++ web/schemas.py | 8 +- web/templates/base.html | 28 +++++++ web/templates/evotor.html | 86 +++++++++++++++++++++ 8 files changed, 303 insertions(+), 7 deletions(-) create mode 100644 web/routes/evotor.py create mode 100644 web/templates/evotor.html diff --git a/requirements.txt b/requirements.txt index 98a6112..f3b3e7e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,4 @@ passlib[bcrypt]==1.7.4 bcrypt==4.2.0 pydantic-settings==2.5.2 itsdangerous==2.1.2 +httpx==0.27.2 diff --git a/web/config.py b/web/config.py index 7916f41..1f456c9 100644 --- a/web/config.py +++ b/web/config.py @@ -7,6 +7,16 @@ class Settings(BaseSettings): BASE_URL: str = "http://localhost:8000" PASSWORD_RESET_EXPIRE_MINUTES: int = 60 + EVOTOR_CLIENT_ID: str = "" + EVOTOR_CLIENT_SECRET: str = "" + EVOTOR_SCOPES: str = "store:read product:read" + + # Docker compose vars (ignored in app, kept for env compatibility) + DB_ROOT_PASSWORD: str = "" + DB_NAME: str = "" + DB_USER: str = "" + DB_PASSWORD: str = "" + model_config = {"env_file": ".env", "case_sensitive": False} diff --git a/web/main.py b/web/main.py index a317f06..8b73253 100644 --- a/web/main.py +++ b/web/main.py @@ -4,8 +4,8 @@ from starlette.middleware.sessions import SessionMiddleware from web.config import settings from web.database import engine, Base -from web.models import User # noqa: F401 — registers model with Base -from web.routes import auth, profile, reset +from web.models import User, EvotorConnection # noqa: F401 — registers models with Base +from web.routes import auth, profile, reset, evotor app = FastAPI(title="EvoSync — Личный кабинет") @@ -15,6 +15,7 @@ app.mount("/static", StaticFiles(directory="web/static"), name="static") app.include_router(auth.router) app.include_router(profile.router) app.include_router(reset.router) +app.include_router(evotor.router) @app.on_event("startup") diff --git a/web/models.py b/web/models.py index d584ca9..353eb47 100644 --- a/web/models.py +++ b/web/models.py @@ -1,4 +1,5 @@ -from sqlalchemy import Column, Integer, String, Boolean, DateTime +from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text +from sqlalchemy.orm import relationship from sqlalchemy.sql import func from web.database import Base @@ -19,3 +20,19 @@ class User(Base): password_reset_expires = Column(DateTime, nullable=True) created_at = Column(DateTime, server_default=func.now(), nullable=False) updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False) + + evotor_connection = relationship("EvotorConnection", back_populates="user", uselist=False) + + +class EvotorConnection(Base): + __tablename__ = "evotor_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) + store_id = Column(String(255), nullable=True) + store_name = Column(String(255), 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="evotor_connection") diff --git a/web/routes/evotor.py b/web/routes/evotor.py new file mode 100644 index 0000000..c2b756e --- /dev/null +++ b/web/routes/evotor.py @@ -0,0 +1,153 @@ +import secrets +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, EvotorConnection + +router = APIRouter(prefix="/evotor") +templates = Jinja2Templates(directory="web/templates") + +EVOTOR_AUTHORIZE_URL = "https://oauth.evotor.ru/oauth/authorize" +EVOTOR_TOKEN_URL = "https://oauth.evotor.ru/oauth/token" +EVOTOR_STORES_URL = "https://api.evotor.ru/stores" + + +def _redirect_uri() -> str: + return f"{settings.BASE_URL}/evotor/callback" + + +@router.get("") +def evotor_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(EvotorConnection).filter(EvotorConnection.user_id == user.id).first() + error = request.query_params.get("error") + return templates.TemplateResponse("evotor.html", { + "request": request, + "user": user, + "connection": connection, + "error": error, + }) + + +@router.get("/connect") +def evotor_connect(request: Request, user: User | None = Depends(get_current_user)): + if not user: + return RedirectResponse("/login", 303) + + state = secrets.token_urlsafe(32) + request.session["evotor_oauth_state"] = state + + params = ( + f"?client_id={settings.EVOTOR_CLIENT_ID}" + f"&response_type=code" + f"&redirect_uri={_redirect_uri()}" + f"&scope={settings.EVOTOR_SCOPES.replace(' ', '%20')}" + f"&state={state}" + ) + return RedirectResponse(EVOTOR_AUTHORIZE_URL + params, 302) + + +@router.get("/callback") +async def evotor_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("evotor_oauth_state", None) + + if not code or not state or state != saved_state: + return RedirectResponse("/evotor?error=invalid_state", 303) + + # Exchange code for access token + try: + async with httpx.AsyncClient() as client: + token_response = await client.post( + EVOTOR_TOKEN_URL, + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": _redirect_uri(), + }, + auth=(settings.EVOTOR_CLIENT_ID, settings.EVOTOR_CLIENT_SECRET), + timeout=15, + ) + token_response.raise_for_status() + token_data = token_response.json() + except Exception: + return RedirectResponse("/evotor?error=token_exchange", 303) + + access_token = token_data.get("access_token") + if not access_token: + return RedirectResponse("/evotor?error=no_token", 303) + + # Fetch first store to save store info + store_id = None + store_name = None + try: + async with httpx.AsyncClient() as client: + stores_response = await client.get( + EVOTOR_STORES_URL, + headers={"Authorization": f"Bearer {access_token}"}, + timeout=15, + ) + if stores_response.status_code == 200: + stores = stores_response.json() + items = stores.get("items", stores) if isinstance(stores, dict) else stores + if items: + store_id = items[0].get("uuid") or items[0].get("id") + store_name = items[0].get("name") + except Exception: + pass # Store info is optional; token is still saved + + # Save or update connection + connection = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first() + if connection: + connection.access_token = access_token + connection.store_id = store_id + connection.store_name = store_name + else: + connection = EvotorConnection( + user_id=user.id, + access_token=access_token, + store_id=store_id, + store_name=store_name, + ) + db.add(connection) + db.commit() + + return RedirectResponse("/evotor", 303) + + +@router.post("/disconnect") +async def evotor_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(EvotorConnection).filter(EvotorConnection.user_id == user.id).first() + if connection: + db.delete(connection) + db.commit() + + return RedirectResponse("/evotor", 303) diff --git a/web/schemas.py b/web/schemas.py index 34af63b..cb1cc70 100644 --- a/web/schemas.py +++ b/web/schemas.py @@ -11,8 +11,8 @@ def validate_registration(data: dict) -> list[str]: if not email or not re.match(r"^[^@]+@[^@]+\.[^@]+$", email): errors.append("Введите корректный email") phone = data.get("phone", "").strip() - if not phone or not re.match(r"^\+?[\d\s\-()]{7,20}$", phone): - errors.append("Введите корректный телефон") + if not phone or not re.match(r"^\+7 \(\d{3}\) \d{3}-\d{2}-\d{2}$", phone): + errors.append("Введите телефон в формате +7 (XXX) XXX-XX-XX") password = data.get("password", "") if len(password) < 8: errors.append("Пароль должен быть не менее 8 символов") @@ -47,6 +47,6 @@ def validate_profile(data: dict) -> list[str]: if not data.get("last_name", "").strip(): errors.append("Введите фамилию") phone = data.get("phone", "").strip() - if not phone or not re.match(r"^\+?[\d\s\-()]{7,20}$", phone): - errors.append("Введите корректный телефон") + if not phone or not re.match(r"^\+7 \(\d{3}\) \d{3}-\d{2}-\d{2}$", phone): + errors.append("Введите телефон в формате +7 (XXX) XXX-XX-XX") return errors diff --git a/web/templates/base.html b/web/templates/base.html index 0f9da67..6fa8865 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -18,6 +18,9 @@