From 379f781e1e822e4c2d31a3beea7e2c3fb34cd9eb Mon Sep 17 00:00:00 2001 From: mguschin Date: Fri, 6 Mar 2026 15:26:49 +0300 Subject: [PATCH] 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 --- docker-compose.yml | 18 +- docs/plans/connections-dashboard.md | 95 +++++++++ docs/plans/vk-connection.md | 189 ++++++++++++++++++ web/config.py | 2 + web/health_checker.py | 48 +++++ web/main.py | 32 ++- ...e_last_checked_at_to_evotor_connections.py | 26 +++ web/models.py | 2 + web/routes/connections.py | 42 ++++ web/routes/evotor.py | 9 +- web/routes/reset.py | 2 +- web/templates/base.html | 2 +- web/templates/confirm_email.html | 3 +- web/templates/connections.html | 55 +++++ web/templates/evotor.html | 4 +- 15 files changed, 510 insertions(+), 19 deletions(-) create mode 100644 docs/plans/connections-dashboard.md create mode 100644 docs/plans/vk-connection.md create mode 100644 web/health_checker.py create mode 100644 web/migrations/versions/a1b2c3d4e5f6_add_is_online_last_checked_at_to_evotor_connections.py create mode 100644 web/routes/connections.py create mode 100644 web/templates/connections.html diff --git a/docker-compose.yml b/docker-compose.yml index 8600d5b..56d5aa6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/plans/connections-dashboard.md b/docs/plans/connections-dashboard.md new file mode 100644 index 0000000..bdf7285 --- /dev/null +++ b/docs/plans/connections-dashboard.md @@ -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 diff --git a/docs/plans/vk-connection.md b/docs/plans/vk-connection.md new file mode 100644 index 0000000..220127c --- /dev/null +++ b/docs/plans/vk-connection.md @@ -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 diff --git a/web/config.py b/web/config.py index 1f456c9..f122e49 100644 --- a/web/config.py +++ b/web/config.py @@ -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 = "" diff --git a/web/health_checker.py b/web/health_checker.py new file mode 100644 index 0000000..371b561 --- /dev/null +++ b/web/health_checker.py @@ -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) diff --git a/web/main.py b/web/main.py index 81b0c02..85db4f9 100644 --- a/web/main.py +++ b/web/main.py @@ -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) diff --git a/web/migrations/versions/a1b2c3d4e5f6_add_is_online_last_checked_at_to_evotor_connections.py b/web/migrations/versions/a1b2c3d4e5f6_add_is_online_last_checked_at_to_evotor_connections.py new file mode 100644 index 0000000..6a86a68 --- /dev/null +++ b/web/migrations/versions/a1b2c3d4e5f6_add_is_online_last_checked_at_to_evotor_connections.py @@ -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') diff --git a/web/models.py b/web/models.py index 353eb47..5380ad4 100644 --- a/web/models.py +++ b/web/models.py @@ -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) diff --git a/web/routes/connections.py b/web/routes/connections.py new file mode 100644 index 0000000..c17127f --- /dev/null +++ b/web/routes/connections.py @@ -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, + }) diff --git a/web/routes/evotor.py b/web/routes/evotor.py index c2b756e..521957c 100644 --- a/web/routes/evotor.py +++ b/web/routes/evotor.py @@ -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) diff --git a/web/routes/reset.py b/web/routes/reset.py index e17454a..018e3b3 100644 --- a/web/routes/reset.py +++ b/web/routes/reset.py @@ -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 существует, мы отправили письмо со ссылкой для сброса пароля.", }) diff --git a/web/templates/base.html b/web/templates/base.html index 6fa8865..55a6efe 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -19,7 +19,7 @@