276 lines
9.1 KiB
Python

import asyncio
import logging
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
logging.basicConfig(level=logging.INFO)
# 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")