From d134cdee7bbcc412ae70b317fd689a2e6e942673 Mon Sep 17 00:00:00 2001 From: mxatmx Date: Sun, 21 Jun 2026 05:13:19 -0400 Subject: [PATCH] [departments] Cascade-delete salary scale entries on department deletion Problem: departments could not be deleted once the budget salary scale page had been opened. Loading GET /data/salary-scales auto-creates a salary_scale row for every department/position/seniority combination, and salary_scale.department_id referenced department.id without an ON DELETE rule (default RESTRICT). Deleting a department then raised an IntegrityError (surfaced as a generic 400), and there is no UI or bulk API to clear the referencing rows first, leaving the department permanently undeletable. Solution: recreate the foreign key with ON DELETE CASCADE so the auto-generated, department-scoped salary scale rows are removed together with their department. Adds the model change, an Alembic migration that swaps the constraint, and a regression test covering the cascade. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/models/test_salary_scale.py | 32 ++++++++++++ zou/app/models/salary_scale.py | 2 +- ...scade_delete_salary_scale_on_department.py | 52 +++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 zou/migrations/versions/f1a2b3c4d5e6_cascade_delete_salary_scale_on_department.py diff --git a/tests/models/test_salary_scale.py b/tests/models/test_salary_scale.py index f89c54833..5e7502f3f 100644 --- a/tests/models/test_salary_scale.py +++ b/tests/models/test_salary_scale.py @@ -1,5 +1,6 @@ from tests.base import ApiDBTestCase +from zou.app.models.department import Department from zou.app.models.salary_scale import SalaryScale from zou.app.utils import fields @@ -15,6 +16,37 @@ def test_get_salary_scales(self): # salary_scales = self.get("data/salary-scales") # self.assertEqual(len(salary_scales), 3) + def test_delete_department_cascades_salary_scales(self): + """ + A department must stay deletable even though salary scale entries + reference it: the entries are auto-generated and meaningless without + the department, so they are removed by the ON DELETE CASCADE foreign + key instead of blocking the deletion. + """ + department = Department.create(name="Modeling", color="#FFFFFF") + SalaryScale.create( + department_id=department.id, + position="artist", + seniority="junior", + salary=500, + ) + SalaryScale.create( + department_id=department.id, + position="lead", + seniority="senior", + salary=900, + ) + self.assertEqual( + SalaryScale.query.filter_by(department_id=department.id).count(), 2 + ) + + department.delete() + + self.assertIsNone(Department.get(department.id)) + self.assertEqual( + SalaryScale.query.filter_by(department_id=department.id).count(), 0 + ) + """ def test_get_salary_scale(self): salary_scale = self.get_first("data/salary-scales") diff --git a/zou/app/models/salary_scale.py b/zou/app/models/salary_scale.py index a72829dd5..d9f16562e 100644 --- a/zou/app/models/salary_scale.py +++ b/zou/app/models/salary_scale.py @@ -15,7 +15,7 @@ class SalaryScale(db.Model, BaseMixin, SerializerMixin): department_id = db.Column( UUIDType(binary=False), - db.ForeignKey("department.id"), + db.ForeignKey("department.id", ondelete="CASCADE"), index=True, nullable=False, ) diff --git a/zou/migrations/versions/f1a2b3c4d5e6_cascade_delete_salary_scale_on_department.py b/zou/migrations/versions/f1a2b3c4d5e6_cascade_delete_salary_scale_on_department.py new file mode 100644 index 000000000..369c85cf5 --- /dev/null +++ b/zou/migrations/versions/f1a2b3c4d5e6_cascade_delete_salary_scale_on_department.py @@ -0,0 +1,52 @@ +"""cascade delete salary scale entries when a department is deleted + +Salary scale rows are auto-generated for every department/position/seniority +combination and have no meaning without their department, so they should be +removed together with it. The foreign key is recreated with ON DELETE CASCADE +to replace the default RESTRICT behaviour that blocked department deletion. + +Revision ID: f1a2b3c4d5e6 +Revises: c5e8b2a4f1d3 +Create Date: 2026-06-21 10:00:00.000000 + +""" + +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "f1a2b3c4d5e6" +down_revision = "c5e8b2a4f1d3" +branch_labels = None +depends_on = None + + +def upgrade(): + op.drop_constraint( + "salary_scale_department_id_fkey", + "salary_scale", + type_="foreignkey", + ) + op.create_foreign_key( + "salary_scale_department_id_fkey", + "salary_scale", + "department", + ["department_id"], + ["id"], + ondelete="CASCADE", + ) + + +def downgrade(): + op.drop_constraint( + "salary_scale_department_id_fkey", + "salary_scale", + type_="foreignkey", + ) + op.create_foreign_key( + "salary_scale_department_id_fkey", + "salary_scale", + "department", + ["department_id"], + ["id"], + )