utp_service/backend/app/routers/snapshot_policies.py

351 lines
12 KiB
Python

from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db
from app.models.device import Device
from app.models.object import Object
from app.models.snapshot import ConfigSnapshot, SnapshotContent
from app.models.snapshot_policy import SnapshotPolicy
from app.models.user import User
from app.schemas.snapshot_policy import (
ObjectSnapshotSummary,
PolicySnapshotStatus,
SnapshotPolicyCreate,
SnapshotPolicyRead,
SnapshotPolicyUpdate,
)
from app.services import task as task_service
router = APIRouter(prefix="/api/v1", tags=["snapshot-policies"])
def _policy_snapshot_label(policy: SnapshotPolicy) -> str:
if policy.action_type == "mikrotik_get_config":
return "routeros-export"
return policy.params.get("path", "")
# ─── Dashboard ───────────────────────────────────────────────────────────────
@router.get("/snapshots/dashboard", response_model=list[ObjectSnapshotSummary])
async def snapshot_dashboard(
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
# 1. Load all active global policies
policies_result = await db.execute(
select(SnapshotPolicy).where(SnapshotPolicy.is_active == True).order_by(SnapshotPolicy.label)
)
policies = policies_result.scalars().all()
if not policies:
return []
# Collect categories we care about
categories = list({p.category for p in policies})
# 2. Find all active objects that have devices in relevant categories
obj_q = (
select(Object.id, Object.name, Object.city, Object.client)
.where(Object.is_active == True)
.order_by(Object.city, Object.name)
)
obj_result = await db.execute(obj_q)
all_objects = obj_result.all()
if not all_objects:
return []
# 3. Count eligible devices per (object_id, category)
eligible_q = (
select(
Device.object_id,
Device.category,
func.count(Device.id).label("total"),
)
.where(Device.is_active == True, Device.category.in_(categories))
.group_by(Device.object_id, Device.category)
)
eligible_result = await db.execute(eligible_q)
eligible_map: dict[tuple[int, str], int] = {
(r.object_id, r.category): r.total for r in eligible_result.all()
}
# 4. Snapshot stats per (object_id, category, label)
stats_q = (
select(
Device.object_id,
Device.category,
ConfigSnapshot.label,
func.count(func.distinct(ConfigSnapshot.device_id)).label("success_count"),
func.max(ConfigSnapshot.created_at).label("last_collected_at"),
)
.join(Device, ConfigSnapshot.device_id == Device.id)
.where(Device.is_active == True, Device.category.in_(categories))
.group_by(Device.object_id, Device.category, ConfigSnapshot.label)
)
stats_result = await db.execute(stats_q)
stats_map: dict[tuple[int, str, str], tuple[int, object]] = {
(r.object_id, r.category, r.label): (r.success_count, r.last_collected_at)
for r in stats_result.all()
}
# 5. Assemble: for each object, build policy statuses
result = []
for obj_id, obj_name, obj_city, obj_client in all_objects:
obj_policies = []
for policy in policies:
total = eligible_map.get((obj_id, policy.category), 0)
if total == 0:
continue
snap_label = _policy_snapshot_label(policy)
stats = stats_map.get((obj_id, policy.category, snap_label))
success_count = stats[0] if stats else 0
last_collected_at = stats[1] if stats else None
obj_policies.append(PolicySnapshotStatus(
policy_id=policy.id,
policy_label=policy.label,
category=policy.category,
action_type=policy.action_type,
params=policy.params,
last_collected_at=last_collected_at,
success_count=success_count,
total_devices=total,
))
if obj_policies:
result.append(ObjectSnapshotSummary(
object_id=obj_id,
object_name=obj_name,
city=obj_city,
client=obj_client,
policies=obj_policies,
))
return result
# ─── Policies CRUD (global) ──────────────────────────────────────────────────
@router.get("/snapshot-policies", response_model=list[SnapshotPolicyRead])
async def list_policies(
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
result = await db.execute(
select(SnapshotPolicy).order_by(SnapshotPolicy.label)
)
return result.scalars().all()
@router.post("/snapshot-policies", response_model=SnapshotPolicyRead, status_code=201)
async def create_policy(
body: SnapshotPolicyCreate,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
policy = SnapshotPolicy(
category=body.category,
action_type=body.action_type,
params=body.params,
label=body.label,
is_active=body.is_active,
)
db.add(policy)
await db.flush()
await db.refresh(policy)
return policy
@router.put("/snapshot-policies/{policy_id}", response_model=SnapshotPolicyRead)
async def update_policy(
policy_id: int,
body: SnapshotPolicyUpdate,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
policy = await db.get(SnapshotPolicy, policy_id)
if policy is None:
raise HTTPException(status_code=404, detail="Policy not found")
for field, value in body.model_dump(exclude_unset=True).items():
setattr(policy, field, value)
await db.flush()
await db.refresh(policy)
return policy
@router.delete("/snapshot-policies/{policy_id}", status_code=204)
async def delete_policy(
policy_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
policy = await db.get(SnapshotPolicy, policy_id)
if policy is None:
raise HTTPException(status_code=404, detail="Policy not found")
await db.delete(policy)
await db.flush()
# ─── Collect ─────────────────────────────────────────────────────────────────
@router.post("/snapshot-policies/{policy_id}/collect", status_code=202)
async def collect_all(
policy_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Collect snapshots for ALL objects matching this policy's category."""
policy = await db.get(SnapshotPolicy, policy_id)
if policy is None:
raise HTTPException(status_code=404, detail="Policy not found")
if not policy.is_active:
raise HTTPException(status_code=400, detail="Policy is disabled")
task = await task_service.create_task(
action_type=policy.action_type,
target_scope={
"type": "category",
"category": policy.category,
},
params=policy.params,
user_id=current_user.id,
)
return {"task_id": task.id}
@router.post("/snapshot-policies/{policy_id}/collect/{obj_id}", status_code=202)
async def collect_for_object(
policy_id: int,
obj_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Collect snapshots for a specific object matching this policy's category."""
policy = await db.get(SnapshotPolicy, policy_id)
if policy is None:
raise HTTPException(status_code=404, detail="Policy not found")
if not policy.is_active:
raise HTTPException(status_code=400, detail="Policy is disabled")
obj = await db.get(Object, obj_id)
if obj is None:
raise HTTPException(status_code=404, detail="Object not found")
task = await task_service.create_task(
action_type=policy.action_type,
target_scope={
"type": "category",
"object_id": obj_id,
"category": policy.category,
},
params=policy.params,
user_id=current_user.id,
)
return {"task_id": task.id}
# ─── Device details per object+policy ────────────────────────────────────────
@router.get("/snapshots/dashboard/{obj_id}/{policy_id}/devices")
async def dashboard_devices(
obj_id: int,
policy_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
"""Return per-device snapshot status for a given object and policy."""
policy = await db.get(SnapshotPolicy, policy_id)
if policy is None:
raise HTTPException(status_code=404, detail="Policy not found")
snap_label = _policy_snapshot_label(policy)
# All eligible devices for this object+category
devices_q = (
select(Device.id, Device.ip, Device.hostname)
.where(
Device.object_id == obj_id,
Device.category == policy.category,
Device.is_active == True,
)
.order_by(Device.ip)
)
devices_result = await db.execute(devices_q)
devices = devices_result.all()
if not devices:
return []
device_ids = [d.id for d in devices]
# Latest snapshot per device matching this label
latest_snap_q = (
select(
ConfigSnapshot.device_id,
func.max(ConfigSnapshot.created_at).label("last_at"),
)
.where(
ConfigSnapshot.device_id.in_(device_ids),
ConfigSnapshot.label == snap_label,
)
.group_by(ConfigSnapshot.device_id)
)
latest_result = await db.execute(latest_snap_q)
latest_map: dict[int, object] = {r.device_id: r.last_at for r in latest_result.all()}
# Get snapshot IDs for content access (latest per device)
snap_ids_q = (
select(ConfigSnapshot.id, ConfigSnapshot.device_id, ConfigSnapshot.sha256)
.where(
ConfigSnapshot.device_id.in_(device_ids),
ConfigSnapshot.label == snap_label,
)
.order_by(ConfigSnapshot.created_at.desc())
)
snap_ids_result = await db.execute(snap_ids_q)
# Take first (latest) per device
device_snap_map: dict[int, tuple[int, str]] = {}
for r in snap_ids_result.all():
if r.device_id not in device_snap_map:
device_snap_map[r.device_id] = (r.id, r.sha256)
result = []
for d in devices:
has_snapshot = d.id in latest_map
snap_info = device_snap_map.get(d.id)
result.append({
"device_id": d.id,
"ip": d.ip,
"hostname": d.hostname,
"has_snapshot": has_snapshot,
"last_collected_at": latest_map.get(d.id),
"snapshot_id": snap_info[0] if snap_info else None,
})
return result
@router.get("/snapshots/dashboard/content/{snapshot_id}")
async def get_snapshot_content(
snapshot_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
"""Return raw snapshot content for download/copy."""
snap = await db.get(ConfigSnapshot, snapshot_id)
if snap is None:
raise HTTPException(status_code=404, detail="Snapshot not found")
content_row = await db.get(SnapshotContent, snap.sha256)
if content_row is None:
raise HTTPException(status_code=404, detail="Content not found")
return {"content": content_row.content}