import asyncio import os from contextlib import asynccontextmanager from datetime import datetime, timezone from contextlib import suppress from fastapi import FastAPI, HTTPException, Response from fastapi.middleware.cors import CORSMiddleware from fastapi.openapi.docs import get_swagger_ui_html from starlette.exceptions import HTTPException as StarletteHTTPException from sqlalchemy import text from sqlalchemy.exc import SQLAlchemyError from fastapi.staticfiles import StaticFiles from starlette.middleware.gzip import GZipMiddleware from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware from src.api.v1.router import api_router from src.core.config import settings from src.core.exception_handlers import register_exception_handlers from src.db.base import engine from src.db.session import create_tables if not settings.DEBUG: from raisa_fastapi_protected_api import ( AuthorizationMiddleware, OpenEndpoint, ProtectedOAuthSettings, ProtectedRolesSettings, ProtectedSettings, SearchType, ) _AUDIT_CLEANUP_LOCK_KEY = 21987431 async def _cleanup_audit_log_once() -> int: if "postgresql" not in settings.DATABASE_URL: return 0 total_deleted = 0 async with engine.begin() as conn: lock_ok = ( await conn.execute( text("SELECT pg_try_advisory_lock(:k)"), {"k": _AUDIT_CLEANUP_LOCK_KEY}, ) ).scalar_one() if not lock_ok: return 0 try: while True: deleted = ( await conn.execute( text( """ WITH doomed AS ( SELECT id FROM v3.audit_log WHERE event_dt < now() - make_interval(days => :retention_days) ORDER BY id LIMIT :batch_size ) DELETE FROM v3.audit_log a USING doomed d WHERE a.id = d.id RETURNING a.id """ ), { "retention_days": settings.AUDIT_LOG_RETENTION_DAYS, "batch_size": settings.AUDIT_LOG_CLEANUP_BATCH_SIZE, }, ) ).rowcount if not deleted: break total_deleted += deleted finally: await conn.execute( text("SELECT pg_advisory_unlock(:k)"), {"k": _AUDIT_CLEANUP_LOCK_KEY}, ) return total_deleted async def _audit_cleanup_loop() -> None: # Первый прогон сразу после старта, дальше — по интервалу. while True: try: await _cleanup_audit_log_once() except Exception: pass await asyncio.sleep(max(60, settings.AUDIT_LOG_CLEANUP_INTERVAL_SECONDS)) @asynccontextmanager async def lifespan(app: FastAPI): cleanup_task: asyncio.Task | None = None if not settings.DEBUG: await create_tables() ProtectedSettings( ProtectedOAuthSettings(JWKS_URI=settings.JWKS_URL), ProtectedRolesSettings( APP_NAMESPACE=settings.APP_NAMESPACE, APP_NAME=settings.APP_NAME, ), ) cleanup_task = asyncio.create_task(_audit_cleanup_loop()) try: yield finally: if cleanup_task: cleanup_task.cancel() with suppress(asyncio.CancelledError): await cleanup_task await engine.dispose() app = FastAPI( title="DFiP Budget Planning API", version="1.0.0", lifespan=lifespan, root_path=settings.ROOT_PATH, ) register_exception_handlers(app) VERSION = "1.0.0" UP_TIME = datetime.now(timezone.utc) @app.get("/healthcheck", status_code=200) async def healthcheck(): return {"uptime": UP_TIME, "version": VERSION} @app.get("/healthcheck2", status_code=200) async def healthcheck2(): return { "uptime": UP_TIME, "version": VERSION, "debug": settings.DEBUG, "host": settings.HOST, "port": settings.PORT, } @app.get("/healthz", status_code=200) async def healthz(): return {"status": "ok", "uptime": UP_TIME, "version": VERSION} @app.get("/readyz") async def readyz(response: Response): result = {"status": "ok", "db_status": "ok", "mv_expense_item_tree": "ok"} try: async with engine.connect() as conn: await conn.execute(text("SELECT 1")) if "postgresql" in settings.DATABASE_URL: my_exists = ( await conn.execute( text( """ SELECT 1 FROM pg_matviews WHERE schemaname = 'v3' AND matviewname = 'mv_expense_item_tree' """ ) ) ).scalar_one_or_none() if my_exists is None: result["status"] = "fail" result["mv_expense_item_tree"] = "missing" response.status_code = 503 except SQLAlchemyError as exc: result["status"] = "fail" result["db"] = f"error: {exc.__class__.__name__}" response.status_code = 503 return result if not settings.DEBUG: app.add_middleware( AuthorizationMiddleware, open_endpoints=[ OpenEndpoint(path="", type_search=SearchType.ABSOLUTE), OpenEndpoint(path=settings.ROOT_PATH + "", type_search=SearchType.ABSOLUTE), OpenEndpoint(path="/", type_search=SearchType.ABSOLUTE), OpenEndpoint(path=settings.ROOT_PATH + "/", type_search=SearchType.ABSOLUTE), OpenEndpoint(path="/healthcheck", type_search=SearchType.ABSOLUTE), OpenEndpoint( path=settings.ROOT_PATH + "/healthcheck", type_search=SearchType.ABSOLUTE, ), OpenEndpoint(path="/healthz", type_search=SearchType.ABSOLUTE), OpenEndpoint( path=settings.ROOT_PATH + "/healthz", type_search=SearchType.ABSOLUTE, ), OpenEndpoint(path="/readyz", type_search=SearchType.ABSOLUTE), OpenEndpoint( path=settings.ROOT_PATH + "/readyz", type_search=SearchType.ABSOLUTE, ), OpenEndpoint(path="/openapi.json", type_search=SearchType.START), OpenEndpoint( path=settings.ROOT_PATH + "/openapi.json", type_search=SearchType.START, ), OpenEndpoint(path="/docs", type_search=SearchType.START), OpenEndpoint( path=settings.ROOT_PATH + "/docs", type_search=SearchType.START, ), ], ) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) app.add_middleware(ProxyHeadersMiddleware, trusted_hosts=["*"]) app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=1) os.makedirs("web", exist_ok=True) app.include_router(api_router, prefix="/api/v1") @app.get("/docs-local", include_in_schema=False) async def custom_swagger_ui_html(): return get_swagger_ui_html( openapi_url=f"{settings.ROOT_PATH}{app.openapi_url}", # Путь к OpenAPI схеме (обычно /openapi.json) title=app.title + " - Swagger UI", oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url, # Указываем пути к локальным файлам swagger_js_url=f"{settings.ROOT_PATH}/back-static/swagger-ui-bundle.js", swagger_css_url=f"{settings.ROOT_PATH}/back-static/swagger-ui.css", ) def sep_static(path, stat_dir): sep_path = path.split("/") index = sep_path.index(stat_dir) return "/".join(sep_path[index:]) class SPAStaticFiles(StaticFiles): async def get_response(self, path: str, scope): try: return await super().get_response(path, scope) except (HTTPException, StarletteHTTPException) as ex: if ex.status_code == 404: if "images" in path: return await super().get_response(sep_static(path, "images"), scope) if "static" in path: return await super().get_response(sep_static(path, "static"), scope) return await super().get_response("index.html", scope) else: raise ex app.mount("/back-static", StaticFiles(directory="back_static"), name="back-static") app.mount("/", SPAStaticFiles(directory="web", html=True), name="web") app.mount("/", StaticFiles(directory="web", html=True), name="web")