"""API v1: эндпоинты публикации, конвертации, управления аккаунтами.""" from __future__ import annotations import json from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.models import User, VkAccount, Publication from app.security import decrypt_token, encrypt_token from app.vk_client import VkClient, VkApiError from app.converters.markdown_to_vk import markdown_to_vk, format_data_json from app.api.schemas import ( AccountCreateRequest, AccountResponse, AccountListResponse, PublishRequest, PublishResponse, ConvertRequest, ConvertResponse, PublicationFilterRequest, PublicationItem, PublicationListResponse, ) from app.api.deps import get_current_user_from_header, get_user_by_api_key router = APIRouter(prefix="/api/v1", tags=["v1"]) # ─── Health ───────────────────────────────────────────────────────────────── @router.get("/health") async def health(): return {"status": "ok"} # ─── Аккаунты ────────────────────────────────────────────────────────────── @router.post("/accounts", response_model=AccountResponse) async def create_account( req: AccountCreateRequest, db: AsyncSession = Depends(get_db), ): """Добавить VK-аккаунт. Проверяет токен через VK API перед сохранением.""" user = await get_user_by_api_key(req.api_key, db) # Проверяем токен через VK API vk = VkClient(req.access_token) try: is_valid, display_name = await vk.check_token() if not is_valid: raise HTTPException(status_code=400, detail=f"VK token invalid: {display_name}") except VkApiError as e: raise HTTPException(status_code=400, detail=f"VK API error: {e}") finally: await vk.close() # Шифруем токен перед сохранением encrypted = encrypt_token(req.access_token) name = req.display_name or display_name or f"VK-{req.vk_user_id}" # Проверяем, нет ли уже такого VK-аккаунта у пользователя existing = await db.execute( select(VkAccount).where( VkAccount.user_id == user.id, VkAccount.vk_user_id == req.vk_user_id, VkAccount.is_active == True, ) ) if existing.scalar_one_or_none(): raise HTTPException(status_code=409, detail="This VK account is already registered") account = VkAccount( user_id=user.id, vk_user_id=req.vk_user_id, display_name=name, access_token_enc=encrypted, token_type=req.token_type, ) db.add(account) await db.flush() await db.refresh(account) return AccountResponse( id=account.id, vk_user_id=account.vk_user_id, display_name=account.display_name, token_type=account.token_type, is_active=account.is_active, created_at=account.created_at, ) @router.get("/accounts", response_model=AccountListResponse) async def list_accounts( user: User = Depends(get_current_user_from_header), db: AsyncSession = Depends(get_db), ): """Список VK-аккаунтов текущего пользователя.""" result = await db.execute( select(VkAccount).where(VkAccount.user_id == user.id, VkAccount.is_active == True) ) accounts = result.scalars().all() return AccountListResponse( accounts=[ AccountResponse( id=a.id, vk_user_id=a.vk_user_id, display_name=a.display_name, token_type=a.token_type, is_active=a.is_active, created_at=a.created_at, ) for a in accounts ] ) @router.delete("/accounts/{account_id}", status_code=204) async def delete_account( account_id: int, user: User = Depends(get_current_user_from_header), db: AsyncSession = Depends(get_db), ): """Удалить VK-аккаунт (soft delete).""" result = await db.execute( select(VkAccount).where( VkAccount.id == account_id, VkAccount.user_id == user.id, ) ) account = result.scalar_one_or_none() if account is None: raise HTTPException(status_code=404, detail="Account not found") account.is_active = False await db.flush() # ─── Публикация ──────────────────────────────────────────────────────────── @router.post("/publish", response_model=PublishResponse) async def publish( req: PublishRequest, db: AsyncSession = Depends(get_db), ): """Опубликовать пост на стене VK.""" # Аутентификация по api_key из тела запроса user = await get_user_by_api_key(req.api_key, db) # Получаем VK-аккаунт result = await db.execute( select(VkAccount).where( VkAccount.id == req.vk_account_id, VkAccount.user_id == user.id, VkAccount.is_active == True, ) ) account = result.scalar_one_or_none() if account is None: raise HTTPException(status_code=404, detail="VK account not found") # Расшифровываем токен (только в памяти!) token = decrypt_token(account.access_token_enc) if token is None: raise HTTPException(status_code=500, detail="Failed to decrypt VK token (key mismatch?)") # Конвертируем Markdown chunks = markdown_to_vk(req.message_md) if not chunks or not chunks[0].text.strip(): raise HTTPException(status_code=400, detail="Empty message after markdown conversion") chunk = chunks[0] fd_json = format_data_json(chunk.items) if chunk.items else None # Создаём запись о публикации publication = Publication( vk_account_id=account.id, status="draft", markdown_original=req.message_md, vk_text=chunk.text, vk_format_data=fd_json, scheduled_at=req.publish_date, ) db.add(publication) await db.flush() # Если отложенная — сохраняем и выходим if req.publish_date: publication.status = "scheduled" await db.flush() return PublishResponse( success=True, publication_id=publication.id, ) # Публикуем через VK API vk = VkClient(token) try: result = await vk.wall_post( message=chunk.text, owner_id=account.vk_user_id if account.token_type == "group" else None, from_group=(account.token_type == "group"), friends_only=req.friends_only, publish_date=int(req.publish_date.timestamp()) if req.publish_date else None, attachments=req.attachments, signed=req.signed, format_data=chunk.items if chunk.items else None, ) post_id = result.get("post_id") owner_id = result.get("owner_id") or account.vk_user_id publication.status = "published" publication.vk_post_id = post_id publication.vk_owner_id = owner_id publication.published_at = datetime.utcnow() await db.flush() url = f"https://vk.com/wall{owner_id}_{post_id}" return PublishResponse( success=True, publication_id=publication.id, vk_post_id=post_id, vk_owner_id=owner_id, url=url, ) except VkApiError as e: publication.status = "error" publication.error_message = str(e) await db.flush() return PublishResponse( success=False, publication_id=publication.id, error=str(e), ) finally: await vk.close() @router.post("/convert", response_model=ConvertResponse) async def convert(req: ConvertRequest): """Конвертировать Markdown в VK format_data (без публикации).""" chunks = markdown_to_vk(req.message_md) if not chunks: return ConvertResponse(text="", format_data={"version": 1, "items": []}) chunk = chunks[0] items = [] for i in chunk.items: items.append({"type": i.type, "offset": i.offset, "length": i.length, "url": i.url}) return ConvertResponse( text=chunk.text, format_data={"version": 1, "items": items}, ) # ─── Архив публикаций ────────────────────────────────────────────────────── @router.post("/publications", response_model=PublicationListResponse) async def list_publications( req: PublicationFilterRequest, db: AsyncSession = Depends(get_db), ): """Архив публикаций с фильтрацией.""" user = await get_user_by_api_key(req.api_key, db) # Базовый запрос — только публикации пользователя base_filter = VkAccount.user_id == user.id query = ( select(Publication) .join(VkAccount) .where(base_filter) ) count_query = ( select(func.count(Publication.id)) .join(VkAccount) .where(base_filter) ) if req.vk_account_id is not None: query = query.where(Publication.vk_account_id == req.vk_account_id) count_query = count_query.where(Publication.vk_account_id == req.vk_account_id) if req.status is not None: query = query.where(Publication.status == req.status) count_query = count_query.where(Publication.status == req.status) total_result = await db.execute(count_query) total = total_result.scalar() or 0 query = query.order_by(Publication.created_at.desc()).offset(req.offset).limit(req.limit) result = await db.execute(query) publications = result.scalars().all() return PublicationListResponse( publications=[ PublicationItem( id=p.id, vk_account_id=p.vk_account_id, status=p.status, markdown_original=p.markdown_original, vk_post_id=p.vk_post_id, vk_owner_id=p.vk_owner_id, scheduled_at=p.scheduled_at, published_at=p.published_at, error_message=p.error_message, created_at=p.created_at, ) for p in publications ], total=total, )