Files
evo-sync/web/routes/connections.py
mguschin 7b4f52b005 feat: VK OAuth flow, catalog sync improvements, and expanded test suite
- Add VK OAuth implicit flow: /vk-auth redirect, /vk-callback JS page,
  /vk-callback/save endpoint with state validation
- Add VK_CLIENT_ID/VK_CLIENT_SECRET to config
- Add refresh_token/token_expires_at columns to vk_connections (migration 0006)
- Fix vk_catalog task: handle price/thumb_photo as string or dict (VK API v5.199)
- Fix connections/vk/test: use groups.getById instead of market.getAlbums
  (works with both user and group tokens)
- Add orphan deletion to mirror_to_vk: VK products not in Evotor are removed
- Handle ungrouped Evotor products: push to "Без категории" VK album
- Respect SyncConfig.is_enabled in mirror_to_vk
- Add product count column to catalog groups page
- Add group name column to catalog products page
- Expand test suite: 73 new tests covering connections routes, catalog routes,
  vk_sync task logic, and catalog task helpers (138 total, all passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 15:09:47 +03:00

376 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import secrets
from datetime import datetime, timedelta, timezone
from urllib.parse import urlencode
import httpx
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from sqlalchemy.orm import Session
from web.auth.session import get_current_user
from web.config import settings
from web.database import get_db
from web.models.connections import EvotorConnection, VkConnection
from web.templates_env import templates
VK_SCOPE = 335876 # photos(4) + wall(8192) + groups(262144) + offline(65536)
router = APIRouter()
def _render(request: Request, template: str, ctx: dict) -> HTMLResponse:
ctx["request"] = request
ctx.setdefault("jivosite_widget_id", settings.JIVOSITE_WIDGET_ID)
return templates.TemplateResponse(ctx.pop("request"), template, ctx)
def _now() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
@router.get("/connections")
async def connections_get(request: Request, db: Session = Depends(get_db)):
try:
user = get_current_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
evotor = db.query(EvotorConnection).filter_by(user_id=user.id).first()
vk = db.query(VkConnection).filter_by(user_id=user.id).first()
return _render(request, "connections.html", {"user": user, "evotor": evotor, "vk": vk})
@router.post("/connections/evotor")
async def connections_evotor_post(request: Request, db: Session = Depends(get_db)):
try:
user = get_current_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
form = await request.form()
access_token = str(form.get("access_token", "")).strip()
evotor_user_id = str(form.get("evotor_user_id", "")).strip() or None
if not access_token:
evotor = db.query(EvotorConnection).filter_by(user_id=user.id).first()
return _render(request, "connections.html", {
"user": user,
"evotor": evotor,
"errors": ["API-токен обязателен"],
})
now = _now()
conn = db.query(EvotorConnection).filter_by(user_id=user.id).first()
if conn:
conn.access_token = access_token
if evotor_user_id:
conn.evotor_user_id = evotor_user_id
conn.updated_at = now
else:
conn = EvotorConnection(
user_id=user.id,
evotor_user_id=evotor_user_id,
access_token=access_token,
api_token=secrets.token_urlsafe(32),
connected_at=now,
updated_at=now,
)
db.add(conn)
if evotor_user_id and not user.evotor_user_id:
user.evotor_user_id = evotor_user_id
db.commit()
return RedirectResponse("/connections?success=1", 303)
@router.post("/connections/evotor/disconnect")
async def connections_evotor_disconnect(request: Request, db: Session = Depends(get_db)):
try:
user = get_current_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
conn = db.query(EvotorConnection).filter_by(user_id=user.id).first()
if conn:
db.delete(conn)
db.commit()
return RedirectResponse("/connections", 303)
@router.post("/connections/vk")
async def connections_vk_post(request: Request, db: Session = Depends(get_db)):
try:
user = get_current_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
form = await request.form()
access_token = str(form.get("access_token", "")).strip()
vk_group_id = str(form.get("vk_group_id", "")).strip() or None
if not access_token:
evotor = db.query(EvotorConnection).filter_by(user_id=user.id).first()
vk = db.query(VkConnection).filter_by(user_id=user.id).first()
return _render(request, "connections.html", {
"user": user,
"evotor": evotor,
"vk": vk,
"errors": ["Токен VK обязателен"],
})
now = _now()
conn = db.query(VkConnection).filter_by(user_id=user.id).first()
if conn:
conn.access_token = access_token
if vk_group_id:
conn.vk_user_id = vk_group_id
conn.updated_at = now
else:
conn = VkConnection(
user_id=user.id,
access_token=access_token,
vk_user_id=vk_group_id,
connected_at=now,
updated_at=now,
)
db.add(conn)
db.commit()
return RedirectResponse("/connections?success=1", 303)
@router.get("/vk-auth")
async def vk_auth(request: Request):
try:
get_current_user(request, next(get_db()))
except Exception:
return RedirectResponse("/login", 303)
if not settings.VK_CLIENT_ID:
return RedirectResponse("/connections?error=vk_not_configured", 303)
state = secrets.token_urlsafe(16)
request.session["vk_oauth_state"] = state
redirect_uri = f"{settings.BASE_URL}/vk-callback"
params = urlencode({
"client_id": settings.VK_CLIENT_ID,
"redirect_uri": redirect_uri,
"scope": VK_SCOPE,
"response_type": "token",
"display": "page",
"state": state,
"revoke": "1",
})
return RedirectResponse(f"https://oauth.vk.com/authorize?{params}", 302)
@router.get("/vk-callback")
async def vk_callback_page(request: Request):
"""Serves the callback page that reads the token from the URL fragment and POSTs it."""
return HTMLResponse("""<!DOCTYPE html>
<html lang="ru">
<head><meta charset="utf-8"><title>VK авторизация…</title>
<style>
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center;
min-height: 100vh; margin: 0; background: #f4f4f4; }
.box { background: #fff; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,.1);
text-align: center; max-width: 360px; }
.spinner { width: 36px; height: 36px; border: 4px solid #e0e0e0;
border-top-color: #0077ff; border-radius: 50%;
animation: spin .8s linear infinite; margin: 0 auto 1rem; }
@keyframes spin { to { transform: rotate(360deg); } }
.error { color: #c0392b; }
</style>
</head>
<body>
<div class="box" id="box">
<div class="spinner"></div>
<p>Завершаем авторизацию…</p>
</div>
<script>
(function() {
const hash = window.location.hash.slice(1);
const params = new URLSearchParams(hash);
const token = params.get('access_token');
const state = params.get('state');
const userId = params.get('user_id');
const expiresIn = params.get('expires_in');
if (!token) {
document.getElementById('box').innerHTML =
'<p class="error">Токен не получен. <a href="/connections">Вернуться назад</a></p>';
return;
}
fetch('/vk-callback/save', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ access_token: token, state: state,
user_id: userId, expires_in: expiresIn })
})
.then(r => r.json())
.then(data => {
if (data.ok) {
window.location.href = '/connections?success=1';
} else {
document.getElementById('box').innerHTML =
'<p class="error">' + (data.message || 'Ошибка сохранения') +
' <a href="/connections">Вернуться назад</a></p>';
}
})
.catch(() => {
document.getElementById('box').innerHTML =
'<p class="error">Ошибка сети. <a href="/connections">Вернуться назад</a></p>';
});
})();
</script>
</body>
</html>""")
@router.post("/vk-callback/save")
async def vk_callback_save(request: Request, db: Session = Depends(get_db)):
try:
user = get_current_user(request, db)
except Exception:
return JSONResponse({"ok": False, "message": "Сессия истекла, войдите снова"}, status_code=401)
body = await request.json()
access_token = (body.get("access_token") or "").strip()
state = body.get("state") or ""
vk_user_id = str(body.get("user_id") or "").strip() or None
expires_in = body.get("expires_in")
expected_state = request.session.pop("vk_oauth_state", None)
if not expected_state or state != expected_state:
return JSONResponse({"ok": False, "message": "Недействительный state, попробуйте снова"})
if not access_token:
return JSONResponse({"ok": False, "message": "Токен не получен"})
token_expires_at = None
if expires_in and str(expires_in) != "0":
try:
token_expires_at = _now() + timedelta(seconds=int(expires_in))
except (ValueError, TypeError):
pass
now = _now()
conn = db.query(VkConnection).filter_by(user_id=user.id).first()
if conn:
conn.access_token = access_token
conn.token_expires_at = token_expires_at
if vk_user_id:
conn.vk_user_id = vk_user_id
conn.updated_at = now
else:
conn = VkConnection(
user_id=user.id,
access_token=access_token,
token_expires_at=token_expires_at,
vk_user_id=vk_user_id,
connected_at=now,
updated_at=now,
)
db.add(conn)
db.commit()
return JSONResponse({"ok": True})
@router.post("/connections/vk/disconnect")
async def connections_vk_disconnect(request: Request, db: Session = Depends(get_db)):
try:
user = get_current_user(request, db)
except Exception:
return RedirectResponse("/login", 303)
conn = db.query(VkConnection).filter_by(user_id=user.id).first()
if conn:
db.delete(conn)
db.commit()
return RedirectResponse("/connections", 303)
@router.post("/connections/evotor/test")
async def connections_evotor_test(request: Request, db: Session = Depends(get_db)):
try:
user = get_current_user(request, db)
except Exception:
return JSONResponse({"ok": False, "message": "Не авторизован"}, status_code=401)
conn = db.query(EvotorConnection).filter_by(user_id=user.id).first()
if not conn:
return JSONResponse({"ok": False, "message": "Подключение не настроено"})
try:
r = httpx.get(
"https://api.evotor.ru/stores",
headers={
"Authorization": f"Bearer {conn.access_token}",
"Accept": "application/vnd.evotor.v2+json",
},
timeout=10,
)
if r.status_code == 200:
data = r.json()
items = data.get("items", data) if isinstance(data, dict) else data
count = len(items) if isinstance(items, list) else "?"
return JSONResponse({"ok": True, "message": f"Успешно. Найдено магазинов: {count}"})
elif r.status_code == 401:
return JSONResponse({"ok": False, "message": "Токен недействителен (401)"})
else:
return JSONResponse({"ok": False, "message": f"Ошибка API: HTTP {r.status_code}"})
except httpx.TimeoutException:
return JSONResponse({"ok": False, "message": "Таймаут запроса к Эвотор"})
except Exception as e:
return JSONResponse({"ok": False, "message": f"Ошибка: {e}"})
@router.post("/connections/vk/test")
async def connections_vk_test(request: Request, db: Session = Depends(get_db)):
try:
user = get_current_user(request, db)
except Exception:
return JSONResponse({"ok": False, "message": "Не авторизован"}, status_code=401)
conn = db.query(VkConnection).filter_by(user_id=user.id).first()
if not conn:
return JSONResponse({"ok": False, "message": "Подключение не настроено"})
try:
if not conn.vk_user_id:
return JSONResponse({"ok": False, "message": "Укажите ID сообщества для проверки подключения."})
r = httpx.get(
"https://api.vk.com/method/groups.getById",
params={
"group_id": conn.vk_user_id,
"fields": "market",
"access_token": conn.access_token,
"v": settings.VK_API_VERSION,
},
timeout=10,
)
data = r.json()
if "error" in data:
code = data["error"].get("error_code")
msg = data["error"].get("error_msg", "Неизвестная ошибка")
return JSONResponse({"ok": False, "message": f"Ошибка VK API ({code}): {msg}"})
groups = data.get("response", {}).get("groups", [])
if not groups:
return JSONResponse({"ok": False, "message": "Сообщество не найдено"})
group = groups[0]
name = group.get("name", "")
market = group.get("market", {})
market_status = "включён" if market.get("enabled") else "выключен"
return JSONResponse({"ok": True, "message": f"Успешно. Сообщество: «{name}», Маркет {market_status}"})
except httpx.TimeoutException:
return JSONResponse({"ok": False, "message": "Таймаут запроса к VK"})
except Exception as e:
return JSONResponse({"ok": False, "message": f"Ошибка: {e}"})