5 Commits

Author SHA1 Message Date
mguschin
cde2069d74 Remove unused VK OAuth env vars (VK_CLIENT_ID/SECRET/SCOPES)
VK connection now uses manual community token entry, so OAuth credentials
are no longer needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 15:35:53 +03:00
mguschin
debb2efb3d Replace VK OAuth with manual community token entry
Resolves #4 — VK OAuth flow caused "Security Error" because market sync
requires a community access token, not a personal user token. Replaced
OAuth with manual token input (same pattern as Evotor). Added
step-by-step instructions. Updated health checker to validate community
tokens via groups.getById instead of users.get.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 15:32:13 +03:00
mguschin
4d4d5b0118 Add Jivosite live chat widget support
Resolves #3 — widget is loaded on every page via base.html when
JIVOSITE_WIDGET_ID env var is set. Centralized Jinja2Templates instance
in web/templates_env.py with jivosite_widget_id as a global.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 14:11:25 +03:00
mguschin
00b74b8aa9 Simplify Evotor connect to manual token entry only
Resolves #2 — removes semi-automatic OAuth flow (Переподключить button,
/evotor/connect and /evotor/link routes) and makes manual token entry
the sole connect option. Adds step-by-step instructions with a direct
link to the app on Evotor marketplace (opens in new tab).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 13:01:12 +03:00
mguschin
577c5de200 Add background catalog cache refresh to health check loop
Resolves #1 — the health checker now refreshes catalog cache for all
online Evotor connections when cache is missing or older than
CATALOG_REFRESH_INTERVAL_SECONDS (default: 3600s).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 12:53:44 +03:00
15 changed files with 143 additions and 234 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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")

View File

@@ -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")

View File

@@ -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:

View File

@@ -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 = [
{ {

View File

@@ -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,

View File

@@ -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

View File

@@ -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")

View File

@@ -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:

View File

@@ -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()

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
View 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