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 all categories across all policies all_categories = list({cat for p in policies for cat in p.categories}) # 2. Find all active objects 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_(all_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_(all_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: # Sum across all categories in this policy total = sum(eligible_map.get((obj_id, cat), 0) for cat in policy.categories) if total == 0: continue snap_label = _policy_snapshot_label(policy) success_count = sum( (stats_map.get((obj_id, cat, snap_label)) or (0, None))[0] for cat in policy.categories ) # Latest collected_at across categories last_collected_at = None for cat in policy.categories: stats = stats_map.get((obj_id, cat, snap_label)) if stats and stats[1]: if last_collected_at is None or stats[1] > last_collected_at: last_collected_at = stats[1] obj_policies.append(PolicySnapshotStatus( policy_id=policy.id, policy_label=policy.label, categories=policy.categories, 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( categories=body.categories, 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 categories.""" 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": "filter", "category": policy.categories, }, 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 categories.""" 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": "filter", "object_id": obj_id, "category": policy.categories, }, 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 matching policy categories devices_q = ( select(Device.id, Device.ip, Device.hostname, Device.status) .where( Device.object_id == obj_id, Device.category.in_(policy.categories), 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) 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, "status": d.status, "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}