"""Точка входа FastAPI приложения — Академический помощник.""" import logging from contextlib import asynccontextmanager from typing import AsyncGenerator from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from app.api import admin, auth, documents, reports, search, tasks from app.config import settings from app.core.minio_client import ensure_bucket from app.core.redis_client import close_pool from app.core.security import get_ws_user from app.core.websocket_manager import ws_manager from app.database import engine logger = logging.getLogger(__name__) logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: """Инициализация при старте, очистка при остановке.""" logger.info("Запуск Академического помощника...") # Проверяем PostgreSQL try: import sqlalchemy async with engine.connect() as conn: await conn.execute(sqlalchemy.text("SELECT 1")) logger.info("PostgreSQL: OK") except Exception as e: logger.error(f"PostgreSQL: {e}") # Инициализируем MinIO бакеты (один раз при старте, не на каждый запрос) try: ensure_bucket(settings.MINIO_BUCKET_DOCS) ensure_bucket(settings.MINIO_BUCKET_BACKUPS) logger.info("MinIO: бакеты готовы") except Exception as e: logger.error(f"MinIO: {e}") yield logger.info("Остановка приложения...") await engine.dispose() await close_pool() app = FastAPI( title="Академический помощник API", version="0.1.0", lifespan=lifespan, docs_url="/api/docs" if settings.ENVIRONMENT == "development" else None, redoc_url=None, openapi_url="/api/openapi.json" if settings.ENVIRONMENT == "development" else None, ) # ─── CORS ───────────────────────────────────────────────────────────────────── # allow_credentials=True несовместим с allow_origins=["*"]. # Указываем явные origins. Wildcard заголовки/методы запрещены с credentials. app.add_middleware( CORSMiddleware, allow_origins=settings.CORS_ORIGINS, allow_credentials=True, allow_methods=["GET", "POST", "DELETE", "OPTIONS"], allow_headers=["Authorization", "Content-Type", "Accept"], ) # ─── Роутеры ────────────────────────────────────────────────────────────────── app.include_router(auth.router, prefix="/api") app.include_router(tasks.router, prefix="/api") app.include_router(search.router, prefix="/api") app.include_router(documents.router, prefix="/api") app.include_router(reports.router, prefix="/api") app.include_router(admin.router, prefix="/api") # ─── WebSocket ──────────────────────────────────────────────────────────────── @app.websocket("/ws/tasks/{public_id}") async def websocket_task_updates( websocket: WebSocket, public_id: str, # Аутентификация через ?token=JWT (браузеры не могут передавать Authorization header в WS) current_user=Depends(get_ws_user), ) -> None: """ Подписка на обновления задачи в реальном времени. Подключение: ws://host/ws/tasks/{public_id}?token= Пользователь получает только обновления СВОИХ задач — ownership проверяется до установки соединения. """ from sqlalchemy import select from app.database import AsyncSessionLocal from app.models.task import Task # Проверяем что задача принадлежит этому пользователю async with AsyncSessionLocal() as db: result = await db.execute( select(Task).where( Task.public_id == public_id, Task.user_id == current_user.id, ) ) task = result.scalar_one_or_none() if task is None: await websocket.close(code=4004, reason="Task not found or access denied") return await ws_manager.connect(public_id, websocket) try: while True: data = await websocket.receive_text() if data == "ping": await websocket.send_text("pong") except WebSocketDisconnect: ws_manager.disconnect(public_id, websocket) # ─── Health ──────────────────────────────────────────────────────────────────── @app.get("/health", tags=["monitoring"]) async def health_check() -> JSONResponse: return JSONResponse({"status": "ok", "version": "0.1.0"}) @app.get("/", include_in_schema=False) async def root() -> JSONResponse: return JSONResponse({"message": "Академический помощник API"})