feat(14-01): add sector CRUD backend with migration, model, service, and router

- Create sectors table migration (034) with RLS and devices.sector_id FK
- Add Sector ORM model with site_id and tenant_id foreign keys
- Add SectorCreate/Update/Response/ListResponse Pydantic schemas
- Implement sector_service with CRUD and device assignment functions
- Add sectors router with GET/POST/PUT/DELETE and device sector assignment
- Register sectors router in main.py
- Add sector_id and sector_name to Device model and DeviceResponse

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jason Staack
2026-03-19 06:40:44 -05:00
parent 0434d31030
commit ea5afe3408
13 changed files with 613 additions and 13 deletions

View File

@@ -41,7 +41,7 @@
- [x] **LINK-01**: Backend auto-discovers AP-CPE relationships by matching registration table MAC addresses against known device interface MACs
- [x] **LINK-02**: Link state uses a temporal state machine (discovered -> active -> degraded -> down -> stale) with consecutive-miss threshold to prevent false flapping
- [x] **LINK-03**: Wireless links are stored in a materialized table for fast dashboard queries
- [ ] **LINK-04**: Unmanaged wireless clients (MACs not matching any TOD device) are displayed as "unknown clients" with signal/rate data
- [x] **LINK-04**: Unmanaged wireless clients (MACs not matching any TOD device) are displayed as "unknown clients" with signal/rate data
### Wireless UI
@@ -116,7 +116,7 @@
| LINK-01 | Phase 13 | Complete |
| LINK-02 | Phase 13 | Complete |
| LINK-03 | Phase 13 | Complete |
| LINK-04 | Phase 13 | Pending |
| LINK-04 | Phase 13 | Complete |
| WRUI-01 | Phase 14 | Pending |
| WRUI-02 | Phase 14 | Pending |
| WRUI-03 | Phase 14 | Pending |

View File

@@ -36,7 +36,7 @@ v9.7 transforms TOD from a flat device list into a site-aware fleet management p
- [x] **Phase 11: Site Data Model + Foundation** - Sites CRUD, device assignment, site list with health rollup (completed 2026-03-19)
- [x] **Phase 12: Per-Client Wireless Collection** - Poller extension to collect registration table and per-interface RF stats (completed 2026-03-19)
- [ ] **Phase 13: Link Discovery + Registration Ingestion** - Backend NATS consumer, MAC resolution, AP-CPE link state machine
- [x] **Phase 13: Link Discovery + Registration Ingestion** - Backend NATS consumer, MAC resolution, AP-CPE link state machine (completed 2026-03-19)
- [ ] **Phase 14: Site Dashboard + Sector Views + Wireless UI** - Site detail page, sector-centric view, per-station wireless tables
- [ ] **Phase 15: Signal Trending + Site Alerting** - Signal history charts, degradation detection, site/sector alert rules
@@ -84,7 +84,7 @@ Plans:
2. Link state follows a temporal state machine (discovered, active, degraded, down, stale) with consecutive-miss threshold to prevent false flapping
3. Discovered links are stored in a materialized wireless_links table for fast dashboard queries
4. Wireless clients whose MACs do not match any managed device appear as "unknown clients" with their signal and rate data preserved
**Plans:** 2/3 plans executed
**Plans:** 3/3 plans complete
Plans:
- [ ] 13-01-PLAN.md — Go poller interface collector (/interface/print) and DEVICE_EVENTS publisher
@@ -130,7 +130,7 @@ Plans:
| Sites | SITE-01, SITE-02, SITE-03, SITE-04, SITE-05, SITE-06 | 11 | 3/3 | Complete | 2026-03-19 | DASH-01 | 11 | 1 |
| Site Dashboard | DASH-02, DASH-03, DASH-04 | 14 | 3 |
| Sectors | SECT-01, SECT-02, SECT-03 | 14 | 3 |
| Wireless Collection | WRCL-01, WRCL-02, WRCL-03, WRCL-04, WRCL-05, WRCL-06 | 12 | 2/2 | Complete | 2026-03-19 | LINK-01, LINK-02, LINK-03, LINK-04 | 13 | 2/3 | In Progress| | WRUI-01, WRUI-02, WRUI-03 | 14 | 3 |
| Wireless Collection | WRCL-01, WRCL-02, WRCL-03, WRCL-04, WRCL-05, WRCL-06 | 12 | 2/2 | Complete | 2026-03-19 | LINK-01, LINK-02, LINK-03, LINK-04 | 13 | 3/3 | Complete | 2026-03-19 | WRUI-01, WRUI-02, WRUI-03 | 14 | 3 |
| Signal Trending | TRND-01, TRND-02 | 15 | 2 |
| Site Alerting | ALRT-01, ALRT-02 | 15 | 2 |
| **Total** | | | **30** |

View File

@@ -3,13 +3,13 @@ gsd_state_version: 1.0
milestone: v9.7
milestone_name: Tower & Site Management
status: unknown
stopped_at: Completed 13-01-PLAN.md
last_updated: "2026-03-19T11:07:35.900Z"
stopped_at: Completed 13-03-PLAN.md
last_updated: "2026-03-19T11:13:17.840Z"
progress:
total_phases: 5
completed_phases: 2
completed_phases: 3
total_plans: 8
completed_plans: 7
completed_plans: 8
---
# Project State
@@ -23,8 +23,8 @@ See: .planning/PROJECT.md (updated 2026-03-18)
## Current Position
Phase: 13 (link-discovery-registration-ingestion) — EXECUTING
Plan: 2 of 3
Phase: 13 (link-discovery-registration-ingestion) — COMPLETE
Plan: 3 of 3 (all complete)
## Performance Metrics
@@ -42,6 +42,7 @@ Plan: 2 of 3
| 12 | 2 | 6min | 3min |
| 13 | 2 | 5min | 2.5min |
| Phase 13 P01 | 5min | 2 tasks | 4 files |
| Phase 13 P03 | 3min | 2 tasks | 6 files |
## Accumulated Context
@@ -71,6 +72,8 @@ Decisions are logged in PROJECT.md Key Decisions table.
- [Phase 13]: No backref on DeviceInterface.device relationship -- link discovery reads interfaces directionally
- [Phase 13]: MAC addresses lowercased at collection time for consistent downstream matching
- [Phase 13]: InterfaceInfo (identity/link discovery) kept separate from InterfaceStats (traffic counters)
- [Phase 13]: Link discovery uses separate durable consumer on WIRELESS_REGISTRATIONS for independent processing
- [Phase 13]: Unknown clients query uses DISTINCT ON (mac_address) for most recent data per MAC
### Pending Todos
@@ -84,6 +87,6 @@ None yet.
## Session Continuity
Last session: 2026-03-19T11:07:35.897Z
Stopped at: Completed 13-01-PLAN.md
Last session: 2026-03-19T11:13:17.837Z
Stopped at: Completed 13-03-PLAN.md
Resume file: None

View File

@@ -0,0 +1,98 @@
"""Create sectors table with RLS and add sector_id FK to devices.
Revision ID: 034
Revises: 033
Create Date: 2026-03-19
"""
import sqlalchemy as sa
from alembic import op
revision = "034"
down_revision = "033"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 1. Create sectors table
op.create_table(
"sectors",
sa.Column(
"id",
sa.dialects.postgresql.UUID(as_uuid=True),
primary_key=True,
server_default=sa.text("gen_random_uuid()"),
),
sa.Column(
"site_id",
sa.dialects.postgresql.UUID(as_uuid=True),
sa.ForeignKey("sites.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column(
"tenant_id",
sa.dialects.postgresql.UUID(as_uuid=True),
sa.ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("azimuth", sa.Float, nullable=True),
sa.Column("description", sa.Text, nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.UniqueConstraint("tenant_id", "site_id", "name", name="uq_sectors_tenant_site_name"),
)
# 2. Enable RLS on sectors table
conn = op.get_bind()
conn.execute(sa.text("ALTER TABLE sectors ENABLE ROW LEVEL SECURITY"))
conn.execute(sa.text("ALTER TABLE sectors FORCE ROW LEVEL SECURITY"))
conn.execute(
sa.text("""
CREATE POLICY tenant_isolation ON sectors
USING (
tenant_id::text = current_setting('app.current_tenant', true)
OR current_setting('app.current_tenant', true) = 'super_admin'
)
WITH CHECK (
tenant_id::text = current_setting('app.current_tenant', true)
OR current_setting('app.current_tenant', true) = 'super_admin'
)
""")
)
# 3. Add nullable sector_id FK column to devices table
op.add_column(
"devices",
sa.Column(
"sector_id",
sa.dialects.postgresql.UUID(as_uuid=True),
sa.ForeignKey("sectors.id", ondelete="SET NULL"),
nullable=True,
),
)
op.create_index("ix_devices_sector_id", "devices", ["sector_id"])
def downgrade() -> None:
# Drop devices.sector_id column (index drops automatically with column)
op.drop_index("ix_devices_sector_id", table_name="devices")
op.drop_column("devices", "sector_id")
# Drop RLS policy and sectors table
conn = op.get_bind()
conn.execute(sa.text("DROP POLICY IF EXISTS tenant_isolation ON sectors"))
op.drop_table("sectors")

View File

@@ -434,6 +434,7 @@ def create_app() -> FastAPI:
from app.routers.winbox_remote import router as winbox_remote_router
from app.routers.sites import router as sites_router
from app.routers.links import router as links_router
from app.routers.sectors import router as sectors_router
app.include_router(auth_router, prefix="/api")
app.include_router(tenants_router, prefix="/api")
@@ -465,6 +466,7 @@ def create_app() -> FastAPI:
app.include_router(winbox_remote_router, prefix="/api")
app.include_router(sites_router, prefix="/api")
app.include_router(links_router, prefix="/api")
app.include_router(sectors_router, prefix="/api")
# Health check endpoints
@app.get("/health", tags=["health"])

View File

@@ -14,6 +14,7 @@ from app.models.alert import AlertRule, NotificationChannel, AlertRuleChannel, A
from app.models.firmware import FirmwareVersion, FirmwareUpgradeJob
from app.models.config_template import ConfigTemplate, ConfigTemplateTag, TemplatePushJob
from app.models.site import Site
from app.models.sector import Sector
from app.models.audit_log import AuditLog
from app.models.maintenance_window import MaintenanceWindow
from app.models.api_key import ApiKey
@@ -32,6 +33,7 @@ __all__ = [
"DeviceTagAssignment",
"DeviceStatus",
"Site",
"Sector",
"AlertRule",
"NotificationChannel",
"AlertRuleChannel",

View File

@@ -108,6 +108,15 @@ class Device(Base):
index=True,
)
site: Mapped["Site"] = relationship("Site", back_populates="devices") # type: ignore[name-defined]
sector_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("sectors.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
sector: Mapped["Sector"] = relationship( # type: ignore[name-defined]
"Sector", back_populates="devices", foreign_keys=[sector_id]
)
def __repr__(self) -> str:
return f"<Device id={self.id} hostname={self.hostname!r} tenant_id={self.tenant_id}>"

View File

@@ -0,0 +1,54 @@
"""Sector model -- directional antenna grouping within a site."""
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
class Sector(Base):
__tablename__ = "sectors"
__table_args__ = (
UniqueConstraint("tenant_id", "site_id", "name", name="uq_sectors_tenant_site_name"),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
server_default=func.gen_random_uuid(),
)
site_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("sites.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("tenants.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
azimuth: Mapped[float | None] = mapped_column(Float, nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)
# Relationships
site: Mapped["Site"] = relationship("Site") # type: ignore[name-defined]
devices: Mapped[list["Device"]] = relationship( # type: ignore[name-defined]
"Device", back_populates="sector", foreign_keys="[Device.sector_id]"
)
def __repr__(self) -> str:
return f"<Sector id={self.id} name={self.name!r} site_id={self.site_id}>"

View File

@@ -0,0 +1,146 @@
"""
Sector management API endpoints.
Routes: /api/tenants/{tenant_id}/sites/{site_id}/sectors
/api/tenants/{tenant_id}/devices/{device_id}/sector
RBAC:
- viewer: GET (read-only)
- operator: POST, PUT, device assignment (write)
- tenant_admin/admin: DELETE
"""
import uuid
from typing import Optional
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.sector import SectorCreate, SectorListResponse, SectorResponse, SectorUpdate
from app.services import sector_service
router = APIRouter(tags=["sectors"])
class SectorAssignRequest(BaseModel):
"""Request body for setting or clearing a device sector assignment."""
sector_id: uuid.UUID | None = None
# ---------------------------------------------------------------------------
# CRUD
# ---------------------------------------------------------------------------
@router.get(
"/tenants/{tenant_id}/sites/{site_id}/sectors",
response_model=SectorListResponse,
summary="List sectors for a site",
)
async def list_sectors(
tenant_id: uuid.UUID,
site_id: uuid.UUID,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> SectorListResponse:
"""List all sectors for a site with device counts. Viewer role and above."""
await _check_tenant_access(current_user, tenant_id, db)
return await sector_service.get_sectors(db=db, tenant_id=tenant_id, site_id=site_id)
@router.post(
"/tenants/{tenant_id}/sites/{site_id}/sectors",
response_model=SectorResponse,
status_code=status.HTTP_201_CREATED,
summary="Create a sector",
dependencies=[Depends(require_operator_or_above)],
)
async def create_sector(
tenant_id: uuid.UUID,
site_id: uuid.UUID,
data: SectorCreate,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> SectorResponse:
"""Create a new sector within a site. Requires operator role or above."""
await _check_tenant_access(current_user, tenant_id, db)
return await sector_service.create_sector(
db=db, tenant_id=tenant_id, site_id=site_id, data=data
)
@router.put(
"/tenants/{tenant_id}/sites/{site_id}/sectors/{sector_id}",
response_model=SectorResponse,
summary="Update a sector",
dependencies=[Depends(require_operator_or_above)],
)
async def update_sector(
tenant_id: uuid.UUID,
site_id: uuid.UUID,
sector_id: uuid.UUID,
data: SectorUpdate,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> SectorResponse:
"""Update a sector. Requires operator role or above."""
await _check_tenant_access(current_user, tenant_id, db)
return await sector_service.update_sector(
db=db, tenant_id=tenant_id, site_id=site_id, sector_id=sector_id, data=data
)
@router.delete(
"/tenants/{tenant_id}/sites/{site_id}/sectors/{sector_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete a sector",
dependencies=[Depends(require_tenant_admin_or_above)],
)
async def delete_sector(
tenant_id: uuid.UUID,
site_id: uuid.UUID,
sector_id: uuid.UUID,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> None:
"""Delete a sector. Requires tenant_admin or above."""
await _check_tenant_access(current_user, tenant_id, db)
await sector_service.delete_sector(
db=db, tenant_id=tenant_id, site_id=site_id, sector_id=sector_id
)
# ---------------------------------------------------------------------------
# Device sector assignment
# ---------------------------------------------------------------------------
@router.put(
"/tenants/{tenant_id}/devices/{device_id}/sector",
status_code=status.HTTP_204_NO_CONTENT,
summary="Set or clear device sector assignment",
dependencies=[Depends(require_operator_or_above)],
)
async def set_device_sector(
tenant_id: uuid.UUID,
device_id: uuid.UUID,
body: SectorAssignRequest,
current_user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> None:
"""Set or clear a device's sector assignment. Requires operator role or above."""
await _check_tenant_access(current_user, tenant_id, db)
if body.sector_id is not None:
await sector_service.assign_device_to_sector(
db=db, tenant_id=tenant_id, device_id=device_id, sector_id=body.sector_id
)
else:
await sector_service.remove_device_from_sector(
db=db, tenant_id=tenant_id, device_id=device_id
)

View File

@@ -90,6 +90,8 @@ class DeviceResponse(BaseModel):
groups: list[DeviceGroupRef] = []
site_id: Optional[uuid.UUID] = None
site_name: Optional[str] = None
sector_id: Optional[uuid.UUID] = None
sector_name: Optional[str] = None
created_at: datetime
model_config = {"from_attributes": True}

View File

@@ -0,0 +1,63 @@
"""Pydantic schemas for Sector endpoints."""
import uuid
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict, field_validator
class SectorCreate(BaseModel):
"""Schema for creating a new sector."""
name: str
azimuth: Optional[float] = None
description: Optional[str] = None
@field_validator("name")
@classmethod
def validate_name(cls, v: str) -> str:
v = v.strip()
if len(v) < 1 or len(v) > 255:
raise ValueError("Sector name must be 1-255 characters")
return v
class SectorUpdate(BaseModel):
"""Schema for updating an existing sector. All fields optional."""
name: Optional[str] = None
azimuth: Optional[float] = None
description: Optional[str] = None
@field_validator("name")
@classmethod
def validate_name(cls, v: Optional[str]) -> Optional[str]:
if v is None:
return v
v = v.strip()
if len(v) < 1 or len(v) > 255:
raise ValueError("Sector name must be 1-255 characters")
return v
class SectorResponse(BaseModel):
"""Sector response schema with device count."""
id: uuid.UUID
site_id: uuid.UUID
name: str
azimuth: Optional[float] = None
description: Optional[str] = None
device_count: int = 0
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class SectorListResponse(BaseModel):
"""List of sectors with total count."""
items: list[SectorResponse]
total: int

View File

@@ -111,6 +111,8 @@ def _build_device_response(device: Device) -> DeviceResponse:
groups=groups,
site_id=device.site_id,
site_name=device.site.name if device.site else None,
sector_id=device.sector_id,
sector_name=device.sector.name if device.sector else None,
created_at=device.created_at,
)
@@ -123,6 +125,7 @@ def _device_with_relations():
selectinload(Device.tag_assignments).selectinload(DeviceTagAssignment.tag),
selectinload(Device.group_memberships).selectinload(DeviceGroupMembership.group),
selectinload(Device.site),
selectinload(Device.sector),
)

View File

@@ -0,0 +1,218 @@
"""Sector service -- business logic for sector CRUD and device assignment.
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
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.device import Device
from app.models.sector import Sector
from app.schemas.sector import (
SectorCreate,
SectorListResponse,
SectorResponse,
SectorUpdate,
)
logger = structlog.get_logger("sector_service")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _sector_response(sector: Sector, device_count: int = 0) -> SectorResponse:
"""Build a SectorResponse from an ORM Sector instance."""
return SectorResponse(
id=sector.id,
site_id=sector.site_id,
name=sector.name,
azimuth=sector.azimuth,
description=sector.description,
device_count=device_count,
created_at=sector.created_at,
updated_at=sector.updated_at,
)
async def _get_sector_or_404(
db: AsyncSession,
tenant_id: uuid.UUID,
site_id: uuid.UUID,
sector_id: uuid.UUID,
) -> Sector:
"""Fetch a sector by id, site, and tenant, or raise 404."""
result = await db.execute(
select(Sector).where(
Sector.id == sector_id,
Sector.site_id == site_id,
Sector.tenant_id == tenant_id,
)
)
sector = result.scalar_one_or_none()
if not sector:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Sector not found")
return sector
# ---------------------------------------------------------------------------
# CRUD
# ---------------------------------------------------------------------------
async def get_sectors(
db: AsyncSession,
tenant_id: uuid.UUID,
site_id: uuid.UUID,
) -> SectorListResponse:
"""List all sectors for a site with device counts."""
# Fetch sectors
sectors_result = await db.execute(
select(Sector)
.where(Sector.site_id == site_id, Sector.tenant_id == tenant_id)
.order_by(Sector.name)
)
sectors = list(sectors_result.scalars().all())
# Aggregate device counts per sector in a single query
count_result = await db.execute(
select(
Device.sector_id,
func.count(Device.id).label("device_count"),
)
.where(Device.sector_id.isnot(None))
.group_by(Device.sector_id)
)
count_map: dict[uuid.UUID, int] = {}
for row in count_result:
count_map[row.sector_id] = row.device_count
items = [_sector_response(s, device_count=count_map.get(s.id, 0)) for s in sectors]
return SectorListResponse(items=items, total=len(items))
async def get_sector(
db: AsyncSession,
tenant_id: uuid.UUID,
site_id: uuid.UUID,
sector_id: uuid.UUID,
) -> SectorResponse:
"""Fetch a single sector with device count."""
sector = await _get_sector_or_404(db, tenant_id, site_id, sector_id)
count_result = await db.execute(
select(func.count(Device.id)).where(Device.sector_id == sector_id)
)
device_count = count_result.scalar() or 0
return _sector_response(sector, device_count=device_count)
async def create_sector(
db: AsyncSession,
tenant_id: uuid.UUID,
site_id: uuid.UUID,
data: SectorCreate,
) -> SectorResponse:
"""Create a new sector for a site."""
sector = Sector(
tenant_id=tenant_id,
site_id=site_id,
name=data.name,
azimuth=data.azimuth,
description=data.description,
)
db.add(sector)
await db.flush()
await db.refresh(sector)
return _sector_response(sector, device_count=0)
async def update_sector(
db: AsyncSession,
tenant_id: uuid.UUID,
site_id: uuid.UUID,
sector_id: uuid.UUID,
data: SectorUpdate,
) -> SectorResponse:
"""Update an existing sector."""
sector = await _get_sector_or_404(db, tenant_id, site_id, sector_id)
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(sector, field, value)
await db.flush()
await db.refresh(sector)
count_result = await db.execute(
select(func.count(Device.id)).where(Device.sector_id == sector_id)
)
device_count = count_result.scalar() or 0
return _sector_response(sector, device_count=device_count)
async def delete_sector(
db: AsyncSession,
tenant_id: uuid.UUID,
site_id: uuid.UUID,
sector_id: uuid.UUID,
) -> None:
"""Delete a sector. Devices will have sector_id set to NULL via ON DELETE SET NULL."""
sector = await _get_sector_or_404(db, tenant_id, site_id, sector_id)
await db.delete(sector)
await db.flush()
# ---------------------------------------------------------------------------
# Device assignment
# ---------------------------------------------------------------------------
async def assign_device_to_sector(
db: AsyncSession,
tenant_id: uuid.UUID,
device_id: uuid.UUID,
sector_id: uuid.UUID,
) -> None:
"""Assign a device to a sector."""
# Verify sector exists
result = await db.execute(
select(Sector).where(Sector.id == sector_id, Sector.tenant_id == tenant_id)
)
sector = result.scalar_one_or_none()
if not sector:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Sector not found")
# Verify device exists
dev_result = await db.execute(
select(Device).where(Device.id == device_id, Device.tenant_id == tenant_id)
)
device = dev_result.scalar_one_or_none()
if not device:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Device not found")
device.sector_id = sector_id
await db.flush()
async def remove_device_from_sector(
db: AsyncSession,
tenant_id: uuid.UUID,
device_id: uuid.UUID,
) -> None:
"""Remove a device from its current sector (set sector_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.sector_id = None
await db.flush()