diff --git a/src/backend/api/routers/vision.py b/src/backend/api/routers/vision.py index 9b4de33..626619a 100644 --- a/src/backend/api/routers/vision.py +++ b/src/backend/api/routers/vision.py @@ -8,6 +8,7 @@ import base64 import binascii import io import json +import mimetypes from typing import Literal from fastapi.responses import FileResponse @@ -447,8 +448,50 @@ async def create_dxf_compare_recipe( placed, errors = vision_service.match_recipe_files(body["graph"], uploads) if errors: raise _422(errors) + # #105: l'immagine di riferimento resta nella cartella (non e' in meta, quindi + # /validate e l'esecuzione la ignorano) e il wizard puo' riaprirla; entra nel digest. + placed[f"riferimento.{_IMAGE_EXTENSIONS.get(image.content_type, 'png')}"] = image_bytes recipe_dir, check = await _store_validate_assign(task, body["graph"], placed) await vision_service.ensure_points_quote(db, task, min_points_pct) await db.commit() response.update(saved=True, recipe_dir=recipe_dir, outputs=check["outputs"]) return response + + +@router.get("/tasks/{task_id}/recipe/dxf-compare/source") +async def get_dxf_compare_source( + task_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_maker), +) -> dict: + """#105: i parametri della ricetta fatta con «Crea ricetta da DXF», per modificarla. + + 404 se il task non ha una ricetta del wizard (nessuna, o caricata a mano). + """ + task = await _task_or_404(db, task_id) + source = vision_service.dxf_compare_source(task.vision_json, task.vision_recipe_dir) + if source is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="nessuna ricetta da DXF su questo task", + ) + return source + + +@router.get("/tasks/{task_id}/recipe/files/{path:path}") +async def get_recipe_file( + task_id: int, + path: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_maker), +) -> FileResponse: + """#105: un file della cartella ricetta del task (DXF, immagine di riferimento). + + Percorso non sicuro, file assente o task senza ricetta: sempre 404. + """ + task = await _task_or_404(db, task_id) + target = vision_service.recipe_file_path(task.vision_recipe_dir, path) + if target is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="file not found") + media_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream" + return FileResponse(target, media_type=media_type) diff --git a/src/backend/services/vision_service.py b/src/backend/services/vision_service.py index 8b05f53..95c5661 100644 --- a/src/backend/services/vision_service.py +++ b/src/backend/services/vision_service.py @@ -485,6 +485,84 @@ def list_recipe_files(recipe_dir: str | None) -> list[str]: ) +# #105: nomi e id della ricetta costruita da «Crea ricetta da DXF» (src/vision/costruttori.py). +_WIZARD_DXF_FILE = "disegno.dxf" +_WIZARD_CALIBRATION_FILE = "calibrazione.json" +_WIZARD_REFERENCE_STEM = "riferimento" + + +def _is_real_number(value: object) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) + + +def dxf_compare_source(graph: dict | None, recipe_dir: str | None) -> dict | None: + """#105: i parametri con cui il wizard ha costruito la ricetta, per riaprirlo. + + Legge i valori dal grafo (tool `pezzo` LocateBlob, tool `dxf` CompareDxf) e i + file dalla cartella ricetta. None se il grafo non e' uno del wizard (il task + non ha ricetta, o l'ha caricata a mano): la rotta risponde 404. Pura: niente DB. + """ + if not isinstance(graph, dict) or not isinstance(graph.get("tools"), list): + return None + tools = {t.get("id"): t for t in graph["tools"] if isinstance(t, dict)} + pezzo, confronto = tools.get("pezzo"), tools.get("dxf") + if not pezzo or pezzo.get("kind") != "LocateBlob": + return None + if not confronto or confronto.get("kind") != "CompareDxf": + return None + p_values = pezzo.get("values") if isinstance(pezzo.get("values"), dict) else {} + c_values = confronto.get("values") if isinstance(confronto.get("values"), dict) else {} + band, min_pct = c_values.get("band_plus"), c_values.get("points_in_pct_nominal") + if not (_is_real_number(band) and _is_real_number(min_pct)): + return None + polarity = p_values.get("polarity") + if polarity not in ("dark", "light"): + polarity = "dark" # default di LocateBlob e della rotta di creazione + + folder = None + if recipe_dir: + try: + folder = _safe_join(settings.upload_path, recipe_dir) + except (ValueError, OSError): + folder = None + if folder is not None and not folder.is_dir(): + folder = None + + px_per_mm = None + dxf_name = None + image_name = None + if folder is not None: + try: + cal = json.loads((folder / _WIZARD_CALIBRATION_FILE).read_text(encoding="utf-8")) + value = cal.get("px_per_mm") if isinstance(cal, dict) else None + if _is_real_number(value) and value > 0: + px_per_mm = float(value) + except (OSError, ValueError): + px_per_mm = None # manca o illeggibile: il wizard chiedera' la scala + if (folder / _WIZARD_DXF_FILE).is_file(): + dxf_name = _WIZARD_DXF_FILE + for candidate in sorted(folder.glob(f"{_WIZARD_REFERENCE_STEM}.*")): + if candidate.is_file(): + image_name = candidate.name + break + return { + "band_mm": float(band), "min_points_pct": float(min_pct), "polarity": polarity, + "px_per_mm": px_per_mm, "dxf": dxf_name, "image": image_name, + } + + +def recipe_file_path(recipe_dir: str | None, relative: str) -> Path | None: + """#105: percorso di un file della cartella ricetta, o None se non sicuro o assente.""" + if not recipe_dir: + return None + try: + folder = _safe_join(settings.upload_path, recipe_dir) + target = _safe_join(folder, relative) + except (ValueError, OSError): + return None + return target if target.is_file() else None + + def save_result_files( task_id: int, image_bytes: bytes, overlay: list, image_ext: str = "png", ) -> tuple[str, str]: @@ -880,9 +958,27 @@ async def ensure_points_quote( Una sola volta: se esiste gia' una quota con `vision_output`, non si tocca. Se la versione ha gia' misure non si scrive (il copy-on-write sta nel router dei task, qui non si aggira): ritorna False. Ritorna True se l'ha creata. + #105: unica eccezione, la quota nata da qui (`dxf.points_in_pct`, «Punti in + tolleranza») senza misure segue la soglia nuova: ltl = min_points_pct, e + ritorna True anche in quel caso. """ from src.backend.services import recipe_service # import locale: evita cicli + # #105: ricetta ripresa e salvata con un'altra soglia -> la quota la segue + own = (await db.execute( + select(RecipeSubtask) + .where(RecipeSubtask.task_id == task.id) + .where(RecipeSubtask.vision_output == "dxf.points_in_pct") + .where(RecipeSubtask.description == "Punti in tolleranza") + .limit(1) + )).scalar_one_or_none() + if own is not None: + if (own.ltl is not None and float(own.ltl) == float(min_points_pct)) or await recipe_service.version_has_measurements(db, task.version_id): + return False + own.ltl = min_points_pct + await db.flush() + return True + existing = (await db.execute( select(RecipeSubtask.id) .where(RecipeSubtask.task_id == task.id) diff --git a/src/backend/tests/test_vision_dxf_source.py b/src/backend/tests/test_vision_dxf_source.py new file mode 100644 index 0000000..0066eb6 --- /dev/null +++ b/src/backend/tests/test_vision_dxf_source.py @@ -0,0 +1,226 @@ +"""#105: la ricetta «Crea ricetta da DXF» si puo' riaprire e modificare. + +Il worker non gira nei test: si finge come in test_vision_dxf_compare. +""" +import base64 +import io +import json + +import pytest +from PIL import Image +from sqlalchemy import select + +from src.backend.config import settings +from src.backend.models.orm.task import RecipeSubtask, RecipeTask +from src.backend.services import vision_service +from src.backend.tests.conftest import auth_headers +from src.backend.tests.test_vision_dxf_compare import ( # noqa: F401 (fixture) + redirect_uploads, validate_ok, +) +from src.backend.tests.test_vision_recipe_upload import an_image, make_task + +DXF = b"0\nEOF" + + +def _wizard_graph(band=0.3, pct=90.0, polarity="light") -> dict: + """Grafo come lo costruisce src/vision/costruttori.py::_graph.""" + return { + "schema_version": 2, "name": "Confronto DXF", + "meta": {"dxf": {"disegno": {"path": "disegno.dxf", "sha256": "x"}}, + "calibration": {"path": "calibrazione.json", "sha256": "y"}}, + "tools": [ + {"id": "pezzo", "name": "Pezzo", "kind": "LocateBlob", + "values": {"polarity": polarity}, "links": {}}, + {"id": "dxf", "name": "Confronto DXF", "kind": "CompareDxf", + "values": {"band_plus": band, "band_minus": band, "points_in_pct_nominal": pct}, + "links": {}}, + ], + } + + +@pytest.fixture +def w2(monkeypatch): + async def fake(image_bytes, dxf_bytes, **kwargs): + graph = _wizard_graph(kwargs["band_mm"], kwargs["min_points_pct"], kwargs["polarity"]) + return { + "graph": graph, + "files": { + "disegno.dxf": base64.b64encode(dxf_bytes).decode(), + "calibrazione.json": base64.b64encode( + json.dumps({"px_per_mm": kwargs["px_per_mm"]}).encode()).decode(), + }, + "outputs": {"dxf.points_in_pct": 99.0}, "overlay": [], "outcome": "pass", + } + monkeypatch.setattr("src.backend.services.vision_service.dxf_compare_recipe", fake) + + +def _bmp() -> bytes: + buf = io.BytesIO() + Image.new("L", (32, 32)).save(buf, format="BMP") + return buf.getvalue() + + +def _save(client, user, task, *, image=None, ctype="image/png", **data): + return client.post( + f"/api/vision/tasks/{task.id}/recipe/dxf-compare", + headers=auth_headers(user), + files={"image": ("p", image if image is not None else an_image(), ctype), + "dxf": ("d.dxf", DXF, "application/octet-stream")}, + data={"save": "true", "px_per_mm": "10", **{k: str(v) for k, v in data.items()}}, + ) + + +@pytest.mark.asyncio +async def test_save_keeps_the_reference_image_png(client, db_session, maker_user, w2, validate_ok): + task, _ = await make_task(db_session, maker_user) + image = an_image() + + resp = await _save(client, maker_user, task, image=image) + + assert resp.status_code == 200, resp.text + folder = settings.upload_path / resp.json()["recipe_dir"] + assert (folder / "riferimento.png").read_bytes() == image + assert (folder / "disegno.dxf").read_bytes() == DXF # il resto della cartella non cambia + + +@pytest.mark.asyncio +async def test_save_keeps_the_reference_image_bmp(client, db_session, maker_user, w2, validate_ok): + task, _ = await make_task(db_session, maker_user) + image = _bmp() + + resp = await _save(client, maker_user, task, image=image, ctype="image/bmp") + + assert resp.status_code == 200, resp.text + folder = settings.upload_path / resp.json()["recipe_dir"] + assert (folder / "riferimento.bmp").read_bytes() == image + + +@pytest.mark.asyncio +async def test_source_returns_what_the_wizard_saved(client, db_session, maker_user, w2, validate_ok): + task, _ = await make_task(db_session, maker_user) + await _save(client, maker_user, task, band_mm=0.3, min_points_pct=90, polarity="light") + + resp = await client.get( + f"/api/vision/tasks/{task.id}/recipe/dxf-compare/source", headers=auth_headers(maker_user), + ) + + assert resp.status_code == 200, resp.text + assert resp.json() == { + "band_mm": 0.3, "min_points_pct": 90.0, "polarity": "light", "px_per_mm": 10.0, + "dxf": "disegno.dxf", "image": "riferimento.png", + } + + +@pytest.mark.asyncio +async def test_source_404_without_recipe_or_with_a_hand_made_graph(client, db_session, maker_user): + task, _ = await make_task(db_session, maker_user) + url = f"/api/vision/tasks/{task.id}/recipe/dxf-compare/source" + + assert (await client.get(url, headers=auth_headers(maker_user))).status_code == 404 + + task.vision_json = {"schema_version": 2, "name": "a mano", "tools": [], "meta": {}} + await db_session.commit() + resp = await client.get(url, headers=auth_headers(maker_user)) + assert resp.status_code == 404 + assert resp.json()["detail"] == "nessuna ricetta da DXF su questo task" + + +@pytest.mark.asyncio +async def test_source_needs_maker(client, db_session, maker_user, measurement_tec_user): + task, _ = await make_task(db_session, maker_user) + resp = await client.get( + f"/api/vision/tasks/{task.id}/recipe/dxf-compare/source", + headers=auth_headers(measurement_tec_user), + ) + assert resp.status_code == 403 + + +def test_source_of_an_old_recipe_has_no_image_and_no_unreadable_scale(tmp_path, monkeypatch): + monkeypatch.setattr(settings, "upload_dir", str(tmp_path)) + folder = tmp_path / "vision" / "recipes" / "1" / "abc" + folder.mkdir(parents=True) + (folder / "disegno.dxf").write_bytes(DXF) + (folder / "calibrazione.json").write_text("{non json", encoding="utf-8") + + source = vision_service.dxf_compare_source(_wizard_graph(), "vision/recipes/1/abc") + + assert source["image"] is None and source["px_per_mm"] is None + assert source["dxf"] == "disegno.dxf" + assert vision_service.dxf_compare_source(None, None) is None + # senza cartella i valori del grafo restano + assert vision_service.dxf_compare_source(_wizard_graph(), None)["dxf"] is None + + +@pytest.mark.asyncio +async def test_files_returns_the_bytes(client, db_session, maker_user, w2, validate_ok): + task, _ = await make_task(db_session, maker_user) + image = _bmp() + await _save(client, maker_user, task, image=image, ctype="image/bmp") + + img = await client.get( + f"/api/vision/tasks/{task.id}/recipe/files/riferimento.bmp", headers=auth_headers(maker_user), + ) + dxf = await client.get( + f"/api/vision/tasks/{task.id}/recipe/files/disegno.dxf", headers=auth_headers(maker_user), + ) + + assert img.status_code == 200 and img.content == image + assert img.headers["content-type"].startswith("image/") + assert dxf.status_code == 200 and dxf.content == DXF + assert dxf.headers["content-type"] # dal tipo dell'estensione, di ripiego octet-stream + + +@pytest.mark.asyncio +async def test_files_404_on_unsafe_missing_or_no_recipe(client, db_session, maker_user, w2, validate_ok): + task, _ = await make_task(db_session, maker_user) + base = f"/api/vision/tasks/{task.id}/recipe/files/" + headers = auth_headers(maker_user) + + assert (await client.get(base + "disegno.dxf", headers=headers)).status_code == 404 # nessuna ricetta + + await _save(client, maker_user, task) + assert (await client.get(base + "manca.bmp", headers=headers)).status_code == 404 + for unsafe in ("../x", "%2e%2e/x", "..%2f..%2fx", "sub/../../x"): + assert (await client.get(base + unsafe, headers=headers)).status_code == 404, unsafe + # la funzione non lascia uscire dalla cartella nemmeno con un assoluto + assert vision_service.recipe_file_path("vision/recipes/1/abc", "../x") is None + assert vision_service.recipe_file_path("vision/recipes/1/abc", "/etc/passwd") is None + assert vision_service.recipe_file_path("../fuori", "x") is None + + +@pytest.mark.asyncio +async def test_resave_follows_the_new_threshold(client, db_session, maker_user, w2, validate_ok): + task, _ = await make_task(db_session, maker_user) + task_id = task.id + await _save(client, maker_user, task, min_points_pct=95) + + resp = await _save(client, maker_user, task, min_points_pct=80) + + assert resp.status_code == 200, resp.text + db_session.expire_all() + quotes = (await db_session.execute( + select(RecipeSubtask).where(RecipeSubtask.vision_output == "dxf.points_in_pct") + .where(RecipeSubtask.task_id == task_id))).scalars().all() + assert len(quotes) == 1 and float(quotes[0].ltl) == 80.0 + + +@pytest.mark.asyncio +async def test_ensure_points_quote_leaves_quote_with_measurements( + client, db_session, maker_user, w2, validate_ok, monkeypatch, +): + task, _ = await make_task(db_session, maker_user) + task_id = task.id + await _save(client, maker_user, task, min_points_pct=95) + + async def has_measurements(db, version_id): + return True + monkeypatch.setattr("src.backend.services.recipe_service.version_has_measurements", has_measurements) + task = await db_session.get(RecipeTask, task_id) + + changed = await vision_service.ensure_points_quote(db_session, task, 80) + + assert changed is False + quote = (await db_session.execute( + select(RecipeSubtask).where(RecipeSubtask.vision_output == "dxf.points_in_pct") + .where(RecipeSubtask.task_id == task_id))).scalar_one() + assert float(quote.ltl) == 95.0