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:
@@ -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"])
|
||||
|
||||
191
backend/app/routers/sites.py
Normal file
191
backend/app/routers/sites.py
Normal 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}
|
||||
278
backend/app/services/site_service.py
Normal file
278
backend/app/services/site_service.py
Normal 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
|
||||
Reference in New Issue
Block a user