diff --git a/backend/app/main.py b/backend/app/main.py index e094696..ed4ffea 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -390,6 +390,7 @@ def create_app() -> FastAPI: from app.routers.settings import router as settings_router from app.routers.remote_access import router as remote_access_router from app.routers.winbox_remote import router as winbox_remote_router + from app.routers.sites import router as sites_router app.include_router(auth_router, prefix="/api") app.include_router(tenants_router, prefix="/api") @@ -419,6 +420,7 @@ def create_app() -> FastAPI: app.include_router(settings_router, prefix="/api") app.include_router(remote_access_router, prefix="/api") app.include_router(winbox_remote_router, prefix="/api") + app.include_router(sites_router, prefix="/api") # Health check endpoints @app.get("/health", tags=["health"]) diff --git a/backend/app/routers/sites.py b/backend/app/routers/sites.py new file mode 100644 index 0000000..63701db --- /dev/null +++ b/backend/app/routers/sites.py @@ -0,0 +1,191 @@ +""" +Site management API endpoints. + +Routes: /api/tenants/{tenant_id}/sites + +RBAC: +- viewer: GET (read-only) +- operator: POST, PUT, device assignment (write) +- tenant_admin/admin: DELETE +""" + +import uuid + +from fastapi import APIRouter, Depends, status +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.middleware.rbac import require_operator_or_above, require_tenant_admin_or_above +from app.middleware.tenant_context import CurrentUser, get_current_user +from app.routers.devices import _check_tenant_access +from app.schemas.site import SiteCreate, SiteListResponse, SiteResponse, SiteUpdate +from app.services import site_service + +router = APIRouter(tags=["sites"]) + + +class BulkAssignRequest(BaseModel): + """Request body for bulk device assignment.""" + + device_ids: list[uuid.UUID] + + +# --------------------------------------------------------------------------- +# CRUD +# --------------------------------------------------------------------------- + + +@router.get( + "/tenants/{tenant_id}/sites", + response_model=SiteListResponse, + summary="List sites", +) +async def list_sites( + tenant_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> SiteListResponse: + """List all sites for a tenant with health rollup. Viewer role and above.""" + await _check_tenant_access(current_user, tenant_id, db) + return await site_service.get_sites(db=db, tenant_id=tenant_id) + + +@router.get( + "/tenants/{tenant_id}/sites/{site_id}", + response_model=SiteResponse, + summary="Get site details", +) +async def get_site( + tenant_id: uuid.UUID, + site_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> SiteResponse: + """Get a single site with health rollup. Viewer role and above.""" + await _check_tenant_access(current_user, tenant_id, db) + return await site_service.get_site(db=db, tenant_id=tenant_id, site_id=site_id) + + +@router.post( + "/tenants/{tenant_id}/sites", + response_model=SiteResponse, + status_code=status.HTTP_201_CREATED, + summary="Create a site", + dependencies=[Depends(require_operator_or_above)], +) +async def create_site( + tenant_id: uuid.UUID, + data: SiteCreate, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> SiteResponse: + """Create a new site. Requires operator role or above.""" + await _check_tenant_access(current_user, tenant_id, db) + return await site_service.create_site( + db=db, tenant_id=tenant_id, data=data, user_id=current_user.id + ) + + +@router.put( + "/tenants/{tenant_id}/sites/{site_id}", + response_model=SiteResponse, + summary="Update a site", + dependencies=[Depends(require_operator_or_above)], +) +async def update_site( + tenant_id: uuid.UUID, + site_id: uuid.UUID, + data: SiteUpdate, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> SiteResponse: + """Update a site. Requires operator role or above.""" + await _check_tenant_access(current_user, tenant_id, db) + return await site_service.update_site( + db=db, tenant_id=tenant_id, site_id=site_id, data=data, user_id=current_user.id + ) + + +@router.delete( + "/tenants/{tenant_id}/sites/{site_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete a site", + dependencies=[Depends(require_tenant_admin_or_above)], +) +async def delete_site( + tenant_id: uuid.UUID, + site_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> None: + """Delete a site. Requires tenant_admin or above.""" + await _check_tenant_access(current_user, tenant_id, db) + await site_service.delete_site( + db=db, tenant_id=tenant_id, site_id=site_id, user_id=current_user.id + ) + + +# --------------------------------------------------------------------------- +# Device assignment +# --------------------------------------------------------------------------- + + +@router.post( + "/tenants/{tenant_id}/sites/{site_id}/devices/{device_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Assign device to site", + dependencies=[Depends(require_operator_or_above)], +) +async def assign_device( + tenant_id: uuid.UUID, + site_id: uuid.UUID, + device_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> None: + """Assign a single device to a site. Requires operator role or above.""" + await _check_tenant_access(current_user, tenant_id, db) + await site_service.assign_device_to_site( + db=db, tenant_id=tenant_id, site_id=site_id, device_id=device_id + ) + + +@router.delete( + "/tenants/{tenant_id}/sites/{site_id}/devices/{device_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Remove device from site", + dependencies=[Depends(require_operator_or_above)], +) +async def unassign_device( + tenant_id: uuid.UUID, + site_id: uuid.UUID, + device_id: uuid.UUID, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> None: + """Remove a device from a site. Requires operator role or above.""" + await _check_tenant_access(current_user, tenant_id, db) + await site_service.remove_device_from_site( + db=db, tenant_id=tenant_id, device_id=device_id + ) + + +@router.post( + "/tenants/{tenant_id}/sites/{site_id}/devices/bulk-assign", + summary="Bulk assign devices to site", + dependencies=[Depends(require_operator_or_above)], +) +async def bulk_assign_devices( + tenant_id: uuid.UUID, + site_id: uuid.UUID, + body: BulkAssignRequest, + current_user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict: + """Bulk-assign multiple devices to a site. Requires operator role or above.""" + await _check_tenant_access(current_user, tenant_id, db) + count = await site_service.bulk_assign_devices_to_site( + db=db, tenant_id=tenant_id, site_id=site_id, device_ids=body.device_ids + ) + return {"assigned": count} diff --git a/backend/app/services/site_service.py b/backend/app/services/site_service.py new file mode 100644 index 0000000..72e7419 --- /dev/null +++ b/backend/app/services/site_service.py @@ -0,0 +1,278 @@ +"""Site service -- business logic for site CRUD, device assignment, and health rollup. + +All functions operate via the app_user engine (RLS enforced). +Tenant isolation is handled automatically by PostgreSQL RLS policies. +""" + +import uuid + +import structlog +from fastapi import HTTPException, status +from sqlalchemy import func, select, text, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.device import Device +from app.models.site import Site +from app.schemas.site import ( + SiteCreate, + SiteListResponse, + SiteResponse, + SiteUpdate, +) +from app.services import audit_service + +logger = structlog.get_logger("site_service") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _site_response(site: Site, device_count: int = 0, online_count: int = 0, alert_count: int = 0) -> SiteResponse: + """Build a SiteResponse from an ORM Site instance with health stats.""" + online_percent = (online_count / device_count * 100) if device_count > 0 else 0.0 + return SiteResponse( + id=site.id, + name=site.name, + latitude=site.latitude, + longitude=site.longitude, + address=site.address, + elevation=site.elevation, + notes=site.notes, + device_count=device_count, + online_count=online_count, + online_percent=round(online_percent, 1), + alert_count=alert_count, + created_at=site.created_at, + updated_at=site.updated_at, + ) + + +async def _get_site_or_404(db: AsyncSession, tenant_id: uuid.UUID, site_id: uuid.UUID) -> Site: + """Fetch a site by id and tenant, or raise 404.""" + result = await db.execute( + select(Site).where(Site.id == site_id, Site.tenant_id == tenant_id) + ) + site = result.scalar_one_or_none() + if not site: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Site not found") + return site + + +async def _get_health_for_site(db: AsyncSession, site_id: uuid.UUID) -> tuple[int, int]: + """Return (device_count, online_count) for a single site.""" + result = await db.execute( + select( + func.count(Device.id).label("device_count"), + func.count(Device.id).filter(Device.status == "online").label("online_count"), + ).where(Device.site_id == site_id) + ) + row = result.one() + return row.device_count, row.online_count + + +# --------------------------------------------------------------------------- +# CRUD +# --------------------------------------------------------------------------- + + +async def get_sites(db: AsyncSession, tenant_id: uuid.UUID) -> SiteListResponse: + """List all sites for a tenant with health rollup stats.""" + # Fetch all sites + sites_result = await db.execute( + select(Site).where(Site.tenant_id == tenant_id).order_by(Site.name) + ) + sites = list(sites_result.scalars().all()) + + # Aggregate health stats per site in a single query + health_result = await db.execute( + select( + Device.site_id, + func.count(Device.id).label("device_count"), + func.count(Device.id).filter(Device.status == "online").label("online_count"), + ) + .where(Device.site_id.isnot(None)) + .group_by(Device.site_id) + ) + health_map: dict[uuid.UUID, tuple[int, int]] = {} + for row in health_result: + health_map[row.site_id] = (row.device_count, row.online_count) + + # Unassigned device count + unassigned_result = await db.execute( + select(func.count(Device.id)).where( + Device.tenant_id == tenant_id, + Device.site_id.is_(None), + ) + ) + unassigned_count = unassigned_result.scalar() or 0 + + # Build responses + site_responses = [] + for site in sites: + dc, oc = health_map.get(site.id, (0, 0)) + # TODO: alert_count from alert_events table -- set to 0 until alert system integration + site_responses.append(_site_response(site, device_count=dc, online_count=oc, alert_count=0)) + + return SiteListResponse(sites=site_responses, unassigned_count=unassigned_count) + + +async def get_site(db: AsyncSession, tenant_id: uuid.UUID, site_id: uuid.UUID) -> SiteResponse: + """Fetch a single site with health rollup stats.""" + site = await _get_site_or_404(db, tenant_id, site_id) + dc, oc = await _get_health_for_site(db, site_id) + # TODO: alert_count from alert_events table -- set to 0 until alert system integration + return _site_response(site, device_count=dc, online_count=oc, alert_count=0) + + +async def create_site( + db: AsyncSession, + tenant_id: uuid.UUID, + data: SiteCreate, + user_id: uuid.UUID | None = None, +) -> SiteResponse: + """Create a new site for a tenant.""" + site = Site( + tenant_id=tenant_id, + name=data.name, + latitude=data.latitude, + longitude=data.longitude, + address=data.address, + elevation=data.elevation, + notes=data.notes, + ) + db.add(site) + await db.flush() + await db.refresh(site) + + if user_id: + await audit_service.log_action( + db=db, + tenant_id=tenant_id, + user_id=user_id, + action="site.created", + resource_type="site", + resource_id=str(site.id), + details={"name": site.name}, + ) + + return _site_response(site, device_count=0, online_count=0, alert_count=0) + + +async def update_site( + db: AsyncSession, + tenant_id: uuid.UUID, + site_id: uuid.UUID, + data: SiteUpdate, + user_id: uuid.UUID | None = None, +) -> SiteResponse: + """Update an existing site.""" + site = await _get_site_or_404(db, tenant_id, site_id) + + update_data = data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(site, field, value) + + await db.flush() + await db.refresh(site) + + dc, oc = await _get_health_for_site(db, site_id) + + if user_id: + await audit_service.log_action( + db=db, + tenant_id=tenant_id, + user_id=user_id, + action="site.updated", + resource_type="site", + resource_id=str(site.id), + details={"updated_fields": list(update_data.keys())}, + ) + + return _site_response(site, device_count=dc, online_count=oc, alert_count=0) + + +async def delete_site( + db: AsyncSession, + tenant_id: uuid.UUID, + site_id: uuid.UUID, + user_id: uuid.UUID | None = None, +) -> None: + """Delete a site. Devices will have site_id set to NULL via ON DELETE SET NULL.""" + site = await _get_site_or_404(db, tenant_id, site_id) + site_name = site.name + + await db.delete(site) + await db.flush() + + if user_id: + await audit_service.log_action( + db=db, + tenant_id=tenant_id, + user_id=user_id, + action="site.deleted", + resource_type="site", + resource_id=str(site_id), + details={"name": site_name}, + ) + + +# --------------------------------------------------------------------------- +# Device assignment +# --------------------------------------------------------------------------- + + +async def assign_device_to_site( + db: AsyncSession, + tenant_id: uuid.UUID, + site_id: uuid.UUID, + device_id: uuid.UUID, +) -> None: + """Assign a single device to a site.""" + await _get_site_or_404(db, tenant_id, site_id) + + result = await db.execute( + select(Device).where(Device.id == device_id, Device.tenant_id == tenant_id) + ) + device = result.scalar_one_or_none() + if not device: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Device not found") + + device.site_id = site_id + await db.flush() + + +async def remove_device_from_site( + db: AsyncSession, + tenant_id: uuid.UUID, + device_id: uuid.UUID, +) -> None: + """Remove a device from its current site (set site_id to NULL).""" + result = await db.execute( + select(Device).where(Device.id == device_id, Device.tenant_id == tenant_id) + ) + device = result.scalar_one_or_none() + if not device: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Device not found") + + device.site_id = None + await db.flush() + + +async def bulk_assign_devices_to_site( + db: AsyncSession, + tenant_id: uuid.UUID, + site_id: uuid.UUID, + device_ids: list[uuid.UUID], +) -> int: + """Bulk-assign multiple devices to a site. Returns count of updated rows.""" + await _get_site_or_404(db, tenant_id, site_id) + + result = await db.execute( + update(Device) + .where(Device.id.in_(device_ids), Device.tenant_id == tenant_id) + .values(site_id=site_id) + ) + await db.flush() + return result.rowcount