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 <noreply@anthropic.com>
This commit is contained in:
mguschin
2026-03-05 21:33:41 +03:00
parent d486ba1f83
commit 865798967a
8 changed files with 303 additions and 7 deletions

View File

@@ -9,3 +9,4 @@ passlib[bcrypt]==1.7.4
bcrypt==4.2.0 bcrypt==4.2.0
pydantic-settings==2.5.2 pydantic-settings==2.5.2
itsdangerous==2.1.2 itsdangerous==2.1.2
httpx==0.27.2

View File

@@ -7,6 +7,16 @@ class Settings(BaseSettings):
BASE_URL: str = "http://localhost:8000" BASE_URL: str = "http://localhost:8000"
PASSWORD_RESET_EXPIRE_MINUTES: int = 60 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} model_config = {"env_file": ".env", "case_sensitive": False}

View File

@@ -4,8 +4,8 @@ from starlette.middleware.sessions import SessionMiddleware
from web.config import settings from web.config import settings
from web.database import engine, Base from web.database import engine, Base
from web.models import User # noqa: F401 — registers model with Base from web.models import User, EvotorConnection # noqa: F401 — registers models with Base
from web.routes import auth, profile, reset from web.routes import auth, profile, reset, evotor
app = FastAPI(title="EvoSync — Личный кабинет") 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(auth.router)
app.include_router(profile.router) app.include_router(profile.router)
app.include_router(reset.router) app.include_router(reset.router)
app.include_router(evotor.router)
@app.on_event("startup") @app.on_event("startup")

View File

@@ -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 sqlalchemy.sql import func
from web.database import Base from web.database import Base
@@ -19,3 +20,19 @@ class User(Base):
password_reset_expires = Column(DateTime, nullable=True) password_reset_expires = Column(DateTime, nullable=True)
created_at = Column(DateTime, server_default=func.now(), nullable=False) created_at = Column(DateTime, server_default=func.now(), nullable=False)
updated_at = Column(DateTime, server_default=func.now(), onupdate=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")

153
web/routes/evotor.py Normal file
View File

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

View File

@@ -11,8 +11,8 @@ def validate_registration(data: dict) -> list[str]:
if not email or not re.match(r"^[^@]+@[^@]+\.[^@]+$", email): if not email or not re.match(r"^[^@]+@[^@]+\.[^@]+$", email):
errors.append("Введите корректный email") errors.append("Введите корректный email")
phone = data.get("phone", "").strip() phone = data.get("phone", "").strip()
if not phone or not re.match(r"^\+?[\d\s\-()]{7,20}$", phone): if not phone or not re.match(r"^\+7 \(\d{3}\) \d{3}-\d{2}-\d{2}$", phone):
errors.append("Введите корректный телефон") errors.append("Введите телефон в формате +7 (XXX) XXX-XX-XX")
password = data.get("password", "") password = data.get("password", "")
if len(password) < 8: if len(password) < 8:
errors.append("Пароль должен быть не менее 8 символов") errors.append("Пароль должен быть не менее 8 символов")
@@ -47,6 +47,6 @@ def validate_profile(data: dict) -> list[str]:
if not data.get("last_name", "").strip(): if not data.get("last_name", "").strip():
errors.append("Введите фамилию") errors.append("Введите фамилию")
phone = data.get("phone", "").strip() phone = data.get("phone", "").strip()
if not phone or not re.match(r"^\+?[\d\s\-()]{7,20}$", phone): if not phone or not re.match(r"^\+7 \(\d{3}\) \d{3}-\d{2}-\d{2}$", phone):
errors.append("Введите корректный телефон") errors.append("Введите телефон в формате +7 (XXX) XXX-XX-XX")
return errors return errors

View File

@@ -18,6 +18,9 @@
<div class="collapse navbar-collapse" id="navbarNav"> <div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto"> <ul class="navbar-nav ms-auto">
{% if user %} {% if user %}
<li class="nav-item">
<a href="/evotor" class="nav-link">Эвотор</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a href="/profile" class="nav-link"><i class="bi bi-person-circle me-1"></i>Личный кабинет</a> <a href="/profile" class="nav-link"><i class="bi bi-person-circle me-1"></i>Личный кабинет</a>
</li> </li>
@@ -56,5 +59,30 @@
</main> </main>
<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>
document.addEventListener('DOMContentLoaded', function() {
var phoneInputs = document.querySelectorAll('input[name="phone"]');
if (phoneInputs.length) {
Inputmask('+7 (999) 999-99-99', {
placeholder: '_',
showMaskOnHover: false,
clearMaskOnLostFocus: false
}).mask(phoneInputs);
}
});
</script>
<script>
document.addEventListener('invalid', function(e) {
if (e.target.validity.valueMissing) {
e.target.setCustomValidity('Пожалуйста, заполните это поле');
} else if (e.target.validity.typeMismatch) {
e.target.setCustomValidity('Пожалуйста, введите корректное значение');
}
}, true);
document.addEventListener('input', function(e) {
if (e.target.required) e.target.setCustomValidity('');
}, true);
</script>
</body> </body>
</html> </html>

86
web/templates/evotor.html Normal file
View File

@@ -0,0 +1,86 @@
{% extends "base.html" %}
{% block title %}Подключение Эвотор — EvoSync{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-sm-10 col-md-7 col-lg-6">
{% if error %}
<div class="alert alert-danger mt-4">
{% if error == "invalid_state" %}
<i class="bi bi-exclamation-triangle me-2"></i>Ошибка безопасности. Попробуйте подключить аккаунт заново.
{% elif error == "token_exchange" %}
<i class="bi bi-exclamation-triangle me-2"></i>Не удалось получить токен доступа от Эвотор. Попробуйте позже.
{% elif error == "no_token" %}
<i class="bi bi-exclamation-triangle me-2"></i>Эвотор не вернул токен доступа. Попробуйте позже.
{% else %}
<i class="bi bi-exclamation-triangle me-2"></i>Произошла ошибка при подключении: {{ error }}
{% endif %}
</div>
{% endif %}
<div class="card shadow-sm mt-4">
<div class="card-header">
<h1 class="h5 mb-0">Подключение Эвотор</h1>
</div>
{% if connection %}
{# ── CONNECTED STATE ── #}
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="text-muted small">Статус</span>
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Подключено</span>
</li>
{% if connection.store_name %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="text-muted small">Магазин</span>
<span>{{ connection.store_name }}</span>
</li>
{% endif %}
{% if connection.store_id %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="text-muted small">ID магазина</span>
<span class="font-monospace small text-muted">{{ connection.store_id }}</span>
</li>
{% endif %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="text-muted small">Подключено</span>
<span class="small">{{ connection.connected_at.strftime("%d.%m.%Y %H:%M") }}</span>
</li>
</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>
{% else %}
{# ── NOT CONNECTED STATE ── #}
<div class="card-body">
<p class="text-muted mb-3">
Подключите ваш аккаунт Эвотор, чтобы система могла автоматически синхронизировать
каталог товаров из вашей кассы в ВКонтакте.
</p>
<ul class="text-muted small mb-4">
<li>Вы будете перенаправлены на сайт Эвотор для авторизации</li>
<li>После подтверждения доступа синхронизация будет настроена автоматически</li>
<li>Вы можете отключить доступ в любой момент</li>
</ul>
<div class="d-grid">
<a href="/evotor/connect" class="btn btn-primary btn-lg">Подключить Эвотор</a>
</div>
</div>
{% endif %}
</div>
<div class="mt-3 text-center">
<a href="/profile" class="text-muted small">
<i class="bi bi-arrow-left me-1"></i>Вернуться в личный кабинет
</a>
</div>
</div>
</div>
{% endblock %}