- 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>
376 lines
13 KiB
Python
376 lines
13 KiB
Python
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}"})
|
||
|