Files
the-other-dude/backend/app/services/site_service.py
Jason Staack 6a5829e0ff style: ruff format 10 python files
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 13:49:59 -05:00

279 lines
8.6 KiB
Python

"""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, 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