271 lines
9.1 KiB
Python
271 lines
9.1 KiB
Python
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")
|