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:
98
backend/alembic/versions/034_create_sectors_table.py
Normal file
98
backend/alembic/versions/034_create_sectors_table.py
Normal 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")
|
||||
@@ -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"])
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}>"
|
||||
|
||||
54
backend/app/models/sector.py
Normal file
54
backend/app/models/sector.py
Normal 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}>"
|
||||
146
backend/app/routers/sectors.py
Normal file
146
backend/app/routers/sectors.py
Normal 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
|
||||
)
|
||||
@@ -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}
|
||||
|
||||
63
backend/app/schemas/sector.py
Normal file
63
backend/app/schemas/sector.py
Normal 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
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
218
backend/app/services/sector_service.py
Normal file
218
backend/app/services/sector_service.py
Normal 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()
|
||||
Reference in New Issue
Block a user