From 0d54e42f7c11dd423f852728b9e6bb37d51c316f Mon Sep 17 00:00:00 2001 From: Norair Arutshyan Date: Thu, 1 Oct 2026 16:40:17 +0100 Subject: [PATCH] fix(budget): cascade-delete budget lines when deleting a budget BudgetModel.lines now uses cascade="all, delete-orphan" with passive_deletes=True, and budget_lines.budget_id gets ON DELETE CASCADE (migration 000021). Previously the ORM nulled budget_id on delete, violating NOT NULL and surfacing as a 400. Budgets whose lines have report lines still fail with 400. Closes #378 Co-Authored-By: Claude Sonnet 5.5 --- services/budget/app/models/budget.py | 9 +++- .../000021_budget_lines_budget_id_cascade.py | 35 ++++++++++++++++ services/budget/tests/test_budget_crud.py | 41 +++++++++++++++++++ 3 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 services/budget/migrations/versions/000021_budget_lines_budget_id_cascade.py diff --git a/services/budget/app/models/budget.py b/services/budget/app/models/budget.py index 48229b2..49df89d 100644 --- a/services/budget/app/models/budget.py +++ b/services/budget/app/models/budget.py @@ -77,7 +77,10 @@ class BudgetModel(Base, AuditMixin): excel_import_structure: Mapped[dict | None] = mapped_column(JSON, nullable=True) excel_import_lines_locked_count: Mapped[int | None] = mapped_column(Integer, nullable=True) lines: Mapped[list["BudgetLineModel"]] = relationship( - "BudgetLineModel", back_populates="budget" + "BudgetLineModel", + back_populates="budget", + cascade="all, delete-orphan", + passive_deletes=True, ) reports: Mapped[list["ReportModel"]] = relationship("ReportModel", back_populates="budget") categories: Mapped[list["BudgetCategoryModel"]] = relationship( @@ -94,7 +97,9 @@ class BudgetLineModel(Base, AuditMixin): id: Mapped[uuid.UUID] = mapped_column( GUID(), primary_key=True, index=True, default=lambda: uuid.uuid4() ) - budget_id: Mapped[uuid.UUID] = mapped_column(GUID(), ForeignKey("budgets.id"), nullable=False) + budget_id: Mapped[uuid.UUID] = mapped_column( + GUID(), ForeignKey("budgets.id", ondelete="CASCADE"), nullable=False + ) category_id: Mapped[uuid.UUID | None] = mapped_column( GUID(), ForeignKey("budget_categories.id", ondelete="SET NULL"), nullable=True ) diff --git a/services/budget/migrations/versions/000021_budget_lines_budget_id_cascade.py b/services/budget/migrations/versions/000021_budget_lines_budget_id_cascade.py new file mode 100644 index 0000000..2888aa9 --- /dev/null +++ b/services/budget/migrations/versions/000021_budget_lines_budget_id_cascade.py @@ -0,0 +1,35 @@ +"""Cascade-delete budget lines with their budget + +Revision ID: 000021 +Revises: 000020 +Create Date: 2026-10-01 00:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "000021" +down_revision: Union[str, Sequence[str], None] = "000020" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.drop_constraint("budget_lines_budget_id_fkey", "budget_lines", type_="foreignkey") + op.create_foreign_key( + "budget_lines_budget_id_fkey", + "budget_lines", + "budgets", + ["budget_id"], + ["id"], + ondelete="CASCADE", + ) + + +def downgrade() -> None: + op.drop_constraint("budget_lines_budget_id_fkey", "budget_lines", type_="foreignkey") + op.create_foreign_key( + "budget_lines_budget_id_fkey", "budget_lines", "budgets", ["budget_id"], ["id"] + ) diff --git a/services/budget/tests/test_budget_crud.py b/services/budget/tests/test_budget_crud.py index 570fcf2..9ba76c1 100644 --- a/services/budget/tests/test_budget_crud.py +++ b/services/budget/tests/test_budget_crud.py @@ -117,3 +117,44 @@ async def test_delete_succeeds_with_categories_already_loaded(self, db): assert len(list(loaded_budget.categories)) == 1 assert await delete_budget(session=db, budget=loaded_budget) is True + + +async def _budget_with_line(db) -> BudgetModel: + user = ValidUserFactory() + budget = await create_budget( + session=db, user_id=user["user_id"], name="WithLines", owner_id=user["customer_id"] + ) + category = await create_budget_category( + session=db, user_id=user["user_id"], budget_id=budget.id, name="Travel" + ) + await create_budget_line( + session=db, + user_id=user["user_id"], + budget_id=budget.id, + category_id=category.id, + description="Flight", + amount=100.0, + ) + return budget + + +@pytest.mark.anyio +class TestDeleteBudgetCascadesLines: + async def test_delete_succeeds_with_lines_unloaded(self, db): + budget = await _budget_with_line(db) + + # sqlite doesn't enforce the DB-level cascade; Postgres removes the lines. + assert await delete_budget(session=db, budget=budget) is True + + async def test_delete_succeeds_with_lines_already_loaded(self, db): + budget = await _budget_with_line(db) + + result = await db.execute( + select(BudgetModel) + .where(BudgetModel.id == budget.id) + .options(selectinload(BudgetModel.lines)) + ) + loaded_budget = result.scalar_one() + assert len(list(loaded_budget.lines)) == 1 + + assert await delete_budget(session=db, budget=loaded_budget) is True