From 0bea26c784cf6cf3f9b8e0dd43b8e8daad011039 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Sun, 24 May 2026 13:23:12 +0000 Subject: [PATCH] 1.1 Backend data model and migrations (el-5fe) --- .../versions/0013_add_config_profiles.py | 104 ++++++++++++++++++ apps/api/src/models/__init__.py | 18 ++- apps/api/src/models/config_folder.py | 2 + apps/api/src/models/config_include.py | 36 ++++++ apps/api/src/models/config_mount.py | 35 ++++++ apps/api/src/models/config_profile.py | 39 +++++++ apps/api/src/models/tool_instance.py | 5 + apps/api/src/models/user_config.py | 20 ++++ .../api/tests/unit/test_migration_metadata.py | 15 +++ 9 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 apps/api/alembic/versions/0013_add_config_profiles.py create mode 100644 apps/api/src/models/config_include.py create mode 100644 apps/api/src/models/config_mount.py create mode 100644 apps/api/src/models/config_profile.py diff --git a/apps/api/alembic/versions/0013_add_config_profiles.py b/apps/api/alembic/versions/0013_add_config_profiles.py new file mode 100644 index 0000000..d042419 --- /dev/null +++ b/apps/api/alembic/versions/0013_add_config_profiles.py @@ -0,0 +1,104 @@ +"""add config profiles, includes, mounts, and tool instance profile selection + +Revision ID: 0013_add_config_profiles +Revises: 0012_default_port_req +Create Date: 2026-05-24 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "0013_add_config_profiles" +down_revision: Union[str, None] = "0012_default_port_req" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Create config_profiles table + op.create_table( + "config_profiles", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"), + ) + op.create_index("idx_config_profiles_user", "config_profiles", ["user_id"]) + + # Create config_includes table + op.create_table( + "config_includes", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("profile_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("included_profile_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + sa.ForeignKeyConstraint(["profile_id"], ["config_profiles.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["included_profile_id"], ["config_profiles.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("profile_id", "included_profile_id", name="uq_config_includes_pair"), + ) + op.create_index("idx_config_includes_profile", "config_includes", ["profile_id"]) + op.create_index("idx_config_includes_included", "config_includes", ["included_profile_id"]) + + # Create config_mounts table + op.create_table( + "config_mounts", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("profile_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("mount_path", sa.String(length=1024), nullable=False), + sa.Column("content", sa.Text(), nullable=True), + sa.Column("source_profile_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("NOW()"), nullable=False), + sa.ForeignKeyConstraint(["profile_id"], ["config_profiles.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["source_profile_id"], ["config_profiles.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("idx_config_mounts_profile", "config_mounts", ["profile_id"]) + + # Add selected_profile_id to tool_instances + op.add_column( + "tool_instances", + sa.Column("selected_profile_id", postgresql.UUID(as_uuid=True), nullable=True), + ) + op.create_foreign_key( + "fk_tool_instances_selected_profile", + "tool_instances", + "config_profiles", + ["selected_profile_id"], + ["id"], + ondelete="SET NULL", + ) + op.create_index("idx_tool_instances_selected_profile", "tool_instances", ["selected_profile_id"]) + + +def downgrade() -> None: + # Remove selected_profile_id from tool_instances + op.drop_index("idx_tool_instances_selected_profile", table_name="tool_instances") + op.drop_constraint("fk_tool_instances_selected_profile", "tool_instances", type_="foreignkey") + op.drop_column("tool_instances", "selected_profile_id") + + # Drop config_mounts + op.drop_index("idx_config_mounts_profile", table_name="config_mounts") + op.drop_table("config_mounts") + + # Drop config_includes + op.drop_index("idx_config_includes_included", table_name="config_includes") + op.drop_index("idx_config_includes_profile", table_name="config_includes") + op.drop_table("config_includes") + + # Drop config_profiles + op.drop_index("idx_config_profiles_user", table_name="config_profiles") + op.drop_table("config_profiles") diff --git a/apps/api/src/models/__init__.py b/apps/api/src/models/__init__.py index 38599b6..3358553 100644 --- a/apps/api/src/models/__init__.py +++ b/apps/api/src/models/__init__.py @@ -1,5 +1,8 @@ from src.models.base import Base from src.models.config_folder import ConfigFolder +from src.models.config_include import ConfigInclude +from src.models.config_mount import ConfigMount +from src.models.config_profile import ConfigProfile from src.models.git_repository import GitRepository from src.models.project import Project from src.models.ssh_key import SSHKey @@ -8,4 +11,17 @@ from src.models.tool_type import ToolType from src.models.user import User from src.models.user_config import UserConfig -__all__ = ["Base", "ConfigFolder", "GitRepository", "Project", "SSHKey", "ToolInstance", "ToolType", "User", "UserConfig"] +__all__ = [ + "Base", + "ConfigFolder", + "ConfigInclude", + "ConfigMount", + "ConfigProfile", + "GitRepository", + "Project", + "SSHKey", + "ToolInstance", + "ToolType", + "User", + "UserConfig", +] diff --git a/apps/api/src/models/config_folder.py b/apps/api/src/models/config_folder.py index 0ca2499..9c232fe 100644 --- a/apps/api/src/models/config_folder.py +++ b/apps/api/src/models/config_folder.py @@ -26,6 +26,8 @@ class ConfigFolder(UUIDPrimaryKeyMixin, TimestampMixin, Base): project_overrides: Mapped[dict | None] = mapped_column( JSON, default=dict, nullable=True ) # {"project_id": {"mount_path": "...", "files": {...}}} + # DEPRECATED: Legacy auto-mounting flag. No longer used for launch-time + # auto-mounting. Use ConfigProfile and ToolInstance.selected_profile_id instead. is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) user: Mapped["User"] = relationship() diff --git a/apps/api/src/models/config_include.py b/apps/api/src/models/config_include.py new file mode 100644 index 0000000..90cc2c8 --- /dev/null +++ b/apps/api/src/models/config_include.py @@ -0,0 +1,36 @@ +import uuid +from typing import TYPE_CHECKING + +from sqlalchemy import ForeignKey, Integer, UniqueConstraint +from sqlalchemy import Uuid as UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin + +if TYPE_CHECKING: + from src.models.config_profile import ConfigProfile + + +class ConfigInclude(UUIDPrimaryKeyMixin, TimestampMixin, Base): + __tablename__ = "config_includes" + __table_args__ = ( + UniqueConstraint("profile_id", "included_profile_id", name="uq_config_includes_pair"), + ) + + profile_id: Mapped[uuid.UUID] = mapped_column( + UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False + ) + included_profile_id: Mapped[uuid.UUID] = mapped_column( + UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False + ) + order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + profile: Mapped["ConfigProfile"] = relationship( + "ConfigProfile", + foreign_keys=[profile_id], + back_populates="includes", + ) + included_profile: Mapped["ConfigProfile"] = relationship( + "ConfigProfile", + foreign_keys=[included_profile_id], + ) diff --git a/apps/api/src/models/config_mount.py b/apps/api/src/models/config_mount.py new file mode 100644 index 0000000..ab3e1d5 --- /dev/null +++ b/apps/api/src/models/config_mount.py @@ -0,0 +1,35 @@ +import uuid +from typing import TYPE_CHECKING + +from sqlalchemy import ForeignKey, Integer, String, Text +from sqlalchemy import Uuid as UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin + +if TYPE_CHECKING: + from src.models.config_profile import ConfigProfile + + +class ConfigMount(UUIDPrimaryKeyMixin, TimestampMixin, Base): + __tablename__ = "config_mounts" + + profile_id: Mapped[uuid.UUID] = mapped_column( + UUID(), ForeignKey("config_profiles.id", ondelete="CASCADE"), nullable=False + ) + mount_path: Mapped[str] = mapped_column(String(1024), nullable=False) + content: Mapped[str | None] = mapped_column(Text, nullable=True) + source_profile_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True + ) + order_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + profile: Mapped["ConfigProfile"] = relationship( + "ConfigProfile", + foreign_keys=[profile_id], + back_populates="mounts", + ) + source_profile: Mapped["ConfigProfile | None"] = relationship( + "ConfigProfile", + foreign_keys=[source_profile_id], + ) diff --git a/apps/api/src/models/config_profile.py b/apps/api/src/models/config_profile.py new file mode 100644 index 0000000..65be5d4 --- /dev/null +++ b/apps/api/src/models/config_profile.py @@ -0,0 +1,39 @@ +import uuid +from typing import TYPE_CHECKING + +from sqlalchemy import ForeignKey, String, Text, UniqueConstraint +from sqlalchemy import Uuid as UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin + +if TYPE_CHECKING: + from src.models.user import User + + +class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base): + __tablename__ = "config_profiles" + __table_args__ = ( + UniqueConstraint("user_id", "name", name="uq_config_profiles_user_name"), + ) + + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + + user: Mapped["User"] = relationship() + includes: Mapped[list["ConfigInclude"]] = relationship( + "ConfigInclude", + foreign_keys="ConfigInclude.profile_id", + back_populates="profile", + cascade="all, delete-orphan", + order_by="ConfigInclude.order_index", + ) + mounts: Mapped[list["ConfigMount"]] = relationship( + "ConfigMount", + back_populates="profile", + cascade="all, delete-orphan", + order_by="ConfigMount.order_index", + ) diff --git a/apps/api/src/models/tool_instance.py b/apps/api/src/models/tool_instance.py index b556bab..4fa0a89 100644 --- a/apps/api/src/models/tool_instance.py +++ b/apps/api/src/models/tool_instance.py @@ -9,6 +9,7 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin if TYPE_CHECKING: + from src.models.config_profile import ConfigProfile from src.models.git_repository import GitRepository from src.models.project import Project from src.models.tool_type import ToolType @@ -62,8 +63,12 @@ class ToolInstance(UUIDPrimaryKeyMixin, TimestampMixin, Base): last_stopped_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) + selected_profile_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(), ForeignKey("config_profiles.id", ondelete="SET NULL"), nullable=True + ) tool_type: Mapped["ToolType"] = relationship() repository: Mapped["GitRepository"] = relationship() project: Mapped["Project"] = relationship() owner: Mapped["User"] = relationship() + selected_profile: Mapped["ConfigProfile | None"] = relationship() diff --git a/apps/api/src/models/user_config.py b/apps/api/src/models/user_config.py index 169de24..fae0c1a 100644 --- a/apps/api/src/models/user_config.py +++ b/apps/api/src/models/user_config.py @@ -18,3 +18,23 @@ class UserConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base): config: Mapped[dict[str, object]] = mapped_column(JSON, default=dict, nullable=False) user: Mapped["User"] = relationship(back_populates="user_config") + + @property + def default_profile_id(self) -> uuid.UUID | None: + profile_id = self.config.get("default_profile_id") + return uuid.UUID(profile_id) if profile_id else None + + @default_profile_id.setter + def default_profile_id(self, value: uuid.UUID | None) -> None: + if value is not None: + self.config["default_profile_id"] = str(value) + elif "default_profile_id" in self.config: + del self.config["default_profile_id"] + + @property + def default_profiles(self) -> dict[str, str]: + return self.config.get("default_profiles", {}) + + @default_profiles.setter + def default_profiles(self, value: dict[str, str]) -> None: + self.config["default_profiles"] = value diff --git a/apps/api/tests/unit/test_migration_metadata.py b/apps/api/tests/unit/test_migration_metadata.py index c39b91e..311fae1 100644 --- a/apps/api/tests/unit/test_migration_metadata.py +++ b/apps/api/tests/unit/test_migration_metadata.py @@ -39,3 +39,18 @@ def test_refresh_tokens_migration_has_expected_revision_chain() -> None: assert module.revision == "0002_refresh_tokens" assert module.down_revision == "0001_initial_schema" + + +@pytest.mark.unit +def test_config_profiles_migration_has_expected_revision_chain() -> None: + migration_path = Path(__file__).resolve().parents[2] / "alembic" / "versions" / "0013_add_config_profiles.py" + spec = spec_from_file_location("add_config_profiles", migration_path) + + assert spec is not None + assert spec.loader is not None + + module = module_from_spec(spec) + spec.loader.exec_module(module) + + assert module.revision == "0013_add_config_profiles" + assert module.down_revision == "0012_default_port_req"