merge #105 105a: ricetta DXF riapribile, backend (Refs #105)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
This commit is contained in:
2026-10-25 16:31:10 +01:00
3 changed files with 365 additions and 0 deletions
+43
View File
@@ -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)
+96
View File
@@ -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)
+226
View File
@@ -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