Files
ipam_control/app/main.py
T
ayurishchevandClaude Sonnet 5 cd09ef0805 Задачи 006-010: пользователи, исправление удаления, журнал отказов, групповые операции, автовыделение префиксов
006 Пользователи: API /users (CRUD, смена своего пароля), раздел UI «Пользователи»,
    события журнала user.*, защита от отключения/удаления себя и последнего админа.
007 Исправление удаления организации: VRF удаляются явным DELETE до организации
    (без relationship() порядок DELETE не гарантирован → ложный 409).
008 Журнал фиксирует отказы в удалении (<entity>.delete_blocked) со списком
    мешающих объектов в «Данных»: организация, VRF, тип устройства, префикс, пользователь.
009 Выбор строк чекбоксами и групповые операции в UI (удаление, смена типа устройств,
    статус префиксов и адресов, доступ пользователей); цикл запросов из UI, итог и список отказов.
010 Автовыделение следующего вложенного префикса: POST/GET /prefixes/{id}/subnets/next,
    первый свободный выровненный блок; пункт «Добавить вложенный (авто)» в меню префикса.

Тесты: 14 (добавлены сценарии для 006, 007/008, 010); исправлена нестабильность
тестов журнала (IPv6-группы с ведущими нулями нормализуются PostgreSQL).
Документация: README.md, docs/changes/006-010 (планы и итоги).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 13:25:33 +03:00

82 lines
3.4 KiB
Python

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, users
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, users.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")