From 086c4580f13474fc9c9c6f73ce576b1ccb4ca919 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Thu, 9 Apr 2026 05:20:56 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=201=20Group=206=20=E2=80=94=20add?= =?UTF-8?q?=20account=5Fid=20to=20maintenance=5Fschedules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Primary backfill: tree_id → trees.account_id Fallback: created_by → users.account_id (for is_default tree rows) Co-Authored-By: Claude Sonnet 4.6 --- ...7f136778f5a8_add_account_id_maintenance.py | 62 +++++++++++++++++++ backend/app/models/maintenance_schedule.py | 6 ++ backend/tests/test_phase1_migrations.py | 39 ++++++++++++ 3 files changed, 107 insertions(+) create mode 100644 backend/alembic/versions/7f136778f5a8_add_account_id_maintenance.py diff --git a/backend/alembic/versions/7f136778f5a8_add_account_id_maintenance.py b/backend/alembic/versions/7f136778f5a8_add_account_id_maintenance.py new file mode 100644 index 00000000..fbbc5cbd --- /dev/null +++ b/backend/alembic/versions/7f136778f5a8_add_account_id_maintenance.py @@ -0,0 +1,62 @@ +"""add account_id to maintenance_schedules + +Revision ID: 7f136778f5a8 +Revises: 8aac5b372402 +Create Date: 2026-04-09 00:00:00.000000 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = '7f136778f5a8' +down_revision: Union[str, None] = '8aac5b372402' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('maintenance_schedules', + sa.Column('account_id', sa.UUID(), nullable=True)) + op.create_foreign_key( + 'fk_maintenance_schedules_account_id', 'maintenance_schedules', 'accounts', + ['account_id'], ['id'], ondelete='CASCADE', + ) + + # Primary: tree_id → trees.account_id (only where tree.account_id is NOT NULL) + op.execute(""" + UPDATE maintenance_schedules ms + SET account_id = t.account_id + FROM trees t + WHERE ms.tree_id = t.id + AND t.account_id IS NOT NULL + AND ms.account_id IS NULL + """) + + # Fallback: created_by → users.account_id (for is_default trees with NULL account_id) + op.execute(""" + UPDATE maintenance_schedules ms + SET account_id = u.account_id + FROM users u + WHERE ms.created_by = u.id + AND u.account_id IS NOT NULL + AND ms.account_id IS NULL + """) + + result = op.get_bind().execute( + sa.text("SELECT COUNT(*) FROM maintenance_schedules WHERE account_id IS NULL") + ) + count = result.scalar() + if count > 0: + raise RuntimeError( + f"ROLLBACK: {count} maintenance_schedules rows have NULL account_id. " + "Check if created_by is NULL — those rows need manual resolution." + ) + + op.alter_column('maintenance_schedules', 'account_id', nullable=False) + op.create_index('ix_maintenance_schedules_account_id', 'maintenance_schedules', ['account_id']) + + +def downgrade() -> None: + op.drop_index('ix_maintenance_schedules_account_id', table_name='maintenance_schedules') + op.drop_constraint('fk_maintenance_schedules_account_id', 'maintenance_schedules', type_='foreignkey') + op.drop_column('maintenance_schedules', 'account_id') diff --git a/backend/app/models/maintenance_schedule.py b/backend/app/models/maintenance_schedule.py index 91280eb4..f8e38246 100644 --- a/backend/app/models/maintenance_schedule.py +++ b/backend/app/models/maintenance_schedule.py @@ -23,6 +23,12 @@ class MaintenanceSchedule(Base): created_by: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True ) + account_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("accounts.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) cron_expression: Mapped[str] = mapped_column(String(100), nullable=False) timezone: Mapped[str] = mapped_column(String(100), nullable=False, default="UTC") target_list_id: Mapped[Optional[uuid.UUID]] = mapped_column( diff --git a/backend/tests/test_phase1_migrations.py b/backend/tests/test_phase1_migrations.py index 28ae3bfb..32de973c 100644 --- a/backend/tests/test_phase1_migrations.py +++ b/backend/tests/test_phase1_migrations.py @@ -381,3 +381,42 @@ async def test_psa_member_mapping_account_id_matches_connection(test_db: AsyncSe ) row = result.scalar_one() assert row.account_id == account.id + + +# ── Group 6: Maintenance ────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_maintenance_schedule_account_id_matches_tree(test_db: AsyncSession): + """maintenance_schedules.account_id must match the tree's account_id.""" + from app.models.maintenance_schedule import MaintenanceSchedule + + account, user = await _make_account_and_user(test_db, "ms1") + tree = Tree( + name="Maintenance Flow", + account_id=account.id, + author_id=user.id, + visibility="team", + tree_type="maintenance", + tree_structure={"id": "root", "type": "start", "children": []}, + is_active=True, + status="published", + ) + test_db.add(tree) + await test_db.flush() + + schedule = MaintenanceSchedule( + tree_id=tree.id, + account_id=account.id, + created_by=user.id, + cron_expression="0 9 * * 1", + timezone="UTC", + is_active=True, + ) + test_db.add(schedule) + await test_db.commit() + + result = await test_db.execute( + select(MaintenanceSchedule).where(MaintenanceSchedule.id == schedule.id) + ) + row = result.scalar_one() + assert row.account_id == account.id