2026-05-01 18:09:11 +03:00
|
|
|
|
import secrets
|
2026-05-12 15:09:47 +03:00
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
|
|
from urllib.parse import urlencode
|
2026-05-01 18:09:11 +03:00
|
|
|
|
|
|
|
|
|
|
import httpx
|
2026-05-12 22:00:14 +03:00
|
|
|
|
import web.lib.api_logger as api_logger
|
2026-05-01 18:09:11 +03:00
|
|
|
|
from fastapi import APIRouter, Depends, Request
|
|
|
|
|
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
|
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
|
2026-05-13 20:44:25 +03:00
|
|
|
|
from web.auth.session import get_current_user, get_viewed_user
|
2026-05-01 18:09:11 +03:00
|
|
|
|
from web.config import settings
|
|
|
|
|
|
from web.database import get_db
|
|
|
|
|
|
from web.models.connections import EvotorConnection, VkConnection
|
|
|
|
|
|
from web.templates_env import templates
|
|
|
|
|
|
|
2026-05-12 15:09:47 +03:00
|
|
|
|
VK_SCOPE = 335876 # photos(4) + wall(8192) + groups(262144) + offline(65536)
|
|
|
|
|
|
|
2026-05-01 18:09:11 +03:00
|
|
|
|
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:
|
2026-05-13 20:44:25 +03:00
|
|
|
|
real_user, viewed_user = get_viewed_user(request, db)
|
2026-05-01 18:09:11 +03:00
|
|
|
|
except Exception:
|
|
|
|
|
|
return RedirectResponse("/login", 303)
|
|
|
|
|
|
|
2026-05-13 20:44:25 +03:00
|
|
|
|
evotor = db.query(EvotorConnection).filter_by(user_id=viewed_user.id).first()
|
|
|
|
|
|
vk = db.query(VkConnection).filter_by(user_id=viewed_user.id).first()
|
|
|
|
|
|
return _render(request, "connections.html", {
|
|
|
|
|
|
"user": real_user,
|
|
|
|
|
|
"viewed_user": viewed_user if viewed_user.id != real_user.id else None,
|
|
|
|
|
|
"evotor": evotor,
|
|
|
|
|
|
"vk": vk,
|
|
|
|
|
|
})
|
2026-05-01 18:09:11 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-12 15:09:47 +03:00
|
|
|
|
@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})
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-01 18:09:11 +03:00
|
|
|
|
@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:
|
2026-05-12 22:00:14 +03:00
|
|
|
|
r = api_logger.get(
|
2026-05-01 18:09:11 +03:00
|
|
|
|
"https://api.evotor.ru/stores",
|
2026-05-12 22:00:14 +03:00
|
|
|
|
user_id=user.id,
|
2026-05-01 18:09:11 +03:00
|
|
|
|
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:
|
2026-05-12 15:09:47 +03:00
|
|
|
|
if not conn.vk_user_id:
|
|
|
|
|
|
return JSONResponse({"ok": False, "message": "Укажите ID сообщества для проверки подключения."})
|
2026-05-01 18:09:11 +03:00
|
|
|
|
|
2026-05-12 22:00:14 +03:00
|
|
|
|
r = api_logger.get(
|
2026-05-01 18:09:11 +03:00
|
|
|
|
"https://api.vk.com/method/groups.getById",
|
2026-05-12 22:00:14 +03:00
|
|
|
|
user_id=user.id,
|
2026-05-12 15:09:47 +03:00
|
|
|
|
params={
|
|
|
|
|
|
"group_id": conn.vk_user_id,
|
|
|
|
|
|
"fields": "market",
|
|
|
|
|
|
"access_token": conn.access_token,
|
|
|
|
|
|
"v": settings.VK_API_VERSION,
|
|
|
|
|
|
},
|
2026-05-01 18:09:11 +03:00
|
|
|
|
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", [])
|
2026-05-12 15:09:47 +03:00
|
|
|
|
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}"})
|
2026-05-01 18:09:11 +03:00
|
|
|
|
except httpx.TimeoutException:
|
|
|
|
|
|
return JSONResponse({"ok": False, "message": "Таймаут запроса к VK"})
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return JSONResponse({"ok": False, "message": f"Ошибка: {e}"})
|
|
|
|
|
|
|