vision: ricetta DXF riapribile, immagine salvata, rotte source e files, soglia segue la quota (Refs #105)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user