IPAM Manager: API, UI-админка, журнал аудита
Backend (FastAPI, SQLAlchemy 2, Alembic, PostgreSQL 16): - организации, VRF, префиксы (дерево, использование, автоназначение), адреса, операторы связи, устройства и типы устройств; JWT, роли admin/viewer; - VRF принадлежит организации (составной FK), смена VRF у префикса переносит поддерево, имя VRF уникально в организации; - журнал аудита: поиск и фильтры, ротация (срок/количество), очистка по паролю с блокировкой, IP клиента и метаданные запроса (X-Forwarded-For только от TRUSTED_PROXIES). UI (web/, без сборки): экраны и диалоги по макетам «IPAM Manager», кликабельные строки реестров, локальные шрифты IBM Plex, собственные выпадающие списки. Окружение: docker-compose (postgres + app), миграции Alembic 0001-0004, scripts/gen_env.py, scripts/seed_demo.py, 11 автотестов (pytest). Документация: README.md и docs/changes/001-005 (планы и итоги). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
a846d30872
64 files changed
+4194
No files matched your search
+81
@@ -0,0 +1,81 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, FastAPI, HTTPException, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.api.v1 import auth, journal, overview, prefixes, refs
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.models import DeviceType, Role, User
|
||||
from app.request_context import RequestContextMiddleware
|
||||
from app.rotation import rotation_loop
|
||||
from app.security import hash_password
|
||||
|
||||
DEFAULT_TYPES = ["Сервер", "Сетевое оборудование", "Сетевое хранилище", "Рабочая станция", "Другое"]
|
||||
|
||||
|
||||
def seed():
|
||||
with SessionLocal() as db:
|
||||
if not db.scalar(select(User.id).limit(1)) and settings.admin_password:
|
||||
db.add(User(username=settings.admin_username, password_hash=hash_password(settings.admin_password), role=Role.admin))
|
||||
if not db.scalar(select(DeviceType.id).limit(1)):
|
||||
db.add_all(DeviceType(name=n, is_default=(n == "Другое")) for n in DEFAULT_TYPES)
|
||||
db.commit()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
seed()
|
||||
task = asyncio.create_task(rotation_loop())
|
||||
yield
|
||||
task.cancel()
|
||||
|
||||
|
||||
app = FastAPI(title="IPAM Manager API", version="1.0.0", lifespan=lifespan)
|
||||
app.add_middleware(RequestContextMiddleware)
|
||||
|
||||
api = APIRouter(prefix="/api/v1")
|
||||
for r in (auth.router, overview.router, refs.router, prefixes.router, journal.router):
|
||||
api.include_router(r)
|
||||
app.include_router(api)
|
||||
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_error(_: Request, exc: HTTPException):
|
||||
extra = exc.detail if isinstance(exc.detail, dict) else {"message": exc.detail} # dict — доп. поля (attempts_left и т.п.)
|
||||
return JSONResponse({"code": exc.status_code, "fields": {}, **extra}, status_code=exc.status_code, headers=exc.headers)
|
||||
|
||||
|
||||
@app.exception_handler(IntegrityError)
|
||||
async def integrity_error(_: Request, exc: IntegrityError): # страховка: нарушение ограничения БД не должно давать 500
|
||||
return JSONResponse({"code": 409, "message": "Конфликт с существующими данными", "fields": {}}, status_code=409)
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_error(_: Request, exc: RequestValidationError):
|
||||
fields = {".".join(str(x) for x in e["loc"][1:]): e["msg"].removeprefix("Value error, ") for e in exc.errors()}
|
||||
return JSONResponse({"code": 422, "message": "Ошибка валидации", "fields": fields}, status_code=422)
|
||||
|
||||
|
||||
@app.get("/healthz", include_in_schema=False)
|
||||
def healthz():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
web = Path(__file__).resolve().parent.parent / "web"
|
||||
if web.is_dir():
|
||||
class RevalidatedStatic(StaticFiles):
|
||||
"""Статика с обязательной ревалидацией по ETag: браузер не держит устаревший UI после обновления."""
|
||||
|
||||
async def get_response(self, path, scope):
|
||||
response = await super().get_response(path, scope)
|
||||
response.headers["Cache-Control"] = "no-cache"
|
||||
return response
|
||||
|
||||
app.mount("/", RevalidatedStatic(directory=web, html=True), name="web")
|
||||
Reference in new issue
Block a user