From 54bc38465086306e3e6fdb6cdceb014393e0d511 Mon Sep 17 00:00:00 2001 From: AdrianoDev Date: Mon, 21 Sep 2026 22:44:55 +0200 Subject: [PATCH] fix(vision): task_id di vision_results/vision_reference_images in CASCADE (#38.6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrazione 014 (batch_alter_table, nome del vincolo esistente letto con sa.inspect invece di indovinato) e stessi ondelete nei modelli ORM. Cancellare un task con esecuzioni o immagini di riferimento non fallisce più su un vincolo FK MySQL. MANCA: i file delle immagini di riferimento su disco non vengono rimossi alla cancellazione del task - tasks.py::delete_task (NON TOCCARE da questo lotto) cancella con `db.delete(task)` diretto, senza un punto unico in vision_service da agganciare. `_copy_tasks_to_version` (recipe_service.py) non copia le immagini di riferimento nel copy-on-write: nemmeno questo si tocca qui. Co-Authored-By: Claude Sonnet 5 --- .../versions/014_vision_fk_ondelete.py | 76 +++++++++++++++++++ src/backend/models/orm/vision.py | 6 +- src/backend/tests/test_vision_graph.py | 9 +++ 3 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 src/backend/migrations/versions/014_vision_fk_ondelete.py diff --git a/src/backend/migrations/versions/014_vision_fk_ondelete.py b/src/backend/migrations/versions/014_vision_fk_ondelete.py new file mode 100644 index 0000000..4bef791 --- /dev/null +++ b/src/backend/migrations/versions/014_vision_fk_ondelete.py @@ -0,0 +1,76 @@ +"""vision results and reference images cascade with their task + +Both `vision_results.task_id` and `vision_reference_images.task_id` were +created (012, 013) with a plain, unnamed foreign key - no ON DELETE clause, +so deleting a task with any camera history or reference image attached fails +on a strict FK check (MySQL) instead of taking the vision rows with it, the +way `production_events.run_id` already does for a closed production run. + +The original constraint has no name to drop by (SQLAlchemy leaves inline +`ForeignKey(...)` unnamed unless told otherwise, and MySQL then assigns its +own `_ibfk_N`, which is not portable to reflect on). `sa.inspect` +looks it up at migration time instead of guessing it - the same way on +MySQL and on SQLite (batch mode), where an anonymous FK reflects with +`name=None` and there is nothing to drop before adding the new one. + +Reference images on disk are not touched here - deleting a task's row does +not, on its own, know to also delete the file next to it; that gap is +declared open in issue #38 point 6's write-up (`vision_service` has no +single point today that intercepts a task deletion). + +Revision ID: 014_vision_fk_ondelete +Revises: 013_reference_images +Create Date: 2026-09-21 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = '014_vision_fk_ondelete' +down_revision: Union[str, None] = '013_reference_images' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_NEW_FK_NAMES = { + 'vision_results': 'fk_vision_results_task_id', + 'vision_reference_images': 'fk_vision_reference_images_task_id', +} + + +def _existing_task_id_fk_name(bind, table_name: str) -> Union[str, None]: + """The name reflection gives the pre-existing `task_id` FK, if any. + + None on SQLite for a constraint that was never named - batch mode then + has nothing to drop, and just adds the new named one alongside it. + """ + inspector = sa.inspect(bind) + for fk in inspector.get_foreign_keys(table_name): + if fk.get("constrained_columns") == ["task_id"]: + return fk.get("name") + return None + + +def upgrade() -> None: + bind = op.get_bind() + + for table_name, new_name in _NEW_FK_NAMES.items(): + existing_name = _existing_task_id_fk_name(bind, table_name) + with op.batch_alter_table(table_name) as batch: + if existing_name: + batch.drop_constraint(existing_name, type_='foreignkey') + batch.create_foreign_key( + new_name, 'recipe_tasks', ['task_id'], ['id'], + ondelete='CASCADE', + ) + + +def downgrade() -> None: + # Reverse order: reference_images before results, mirroring upgrade(). + for table_name, name in reversed(list(_NEW_FK_NAMES.items())): + with op.batch_alter_table(table_name) as batch: + batch.drop_constraint(name, type_='foreignkey') + batch.create_foreign_key( + name, 'recipe_tasks', ['task_id'], ['id'], + ) diff --git a/src/backend/models/orm/vision.py b/src/backend/models/orm/vision.py index cdada5c..f51bae1 100644 --- a/src/backend/models/orm/vision.py +++ b/src/backend/models/orm/vision.py @@ -13,7 +13,8 @@ class VisionResult(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) task_id: Mapped[int] = mapped_column( - Integer, ForeignKey("recipe_tasks.id"), nullable=False, index=True + Integer, ForeignKey("recipe_tasks.id", ondelete="CASCADE"), + nullable=False, index=True, ) image_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) overlay_path: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) @@ -48,7 +49,8 @@ class VisionReferenceImage(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) task_id: Mapped[int] = mapped_column( - Integer, ForeignKey("recipe_tasks.id"), nullable=False, index=True + Integer, ForeignKey("recipe_tasks.id", ondelete="CASCADE"), + nullable=False, index=True, ) path: Mapped[str] = mapped_column(String(500), nullable=False) station_id: Mapped[Optional[int]] = mapped_column( diff --git a/src/backend/tests/test_vision_graph.py b/src/backend/tests/test_vision_graph.py index ec7a151..924a410 100644 --- a/src/backend/tests/test_vision_graph.py +++ b/src/backend/tests/test_vision_graph.py @@ -4,6 +4,7 @@ from sqlalchemy import select from src.backend.models.orm.recipe import RecipeVersion from src.backend.models.orm.task import TASK_TYPES, RecipeSubtask, RecipeTask +from src.backend.models.orm.vision import VisionReferenceImage, VisionResult from src.backend.tests.conftest import create_test_recipe @@ -69,3 +70,11 @@ def test_the_typo_is_gone(): """`xf_compare` era un refuso: si corregge finché nessuna riga lo usa.""" assert "dxf_compare" in TASK_TYPES assert "xf_compare" not in TASK_TYPES + + +@pytest.mark.parametrize("model", [VisionResult, VisionReferenceImage]) +def test_task_id_cascades_when_the_task_is_deleted(model): + """#38.6: cancellare un task con esecuzioni/immagini non deve fallire su + un vincolo FK MySQL - la riga di visione deve seguirlo.""" + (fk,) = model.__table__.columns["task_id"].foreign_keys + assert fk.ondelete == "CASCADE"