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>
This commit is contained in:
mguschin
2026-05-12 15:09:47 +03:00
parent 4f4081c54c
commit 7b4f52b005
16 changed files with 1624 additions and 32 deletions

View File

@@ -1,5 +1,6 @@
import secrets
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from urllib.parse import urlencode
import httpx
from fastapi import APIRouter, Depends, Request
@@ -12,6 +13,8 @@ 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()
@@ -137,6 +140,146 @@ async def connections_vk_post(request: Request, db: Session = Depends(get_db)):
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:
@@ -198,16 +341,17 @@ async def connections_vk_test(request: Request, db: Session = Depends(get_db)):
return JSONResponse({"ok": False, "message": "Подключение не настроено"})
try:
params = {
"access_token": conn.access_token,
"v": settings.VK_API_VERSION,
}
if conn.vk_user_id:
params["group_ids"] = conn.vk_user_id
if not conn.vk_user_id:
return JSONResponse({"ok": False, "message": "Укажите ID сообщества для проверки подключения."})
r = httpx.get(
"https://api.vk.com/method/groups.getById",
params=params,
params={
"group_id": conn.vk_user_id,
"fields": "market",
"access_token": conn.access_token,
"v": settings.VK_API_VERSION,
},
timeout=10,
)
data = r.json()
@@ -217,11 +361,13 @@ async def connections_vk_test(request: Request, db: Session = Depends(get_db)):
return JSONResponse({"ok": False, "message": f"Ошибка VK API ({code}): {msg}"})
groups = data.get("response", {}).get("groups", [])
if groups:
name = groups[0].get("name", "")
return JSONResponse({"ok": True, "message": f"Успешно. Сообщество: «{name}»"})
else:
return JSONResponse({"ok": True, "message": "Токен действителен. Укажите ID сообщества для полной проверки."})
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: