- PostgreSQL schema: 14 tables, JSONB attributes, audit triggers, soft delete - FastAPI backend: CRUD, search/filter, relationship graph, bulk import, JWT RBAC - React frontend: CI table, detail card, force-graph, dashboard - Seed data: homelab scenario (Proxmox, Mikrotik, VMs, services) - Docker Compose + Kubernetes manifests - 20 backend tests (pytest + httpx)
392 lines
14 KiB
Python
392 lines
14 KiB
Python
import math
|
|
import io
|
|
import csv
|
|
import json
|
|
from uuid import UUID
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from fastapi.responses import StreamingResponse
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, func, or_
|
|
from sqlalchemy.orm import selectinload
|
|
from app.database import get_db
|
|
from app.models.models import (
|
|
ConfigurationItem, CIType, CIClass, Location, User, IPAddress,
|
|
NetworkInterface, HardwareDetail, SoftwareInstance, CIRelationship, Owner
|
|
)
|
|
from app.schemas.schemas import (
|
|
CICreate, CIUpdate, CIResponse, CIDetailResponse, PaginatedResponse,
|
|
IPAddressCreate, IPAddressResponse, RelationshipCreate, RelationshipResponse,
|
|
GraphData, GraphNode, GraphEdge, BulkImportRequest, BulkImportItem, OwnerCreate, OwnerResponse
|
|
)
|
|
from app.middleware.auth import get_current_user, require_editor, require_viewer, require_admin
|
|
|
|
router = APIRouter(prefix="/api/ci", tags=["ci"])
|
|
|
|
|
|
def build_ci_query(filters: dict):
|
|
q = select(ConfigurationItem).where(ConfigurationItem.deleted_at.is_(None))
|
|
|
|
if filters.get("search"):
|
|
term = f"%{filters['search']}%"
|
|
q = q.where(or_(
|
|
ConfigurationItem.name.ilike(term),
|
|
ConfigurationItem.description.ilike(term),
|
|
ConfigurationItem.serial_number.ilike(term),
|
|
ConfigurationItem.asset_tag.ilike(term),
|
|
))
|
|
|
|
if filters.get("status"):
|
|
q = q.where(ConfigurationItem.status == filters["status"])
|
|
|
|
if filters.get("ci_type_id"):
|
|
q = q.where(ConfigurationItem.ci_type_id == UUID(filters["ci_type_id"]))
|
|
|
|
if filters.get("location_id"):
|
|
q = q.where(ConfigurationItem.location_id == UUID(filters["location_id"]))
|
|
|
|
if filters.get("tag"):
|
|
q = q.where(ConfigurationItem.tags.contains([filters["tag"]]))
|
|
|
|
if filters.get("owner_id"):
|
|
owner_sub = select(Owner.ci_id).where(Owner.user_id == UUID(filters["owner_id"]))
|
|
q = q.where(ConfigurationItem.id.in_(owner_sub))
|
|
|
|
return q
|
|
|
|
|
|
@router.get("", response_model=PaginatedResponse)
|
|
async def list_cis(
|
|
page: int = Query(1, ge=1),
|
|
page_size: int = Query(20, ge=1, le=100),
|
|
search: str = Query(None),
|
|
status: str = Query(None),
|
|
ci_type_id: str = Query(None),
|
|
location_id: str = Query(None),
|
|
tag: str = Query(None),
|
|
owner_id: str = Query(None),
|
|
sort_by: str = Query("name"),
|
|
sort_order: str = Query("asc"),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: User = Depends(require_viewer),
|
|
):
|
|
filters = {
|
|
"search": search, "status": status, "ci_type_id": ci_type_id,
|
|
"location_id": location_id, "tag": tag, "owner_id": owner_id,
|
|
}
|
|
|
|
q = build_ci_query(filters)
|
|
|
|
# Sorting
|
|
sort_col = getattr(ConfigurationItem, sort_by, ConfigurationItem.name)
|
|
if sort_order == "desc":
|
|
q = q.order_by(sort_col.desc())
|
|
else:
|
|
q = q.order_by(sort_col.asc())
|
|
|
|
# Count
|
|
count_q = select(func.count()).select_from(
|
|
build_ci_query(filters).subquery()
|
|
)
|
|
total = (await db.execute(count_q)).scalar()
|
|
|
|
# Paginate
|
|
q = q.offset((page - 1) * page_size).limit(page_size)
|
|
q = q.options(
|
|
selectinload(ConfigurationItem.ci_type),
|
|
selectinload(ConfigurationItem.location),
|
|
)
|
|
result = await db.execute(q)
|
|
items = result.scalars().all()
|
|
|
|
return PaginatedResponse(
|
|
items=[CIResponse.model_validate(i) for i in items],
|
|
total=total,
|
|
page=page,
|
|
page_size=page_size,
|
|
pages=math.ceil(total / page_size) if total else 0,
|
|
)
|
|
|
|
|
|
@router.get("/export")
|
|
async def export_cis(
|
|
format: str = Query("csv"),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: User = Depends(require_viewer),
|
|
):
|
|
q = select(ConfigurationItem).where(ConfigurationItem.deleted_at.is_(None))
|
|
result = await db.execute(q)
|
|
items = result.scalars().all()
|
|
|
|
if format == "csv":
|
|
output = io.StringIO()
|
|
writer = csv.writer(output)
|
|
writer.writerow(["name", "type", "status", "location", "serial_number", "tags", "attributes"])
|
|
for ci in items:
|
|
writer.writerow([
|
|
ci.name, str(ci.ci_type_id), ci.status, str(ci.location_id),
|
|
ci.serial_number or "", ",".join(ci.tags or []), json.dumps(ci.attributes or {})
|
|
])
|
|
output.seek(0)
|
|
return StreamingResponse(io.BytesIO(output.getvalue().encode()),
|
|
media_type="text/csv",
|
|
headers={"Content-Disposition": "attachment; filename=cmdb_export.csv"})
|
|
else:
|
|
data = [
|
|
{"name": ci.name, "status": ci.status, "attributes": ci.attributes, "tags": ci.tags}
|
|
for ci in items
|
|
]
|
|
return json.dumps(data, indent=2)
|
|
|
|
|
|
@router.get("/{ci_id}", response_model=CIDetailResponse)
|
|
async def get_ci(ci_id: UUID, db: AsyncSession = Depends(get_db), _user: User = Depends(require_viewer)):
|
|
q = (
|
|
select(ConfigurationItem)
|
|
.where(ConfigurationItem.id == ci_id, ConfigurationItem.deleted_at.is_(None))
|
|
.options(
|
|
selectinload(ConfigurationItem.ci_type),
|
|
selectinload(ConfigurationItem.location),
|
|
)
|
|
)
|
|
result = await db.execute(q)
|
|
ci = result.scalar_one_or_none()
|
|
if not ci:
|
|
raise HTTPException(status_code=404, detail="CI not found")
|
|
|
|
# Load related data
|
|
ips = (await db.execute(
|
|
select(IPAddress).where(IPAddress.ci_id == ci_id, IPAddress.deleted_at.is_(None))
|
|
)).scalars().all()
|
|
|
|
nics = (await db.execute(
|
|
select(NetworkInterface).where(NetworkInterface.ci_id == ci_id, NetworkInterface.deleted_at.is_(None))
|
|
)).scalars().all()
|
|
|
|
hw = (await db.execute(
|
|
select(HardwareDetail).where(HardwareDetail.ci_id == ci_id, HardwareDetail.deleted_at.is_(None))
|
|
)).scalar_one_or_none()
|
|
|
|
sw = (await db.execute(
|
|
select(SoftwareInstance).where(SoftwareInstance.ci_id == ci_id, SoftwareInstance.deleted_at.is_(None))
|
|
)).scalars().all()
|
|
|
|
rels_out = (await db.execute(
|
|
select(CIRelationship).where(CIRelationship.source_ci_id == ci_id, CIRelationship.deleted_at.is_(None))
|
|
)).scalars().all()
|
|
|
|
rels_in = (await db.execute(
|
|
select(CIRelationship).where(CIRelationship.target_ci_id == ci_id, CIRelationship.deleted_at.is_(None))
|
|
)).scalars().all()
|
|
|
|
owners = (await db.execute(
|
|
select(Owner).where(Owner.ci_id == ci_id)
|
|
)).scalars().all()
|
|
|
|
resp = CIDetailResponse.model_validate(ci)
|
|
resp.ip_addresses = [IPAddressResponse.model_validate(ip) for ip in ips]
|
|
resp.network_interfaces = [NetworkInterfaceResponse.model_validate(n) for n in nics]
|
|
resp.hardware_detail = HardwareDetailResponse.model_validate(hw) if hw else None
|
|
resp.software_instances = [SoftwareInstanceResponse.model_validate(s) for s in sw]
|
|
resp.relationships_out = [RelationshipResponse.model_validate(r) for r in rels_out]
|
|
resp.relationships_in = [RelationshipResponse.model_validate(r) for r in rels_in]
|
|
resp.owners = [OwnerResponse.model_validate(o) for o in owners]
|
|
return resp
|
|
|
|
|
|
@router.post("", response_model=CIResponse, status_code=201)
|
|
async def create_ci(body: CICreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_editor)):
|
|
ci = ConfigurationItem(
|
|
ci_type_id=body.ci_type_id,
|
|
name=body.name,
|
|
description=body.description,
|
|
status=body.status,
|
|
location_id=body.location_id,
|
|
serial_number=body.serial_number,
|
|
asset_tag=body.asset_tag,
|
|
purchase_date=body.purchase_date,
|
|
warranty_expiry=body.warranty_expiry,
|
|
attributes=body.attributes,
|
|
tags=body.tags,
|
|
created_by=user.id,
|
|
)
|
|
db.add(ci)
|
|
await db.flush()
|
|
await db.refresh(ci)
|
|
return CIResponse.model_validate(ci)
|
|
|
|
|
|
@router.patch("/{ci_id}", response_model=CIResponse)
|
|
async def update_ci(ci_id: UUID, body: CIUpdate, db: AsyncSession = Depends(get_db), user: User = Depends(require_editor)):
|
|
result = await db.execute(
|
|
select(ConfigurationItem).where(ConfigurationItem.id == ci_id, ConfigurationItem.deleted_at.is_(None))
|
|
)
|
|
ci = result.scalar_one_or_none()
|
|
if not ci:
|
|
raise HTTPException(status_code=404, detail="CI not found")
|
|
|
|
update_data = body.model_dump(exclude_unset=True)
|
|
for field, value in update_data.items():
|
|
setattr(ci, field, value)
|
|
|
|
await db.flush()
|
|
await db.refresh(ci)
|
|
return CIResponse.model_validate(ci)
|
|
|
|
|
|
@router.delete("/{ci_id}", status_code=204)
|
|
async def delete_ci(ci_id: UUID, db: AsyncSession = Depends(get_db), user: User = Depends(require_editor)):
|
|
result = await db.execute(
|
|
select(ConfigurationItem).where(ConfigurationItem.id == ci_id, ConfigurationItem.deleted_at.is_(None))
|
|
)
|
|
ci = result.scalar_one_or_none()
|
|
if not ci:
|
|
raise HTTPException(status_code=404, detail="CI not found")
|
|
|
|
from datetime import datetime
|
|
ci.deleted_at = datetime.utcnow()
|
|
await db.flush()
|
|
|
|
|
|
@router.post("/{ci_id}/restore", response_model=CIResponse)
|
|
async def restore_ci(ci_id: UUID, db: AsyncSession = Depends(get_db), user: User = Depends(require_editor)):
|
|
result = await db.execute(
|
|
select(ConfigurationItem).where(ConfigurationItem.id == ci_id, ConfigurationItem.deleted_at.is_(None) == False)
|
|
)
|
|
ci = result.scalar_one_or_none()
|
|
if not ci:
|
|
raise HTTPException(status_code=404, detail="CI not found")
|
|
ci.deleted_at = None
|
|
await db.flush()
|
|
await db.refresh(ci)
|
|
return CIResponse.model_validate(ci)
|
|
|
|
|
|
# ── Relationships ───────────────────────────────────────
|
|
@router.post("/{ci_id}/relationships", response_model=RelationshipResponse, status_code=201)
|
|
async def add_relationship(ci_id: UUID, body: RelationshipCreate, db: AsyncSession = Depends(get_db), user: User = Depends(require_editor)):
|
|
rel = CIRelationship(
|
|
source_ci_id=ci_id,
|
|
target_ci_id=body.target_ci_id,
|
|
relationship=body.relationship,
|
|
description=body.description,
|
|
metadata_=body.metadata,
|
|
)
|
|
db.add(rel)
|
|
await db.flush()
|
|
await db.refresh(rel)
|
|
return RelationshipResponse.model_validate(rel)
|
|
|
|
|
|
@router.delete("/{ci_id}/relationships/{rel_id}", status_code=204)
|
|
async def remove_relationship(ci_id: UUID, rel_id: UUID, db: AsyncSession = Depends(get_db), user: User = Depends(require_editor)):
|
|
result = await db.execute(
|
|
select(CIRelationship).where(CIRelationship.id == rel_id, CIRelationship.deleted_at.is_(None))
|
|
)
|
|
rel = result.scalar_one_or_none()
|
|
if not rel:
|
|
raise HTTPException(status_code=404, detail="Relationship not found")
|
|
from datetime import datetime
|
|
rel.deleted_at = datetime.utcnow()
|
|
await db.flush()
|
|
|
|
|
|
# ── Graph ───────────────────────────────────────────────
|
|
@router.get("/graph/visualize", response_model=GraphData)
|
|
async def get_graph(
|
|
depth: int = Query(2, ge=1, le=5),
|
|
ci_id: UUID = Query(None),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: User = Depends(require_viewer),
|
|
):
|
|
visited = set()
|
|
nodes = []
|
|
edges = []
|
|
|
|
async def traverse(current_id: UUID, current_depth: int):
|
|
if current_depth > depth or str(current_id) in visited:
|
|
return
|
|
visited.add(str(current_id))
|
|
|
|
ci_result = await db.execute(
|
|
select(ConfigurationItem).where(
|
|
ConfigurationItem.id == current_id,
|
|
ConfigurationItem.deleted_at.is_(None)
|
|
).options(selectinload(ConfigurationItem.ci_type))
|
|
)
|
|
ci = ci_result.scalar_one_or_none()
|
|
if not ci:
|
|
return
|
|
|
|
class_name = ci.ci_type.name if ci.ci_type else "Unknown"
|
|
nodes.append(GraphNode(
|
|
id=str(ci.id), label=ci.name, group=class_name, status=ci.status
|
|
))
|
|
|
|
rels = (await db.execute(
|
|
select(CIRelationship).where(
|
|
or_(CIRelationship.source_ci_id == current_id, CIRelationship.target_ci_id == current_id),
|
|
CIRelationship.deleted_at.is_(None)
|
|
)
|
|
)).scalars().all()
|
|
|
|
for rel in rels:
|
|
neighbor_id = rel.target_ci_id if rel.source_ci_id == current_id else rel.source_ci_id
|
|
edges.append(GraphEdge(
|
|
source=str(rel.source_ci_id), target=str(rel.target_ci_id),
|
|
label=rel.relationship
|
|
))
|
|
await traverse(neighbor_id, current_depth + 1)
|
|
|
|
if ci_id:
|
|
await traverse(ci_id, 0)
|
|
else:
|
|
# Start from first 50 CIs
|
|
all_cis = (await db.execute(
|
|
select(ConfigurationItem).where(ConfigurationItem.deleted_at.is_(None)).limit(50)
|
|
)).scalars().all()
|
|
for ci in all_cis:
|
|
await traverse(ci.id, 0)
|
|
|
|
return GraphData(nodes=nodes, edges=edges)
|
|
|
|
|
|
# ── Bulk Import ─────────────────────────────────────────
|
|
@router.post("/bulk/import", status_code=201)
|
|
async def bulk_import(body: BulkImportRequest, db: AsyncSession = Depends(get_db), user: User = Depends(require_editor)):
|
|
created = 0
|
|
errors = []
|
|
|
|
for idx, item in enumerate(body.items):
|
|
try:
|
|
type_result = await db.execute(
|
|
select(CIType).join(CIClass).where(CIType.name == item.ci_type_name)
|
|
)
|
|
ci_type = type_result.scalar_one_or_none()
|
|
if not ci_type:
|
|
errors.append({"index": idx, "error": f"Type '{item.ci_type_name}' not found"})
|
|
continue
|
|
|
|
location_id = None
|
|
if item.location_name:
|
|
loc_result = await db.execute(select(Location).where(Location.name == item.location_name))
|
|
loc = loc_result.scalar_one_or_none()
|
|
if loc:
|
|
location_id = loc.id
|
|
|
|
ci = ConfigurationItem(
|
|
ci_type_id=ci_type.id,
|
|
name=item.name,
|
|
description=item.description,
|
|
status=item.status,
|
|
location_id=location_id,
|
|
attributes=item.attributes,
|
|
tags=item.tags,
|
|
created_by=user.id,
|
|
)
|
|
db.add(ci)
|
|
created += 1
|
|
except Exception as e:
|
|
errors.append({"index": idx, "error": str(e)})
|
|
|
|
return {"created": created, "errors": errors}
|