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
pydantic-settings==2.5.2
itsdangerous==2.1.2
httpx==0.27.2

View File

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

View File

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

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

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

View File

@@ -18,6 +18,9 @@
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
{% if user %}
<li class="nav-item">
<a href="/evotor" class="nav-link">Эвотор</a>
</li>
<li class="nav-item">
<a href="/profile" class="nav-link"><i class="bi bi-person-circle me-1"></i>Личный кабинет</a>
</li>
@@ -56,5 +59,30 @@
</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/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>
</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 %}