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:
189
docs/plans/vk-connection.md
Normal file
189
docs/plans/vk-connection.md
Normal 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
|
||||
Reference in New Issue
Block a user