diff --git a/backend/alembic/versions/073_add_device_types_table.py b/backend/alembic/versions/073_add_device_types_table.py new file mode 100644 index 00000000..fb1b86e0 --- /dev/null +++ b/backend/alembic/versions/073_add_device_types_table.py @@ -0,0 +1,95 @@ +"""Add device_types table with system seed data. + +Revision ID: 073 +Revises: 072 +Create Date: 2026-04-04 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID +import uuid + + +revision = "073" +down_revision = "072" +branch_labels = None +depends_on = None + +SYSTEM_DEVICE_TYPES = [ + ("router", "Router", "network", 0), + ("switch", "Switch", "network", 1), + ("firewall", "Firewall", "network", 2), + ("access-point", "Access Point", "network", 3), + ("load-balancer", "Load Balancer", "network", 4), + ("server", "Server", "compute", 0), + ("workstation", "Workstation", "compute", 1), + ("vm", "Virtual Machine", "compute", 2), + ("container", "Container", "compute", 3), + ("nas", "NAS", "storage", 0), + ("san", "SAN", "storage", 1), + ("cloud-storage", "Cloud Storage", "storage", 2), + ("cloud", "Cloud", "cloud", 0), + ("aws", "AWS", "cloud", 1), + ("azure", "Azure", "cloud", 2), + ("gcp", "Google Cloud", "cloud", 3), + ("printer", "Printer", "endpoint", 0), + ("phone", "Phone", "endpoint", 1), + ("iot", "IoT Device", "endpoint", 2), + ("camera", "Camera", "endpoint", 3), + ("tablet", "Tablet", "endpoint", 4), + ("laptop", "Laptop", "endpoint", 5), + ("ups", "UPS", "infrastructure", 0), + ("pdu", "PDU", "infrastructure", 1), + ("rack", "Rack", "infrastructure", 2), + ("patch-panel", "Patch Panel", "infrastructure", 3), + ("nvr", "NVR", "security", 0), + ("badge-reader", "Badge Reader", "security", 1), +] + + +def upgrade() -> None: + op.create_table( + "device_types", + sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), + sa.Column("slug", sa.String(50), nullable=False), + sa.Column("label", sa.String(100), nullable=False), + sa.Column("category", sa.String(50), nullable=False), + sa.Column("is_system", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("team_id", UUID(as_uuid=True), sa.ForeignKey("teams.id", ondelete="CASCADE"), nullable=True), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()")), + ) + + op.execute( + "ALTER TABLE device_types ADD CONSTRAINT uq_device_types_slug_team " + "UNIQUE NULLS NOT DISTINCT (slug, team_id)" + ) + op.create_index("idx_device_types_team", "device_types", ["team_id"]) + + device_types_table = sa.table( + "device_types", + sa.column("id", UUID(as_uuid=True)), + sa.column("slug", sa.String), + sa.column("label", sa.String), + sa.column("category", sa.String), + sa.column("is_system", sa.Boolean), + sa.column("team_id", UUID(as_uuid=True)), + sa.column("sort_order", sa.Integer), + ) + + op.bulk_insert(device_types_table, [ + { + "id": uuid.uuid4(), + "slug": slug, + "label": label, + "category": category, + "is_system": True, + "team_id": None, + "sort_order": sort_order, + } + for slug, label, category, sort_order in SYSTEM_DEVICE_TYPES + ]) + + +def downgrade() -> None: + op.drop_table("device_types") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 0441624f..895e82be 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -56,6 +56,7 @@ from .session_handoff import SessionHandoff from .session_resolution_output import SessionResolutionOutput from .template_tree import TemplateTree from .platform_step import PlatformStep +from .device_type import DeviceType __all__ = [ "User", @@ -126,4 +127,5 @@ __all__ = [ "SessionResolutionOutput", "TemplateTree", "PlatformStep", + "DeviceType", ] diff --git a/backend/app/models/device_type.py b/backend/app/models/device_type.py new file mode 100644 index 00000000..cb419cfb --- /dev/null +++ b/backend/app/models/device_type.py @@ -0,0 +1,51 @@ +"""Device type model for network diagrams.""" +import uuid +from datetime import datetime, timezone +from typing import Optional, TYPE_CHECKING + +from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.dialects.postgresql import UUID + +from app.core.database import Base + +if TYPE_CHECKING: + pass + + +class DeviceType(Base): + """A device type for network diagram nodes (system or team-custom).""" + __tablename__ = "device_types" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + slug: Mapped[str] = mapped_column( + String(50), nullable=False, + comment="Unique identifier used in diagram node data", + ) + label: Mapped[str] = mapped_column( + String(100), nullable=False, + comment="Display name", + ) + category: Mapped[str] = mapped_column( + String(50), nullable=False, + comment="network, compute, storage, cloud, endpoint, infrastructure, security", + ) + is_system: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, + comment="True for built-in types that cannot be deleted", + ) + team_id: Mapped[Optional[uuid.UUID]] = mapped_column( + UUID(as_uuid=True), + ForeignKey("teams.id", ondelete="CASCADE"), + nullable=True, + comment="NULL for system types, set for team-custom types", + ) + sort_order: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, + comment="Display order within category", + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + )