feat: add connections dashboard with background health checks

- Add /connections page showing all integrations with online/offline status
- Add background health checker that polls Evotor API every 10 minutes
- Add is_online and last_checked_at fields to evotor_connections table
- Replace Evotor navbar link with unified Connections link
- Redirect connect/disconnect flows to /connections
- Add Alembic migration for new columns

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mguschin
2026-03-06 15:26:49 +03:00
parent 9edb77efba
commit 379f781e1e
15 changed files with 510 additions and 19 deletions

View File

@@ -16,12 +16,12 @@ services:
- ./alembic.ini:/app/alembic.ini
- ./docker-entrypoint.sh:/app/docker-entrypoint.sh
sync:
build:
context: .
dockerfile: Dockerfile
volumes:
- ./evo:/var/www/evo
- ./vk:/var/www/vk
- ./run:/var/www/run
- ./logs:/var/www/logs
# sync:
# build:
# context: .
# dockerfile: Dockerfile
# volumes:
# - ./evo:/var/www/evo
# - ./vk:/var/www/vk
# - ./run:/var/www/run
# - ./logs:/var/www/logs

View File

@@ -0,0 +1,95 @@
# Connections Dashboard with Background Health Checks
## Context
Users currently access Evotor connection via a dedicated `/evotor` page linked from the navbar. As more integrations are planned, we need a unified **Connections** page where users can see all their connections at a glance, with real-time status indicators powered by background health checks.
## Plan
### 1. Model Changes — `web/models.py`
Add to `EvotorConnection`:
- `is_online` (Boolean, default=False, server_default="0")
- `last_checked_at` (DateTime, nullable)
### 2. Alembic Migration
Generate migration for the two new columns on `evotor_connections` table.
### 3. Config Addition — `web/config.py`
Add `HEALTH_CHECK_INTERVAL_SECONDS: int = 600` (10 minutes default).
### 4. Background Health Checker — `web/health_checker.py` (new)
- `check_evotor_connection(access_token) -> bool` — async, calls `GET https://api.evotor.ru/stores` with Bearer token, returns True if 200
- `run_health_checks()` — queries all `EvotorConnection` rows using its own `SessionLocal()`, checks each, updates `is_online` and `last_checked_at`
- `health_check_loop(interval)` — infinite loop with `asyncio.sleep`, calls `run_health_checks()`
### 5. Wire Background Task — `web/main.py`
Add FastAPI lifespan context manager:
- On startup: `asyncio.create_task(health_check_loop(...))`
- On shutdown: cancel the task
- Register new connections router
### 6. Connections Route — `web/routes/connections.py` (new)
`GET /connections` — requires auth, builds a list of connection descriptors:
```python
connections = [{
"name": "Эвотор",
"icon": "bi-shop",
"connected": bool,
"is_online": bool,
"last_checked_at": datetime | None,
"details": store_name,
"connect_url": "/evotor",
"disconnect_url": "/evotor/disconnect",
}]
```
Future connections just append another dict — template stays generic.
### 7. Connections Template — `web/templates/connections.html` (new)
Card per connection showing:
- Icon + name + optional details (store name)
- Status: green `bi-circle-fill` (online), red `bi-circle-fill` (offline), grey `bi-circle` (not connected)
- Action button: "Подключить" (link to connect_url) or "Отключить" (POST form to disconnect_url)
- Card footer with last check timestamp
### 8. Navbar Update — `web/templates/base.html`
Replace "Эвотор" link with "Подключения" → `/connections`.
### 9. Evotor Callback Update — `web/routes/evotor.py`
On successful OAuth callback, set `is_online=True` and `last_checked_at=func.now()`.
### 10. Evotor Template Back Link — `web/templates/evotor.html`
Change "Вернуться в личный кабинет" → "Вернуться к подключениям" linking to `/connections`.
## Files Summary
| File | Action |
|------|--------|
| `web/models.py` | Modify — add 2 fields |
| `web/config.py` | Modify — add interval setting |
| `web/main.py` | Modify — lifespan + router |
| `web/routes/evotor.py` | Modify — set online on callback |
| `web/routes/connections.py` | Create |
| `web/health_checker.py` | Create |
| `web/templates/connections.html` | Create |
| `web/templates/base.html` | Modify — navbar |
| `web/templates/evotor.html` | Modify — back link |
| Alembic migration | Create |
## Verification
1. Run `alembic upgrade head` to apply migration
2. Start the app, verify background task logs appear
3. Visit `/connections` — should show Evotor as disconnected (grey)
4. Connect Evotor via `/evotor` — should redirect back, connections page shows green status
5. Disconnect — status returns to grey
6. Wait for health check interval or trigger manually — verify `is_online` and `last_checked_at` update

189
docs/plans/vk-connection.md Normal file
View File

@@ -0,0 +1,189 @@
# VK OAuth Connection Feature
## Context
EvoSync syncs product catalogs from Evotor to VK. Users already connect their Evotor account via OAuth. Now we need the same for VK — users authorize via VK OAuth, we store the access token, and show connection status on the connections dashboard.
## VK OAuth Flow (Web)
- **Authorize URL**: `https://oauth.vk.com/authorize`
- **Token URL**: `https://oauth.vk.com/access_token`
- **Verify endpoint**: `GET https://api.vk.com/method/users.get?access_token={token}&v=5.131`
- Error code 5 = token invalid/expired
- **Scopes**: `market groups offline` (offline = permanent token, no expiry)
- **Token response fields**: `access_token`, `user_id`, `expires_in` (0 if offline scope used)
With `offline` scope, tokens don't expire — no refresh logic needed. If a user revokes access on VK's side, the health checker will detect it.
## Plan
### 1. New Model — `VkConnection` in `web/models.py`
```python
class VkConnection(Base):
__tablename__ = "vk_connections"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False)
access_token = Column(Text, nullable=False)
vk_user_id = Column(String(50), nullable=True) # VK user ID from token response
first_name = Column(String(255), nullable=True) # VK profile first name
last_name = Column(String(255), nullable=True) # VK profile last name
is_online = Column(Boolean, default=False, server_default="0", nullable=False)
last_checked_at = Column(DateTime, nullable=True)
connected_at = Column(DateTime, server_default=func.now(), nullable=False)
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
user = relationship("User", back_populates="vk_connection")
```
Add to `User` model:
```python
vk_connection = relationship("VkConnection", back_populates="user", uselist=False)
```
### 2. Alembic Migration
Generate migration for the new `vk_connections` table and the relationship.
### 3. Config — `web/config.py`
Add:
```python
VK_CLIENT_ID: str = ""
VK_CLIENT_SECRET: str = ""
VK_SCOPES: str = "market groups offline"
VK_API_VERSION: str = "5.131"
```
### 4. VK Route — `web/routes/vk.py` (new)
Follow the same pattern as `web/routes/evotor.py`:
**Constants:**
```python
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"
```
**Endpoints:**
- `GET /vk` — Connection page. Shows connected state (VK profile name, user_id) or disconnected state with explanation and connect button.
- `GET /vk/connect` — Generate state token, save in session, redirect to:
```
https://oauth.vk.com/authorize?client_id={id}&response_type=code
&redirect_uri={BASE_URL}/vk/callback&scope={scopes}&state={state}
&display=page&v=5.131
```
- `GET /vk/callback` — OAuth callback:
1. Validate state from session
2. Exchange code for token via GET to `https://oauth.vk.com/access_token` with params: `client_id`, `client_secret`, `code`, `redirect_uri` (NOTE: VK uses GET, not POST, and params in query string, not body)
3. Response contains: `access_token`, `user_id`, `expires_in`
4. Fetch user profile via `users.get` to get first_name, last_name
5. Save/update `VkConnection` record with `is_online=True`, `last_checked_at=now()`
6. Redirect to `/connections`
- `POST /vk/disconnect` — Delete VkConnection record, redirect to `/vk`
### 5. VK Template — `web/templates/vk.html` (new)
Same structure as `evotor.html`:
**Connected state:**
- Status badge: "Подключено" (green)
- VK profile: first_name + last_name
- VK user ID (monospace)
- Connected timestamp
- Buttons: "Переподключить", "Отключить аккаунт ВКонтакте"
**Disconnected state:**
- Explanation text: "Подключите ваш аккаунт ВКонтакте, чтобы система могла автоматически синхронизировать каталог товаров из Эвотор в вашу группу ВКонтакте."
- Bullet points: redirect to VK for auth, auto-setup after confirmation, can disconnect anytime
- Button: "Подключить ВКонтакте"
**Error display:** same pattern as evotor.html (invalid_state, token_exchange, no_token)
**Back link:** "Вернуться к подключениям" → `/connections`
### 6. Register Route — `web/main.py`
```python
from web.routes import vk
app.include_router(vk.router)
```
### 7. Add to Connections Dashboard — `web/routes/connections.py`
Add VK entry to the connections list:
```python
vk_conn = db.query(VkConnection).filter(VkConnection.user_id == user.id).first()
connections.append({
"name": "ВКонтакте",
"icon": "bi-chat-dots", # or another suitable Bootstrap icon
"connected": vk_conn is not None,
"is_online": vk_conn.is_online if vk_conn else False,
"last_checked_at": vk_conn.last_checked_at if vk_conn else None,
"details": f"{vk_conn.first_name} {vk_conn.last_name}" if vk_conn and vk_conn.first_name else None,
"connect_url": "/vk",
"disconnect_url": "/vk/disconnect",
})
```
### 8. Background Health Check — `web/health_checker.py`
Add VK check alongside existing Evotor check:
```python
async def check_vk_connection(access_token: str) -> bool:
"""Call users.get to verify VK token is valid."""
async with httpx.AsyncClient() as client:
resp = await client.get(
"https://api.vk.com/method/users.get",
params={"access_token": access_token, "v": "5.131"},
timeout=10,
)
if resp.status_code != 200:
return False
data = resp.json()
# Error code 5 = invalid token
if "error" in data:
return False
return True
```
In `run_health_checks()`, add a loop over `VkConnection` rows with the same pattern as Evotor checks.
## Files Summary
| File | Action |
|------|--------|
| `web/models.py` | Modify — add `VkConnection` model + User relationship |
| `web/config.py` | Modify — add `VK_*` settings |
| `web/main.py` | Modify — register vk router |
| `web/routes/vk.py` | Create — OAuth flow (connect/callback/disconnect/page) |
| `web/routes/connections.py` | Modify — add VK to connections list |
| `web/health_checker.py` | Modify — add VK health check |
| `web/templates/vk.html` | Create — VK connection page |
| Alembic migration | Create — `vk_connections` table |
## Env Config Needed
```
VK_CLIENT_ID=your_vk_app_id
VK_CLIENT_SECRET=your_vk_app_secret
VK_SCOPES=market groups offline
```
## Verification
1. Run `alembic upgrade head`
2. Visit `/connections` — should show VK as disconnected (grey)
3. Click VK → "Подключить ВКонтакте" → redirects to VK auth
4. After VK auth → callback saves token → redirects to `/connections` → VK shows green
5. Visit `/vk` — shows connected state with VK profile info
6. Disconnect → VK returns to grey on connections page
7. Wait for health check cycle — verify `is_online` and `last_checked_at` update

View File

@@ -11,6 +11,8 @@ class Settings(BaseSettings):
EVOTOR_CLIENT_SECRET: str = ""
EVOTOR_SCOPES: str = "store:read product:read"
HEALTH_CHECK_INTERVAL_SECONDS: int = 600
# Docker compose vars (ignored in app, kept for env compatibility)
DB_ROOT_PASSWORD: str = ""
DB_NAME: str = ""

48
web/health_checker.py Normal file
View File

@@ -0,0 +1,48 @@
import asyncio
import logging
from datetime import datetime
import httpx
from web.database import SessionLocal
from web.models import EvotorConnection
logger = logging.getLogger("uvicorn.error")
EVOTOR_STORES_URL = "https://api.evotor.ru/stores"
async def check_evotor_connection(access_token: str) -> bool:
try:
async with httpx.AsyncClient() as client:
response = await client.get(
EVOTOR_STORES_URL,
headers={"Authorization": f"Bearer {access_token}"},
timeout=15,
)
return response.status_code == 200
except Exception:
return False
async def run_health_checks() -> None:
db = SessionLocal()
try:
connections = db.query(EvotorConnection).all()
for conn in connections:
is_online = await check_evotor_connection(conn.access_token)
conn.is_online = is_online
conn.last_checked_at = datetime.utcnow()
db.commit()
logger.info("Health checks completed for %d connection(s)", len(connections))
except Exception:
logger.exception("Error during health checks")
db.rollback()
finally:
db.close()
async def health_check_loop(interval: int) -> None:
while True:
await run_health_checks()
await asyncio.sleep(interval)

View File

@@ -1,11 +1,31 @@
from fastapi import FastAPI
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends, Request
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from web.auth import get_current_user
from web.config import settings
from web.health_checker import health_check_loop
from web.models import User
from web.routes import auth, profile, reset, evotor
from web.routes import connections
app = FastAPI(title="EvoSync — Личный кабинет")
@asynccontextmanager
async def lifespan(app: FastAPI):
task = asyncio.create_task(health_check_loop(settings.HEALTH_CHECK_INTERVAL_SECONDS))
yield
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
app = FastAPI(title="EvoSync — Личный кабинет", lifespan=lifespan)
app.add_middleware(SessionMiddleware, secret_key=settings.SECRET_KEY)
app.mount("/static", StaticFiles(directory="web/static"), name="static")
@@ -14,3 +34,11 @@ app.include_router(auth.router)
app.include_router(profile.router)
app.include_router(reset.router)
app.include_router(evotor.router)
app.include_router(connections.router)
@app.get("/")
def home(request: Request, user: User | None = Depends(get_current_user)):
if user:
return RedirectResponse("/profile", 302)
return RedirectResponse("/login", 302)

View File

@@ -0,0 +1,26 @@
"""add is_online and last_checked_at to evotor_connections
Revision ID: a1b2c3d4e5f6
Revises: 2c15000e752b
Create Date: 2026-03-06 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
revision = 'a1b2c3d4e5f6'
down_revision = '2c15000e752b'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('evotor_connections',
sa.Column('is_online', sa.Boolean(), nullable=False, server_default='0'))
op.add_column('evotor_connections',
sa.Column('last_checked_at', sa.DateTime(), nullable=True))
def downgrade() -> None:
op.drop_column('evotor_connections', 'last_checked_at')
op.drop_column('evotor_connections', 'is_online')

View File

@@ -32,6 +32,8 @@ class EvotorConnection(Base):
access_token = Column(Text, nullable=False)
store_id = Column(String(255), nullable=True)
store_name = Column(String(255), nullable=True)
is_online = Column(Boolean, default=False, server_default="0", nullable=False)
last_checked_at = Column(DateTime, nullable=True)
connected_at = Column(DateTime, server_default=func.now(), nullable=False)
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)

42
web/routes/connections.py Normal file
View File

@@ -0,0 +1,42 @@
from fastapi import APIRouter, Request, Depends
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from web.auth import get_current_user
from web.database import get_db
from web.models import User, EvotorConnection
router = APIRouter()
templates = Jinja2Templates(directory="web/templates")
@router.get("/connections")
def connections_page(
request: Request,
db: Session = Depends(get_db),
user: User | None = Depends(get_current_user),
):
if not user:
return RedirectResponse("/login", 303)
evotor = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
connections = [
{
"name": "Эвотор",
"icon": "bi-shop",
"connected": evotor is not None,
"is_online": evotor.is_online if evotor else False,
"last_checked_at": evotor.last_checked_at if evotor else None,
"details": evotor.store_name if evotor else None,
"connect_url": "/evotor/connect",
"disconnect_url": "/evotor/disconnect",
}
]
return templates.TemplateResponse("connections.html", {
"request": request,
"user": user,
"connections": connections,
})

View File

@@ -118,22 +118,27 @@ async def evotor_callback(
pass # Store info is optional; token is still saved
# Save or update connection
from datetime import datetime
connection = db.query(EvotorConnection).filter(EvotorConnection.user_id == user.id).first()
if connection:
connection.access_token = access_token
connection.store_id = store_id
connection.store_name = store_name
connection.is_online = True
connection.last_checked_at = datetime.utcnow()
else:
connection = EvotorConnection(
user_id=user.id,
access_token=access_token,
store_id=store_id,
store_name=store_name,
is_online=True,
last_checked_at=datetime.utcnow(),
)
db.add(connection)
db.commit()
return RedirectResponse("/evotor", 303)
return RedirectResponse("/connections", 303)
@router.post("/disconnect")
@@ -150,4 +155,4 @@ async def evotor_disconnect(
db.delete(connection)
db.commit()
return RedirectResponse("/evotor", 303)
return RedirectResponse("/connections", 303)

View File

@@ -47,7 +47,7 @@ async def forgot_submit(request: Request, db: Session = Depends(get_db)):
return templates.TemplateResponse("message.html", {
"request": request, "user": None,
"title": "Сброс пароля",
"message": "Если аккаунт с таким email существует, ссылка для сброса пароля выведена в консоль сервера.",
"message": "Если аккаунт с таким email существует, мы отправили письмо со ссылкой для сброса пароля.",
})

View File

@@ -19,7 +19,7 @@
<ul class="navbar-nav ms-auto">
{% if user %}
<li class="nav-item">
<a href="/evotor" class="nav-link">Эвотор</a>
<a href="/connections" class="nav-link">Подключения</a>
</li>
<li class="nav-item">
<a href="/profile" class="nav-link"><i class="bi bi-person-circle me-1"></i>Личный кабинет</a>

View File

@@ -8,8 +8,7 @@
<div class="card-body p-5">
<i class="bi bi-envelope-check display-4 text-primary mb-3"></i>
<h1 class="h4 mb-3">Подтвердите ваш email</h1>
<p class="text-muted">Ссылка для подтверждения email выведена в консоль сервера.</p>
<p class="text-muted">Скопируйте её и откройте в браузере.</p>
<p class="text-muted">Проверьте почту и нажмите на ссылку для подтверждения.</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,55 @@
{% extends "base.html" %}
{% block title %}Подключения — EvoSync{% endblock %}
{% block content %}
<h1 class="h4 mb-4">Подключения</h1>
<div class="row g-3">
{% for conn in connections %}
<div class="col-sm-6 col-lg-4">
<div class="card shadow-sm h-100">
<div class="card-body">
<div class="d-flex align-items-center mb-3">
<i class="bi {{ conn.icon }} fs-2 me-3 text-secondary"></i>
<div>
<h5 class="mb-0">{{ conn.name }}</h5>
{% if conn.details %}
<small class="text-muted">{{ conn.details }}</small>
{% endif %}
</div>
<div class="ms-auto">
{% if not conn.connected %}
<i class="bi bi-circle text-secondary fs-5" title="Не подключено"></i>
{% elif conn.is_online %}
<i class="bi bi-circle-fill text-success fs-5" title="Онлайн"></i>
{% else %}
<i class="bi bi-circle-fill text-danger fs-5" title="Офлайн"></i>
{% endif %}
</div>
</div>
<div class="d-grid gap-2">
{% if conn.connected %}
<a href="{{ conn.connect_url }}" class="btn btn-outline-primary btn-sm">Переподключить</a>
<form method="post" action="{{ conn.disconnect_url }}">
<button type="submit" class="btn btn-outline-danger btn-sm w-100">Отключить</button>
</form>
{% else %}
<a href="{{ conn.connect_url }}" class="btn btn-primary btn-sm">Подключить</a>
{% endif %}
</div>
</div>
{% if conn.connected %}
<div class="card-footer text-muted small">
{% if conn.last_checked_at %}
Проверено: {{ conn.last_checked_at.strftime("%d.%m.%Y %H:%M") }}
{% else %}
Статус ещё не проверялся
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endblock %}

View File

@@ -76,8 +76,8 @@
</div>
<div class="mt-3 text-center">
<a href="/profile" class="text-muted small">
<i class="bi bi-arrow-left me-1"></i>Вернуться в личный кабинет
<a href="/connections" class="text-muted small">
<i class="bi bi-arrow-left me-1"></i>Вернуться к подключениям
</a>
</div>