Compare commits
7 Commits
background
...
v1.9.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aaeaa4f658 | ||
|
|
aea28ead9c | ||
|
|
cde2069d74 | ||
|
|
debb2efb3d | ||
|
|
4d4d5b0118 | ||
|
|
00b74b8aa9 | ||
|
|
577c5de200 |
@@ -5,9 +5,6 @@ BASE_URL=http://localhost:8000
|
|||||||
EVOTOR_APP_ID=your-evotor-app-id
|
EVOTOR_APP_ID=your-evotor-app-id
|
||||||
EVOTOR_WEBHOOK_SECRET=your-webhook-secret
|
EVOTOR_WEBHOOK_SECRET=your-webhook-secret
|
||||||
|
|
||||||
VK_CLIENT_ID=your-vk-client-id
|
|
||||||
VK_CLIENT_SECRET=your-vk-client-secret
|
|
||||||
|
|
||||||
DB_ROOT_PASSWORD=rootpass
|
DB_ROOT_PASSWORD=rootpass
|
||||||
DB_NAME=evosync
|
DB_NAME=evosync
|
||||||
DB_USER=evosync
|
DB_USER=evosync
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ class Settings(BaseSettings):
|
|||||||
EVOTOR_APP_ID: str = ""
|
EVOTOR_APP_ID: str = ""
|
||||||
EVOTOR_WEBHOOK_SECRET: str = ""
|
EVOTOR_WEBHOOK_SECRET: str = ""
|
||||||
|
|
||||||
HEALTH_CHECK_INTERVAL_SECONDS: int = 600
|
JIVOSITE_WIDGET_ID: str = ""
|
||||||
|
|
||||||
|
HEALTH_CHECK_INTERVAL_SECONDS: int = 600
|
||||||
|
CATALOG_REFRESH_INTERVAL_SECONDS: int = 3600
|
||||||
|
|
||||||
VK_CLIENT_ID: str = ""
|
|
||||||
VK_CLIENT_SECRET: str = ""
|
|
||||||
VK_SCOPES: str = "market groups offline"
|
|
||||||
VK_API_VERSION: str = "5.131"
|
VK_API_VERSION: str = "5.131"
|
||||||
|
|
||||||
# Docker compose vars (ignored in app, kept for env compatibility)
|
# Docker compose vars (ignored in app, kept for env compatibility)
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ from datetime import datetime, timedelta
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from web.database import SessionLocal
|
from web.database import SessionLocal
|
||||||
from web.models import EvotorConnection, VkConnection
|
from web.models import EvotorConnection, VkConnection, CachedStore
|
||||||
|
|
||||||
logger = logging.getLogger("uvicorn.error")
|
logger = logging.getLogger("uvicorn.error")
|
||||||
|
|
||||||
EVOTOR_STORES_URL = "https://api.evotor.ru/stores"
|
EVOTOR_STORES_URL = "https://api.evotor.ru/stores"
|
||||||
EVOTOR_TOKEN_URL = "https://oauth.evotor.ru/oauth/token"
|
EVOTOR_TOKEN_URL = "https://oauth.evotor.ru/oauth/token"
|
||||||
VK_USERS_GET_URL = "https://api.vk.com/method/users.get"
|
VK_GROUPS_GET_URL = "https://api.vk.com/method/groups.getById"
|
||||||
VK_API_VERSION = "5.131"
|
VK_API_VERSION = "5.131"
|
||||||
|
|
||||||
# Refresh Evotor token if it expires within this window
|
# Refresh Evotor token if it expires within this window
|
||||||
@@ -59,7 +59,7 @@ async def check_vk_connection(access_token: str) -> bool:
|
|||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
VK_USERS_GET_URL,
|
VK_GROUPS_GET_URL,
|
||||||
params={"access_token": access_token, "v": VK_API_VERSION},
|
params={"access_token": access_token, "v": VK_API_VERSION},
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
@@ -116,10 +116,28 @@ async def run_health_checks() -> None:
|
|||||||
conn.last_checked_at = now
|
conn.last_checked_at = now
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
# Refresh catalog cache for online Evotor connections
|
||||||
|
from web.config import settings
|
||||||
|
refreshed_catalog = 0
|
||||||
|
for conn in evotor_connections:
|
||||||
|
if not conn.is_online:
|
||||||
|
continue
|
||||||
|
cached = db.query(CachedStore).filter(CachedStore.user_id == conn.user_id).first()
|
||||||
|
cache_age = (now - cached.fetched_at).total_seconds() if cached else None
|
||||||
|
if cached is None or cache_age >= settings.CATALOG_REFRESH_INTERVAL_SECONDS:
|
||||||
|
try:
|
||||||
|
from web.evotor_api import refresh_catalog_cache
|
||||||
|
await refresh_catalog_cache(conn.user_id, conn.access_token, db)
|
||||||
|
refreshed_catalog += 1
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to refresh catalog cache for user_id=%d", conn.user_id)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Health checks completed: %d Evotor, %d VK",
|
"Health checks completed: %d Evotor, %d VK, %d catalogs refreshed",
|
||||||
len(evotor_connections),
|
len(evotor_connections),
|
||||||
len(vk_connections),
|
len(vk_connections),
|
||||||
|
refreshed_catalog,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Error during health checks")
|
logger.exception("Error during health checks")
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import uuid
|
|||||||
|
|
||||||
from fastapi import APIRouter, Request, Depends
|
from fastapi import APIRouter, Request, Depends
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from web.templates_env import templates
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from web.auth import hash_password, verify_password, get_current_user
|
from web.auth import hash_password, verify_password, get_current_user
|
||||||
@@ -12,7 +12,6 @@ from web.models import User
|
|||||||
from web.schemas import validate_registration, validate_login
|
from web.schemas import validate_registration, validate_login
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
templates = Jinja2Templates(directory="web/templates")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/register")
|
@router.get("/register")
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import io
|
|||||||
|
|
||||||
from fastapi import APIRouter, Request, Depends
|
from fastapi import APIRouter, Request, Depends
|
||||||
from fastapi.responses import RedirectResponse, StreamingResponse
|
from fastapi.responses import RedirectResponse, StreamingResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from web.templates_env import templates
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from web.auth import get_current_user
|
from web.auth import get_current_user
|
||||||
@@ -12,7 +12,6 @@ from web.evotor_api import refresh_catalog_cache
|
|||||||
from web.models import User, EvotorConnection, SyncConfig, SyncFilter, CachedStore, CachedGroup, CachedProduct
|
from web.models import User, EvotorConnection, SyncConfig, SyncFilter, CachedStore, CachedGroup, CachedProduct
|
||||||
|
|
||||||
router = APIRouter(prefix="/catalog")
|
router = APIRouter(prefix="/catalog")
|
||||||
templates = Jinja2Templates(directory="web/templates")
|
|
||||||
|
|
||||||
|
|
||||||
def _get_or_create_sync_config(db: Session, user_id: int) -> SyncConfig:
|
def _get_or_create_sync_config(db: Session, user_id: int) -> SyncConfig:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter, Request, Depends
|
from fastapi import APIRouter, Request, Depends
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from web.templates_env import templates
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from web.auth import get_current_user
|
from web.auth import get_current_user
|
||||||
@@ -8,7 +8,6 @@ from web.database import get_db
|
|||||||
from web.models import User, EvotorConnection, VkConnection
|
from web.models import User, EvotorConnection, VkConnection
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
templates = Jinja2Templates(directory="web/templates")
|
|
||||||
|
|
||||||
SERVICE_TYPES = [
|
SERVICE_TYPES = [
|
||||||
{
|
{
|
||||||
@@ -25,7 +24,7 @@ SERVICE_TYPES = [
|
|||||||
"icon": "bi-bag",
|
"icon": "bi-bag",
|
||||||
"description": "Подключите аккаунт ВКонтакте для публикации товаров в вашу группу.",
|
"description": "Подключите аккаунт ВКонтакте для публикации товаров в вашу группу.",
|
||||||
"configure_url": "/vk",
|
"configure_url": "/vk",
|
||||||
"connect_url": "/vk/connect",
|
"connect_url": "/vk",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import logging
|
import logging
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime
|
||||||
from fastapi import APIRouter, Request, Depends, HTTPException
|
from fastapi import APIRouter, Request, Depends, HTTPException
|
||||||
from fastapi.responses import RedirectResponse, JSONResponse
|
from fastapi.responses import RedirectResponse, JSONResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from web.templates_env import templates
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -16,14 +16,10 @@ from web.models import User, EvotorConnection
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/evotor")
|
router = APIRouter(prefix="/evotor")
|
||||||
templates = Jinja2Templates(directory="web/templates")
|
|
||||||
|
|
||||||
EVOTOR_APP_URL = "https://market.evotor.ru/store/apps/{app_id}"
|
EVOTOR_APP_URL = "https://market.evotor.ru/store/apps/{app_id}"
|
||||||
EVOTOR_STORES_URL = "https://api.evotor.ru/stores"
|
EVOTOR_STORES_URL = "https://api.evotor.ru/stores"
|
||||||
|
|
||||||
# Pending connections older than this are ignored during linking
|
|
||||||
PENDING_LINK_WINDOW_SECONDS = 300
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
def evotor_page(
|
def evotor_page(
|
||||||
@@ -36,27 +32,16 @@ def evotor_page(
|
|||||||
|
|
||||||
connection = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
|
connection = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
|
||||||
error = request.query_params.get("error")
|
error = request.query_params.get("error")
|
||||||
|
app_url = EVOTOR_APP_URL.format(app_id=settings.EVOTOR_APP_ID) if settings.EVOTOR_APP_ID else None
|
||||||
return templates.TemplateResponse("evotor.html", {
|
return templates.TemplateResponse("evotor.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"user": user,
|
"user": user,
|
||||||
"connection": connection,
|
"connection": connection,
|
||||||
"error": error,
|
"error": error,
|
||||||
|
"app_url": app_url,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/connect")
|
|
||||||
def evotor_connect(request: Request, user: User | None = Depends(get_current_user)):
|
|
||||||
if not user:
|
|
||||||
return RedirectResponse("/login", 303)
|
|
||||||
|
|
||||||
# Record when this user initiated a connect so we can link the incoming webhook
|
|
||||||
request.session["evotor_connect_user_id"] = user.id
|
|
||||||
request.session["evotor_connect_at"] = datetime.utcnow().isoformat()
|
|
||||||
|
|
||||||
url = EVOTOR_APP_URL.format(app_id=settings.EVOTOR_APP_ID)
|
|
||||||
return RedirectResponse(url, 302)
|
|
||||||
|
|
||||||
|
|
||||||
class EvotorTokenPayload(BaseModel):
|
class EvotorTokenPayload(BaseModel):
|
||||||
userId: str
|
userId: str
|
||||||
token: str
|
token: str
|
||||||
@@ -130,62 +115,6 @@ async def evotor_callback(
|
|||||||
return JSONResponse({"status": "ok"})
|
return JSONResponse({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/link")
|
|
||||||
def evotor_link(
|
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
user: User | None = Depends(get_current_user),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Called when the user returns to our app after authorizing on Evotor.
|
|
||||||
Links the most recently received unlinked token to this user.
|
|
||||||
"""
|
|
||||||
if not user:
|
|
||||||
return RedirectResponse("/login", 303)
|
|
||||||
|
|
||||||
connect_user_id = request.session.pop("evotor_connect_user_id", None)
|
|
||||||
connect_at_str = request.session.pop("evotor_connect_at", None)
|
|
||||||
|
|
||||||
if not connect_user_id or connect_user_id != user.id or not connect_at_str:
|
|
||||||
return RedirectResponse("/evotor?error=session_expired", 303)
|
|
||||||
|
|
||||||
try:
|
|
||||||
connect_at = datetime.fromisoformat(connect_at_str)
|
|
||||||
except ValueError:
|
|
||||||
return RedirectResponse("/evotor?error=session_expired", 303)
|
|
||||||
|
|
||||||
cutoff = connect_at - timedelta(seconds=10) # allow slight clock drift
|
|
||||||
now = datetime.utcnow()
|
|
||||||
|
|
||||||
if (now - connect_at).total_seconds() > PENDING_LINK_WINDOW_SECONDS:
|
|
||||||
return RedirectResponse("/evotor?error=link_timeout", 303)
|
|
||||||
|
|
||||||
# Find an unlinked connection received after the user clicked "Connect"
|
|
||||||
pending = (
|
|
||||||
db.query(EvotorConnection)
|
|
||||||
.filter(
|
|
||||||
EvotorConnection.user_id.is_(None),
|
|
||||||
EvotorConnection.connected_at >= cutoff,
|
|
||||||
)
|
|
||||||
.order_by(EvotorConnection.connected_at.desc())
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
if not pending:
|
|
||||||
return RedirectResponse("/evotor?error=token_not_received", 303)
|
|
||||||
|
|
||||||
# Detach any existing connection for this user
|
|
||||||
existing = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
|
|
||||||
if existing:
|
|
||||||
db.delete(existing)
|
|
||||||
db.flush()
|
|
||||||
|
|
||||||
pending.user_id = user.id
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return RedirectResponse("/connections", 303)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/token")
|
@router.post("/token")
|
||||||
async def evotor_token_manual(
|
async def evotor_token_manual(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter, Request, Depends
|
from fastapi import APIRouter, Request, Depends
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from web.templates_env import templates
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from web.auth import get_current_user, verify_password, hash_password
|
from web.auth import get_current_user, verify_password, hash_password
|
||||||
@@ -9,7 +9,6 @@ from web.models import User
|
|||||||
from web.schemas import validate_profile, validate_reset_password
|
from web.schemas import validate_profile, validate_reset_password
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
templates = Jinja2Templates(directory="web/templates")
|
|
||||||
|
|
||||||
|
|
||||||
# VIEW PROFILE
|
# VIEW PROFILE
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
|
|
||||||
from fastapi import APIRouter, Request, Depends
|
from fastapi import APIRouter, Request, Depends
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from web.templates_env import templates
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from web.auth import hash_password
|
from web.auth import hash_password
|
||||||
@@ -13,7 +13,6 @@ from web.models import User
|
|||||||
from web.schemas import validate_reset_password
|
from web.schemas import validate_reset_password
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
templates = Jinja2Templates(directory="web/templates")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/forgot-password")
|
@router.get("/forgot-password")
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
from fastapi import APIRouter, Request, Depends
|
from fastapi import APIRouter, Request, Depends
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from web.templates_env import templates
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from web.auth import get_current_user
|
from web.auth import get_current_user
|
||||||
@@ -10,7 +10,6 @@ from web.database import get_db
|
|||||||
from web.models import User, EvotorConnection, VkConnection, SyncConfig, SyncFilter
|
from web.models import User, EvotorConnection, VkConnection, SyncConfig, SyncFilter
|
||||||
|
|
||||||
router = APIRouter(prefix="/sync")
|
router = APIRouter(prefix="/sync")
|
||||||
templates = Jinja2Templates(directory="web/templates")
|
|
||||||
|
|
||||||
|
|
||||||
def _get_or_create_sync_config(db: Session, user_id: int) -> SyncConfig:
|
def _get_or_create_sync_config(db: Session, user_id: int) -> SyncConfig:
|
||||||
|
|||||||
118
web/routes/vk.py
118
web/routes/vk.py
@@ -1,11 +1,10 @@
|
|||||||
import secrets
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, Depends
|
from fastapi import APIRouter, Request, Depends
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from web.templates_env import templates
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from web.auth import get_current_user
|
from web.auth import get_current_user
|
||||||
@@ -14,17 +13,10 @@ from web.database import get_db
|
|||||||
from web.models import User, VkConnection
|
from web.models import User, VkConnection
|
||||||
|
|
||||||
router = APIRouter(prefix="/vk")
|
router = APIRouter(prefix="/vk")
|
||||||
templates = Jinja2Templates(directory="web/templates")
|
|
||||||
|
|
||||||
VK_AUTHORIZE_URL = "https://oauth.vk.com/authorize"
|
|
||||||
VK_TOKEN_URL = "https://oauth.vk.com/access_token"
|
|
||||||
VK_API_URL = "https://api.vk.com/method"
|
VK_API_URL = "https://api.vk.com/method"
|
||||||
|
|
||||||
|
|
||||||
def _redirect_uri() -> str:
|
|
||||||
return f"{settings.BASE_URL}/vk/callback"
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
def vk_page(
|
def vk_page(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -44,102 +36,62 @@ def vk_page(
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/connect")
|
@router.post("/token")
|
||||||
def vk_connect(request: Request, user: User | None = Depends(get_current_user)):
|
async def vk_token(
|
||||||
if not user:
|
|
||||||
return RedirectResponse("/login", 303)
|
|
||||||
|
|
||||||
state = secrets.token_urlsafe(32)
|
|
||||||
request.session["vk_oauth_state"] = state
|
|
||||||
|
|
||||||
params = (
|
|
||||||
f"?client_id={settings.VK_CLIENT_ID}"
|
|
||||||
f"&response_type=code"
|
|
||||||
f"&redirect_uri={_redirect_uri()}"
|
|
||||||
f"&scope={settings.VK_SCOPES.replace(' ', '%20')}"
|
|
||||||
f"&state={state}"
|
|
||||||
f"&display=page"
|
|
||||||
f"&v={settings.VK_API_VERSION}"
|
|
||||||
)
|
|
||||||
return RedirectResponse(VK_AUTHORIZE_URL + params, 302)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/callback")
|
|
||||||
async def vk_callback(
|
|
||||||
request: Request,
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: User | None = Depends(get_current_user),
|
user: User | None = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
"""Save a manually entered VK community access token."""
|
||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse("/login", 303)
|
return RedirectResponse("/login", 303)
|
||||||
|
|
||||||
code = request.query_params.get("code")
|
form = await request.form()
|
||||||
state = request.query_params.get("state")
|
token = (form.get("token") or "").strip()
|
||||||
saved_state = request.session.pop("vk_oauth_state", None)
|
if not token:
|
||||||
|
return RedirectResponse("/vk?error=empty_token", 303)
|
||||||
|
|
||||||
if not code or not state or state != saved_state:
|
# Fetch community info to validate the token and get group name/id
|
||||||
return RedirectResponse("/vk?error=invalid_state", 303)
|
group_id = None
|
||||||
|
group_name = None
|
||||||
# Exchange code for token (VK uses GET with query params)
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
token_response = await client.get(
|
resp = await client.get(
|
||||||
VK_TOKEN_URL,
|
f"{VK_API_URL}/groups.getById",
|
||||||
params={
|
params={"access_token": token, "v": settings.VK_API_VERSION},
|
||||||
"client_id": settings.VK_CLIENT_ID,
|
|
||||||
"client_secret": settings.VK_CLIENT_SECRET,
|
|
||||||
"code": code,
|
|
||||||
"redirect_uri": _redirect_uri(),
|
|
||||||
},
|
|
||||||
timeout=15,
|
timeout=15,
|
||||||
)
|
)
|
||||||
token_response.raise_for_status()
|
if resp.status_code == 200:
|
||||||
token_data = token_response.json()
|
data = resp.json()
|
||||||
except Exception:
|
if "error" in data:
|
||||||
return RedirectResponse("/vk?error=token_exchange", 303)
|
return RedirectResponse("/vk?error=invalid_token", 303)
|
||||||
|
groups = data.get("response", {}).get("groups", [])
|
||||||
access_token = token_data.get("access_token")
|
if groups:
|
||||||
vk_user_id = str(token_data.get("user_id", "")) or None
|
group_id = str(groups[0].get("id", ""))
|
||||||
if not access_token:
|
group_name = groups[0].get("name")
|
||||||
return RedirectResponse("/vk?error=no_token", 303)
|
elif resp.status_code == 401:
|
||||||
|
return RedirectResponse("/vk?error=invalid_token", 303)
|
||||||
# Fetch VK profile info
|
|
||||||
first_name = None
|
|
||||||
last_name = None
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
profile_response = await client.get(
|
|
||||||
f"{VK_API_URL}/users.get",
|
|
||||||
params={"access_token": access_token, "v": settings.VK_API_VERSION},
|
|
||||||
timeout=15,
|
|
||||||
)
|
|
||||||
if profile_response.status_code == 200:
|
|
||||||
profile_data = profile_response.json()
|
|
||||||
items = profile_data.get("response", [])
|
|
||||||
if items:
|
|
||||||
first_name = items[0].get("first_name")
|
|
||||||
last_name = items[0].get("last_name")
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Save or update connection
|
|
||||||
connection = db.query(VkConnection).filter(VkConnection.user_id == user.id).first()
|
connection = db.query(VkConnection).filter(VkConnection.user_id == user.id).first()
|
||||||
|
now = datetime.utcnow()
|
||||||
if connection:
|
if connection:
|
||||||
connection.access_token = access_token
|
connection.access_token = token
|
||||||
connection.vk_user_id = vk_user_id
|
connection.vk_user_id = group_id
|
||||||
connection.first_name = first_name
|
connection.first_name = group_name
|
||||||
connection.last_name = last_name
|
connection.last_name = None
|
||||||
connection.is_online = True
|
connection.is_online = True
|
||||||
connection.last_checked_at = datetime.utcnow()
|
connection.last_checked_at = now
|
||||||
else:
|
else:
|
||||||
connection = VkConnection(
|
connection = VkConnection(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
access_token=access_token,
|
access_token=token,
|
||||||
vk_user_id=vk_user_id,
|
vk_user_id=group_id,
|
||||||
first_name=first_name,
|
first_name=group_name,
|
||||||
last_name=last_name,
|
last_name=None,
|
||||||
is_online=True,
|
is_online=True,
|
||||||
last_checked_at=datetime.utcnow(),
|
last_checked_at=now,
|
||||||
)
|
)
|
||||||
db.add(connection)
|
db.add(connection)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -64,6 +64,9 @@
|
|||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{% if jivosite_widget_id %}
|
||||||
|
<script src="//code.jivosite.com/widget/{{ jivosite_widget_id }}" async></script>
|
||||||
|
{% endif %}
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/inputmask@5.0.9/dist/inputmask.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/inputmask@5.0.9/dist/inputmask.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
<p>Товары не найдены.</p>
|
<p>Товары не найдены.</p>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="table-responsive">
|
<div class="table-responsive" style="overflow: visible;">
|
||||||
<table class="table table-striped table-hover align-middle small">
|
<table class="table table-striped table-hover align-middle small">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="dropdown">
|
<div class="dropdown">
|
||||||
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown" data-bs-boundary="document" data-bs-display="dynamic" data-bs-strategy="fixed">
|
<button class="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown" data-bs-strategy="fixed">
|
||||||
<i class="bi bi-funnel"></i>
|
<i class="bi bi-funnel"></i>
|
||||||
</button>
|
</button>
|
||||||
<ul class="dropdown-menu dropdown-menu-end">
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
|||||||
@@ -7,13 +7,7 @@
|
|||||||
|
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<div class="alert alert-danger mt-4">
|
<div class="alert alert-danger mt-4">
|
||||||
{% if error == "token_not_received" %}
|
{% if error == "invalid_token" %}
|
||||||
<i class="bi bi-exclamation-triangle me-2"></i>Токен от Эвотор ещё не получен. Убедитесь, что авторизация прошла успешно, и попробуйте снова.
|
|
||||||
{% elif error == "link_timeout" %}
|
|
||||||
<i class="bi bi-exclamation-triangle me-2"></i>Время ожидания истекло. Попробуйте подключить аккаунт заново.
|
|
||||||
{% elif error == "session_expired" %}
|
|
||||||
<i class="bi bi-exclamation-triangle me-2"></i>Сессия устарела. Попробуйте подключить аккаунт заново.
|
|
||||||
{% elif error == "invalid_token" %}
|
|
||||||
<i class="bi bi-exclamation-triangle me-2"></i>Токен недействителен. Проверьте правильность и попробуйте снова.
|
<i class="bi bi-exclamation-triangle me-2"></i>Токен недействителен. Проверьте правильность и попробуйте снова.
|
||||||
{% elif error == "empty_token" %}
|
{% elif error == "empty_token" %}
|
||||||
<i class="bi bi-exclamation-triangle me-2"></i>Введите токен.
|
<i class="bi bi-exclamation-triangle me-2"></i>Введите токен.
|
||||||
@@ -52,14 +46,8 @@
|
|||||||
<span class="small">{{ connection.connected_at.strftime("%d.%m.%Y %H:%M") }}</span>
|
<span class="small">{{ connection.connected_at.strftime("%d.%m.%Y %H:%M") }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="card-body d-grid gap-2">
|
|
||||||
<a href="/evotor/connect" class="btn btn-primary">Переподключить</a>
|
|
||||||
<form method="post" action="/evotor/disconnect">
|
|
||||||
<button type="submit" class="btn btn-outline-danger w-100">Отключить аккаунт Эвотор</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
<p class="text-muted small mb-2">Обновить токен вручную (Личный кабинет Эвотор → <strong>Приложения → ЭвоСинк → Настройки</strong>):</p>
|
<p class="text-muted small mb-2">Обновить токен (Личный кабинет Эвотор → <strong>Приложения → ЭвоСинк → Настройки</strong>):</p>
|
||||||
<form method="post" action="/evotor/token">
|
<form method="post" action="/evotor/token">
|
||||||
<div class="input-group input-group-sm">
|
<div class="input-group input-group-sm">
|
||||||
<input type="text" name="token" class="form-control font-monospace" placeholder="Новый токен" required>
|
<input type="text" name="token" class="form-control font-monospace" placeholder="Новый токен" required>
|
||||||
@@ -67,30 +55,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card-body d-grid">
|
||||||
|
<form method="post" action="/evotor/disconnect">
|
||||||
|
<button type="submit" class="btn btn-outline-danger w-100">Отключить аккаунт Эвотор</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
{# ── NOT CONNECTED STATE ── #}
|
{# ── NOT CONNECTED STATE ── #}
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p class="text-muted mb-3">
|
<p class="text-muted mb-3">
|
||||||
Подключите ваш аккаунт Эвотор, чтобы система могла автоматически синхронизировать
|
Для подключения вам нужно установить приложение <strong>ЭвоСинк</strong> в личном кабинете Эвотор
|
||||||
каталог товаров из вашей кассы в ВКонтакте.
|
и скопировать токен доступа из его настроек.
|
||||||
</p>
|
</p>
|
||||||
<ul class="text-muted small mb-4">
|
|
||||||
<li>Вы будете перенаправлены на сайт Эвотор для авторизации</li>
|
|
||||||
<li>После подтверждения доступа синхронизация будет настроена автоматически</li>
|
|
||||||
<li>Вы можете отключить доступ в любой момент</li>
|
|
||||||
</ul>
|
|
||||||
<div class="d-grid gap-2">
|
|
||||||
<a href="/evotor/connect" class="btn btn-primary btn-lg">Подключить Эвотор</a>
|
|
||||||
<a href="/evotor/link" class="btn btn-outline-secondary">Уже авторизовался — подтвердить подключение</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr class="my-4">
|
<ol class="text-muted small mb-4">
|
||||||
<p class="text-muted small mb-2">Если приложение уже установлено, введите токен вручную. Его можно найти в личном кабинете Эвотор: <strong>Приложения → ЭвоСинк → Настройки</strong>.</p>
|
{% if app_url %}
|
||||||
|
<li class="mb-1">Откройте <a href="{{ app_url }}" target="_blank" rel="noopener">приложение ЭвоСинк в магазине Эвотор <i class="bi bi-box-arrow-up-right small"></i></a> и установите его.</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="mb-1">Найдите приложение <strong>ЭвоСинк</strong> в магазине Эвотор и установите его.</li>
|
||||||
|
{% endif %}
|
||||||
|
<li class="mb-1">Перейдите в раздел <strong>Приложения → ЭвоСинк → Настройки</strong>.</li>
|
||||||
|
<li class="mb-1">Скопируйте токен доступа и вставьте его в поле ниже.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
<form method="post" action="/evotor/token">
|
<form method="post" action="/evotor/token">
|
||||||
<div class="input-group">
|
<div class="mb-3">
|
||||||
<input type="text" name="token" class="form-control font-monospace" placeholder="Введите токен Эвотор" required>
|
<label class="form-label small text-muted">Токен доступа</label>
|
||||||
<button type="submit" class="btn btn-outline-primary">Сохранить</button>
|
<input type="text" name="token" class="form-control font-monospace" placeholder="Вставьте токен Эвотор" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="d-grid">
|
||||||
|
<button type="submit" class="btn btn-primary">Подключить</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,12 +7,10 @@
|
|||||||
|
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<div class="alert alert-danger mt-4">
|
<div class="alert alert-danger mt-4">
|
||||||
{% if error == "invalid_state" %}
|
{% if error == "invalid_token" %}
|
||||||
<i class="bi bi-exclamation-triangle me-2"></i>Ошибка безопасности. Попробуйте подключить аккаунт заново.
|
<i class="bi bi-exclamation-triangle me-2"></i>Токен недействителен. Убедитесь, что скопировали ключ доступа сообщества правильно.
|
||||||
{% elif error == "token_exchange" %}
|
{% elif error == "empty_token" %}
|
||||||
<i class="bi bi-exclamation-triangle me-2"></i>Не удалось получить токен доступа от ВКонтакте. Попробуйте позже.
|
<i class="bi bi-exclamation-triangle me-2"></i>Введите ключ доступа.
|
||||||
{% elif error == "no_token" %}
|
|
||||||
<i class="bi bi-exclamation-triangle me-2"></i>ВКонтакте не вернул токен доступа. Попробуйте позже.
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<i class="bi bi-exclamation-triangle me-2"></i>Произошла ошибка при подключении: {{ error }}
|
<i class="bi bi-exclamation-triangle me-2"></i>Произошла ошибка при подключении: {{ error }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -31,15 +29,15 @@
|
|||||||
<span class="text-muted small">Статус</span>
|
<span class="text-muted small">Статус</span>
|
||||||
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Подключено</span>
|
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Подключено</span>
|
||||||
</li>
|
</li>
|
||||||
{% if connection.first_name or connection.last_name %}
|
{% if connection.first_name %}
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
<span class="text-muted small">Профиль</span>
|
<span class="text-muted small">Сообщество</span>
|
||||||
<span>{{ connection.first_name }} {{ connection.last_name }}</span>
|
<span>{{ connection.first_name }}</span>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if connection.vk_user_id %}
|
{% if connection.vk_user_id %}
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
<span class="text-muted small">ID пользователя</span>
|
<span class="text-muted small">ID сообщества</span>
|
||||||
<span class="font-monospace small text-muted">{{ connection.vk_user_id }}</span>
|
<span class="font-monospace small text-muted">{{ connection.vk_user_id }}</span>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -48,10 +46,18 @@
|
|||||||
<span class="small">{{ connection.connected_at.strftime("%d.%m.%Y %H:%M") }}</span>
|
<span class="small">{{ connection.connected_at.strftime("%d.%m.%Y %H:%M") }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="card-body d-grid gap-2">
|
<div class="card-footer">
|
||||||
<a href="/vk/connect" class="btn btn-primary">Переподключить</a>
|
<p class="text-muted small mb-2">Обновить ключ доступа:</p>
|
||||||
|
<form method="post" action="/vk/token">
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<input type="text" name="token" class="form-control font-monospace" placeholder="Новый ключ доступа" required>
|
||||||
|
<button type="submit" class="btn btn-outline-secondary">Обновить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="card-body d-grid">
|
||||||
<form method="post" action="/vk/disconnect">
|
<form method="post" action="/vk/disconnect">
|
||||||
<button type="submit" class="btn btn-outline-danger w-100">Отключить аккаунт ВКонтакте</button>
|
<button type="submit" class="btn btn-outline-danger w-100">Отключить ВКонтакте</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -59,17 +65,26 @@
|
|||||||
{# ── NOT CONNECTED STATE ── #}
|
{# ── NOT CONNECTED STATE ── #}
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p class="text-muted mb-3">
|
<p class="text-muted mb-3">
|
||||||
Подключите ваш аккаунт ВКонтакте, чтобы система могла автоматически синхронизировать
|
Для подключения вам нужен <strong>ключ доступа сообщества</strong> ВКонтакте.
|
||||||
каталог товаров из Эвотор в вашу группу ВКонтакте.
|
Синхронизация товаров работает только через сообщество.
|
||||||
</p>
|
</p>
|
||||||
<ul class="text-muted small mb-4">
|
|
||||||
<li>Вы будете перенаправлены на сайт ВКонтакте для авторизации</li>
|
<ol class="text-muted small mb-4">
|
||||||
<li>После подтверждения доступа синхронизация будет настроена автоматически</li>
|
<li class="mb-1">Откройте <a href="https://vk.com" target="_blank" rel="noopener">vk.com <i class="bi bi-box-arrow-up-right small"></i></a> и перейдите в управление вашим сообществом.</li>
|
||||||
<li>Вы можете отключить доступ в любой момент</li>
|
<li class="mb-1">Перейдите в раздел <strong>Настройки → Работа с API</strong>.</li>
|
||||||
</ul>
|
<li class="mb-1">Создайте ключ доступа с правами <strong>Управление товарами</strong> и <strong>Управление сообществом</strong>.</li>
|
||||||
<div class="d-grid">
|
<li class="mb-1">Скопируйте ключ и вставьте его в поле ниже.</li>
|
||||||
<a href="/vk/connect" class="btn btn-primary btn-lg">Подключить ВКонтакте</a>
|
</ol>
|
||||||
</div>
|
|
||||||
|
<form method="post" action="/vk/token">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small text-muted">Ключ доступа сообщества</label>
|
||||||
|
<input type="text" name="token" class="form-control font-monospace" placeholder="Вставьте ключ доступа" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="d-grid">
|
||||||
|
<button type="submit" class="btn btn-primary">Подключить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
6
web/templates_env.py
Normal file
6
web/templates_env.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from web.config import settings
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory="web/templates")
|
||||||
|
templates.env.globals["jivosite_widget_id"] = settings.JIVOSITE_WIDGET_ID
|
||||||
Reference in New Issue
Block a user