feat(11-01): create site service, router, and wire into app

- Add site_service with CRUD, health rollup, device assignment functions
- Add sites router with 8 endpoints (CRUD + assign/unassign/bulk-assign)
- RBAC: viewer for reads, operator for writes, tenant_admin for delete
- Wire sites_router into main.py with /api prefix
- Health rollup computes device_count, online_count, online_percent per site

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jason Staack
2026-03-18 21:38:54 -05:00
parent f7e678532c
commit 7afd918e2f
3 changed files with 471 additions and 0 deletions

View File

@@ -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"])

View File

@@ -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}

View File

@@ -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