Files
----/backend/app/routes/ci.py
smolkik-code 9b15f8b09c feat: CMDB full-stack app - FastAPI + PostgreSQL + React
- 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)
2026-06-25 13:01:40 +07:00

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}