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:
@@ -2,6 +2,7 @@ from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from web.auth.session import get_current_user
|
||||
@@ -99,9 +100,19 @@ async def catalog_groups(store_evotor_id: str, request: Request, db: Session = D
|
||||
.all()
|
||||
)
|
||||
enabled_ids = _enabled_group_ids(db, user.id, store_evotor_id)
|
||||
|
||||
counts_q = (
|
||||
db.query(CachedProduct.group_evotor_id, func.count().label("cnt"))
|
||||
.filter(CachedProduct.user_id == user.id, CachedProduct.store_evotor_id == store_evotor_id)
|
||||
.group_by(CachedProduct.group_evotor_id)
|
||||
.all()
|
||||
)
|
||||
product_counts = {row.group_evotor_id: row.cnt for row in counts_q}
|
||||
|
||||
return _render(request, "catalog/groups.html", {
|
||||
"user": user, "store": store, "groups": groups,
|
||||
"enabled_ids": enabled_ids,
|
||||
"product_counts": product_counts,
|
||||
})
|
||||
|
||||
|
||||
@@ -135,12 +146,14 @@ async def catalog_products(store_evotor_id: str, request: Request, db: Session =
|
||||
.order_by(CachedGroup.name)
|
||||
.all()
|
||||
)
|
||||
group_map = {g.evotor_id: g.name for g in groups}
|
||||
return _render(request, "catalog/products.html", {
|
||||
"user": user,
|
||||
"store": store,
|
||||
"products": products,
|
||||
"groups": groups,
|
||||
"group_id": group_id,
|
||||
"group_map": group_map,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user