diff --git a/backend/alembic/versions/030_create_sites_table.py b/backend/alembic/versions/030_create_sites_table.py new file mode 100644 index 0000000..7de742b --- /dev/null +++ b/backend/alembic/versions/030_create_sites_table.py @@ -0,0 +1,94 @@ +"""Create sites table with RLS and add site_id FK to devices. + +Revision ID: 030 +Revises: 029 +Create Date: 2026-03-19 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "030" +down_revision = "029" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 1. Create sites table + op.create_table( + "sites", + sa.Column( + "id", + sa.dialects.postgresql.UUID(as_uuid=True), + primary_key=True, + server_default=sa.text("gen_random_uuid()"), + ), + 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("latitude", sa.Float, nullable=True), + sa.Column("longitude", sa.Float, nullable=True), + sa.Column("address", sa.Text, nullable=True), + sa.Column("elevation", sa.Float, nullable=True), + sa.Column("notes", 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", "name", name="uq_sites_tenant_name"), + ) + + # 2. Enable RLS on sites table + conn = op.get_bind() + conn.execute(sa.text("ALTER TABLE sites ENABLE ROW LEVEL SECURITY")) + conn.execute(sa.text("ALTER TABLE sites FORCE ROW LEVEL SECURITY")) + conn.execute( + sa.text(""" + CREATE POLICY tenant_isolation ON sites + 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 site_id FK column to devices table + op.add_column( + "devices", + sa.Column( + "site_id", + sa.dialects.postgresql.UUID(as_uuid=True), + sa.ForeignKey("sites.id", ondelete="SET NULL"), + nullable=True, + ), + ) + op.create_index("ix_devices_site_id", "devices", ["site_id"]) + + +def downgrade() -> None: + # Drop devices.site_id column (index drops automatically with column) + op.drop_index("ix_devices_site_id", table_name="devices") + op.drop_column("devices", "site_id") + + # Drop RLS policy and sites table + conn = op.get_bind() + conn.execute(sa.text("DROP POLICY IF EXISTS tenant_isolation ON sites")) + op.drop_table("sites") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 2a62654..7e6cdd2 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -13,6 +13,7 @@ from app.models.device import ( from app.models.alert import AlertRule, NotificationChannel, AlertRuleChannel, AlertEvent 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.audit_log import AuditLog from app.models.maintenance_window import MaintenanceWindow from app.models.api_key import ApiKey @@ -28,6 +29,7 @@ __all__ = [ "DeviceGroupMembership", "DeviceTagAssignment", "DeviceStatus", + "Site", "AlertRule", "NotificationChannel", "AlertRuleChannel", diff --git a/backend/app/models/device.py b/backend/app/models/device.py index b7ba62c..67b3625 100644 --- a/backend/app/models/device.py +++ b/backend/app/models/device.py @@ -101,6 +101,13 @@ class Device(Base): tag_assignments: Mapped[list["DeviceTagAssignment"]] = relationship( "DeviceTagAssignment", back_populates="device", cascade="all, delete-orphan" ) + site_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("sites.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + site: Mapped["Site"] = relationship("Site", back_populates="devices") # type: ignore[name-defined] def __repr__(self) -> str: return f"" diff --git a/backend/app/models/site.py b/backend/app/models/site.py new file mode 100644 index 0000000..73e5eac --- /dev/null +++ b/backend/app/models/site.py @@ -0,0 +1,49 @@ +"""Site model -- physical location grouping for devices.""" + +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 Site(Base): + __tablename__ = "sites" + __table_args__ = (UniqueConstraint("tenant_id", "name", name="uq_sites_tenant_name"),) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + server_default=func.gen_random_uuid(), + ) + 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) + latitude: Mapped[float | None] = mapped_column(Float, nullable=True) + longitude: Mapped[float | None] = mapped_column(Float, nullable=True) + address: Mapped[str | None] = mapped_column(Text, nullable=True) + elevation: Mapped[float | None] = mapped_column(Float, nullable=True) + notes: 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 + tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="sites") # type: ignore[name-defined] + devices: Mapped[list["Device"]] = relationship( # type: ignore[name-defined] + "Device", back_populates="site", foreign_keys="[Device.site_id]" + ) + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/models/tenant.py b/backend/app/models/tenant.py index d38a608..656176d 100644 --- a/backend/app/models/tenant.py +++ b/backend/app/models/tenant.py @@ -52,6 +52,9 @@ class Tenant(Base): device_tags: Mapped[list["DeviceTag"]] = relationship( "DeviceTag", back_populates="tenant", passive_deletes=True ) # type: ignore[name-defined] + sites: Mapped[list["Site"]] = relationship( + "Site", back_populates="tenant", cascade="all, delete-orphan" + ) # type: ignore[name-defined] def __repr__(self) -> str: return f"" diff --git a/backend/app/schemas/site.py b/backend/app/schemas/site.py new file mode 100644 index 0000000..d6c8de6 --- /dev/null +++ b/backend/app/schemas/site.py @@ -0,0 +1,74 @@ +"""Pydantic schemas for Site endpoints.""" + +import uuid +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, field_validator + + +class SiteCreate(BaseModel): + """Schema for creating a new site.""" + + name: str + latitude: Optional[float] = None + longitude: Optional[float] = None + address: Optional[str] = None + elevation: Optional[float] = None + notes: 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("Site name must be 1-255 characters") + return v + + +class SiteUpdate(BaseModel): + """Schema for updating an existing site. All fields optional.""" + + name: Optional[str] = None + latitude: Optional[float] = None + longitude: Optional[float] = None + address: Optional[str] = None + elevation: Optional[float] = None + notes: 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("Site name must be 1-255 characters") + return v + + +class SiteResponse(BaseModel): + """Site response schema with health rollup stats.""" + + id: uuid.UUID + name: str + latitude: Optional[float] = None + longitude: Optional[float] = None + address: Optional[str] = None + elevation: Optional[float] = None + notes: Optional[str] = None + device_count: int = 0 + online_count: int = 0 + online_percent: float = 0.0 + alert_count: int = 0 + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class SiteListResponse(BaseModel): + """List of sites with unassigned device count.""" + + sites: list[SiteResponse] + unassigned_count: int