diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 3bf4964..fda40d9 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -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 | diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index d39d437..eb53e2e 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -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** | diff --git a/.planning/STATE.md b/.planning/STATE.md index 13c5a2d..b796106 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -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 diff --git a/backend/alembic/versions/034_create_sectors_table.py b/backend/alembic/versions/034_create_sectors_table.py new file mode 100644 index 0000000..334a20f --- /dev/null +++ b/backend/alembic/versions/034_create_sectors_table.py @@ -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") diff --git a/backend/app/main.py b/backend/app/main.py index 918386a..6cdb641 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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"]) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index cf7c037..565cb64 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", diff --git a/backend/app/models/device.py b/backend/app/models/device.py index 67b3625..0c2a749 100644 --- a/backend/app/models/device.py +++ b/backend/app/models/device.py @@ -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"" diff --git a/backend/app/models/sector.py b/backend/app/models/sector.py new file mode 100644 index 0000000..c4e85d5 --- /dev/null +++ b/backend/app/models/sector.py @@ -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"" diff --git a/backend/app/routers/sectors.py b/backend/app/routers/sectors.py new file mode 100644 index 0000000..940d83e --- /dev/null +++ b/backend/app/routers/sectors.py @@ -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 + ) diff --git a/backend/app/schemas/device.py b/backend/app/schemas/device.py index f11f141..1455559 100644 --- a/backend/app/schemas/device.py +++ b/backend/app/schemas/device.py @@ -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} diff --git a/backend/app/schemas/sector.py b/backend/app/schemas/sector.py new file mode 100644 index 0000000..a9d6835 --- /dev/null +++ b/backend/app/schemas/sector.py @@ -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 diff --git a/backend/app/services/device.py b/backend/app/services/device.py index 27b71a5..ce7c715 100644 --- a/backend/app/services/device.py +++ b/backend/app/services/device.py @@ -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), ) diff --git a/backend/app/services/sector_service.py b/backend/app/services/sector_service.py new file mode 100644 index 0000000..e68eee6 --- /dev/null +++ b/backend/app/services/sector_service.py @@ -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()