From d24da77604a0d4427351a6bf268460b25db004ba Mon Sep 17 00:00:00 2001 From: chihlasm Date: Thu, 9 Apr 2026 05:25:24 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=201=20Group=208=20=E2=80=94=20add?= =?UTF-8?q?=20account=5Fid=20to=20target=5Flists=20(keep=20team=5Fid)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zero rows in production — this is a schema-only migration in practice. team_id kept for app code compatibility. Drop deferred to later cleanup. Backfill: team_id → team admin user → account_id; fallback: created_by. Co-Authored-By: Claude Sonnet 4.6 --- ...c6aabd89bc6_add_account_id_target_lists.py | 62 +++++++++++++++++++ backend/app/models/target_list.py | 7 +++ backend/tests/test_phase1_migrations.py | 35 +++++++++++ 3 files changed, 104 insertions(+) create mode 100644 backend/alembic/versions/2c6aabd89bc6_add_account_id_target_lists.py diff --git a/backend/alembic/versions/2c6aabd89bc6_add_account_id_target_lists.py b/backend/alembic/versions/2c6aabd89bc6_add_account_id_target_lists.py new file mode 100644 index 00000000..1107e373 --- /dev/null +++ b/backend/alembic/versions/2c6aabd89bc6_add_account_id_target_lists.py @@ -0,0 +1,62 @@ +"""add account_id to target_lists (keep team_id) + +Revision ID: 2c6aabd89bc6 +Revises: 78fc200abac1 +Create Date: 2026-04-09 00:00:00.000000 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = '2c6aabd89bc6' +down_revision: Union[str, None] = '78fc200abac1' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('target_lists', sa.Column('account_id', sa.UUID(), nullable=True)) + op.create_foreign_key( + 'fk_target_lists_account_id', 'target_lists', 'accounts', + ['account_id'], ['id'], ondelete='CASCADE', + ) + + # Primary: team_id → team admin user → account_id + op.execute(""" + UPDATE target_lists tl + SET account_id = u.account_id + FROM users u + WHERE u.team_id = tl.team_id + AND u.is_team_admin = TRUE + AND u.account_id IS NOT NULL + AND tl.account_id IS NULL + """) + + # Fallback: created_by → users.account_id + op.execute(""" + UPDATE target_lists tl + SET account_id = u.account_id + FROM users u + WHERE tl.created_by = u.id + AND u.account_id IS NOT NULL + AND tl.account_id IS NULL + """) + + result = op.get_bind().execute( + sa.text("SELECT COUNT(*) FROM target_lists WHERE account_id IS NULL") + ) + count = result.scalar() + if count > 0: + raise RuntimeError( + f"ROLLBACK: {count} target_lists rows have NULL account_id. " + "No team admin found for these teams. Resolve before re-running." + ) + + op.alter_column('target_lists', 'account_id', nullable=False) + op.create_index('ix_target_lists_account_id', 'target_lists', ['account_id']) + + +def downgrade() -> None: + op.drop_index('ix_target_lists_account_id', table_name='target_lists') + op.drop_constraint('fk_target_lists_account_id', 'target_lists', type_='foreignkey') + op.drop_column('target_lists', 'account_id') diff --git a/backend/app/models/target_list.py b/backend/app/models/target_list.py index f2dbd7ac..b1169d72 100644 --- a/backend/app/models/target_list.py +++ b/backend/app/models/target_list.py @@ -9,6 +9,7 @@ from app.core.database import Base if TYPE_CHECKING: from app.models.user import User from app.models.team import Team + from app.models.account import Account class TargetList(Base): @@ -21,6 +22,12 @@ class TargetList(Base): UUID(as_uuid=True), ForeignKey("teams.id", ondelete="CASCADE"), nullable=False, index=True ) + account_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("accounts.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) created_by: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True ) diff --git a/backend/tests/test_phase1_migrations.py b/backend/tests/test_phase1_migrations.py index fe89c153..06b09b6b 100644 --- a/backend/tests/test_phase1_migrations.py +++ b/backend/tests/test_phase1_migrations.py @@ -443,3 +443,38 @@ async def test_script_builder_session_account_id(test_db: AsyncSession): ) row = result.scalar_one() assert row.account_id == account.id + + +# ── Group 8: TargetList ──────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_target_list_account_id_from_team_admin(test_db: AsyncSession): + """target_lists.account_id must be set to the team admin's account_id.""" + from app.models.target_list import TargetList + from app.models.team import Team + + account, user = await _make_account_and_user(test_db, "tl1") + # Make user a team admin + team = Team(name=f"Team {uuid.uuid4().hex[:6]}") + test_db.add(team) + await test_db.flush() + + user.team_id = team.id + user.is_team_admin = True + await test_db.flush() + + target_list = TargetList( + team_id=team.id, + account_id=account.id, + created_by=user.id, + name="Server Targets", + targets=[{"label": "SRV-01"}], + ) + test_db.add(target_list) + await test_db.commit() + + result = await test_db.execute( + select(TargetList).where(TargetList.id == target_list.id) + ) + row = result.scalar_one() + assert row.account_id == account.id