Compare commits

...

11 Commits

Author SHA1 Message Date
Adriano f986c172c2 Worker: recipe_dir con lettera di unita rifiutato su ogni OS (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:32:34 +02:00
Adriano accf416cc9 Motore v2: run_graph con recipe_dir/calibrazione/overlay, /validate, test (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 11:54:27 +02:00
Adriano 84a4b5f34d Regola submodule VisionSuite su origin/main (bc0df16), uv.lock (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 11:51:06 +02:00
Adriano 9238f8926b visionsuite bc0df16, vs-tools e vs-dxf nelle dipendenze (#46)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 11:50:35 +02:00
Adriano a829bf3eb0 frontend: date UTC convertite in ora italiana alla presentazione (#42)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:59:18 +02:00
Adriano 7ac291b8ac Backend: date dei filtri dei report e utenti setup in ora italiana/UTC esplicito (#42)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:59:07 +02:00
Adriano 486e1288bf Backend: UTC nel DB, +00:00 nelle API, ora italiana solo in presentazione (#42)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:41:22 +02:00
Adriano 0d004c43ff Login: cancella le key scadute dell'utente (#41)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:34:30 +02:00
Adriano 35f46926a9 API key come hash SHA-256, una per sessione, TTL rinnovabile (#41)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:34:30 +02:00
Adriano 1d93eded27 Ripristino delle quote già misurate nel ciclo in corso dopo un reload (#40)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:31:57 +02:00
Adriano 70e2072b79 File di riferimento rimossi con il task, immagini animate rifiutate, filtro production_run_id (#43)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:27:23 +02:00
56 changed files with 1907 additions and 278 deletions
+2
View File
@@ -92,6 +92,8 @@ SESSION_COOKIE_SECURE=true
VISION_RUN_TIMEOUT_S=100
# Quante esecuzioni di visione in parallelo accetta il worker.
VISION_MAX_CONCURRENCY=2
# #46: radice sotto cui il worker legge le cartelle ricetta (recipe_dir relativo).
VISION_RECIPES_ROOT=/app/uploads
# --- Docker ---
# DB_ROOT_PASSWORD: in produzione (docker-compose.yml) obbligatoria, senza default.
+2
View File
@@ -289,6 +289,8 @@ Nginx (dev): gzip abilitato, `client_max_body_size 50M`, `proxy_read_timeout 120
Il runner di visione (`src/vision/`) e il suo worker FastAPI (`src/vision_worker/`) dipendono da `vendor/visionsuite`, un **submodule** su `ssh://git.tielogic.xyz:222`: un `git clone` senza `--recurse-submodules` lascia la cartella vuota (chi ha già clonato: `git submodule update --init`). Le librerie di VisionSuite richiedono **Python 3.13**, mentre backend e frontend restano su Python 3.11 — è un albero a parte nel monorepo, isolato con il proprio extra `uv`.
Regola (Adriano, 07/10/2026): il submodule `vendor/visionsuite` segue `origin/main`: a ogni integrazione si porta all'ultimo commit (`git -C vendor/visionsuite fetch origin && git -C vendor/visionsuite checkout origin/main`) e si rilanciano i test della visione. Pin attuale: bc0df16.
Il worker gira in un container separato (`Dockerfile.vision`), non pubblica porte verso l'esterno ed è raggiungibile solo dal server all'indirizzo interno `http://vision:8100`. Il container non ha accesso al repository Git del progetto principale, quindi il commit di VisionSuite in uso (`engine_version`) non può essere scoperto dentro l'immagine: va stampato al momento del build leggendo il commit dalla macchina che esegue `docker compose build`, con la variabile `VISION_ENGINE_VERSION`. Una build senza questa variabile fallisce subito, invece di produrre un worker che risponde con una versione indovinata:
```bash
+1
View File
@@ -14,6 +14,7 @@ services:
--authentication-policy=mysql_native_password
--character-set-server=utf8mb4
--collation-server=utf8mb4_unicode_ci
--default-time-zone=+00:00
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
+4
View File
@@ -14,6 +14,7 @@ services:
--authentication-policy=mysql_native_password
--character-set-server=utf8mb4
--collation-server=utf8mb4_unicode_ci
--default-time-zone=+00:00
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
@@ -70,6 +71,9 @@ services:
VISION_ENGINE_VERSION: ${VISION_ENGINE_VERSION:-}
container_name: tmflow-vision
restart: unless-stopped
environment:
# #46: radice delle cartelle ricetta (volume uploads, sola lettura)
VISION_RECIPES_ROOT: /app/uploads
volumes:
- upload_data:/app/uploads:ro
healthcheck:
+4
View File
@@ -63,6 +63,8 @@ vision = [
"vs-task; python_version>='3.13'",
"vs-measure; python_version>='3.13'",
"vs-pm2d; python_version>='3.13'",
# #46: tutti gli strumenti della suite (locate/MatchPattern e dxf/CompareDxf)
"vs-tools[locate,dxf]; python_version>='3.13'",
"numpy; python_version>='3.13'",
]
@@ -101,6 +103,8 @@ visionsuite = { path = "vendor/visionsuite/packages/vs-core", editable = true }
vs-task = { path = "vendor/visionsuite/packages/vs-task", editable = true }
vs-measure = { path = "vendor/visionsuite/packages/vs-measure", editable = true }
vs-pm2d = { path = "vendor/visionsuite/packages/vs-pm2d", editable = true }
vs-tools = { path = "vendor/visionsuite/packages/vs-tools", editable = true } # #46
vs-dxf = { path = "vendor/visionsuite/packages/vs-dxf", editable = true } # #46
[tool.pytest.ini_options]
asyncio_mode = "auto"
+22 -4
View File
@@ -1,10 +1,18 @@
"""API Key authentication dependency for FastAPI."""
from datetime import timedelta
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.database import get_db
from src.backend.config import settings
from src.backend.models.orm.api_key import ApiKey
from src.backend.models.orm.user import User
from src.backend.services.auth_service import hash_api_key
from src.backend.utils.tempo import utcnow
_RENEW_INTERVAL = timedelta(minutes=5)
async def get_current_user(
@@ -24,15 +32,25 @@ async def get_current_user(
)
result = await db.execute(
select(User).where(User.api_key == api_key, User.active == True)
select(ApiKey, User)
.join(User, User.id == ApiKey.user_id)
.where(ApiKey.key_hash == hash_api_key(api_key), User.active == True)
)
user = result.scalar_one_or_none()
row = result.first()
now = utcnow()
if user is None:
if row is None or row[0].expires_at <= now:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or inactive API key",
detail="Invalid or expired API key",
)
key_row, user = row
# Sliding TTL, written at most once per _RENEW_INTERVAL to spare the DB.
if key_row.last_used_at is None or now - key_row.last_used_at > _RENEW_INTERVAL:
key_row.last_used_at = now
key_row.expires_at = now + timedelta(hours=settings.api_key_ttl_hours)
await db.flush()
# Store user_id in request state for access logging middleware
request.state.user_id = user.id
+7 -6
View File
@@ -1,5 +1,5 @@
"""Authentication router - login, logout, profile."""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.database import get_db
@@ -34,15 +34,16 @@ async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(
request: Request,
all_sessions: bool = False,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Logout. The API key is shared across stations logged in as the same
user, so by default this does not invalidate it - only this station's
session (its Flask session, on the client side) ends. Pass
?all_sessions=true to invalidate the key everywhere."""
await logout_user(db, user, all_sessions=all_sessions)
"""Logout. Deletes this session's API key only; ?all_sessions=true
deletes every key of the user."""
await logout_user(
db, user, key=request.headers.get("X-API-Key"), all_sessions=all_sessions
)
@router.get("/me", response_model=UserResponse)
+32 -7
View File
@@ -2,6 +2,8 @@
import csv
import io
from datetime import datetime
from src.backend.models.api.tipi import UtcNaiveInput
from src.backend.utils.tempo import as_utc_naive, iso_utc, to_local, utcnow
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import StreamingResponse
@@ -19,6 +21,7 @@ from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.setting import SystemSetting
from src.backend.models.orm.user import User
from src.backend.models.api.measurement import (
CurrentCycleResponse,
MeasurementAuthorisation,
MeasurementBatchCreate,
MeasurementCreate,
@@ -97,6 +100,24 @@ async def get_task_progress(
return TaskProgressListResponse(tasks=tasks)
@router.get("/current-cycle", response_model=CurrentCycleResponse)
async def get_current_cycle(
production_run_id: int = Query(..., gt=0),
task_id: int = Query(..., gt=0),
user: User = Depends(require_measurement_tec),
db: AsyncSession = Depends(get_db),
):
"""Quotes of this task already measured in the cycle under way.
Asked on load, so that a reload mid-cycle does not present quotes again that
were taken a moment ago.
"""
rows = await measurement_service.current_cycle_measurements(
db, production_run_id, task_id,
)
return CurrentCycleResponse(measurements=rows)
@router.post("/{measurement_id}/authorise", response_model=MeasurementResponse)
async def authorise_measurement(
measurement_id: int,
@@ -174,6 +195,7 @@ def _build_measurement_filters(
date_from: datetime | None,
date_to: datetime | None,
pass_fail: str | None,
production_run_id: int | None = None,
) -> list:
"""Build SQLAlchemy filter conditions shared by list and CSV export."""
filters = []
@@ -199,6 +221,8 @@ def _build_measurement_filters(
filters.append(Measurement.measured_at <= date_to)
if pass_fail is not None:
filters.append(Measurement.pass_fail == pass_fail)
if production_run_id is not None:
filters.append(Measurement.production_run_id == production_run_id)
return filters
@@ -210,9 +234,10 @@ async def get_measurements(
measured_by: int | None = Query(None),
lot_number: str | None = Query(None),
serial_number: str | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
date_from: UtcNaiveInput = Query(None),
date_to: UtcNaiveInput = Query(None),
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
production_run_id: int | None = Query(None, gt=0),
page: int = Query(1, ge=1),
per_page: int = Query(50, ge=1, le=500),
user: User = Depends(get_current_user),
@@ -228,7 +253,7 @@ async def get_measurements(
"""
filters = _build_measurement_filters(
recipe_id, version_id, subtask_id, measured_by, lot_number,
serial_number, date_from, date_to, pass_fail,
serial_number, date_from, date_to, pass_fail, production_run_id,
)
# Build query
@@ -267,8 +292,8 @@ async def export_measurements_csv(
measured_by: int | None = Query(None),
lot_number: str | None = Query(None),
serial_number: str | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
date_from: UtcNaiveInput = Query(None),
date_to: UtcNaiveInput = Query(None),
pass_fail: str | None = Query(None, pattern="^(pass|warning|fail)$"),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
@@ -349,7 +374,7 @@ async def export_measurements_csv(
neutralise_csv_cell(m.lot_number or ""),
neutralise_csv_cell(m.serial_number or ""),
m.input_method,
m.measured_at.isoformat(),
iso_utc(m.measured_at),
m.synced_to_csv,
])
@@ -359,6 +384,6 @@ async def export_measurements_csv(
iter([output.getvalue()]),
media_type="text/csv",
headers={
"Content-Disposition": f"attachment; filename=measurements_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
"Content-Disposition": f"attachment; filename=measurements_{to_local(utcnow()).strftime('%Y%m%d_%H%M%S')}.csv"
},
)
+5 -5
View File
@@ -1,5 +1,5 @@
"""Reports router — PDF report generation endpoints for Metrologist."""
from datetime import datetime
from src.backend.models.api.tipi import UtcNaiveInput
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import Response
@@ -18,8 +18,8 @@ async def download_spc_report(
recipe_id: int = Query(...),
subtask_id: int = Query(...),
version_id: int | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
date_from: UtcNaiveInput = Query(None),
date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None),
lot_number: str | None = Query(None),
serial_number: str | None = Query(None),
@@ -52,8 +52,8 @@ async def download_measurement_report(
recipe_id: int = Query(...),
subtask_id: int | None = Query(None),
version_id: int | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
date_from: UtcNaiveInput = Query(None),
date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None),
lot_number: str | None = Query(None),
serial_number: str | None = Query(None),
+9 -12
View File
@@ -5,8 +5,8 @@ NOT protected by API Key auth (standalone access).
"""
import hmac
import random
import secrets
from datetime import datetime, timedelta
from src.backend.utils.tempo import iso_utc, utcnow
from pathlib import Path
from alembic import command
@@ -25,7 +25,7 @@ from src.backend.models.orm import (
User, Recipe, RecipeVersion, RecipeTask, RecipeSubtask, Measurement,
)
from src.backend.models.orm.station import Station, StationRecipeAssignment
from src.backend.services.auth_service import hash_password
from src.backend.services.auth_service import hash_password, issue_api_key, revoke_all_keys
from src.backend.services.measurement_service import calculate_pass_fail
router = APIRouter(prefix="/api/setup", tags=["setup"])
@@ -321,8 +321,6 @@ async def create_admin(body: CreateAdminBody):
"""Create an admin user with all roles."""
_check_password(body.password)
api_key = secrets.token_hex(32)
async with async_session_factory() as session:
async with session.begin():
user = User(
@@ -332,9 +330,10 @@ async def create_admin(body: CreateAdminBody):
display_name=body.admin_display_name,
roles=["Maker", "MeasurementTec", "Metrologist"],
is_admin=True,
api_key=api_key,
)
session.add(user)
await session.flush()
api_key = await issue_api_key(session, user)
return {
"status": "ok",
@@ -404,7 +403,6 @@ async def seed_demo_data(body: PasswordBody):
email=u["email"],
roles=u["roles"],
is_admin=u["is_admin"],
api_key=secrets.token_hex(32),
)
session.add(user)
created_users.append(user)
@@ -558,7 +556,7 @@ async def seed_demo_data(body: PasswordBody):
tec_user = created_users[2] # tec1
lot_numbers = ["LOT-2025-001", "LOT-2025-002", "LOT-2025-003", "LOT-2025-004"]
serial_numbers = [f"SN-{i:04d}" for i in range(1, 13)]
base_date = datetime.now()
base_date = utcnow()
all_subtasks = subtasks_1 + subtasks_2
measurements_created = 0
@@ -606,8 +604,8 @@ async def list_users(body: PasswordBody):
"roles": u.roles or [],
"is_admin": u.is_admin,
"active": u.active,
"created_at": u.created_at.isoformat() if u.created_at else None,
"last_login": u.last_login.isoformat() if u.last_login else None,
"created_at": iso_utc(u.created_at) or None,
"last_login": iso_utc(u.last_login) or None,
}
for u in users
],
@@ -652,7 +650,6 @@ async def manage_user(body: ManageUserBody):
email=body.email,
roles=body.roles,
is_admin=body.is_admin,
api_key=secrets.token_hex(32),
)
session.add(user)
await session.flush()
@@ -707,7 +704,7 @@ async def change_user_password(body: ChangeUserPasswordBody):
detail=f"User ID {body.user_id} not found",
)
user.password_hash = hash_password(body.new_password)
user.api_key = None # Invalidate sessions
await revoke_all_keys(session, user.id) # Invalidate sessions
return {"status": "ok", "message": f"Password changed for '{user.username}'"}
@@ -730,7 +727,7 @@ async def toggle_user_active(body: ToggleUserActiveBody):
)
user.active = not user.active
if not user.active:
user.api_key = None # Invalidate sessions on deactivation
await revoke_all_keys(session, user.id) # on deactivation
new_status = "active" if user.active else "inactive"
return {"status": "ok", "message": f"User '{user.username}' is now {new_status}", "active": user.active}
+9 -8
View File
@@ -1,5 +1,6 @@
"""Statistics router - SPC endpoints for Metrologist dashboard."""
from datetime import datetime
from src.backend.models.api.tipi import UtcNaiveInput
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import and_, select
@@ -100,8 +101,8 @@ async def get_summary(
recipe_id: int = Query(...),
version_id: int | None = Query(None),
subtask_id: int | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
date_from: UtcNaiveInput = Query(None),
date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None),
lot_number: str | None = Query(None),
serial_number: str | None = Query(None),
@@ -122,8 +123,8 @@ async def get_capability(
recipe_id: int = Query(...),
subtask_id: int = Query(...),
version_id: int | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
date_from: UtcNaiveInput = Query(None),
date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None),
lot_number: str | None = Query(None),
serial_number: str | None = Query(None),
@@ -145,8 +146,8 @@ async def get_control_chart(
recipe_id: int = Query(...),
subtask_id: int = Query(...),
version_id: int | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
date_from: UtcNaiveInput = Query(None),
date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None),
lot_number: str | None = Query(None),
serial_number: str | None = Query(None),
@@ -172,8 +173,8 @@ async def get_histogram(
recipe_id: int = Query(...),
subtask_id: int = Query(...),
version_id: int | None = Query(None),
date_from: datetime | None = Query(None),
date_to: datetime | None = Query(None),
date_from: UtcNaiveInput = Query(None),
date_to: UtcNaiveInput = Query(None),
operator_id: int | None = Query(None),
lot_number: str | None = Query(None),
serial_number: str | None = Query(None),
+19 -2
View File
@@ -19,7 +19,8 @@ from src.backend.models.api.task import (
TaskUpdate,
validate_tolerance_order,
)
from src.backend.services import recipe_service
from src.backend.models.orm.vision import VisionReferenceImage
from src.backend.services import recipe_service, vision_service
router = APIRouter(tags=["tasks"])
@@ -366,8 +367,24 @@ async def delete_task(
task = await _task_for_write(db, task, user)
# The rows go with the task (migration 014 cascades them), the files do not:
# collect them now, remove the files only once the commit is durable - same
# order as vision.py::delete_reference_image. Rows are deleted explicitly so
# file_still_referenced sees the post-delete state even where the database
# does not enforce the cascade.
references = (await db.execute(
select(VisionReferenceImage).where(VisionReferenceImage.task_id == task.id)
)).scalars().all()
for reference in references:
await db.delete(reference)
await db.delete(task)
await db.flush()
await db.commit()
# A copy-on-write sibling may still point at the same file (#38.6).
for reference in references:
if not await vision_service.file_still_referenced(db, reference.path):
vision_service.remove_reference_file(reference)
# ---------------------------------------------------------------------------
+4 -4
View File
@@ -7,7 +7,7 @@ from src.backend.database import get_db
from src.backend.api.middleware.api_key import require_admin_user
from src.backend.models.orm.user import User
from src.backend.models.api.user import UserCreate, UserPasswordChange, UserResponse, UserUpdate
from src.backend.services.auth_service import create_user, hash_password, regenerate_api_key
from src.backend.services.auth_service import create_user, hash_password, regenerate_api_key, revoke_all_keys
router = APIRouter(prefix="/api/users", tags=["users"])
@@ -146,7 +146,7 @@ async def change_user_password(
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
user.password_hash = hash_password(data.password)
user.api_key = None
await revoke_all_keys(db, user.id)
await db.flush()
return {"message": f"Password changed for user {user.username}"}
@@ -168,7 +168,7 @@ async def deactivate_user(
detail="Cannot deactivate yourself",
)
user.active = False
user.api_key = None
await revoke_all_keys(db, user.id)
await db.flush()
@@ -183,5 +183,5 @@ async def regenerate_user_key(
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
new_key = await regenerate_api_key(db, user_id)
new_key = await regenerate_api_key(db, user)
return {"api_key": new_key, "message": f"API key regenerated for user {user.username}"}
+21
View File
@@ -4,7 +4,10 @@ It does not know, and must not know, whether the server or the station computed
the numbers: that is what lets a station's configuration change without touching
the frontend or the recipes.
"""
import io
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from PIL import Image
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -90,9 +93,27 @@ async def _read_image(image: UploadFile) -> bytes:
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="the file's content does not match a supported image format",
)
_reject_animated(content)
return content
def _reject_animated(content: bytes) -> None:
"""Animated GIF/WebP/APNG have no single frame to measure (decision of
06/10/2026, #43): refuse them instead of silently reading frame 0."""
try:
with Image.open(io.BytesIO(content)) as img:
animated = getattr(img, "is_animated", False) or getattr(img, "n_frames", 1) > 1
except Exception:
# Unreadable here is not this check's business: the magic bytes passed
# and the worker decides what it can decode.
return
if animated:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="animated images are not supported",
)
@router.post("/execute", response_model=VisionExecuteResponse)
async def execute(
task_id: int = Form(...),
+6
View File
@@ -28,6 +28,9 @@ class Settings(BaseSettings):
db_user: str = "tmflow"
db_password: str = _DEFAULT_DB_PASSWORD
# Presentation time zone (DB is UTC, #42)
display_timezone: str = "Europe/Rome"
# Server
server_host: str = "0.0.0.0"
server_port: int = 8000
@@ -38,6 +41,9 @@ class Settings(BaseSettings):
# configured limits to keep the cluster-wide effective limit close to what
# was configured instead of multiplying it by N.
web_concurrency: int = 1
# API key session lifetime (hours); renewed on use (env API_KEY_TTL_HOURS).
api_key_ttl_hours: int = 24
# How many reverse proxies between the client and this API are trusted to
# have appended their own hop to X-Forwarded-For (Nginx, in the default
# deploy). Used by RateLimitMiddleware._client_ip. 0 disables proxy header
@@ -0,0 +1,54 @@
"""API keys as SHA-256 hashes, one per session (#41)
Replaces the plaintext `users.api_key` column with an `api_keys` table: one
row per login, holding only the SHA-256 hex digest, an expiry and the last
use. Existing keys are invalidated on purpose, not migrated - the plaintext
is exactly what must stop existing, so everybody logs in once again.
Revision ID: 015_api_key_hash
Revises: 014_vision_fk_ondelete
Create Date: 2026-10-06
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '015_api_key_hash'
down_revision: Union[str, None] = '014_vision_fk_ondelete'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'api_keys',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('key_hash', sa.String(64), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('last_used_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(
['user_id'], ['users.id'], name='fk_api_keys_user_id', ondelete='CASCADE'
),
)
op.create_index('ix_api_keys_key_hash', 'api_keys', ['key_hash'], unique=True)
op.create_index('ix_api_keys_user_id', 'api_keys', ['user_id'])
with op.batch_alter_table('users') as batch:
batch.drop_index('ix_users_api_key')
batch.drop_constraint('uq_users_api_key', type_='unique')
batch.drop_column('api_key')
def downgrade() -> None:
with op.batch_alter_table('users') as batch:
batch.add_column(sa.Column('api_key', sa.String(64), nullable=True))
batch.create_unique_constraint('uq_users_api_key', ['api_key'])
batch.create_index('ix_users_api_key', ['api_key'])
op.drop_index('ix_api_keys_user_id', table_name='api_keys')
op.drop_index('ix_api_keys_key_hash', table_name='api_keys')
op.drop_table('api_keys')
+20 -5
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for Measurement operations."""
from datetime import datetime
from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
@@ -54,8 +54,8 @@ class MeasurementResponse(BaseModel):
# Who let this value stand, when it was out of tolerance. Null on a value in
# tolerance - and on one that is not and is still waiting for an answer.
supervisor_id: Optional[int] = None
authorised_at: Optional[datetime] = None
measured_at: datetime
authorised_at: Optional[UtcDateTime] = None
measured_at: UtcDateTime
synced_to_csv: bool
@@ -91,6 +91,21 @@ class TaskProgressListResponse(BaseModel):
tasks: list[TaskProgressResponse]
class CycleMeasurementResponse(BaseModel):
"""One quote already measured in the cycle under way."""
subtask_id: int
value: float
pass_fail: str
measured_at: UtcDateTime
class CurrentCycleResponse(BaseModel):
"""The quotes to restore on the screen after a reload; empty if the task is closed."""
measurements: list[CycleMeasurementResponse]
class MeasurementQuery(BaseModel):
"""Schema for measurement query filters."""
recipe_id: Optional[int] = None
@@ -99,8 +114,8 @@ class MeasurementQuery(BaseModel):
measured_by: Optional[int] = None
lot_number: Optional[str] = None
serial_number: Optional[str] = None
date_from: Optional[datetime] = None
date_to: Optional[datetime] = None
date_from: UtcNaiveInput = None
date_to: UtcNaiveInput = None
pass_fail: Optional[str] = Field(None, pattern="^(pass|warning|fail)$")
page: int = Field(1, ge=1)
per_page: int = Field(50, ge=1, le=500)
+7 -7
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for production runs and their events."""
from datetime import datetime
from src.backend.models.api.tipi import UtcDateTime
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
@@ -21,7 +21,7 @@ class ProductionEventResponse(BaseModel):
supervisor_id: Optional[int]
task_id: Optional[int] = None
note: Optional[str]
created_at: datetime
created_at: UtcDateTime
class ProductionRunResponse(BaseModel):
@@ -36,11 +36,11 @@ class ProductionRunResponse(BaseModel):
serial_number: Optional[str]
status: str
measurement_interval_minutes: Optional[int]
next_measurement_at: Optional[datetime]
next_measurement_at: Optional[UtcDateTime]
cycle_count: int
started_at: datetime
paused_at: Optional[datetime]
closed_at: Optional[datetime]
started_at: UtcDateTime
paused_at: Optional[UtcDateTime]
closed_at: Optional[UtcDateTime]
closed_by: Optional[int]
statistics_path: Optional[str] = None
@@ -49,7 +49,7 @@ class ProductionRunResponse(BaseModel):
# overdue is a fact the operator must see, not deduce.
seconds_to_next_measurement: Optional[int] = None
overdue: bool = False
server_time: Optional[datetime] = None
server_time: Optional[UtcDateTime] = None
# The measurement tasks of this run's version, in execution order. The client
# needs them to bring the operator back to the measurement when the interval
+3 -3
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for Recipe and RecipeVersion operations."""
from datetime import datetime
from src.backend.models.api.tipi import UtcDateTime
from typing import Optional, TYPE_CHECKING
from pydantic import BaseModel, ConfigDict, Field
@@ -51,7 +51,7 @@ class RecipeVersionResponse(BaseModel):
version_number: int
is_current: bool
created_by: int
created_at: datetime
created_at: UtcDateTime
change_notes: Optional[str] = None
tasks: list["TaskResponse"] = []
@@ -72,7 +72,7 @@ class RecipeResponse(BaseModel):
requires_serial: bool = False
allow_manual_input: bool = False
created_by: int
created_at: datetime
created_at: UtcDateTime
active: bool
current_version: Optional[RecipeVersionResponse] = None
+3 -3
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for Station and StationRecipeAssignment."""
from datetime import datetime
from src.backend.models.api.tipi import UtcDateTime
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
@@ -29,7 +29,7 @@ class StationResponse(BaseModel):
notes: Optional[str]
active: bool
created_by: int
created_at: datetime
created_at: UtcDateTime
class StationRecipeAssignmentCreate(BaseModel):
@@ -42,7 +42,7 @@ class StationRecipeAssignmentResponse(BaseModel):
station_id: int
recipe_id: int
assigned_by: int
assigned_at: datetime
assigned_at: UtcDateTime
class RecipeSummary(BaseModel):
+5 -5
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for SPC statistics responses."""
from datetime import datetime
from src.backend.models.api.tipi import UtcDateTime, UtcNaiveInput
from typing import Optional
from pydantic import BaseModel, Field
@@ -10,8 +10,8 @@ class StatisticsQuery(BaseModel):
recipe_id: int
version_id: Optional[int] = None
subtask_id: Optional[int] = None
date_from: Optional[datetime] = None
date_to: Optional[datetime] = None
date_from: UtcNaiveInput = None
date_to: UtcNaiveInput = None
operator_id: Optional[int] = None
lot_number: Optional[str] = None
serial_number: Optional[str] = None
@@ -20,7 +20,7 @@ class StatisticsQuery(BaseModel):
class ControlChartData(BaseModel):
"""Data for X-bar/R control chart."""
values: list[float]
timestamps: list[datetime]
timestamps: list[UtcDateTime]
mean: float
ucl: float # Upper Control Limit
lcl: float # Lower Control Limit
@@ -60,7 +60,7 @@ class CapabilityData(BaseModel):
class TrendData(BaseModel):
"""Temporal trend data for capability indices."""
dates: list[datetime]
dates: list[UtcDateTime]
cpk_values: list[Optional[float]]
ppk_values: list[Optional[float]]
n_per_period: list[int]
+14
View File
@@ -0,0 +1,14 @@
"""Shared Pydantic types for datetimes (#42): UTC in the DB, explicit +00:00 on the wire."""
from datetime import datetime
from typing import Annotated, Optional
from pydantic import AfterValidator, PlainSerializer
from src.backend.utils.tempo import as_utc_naive, to_utc_aware
UtcDateTime = Annotated[
datetime, PlainSerializer(lambda v: to_utc_aware(v).isoformat(), return_type=str)
]
# Input: aware values are converted to UTC naive, naive is taken as UTC.
UtcNaiveInput = Annotated[Optional[datetime], AfterValidator(as_utc_naive)]
+3 -3
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for User operations."""
from datetime import datetime
from src.backend.models.api.tipi import UtcDateTime
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
@@ -48,8 +48,8 @@ class UserResponse(BaseModel):
language_pref: str
theme_pref: str
active: bool
created_at: datetime
last_login: Optional[datetime] = None
created_at: UtcDateTime
last_login: Optional[UtcDateTime] = None
class UserPasswordChange(BaseModel):
+2 -2
View File
@@ -1,5 +1,5 @@
"""Pydantic schemas for the vision execution response."""
from datetime import datetime
from src.backend.models.api.tipi import UtcDateTime
from pydantic import BaseModel, ConfigDict
@@ -28,4 +28,4 @@ class VisionReferenceImageResponse(BaseModel):
note: str | None = None
station_id: int | None = None
device_code: str | None = None
acquired_at: datetime
acquired_at: UtcDateTime
+2
View File
@@ -1,5 +1,6 @@
"""SQLAlchemy models for TieMeasureFlow."""
from src.backend.models.orm.user import User
from src.backend.models.orm.api_key import ApiKey
from src.backend.models.orm.recipe import Recipe, RecipeVersion
from src.backend.models.orm.task import RecipeTask, RecipeSubtask
from src.backend.models.orm.measurement import Measurement
@@ -11,6 +12,7 @@ from src.backend.models.orm.vision import VisionReferenceImage, VisionResult
__all__ = [
"User",
"ApiKey",
"Recipe",
"RecipeVersion",
"RecipeTask",
+25
View File
@@ -0,0 +1,25 @@
"""API key sessions: one row per login, stored as a SHA-256 hash."""
from datetime import datetime
from typing import Optional
from sqlalchemy import DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from src.backend.database import Base
class ApiKey(Base):
"""One API key per login session.
Only the SHA-256 hex digest is stored, never the key. Dates are naive UTC.
"""
__tablename__ = "api_keys"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
key_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
last_used_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
-1
View File
@@ -32,7 +32,6 @@ class User(Base):
# Status
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
api_key: Mapped[Optional[str]] = mapped_column(String(64), unique=True, nullable=True, index=True)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
+63 -35
View File
@@ -1,12 +1,16 @@
"""Authentication service - password hashing, API key management."""
import hashlib
import secrets
from datetime import datetime
from datetime import datetime, timedelta
from src.backend.utils.tempo import utcnow
import bcrypt
from fastapi import HTTPException, status
from sqlalchemy import select, update
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.config import settings
from src.backend.models.orm.api_key import ApiKey
from src.backend.models.orm.user import User
@@ -58,8 +62,8 @@ async def authorise_supervisor(db: AsyncSession, username: str, password: str) -
of them because it is the same question every time - who is this, and are they
allowed to say yes.
Uses authenticate_user rather than a full login: logging in would rotate the
supervisor's API key and knock out whatever session they have open elsewhere.
Uses authenticate_user rather than a full login: logging in would issue a
needless API key for the supervisor.
"""
supervisor = await authenticate_user(db, username, password)
if supervisor is None:
@@ -75,39 +79,67 @@ async def authorise_supervisor(db: AsyncSession, username: str, password: str) -
return supervisor
async def login_user(db: AsyncSession, user: User) -> str:
"""Generate (or reuse) the API key and update last_login for user.
def hash_api_key(key: str) -> str:
"""SHA-256 hex digest of an API key; this is all the DB ever stores."""
return hashlib.sha256(key.encode("utf-8")).hexdigest()
Reuses an existing key rather than rotating it on every login: the same
account logged in from two stations at once (a common shift-account
pattern here) must keep both sessions working. Rotating the key would
silently log the other station out.
"""
api_key = user.api_key or generate_api_key()
async def issue_api_key(db: AsyncSession, user: User) -> str:
"""Create a new session key for the user and return it in clear (once)."""
key = generate_api_key()
now = utcnow()
# Housekeeping: drop this user's expired keys so the table does not grow forever.
await db.execute(
update(User)
.where(User.id == user.id)
.values(api_key=api_key, last_login=datetime.utcnow())
delete(ApiKey).where(ApiKey.user_id == user.id, ApiKey.expires_at <= now)
)
db.add(
ApiKey(
user_id=user.id,
key_hash=hash_api_key(key),
created_at=now,
expires_at=now + timedelta(hours=settings.api_key_ttl_hours),
last_used_at=now,
)
)
await db.flush()
return key
async def revoke_all_keys(db: AsyncSession, user_id: int) -> None:
"""Invalidate every session key of a user."""
await db.execute(delete(ApiKey).where(ApiKey.user_id == user_id))
await db.flush()
async def login_user(db: AsyncSession, user: User) -> str:
"""Issue a new API key for this login and update last_login.
One key per login: the same account used from several stations at once
(#25) keeps every station working, because no login revokes another's key.
"""
api_key = await issue_api_key(db, user)
await db.execute(
update(User).where(User.id == user.id).values(last_login=utcnow())
)
await db.flush()
return api_key
async def logout_user(
db: AsyncSession, user: User, all_sessions: bool = False
db: AsyncSession, user: User, key: str | None = None, all_sessions: bool = False
) -> None:
"""Log out. By default a no-op on the shared API key.
Clearing the key on every logout would kick out any other station still
using the same shift account. `all_sessions=True` is the explicit
override for a supervisor who wants every session logged out.
"""
if not all_sessions:
"""Log out. Deletes only the key of this session; all_sessions=True
deletes every key of the user."""
if all_sessions:
await revoke_all_keys(db, user.id)
return
await db.execute(
update(User).where(User.id == user.id).values(api_key=None)
)
await db.flush()
if key:
await db.execute(
delete(ApiKey).where(
ApiKey.user_id == user.id, ApiKey.key_hash == hash_api_key(key)
)
)
await db.flush()
async def create_user(
@@ -138,11 +170,7 @@ async def create_user(
return user
async def regenerate_api_key(db: AsyncSession, user_id: int) -> str:
"""Regenerate API key for a user."""
new_key = generate_api_key()
await db.execute(
update(User).where(User.id == user_id).values(api_key=new_key)
)
await db.flush()
return new_key
async def regenerate_api_key(db: AsyncSession, user: User) -> str:
"""Revoke every key of the user and issue a fresh one."""
await revoke_all_keys(db, user.id)
return await issue_api_key(db, user)
+70 -1
View File
@@ -1,6 +1,7 @@
"""Measurement service - pass/fail calculation, data storage."""
import math
from datetime import datetime
from src.backend.utils.tempo import utcnow
from decimal import Decimal
from typing import TYPE_CHECKING
@@ -9,6 +10,7 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.production import ProductionEvent
from src.backend.models.orm.recipe import Recipe, RecipeVersion
from src.backend.models.orm.task import (
MEASURING_TASK_TYPES, RecipeSubtask, RecipeTask,
@@ -241,6 +243,73 @@ async def task_progress(
return progress
async def current_cycle_measurements(
db: AsyncSession, run_id: int, task_id: int,
) -> list[dict]:
"""The quotes of a task already measured in the cycle that is under way.
A reload, a timer redirect or an auto-logout throws away what the page held in
memory; the operator would measure the same quotes again and the duplicates
would reach the SPC statistics. The cycle needs no column of its own: it starts
at the latest event of the run that opens one for this task - the run start, a
cycle_completed, or a remeasure of this task.
If a task_measured of this task follows that boundary the task is already
closed in this cycle and there is nothing to restore. Where a quote was measured
more than once, the last reading stands.
"""
boundary = (await db.execute(
select(ProductionEvent.id, ProductionEvent.created_at)
.where(
ProductionEvent.run_id == run_id,
(ProductionEvent.event_type.in_(("start", "cycle_completed")))
| (
(ProductionEvent.event_type == "remeasure")
& (ProductionEvent.task_id == task_id)
),
)
.order_by(ProductionEvent.id.desc())
.limit(1)
)).first()
event_filter = [ProductionEvent.run_id == run_id]
measurement_filter = [Measurement.production_run_id == run_id]
if boundary is not None:
boundary_id, boundary_at = boundary
event_filter.append(ProductionEvent.id > boundary_id)
measurement_filter.append(Measurement.measured_at > boundary_at)
closed = (await db.execute(
select(ProductionEvent.id).where(
*event_filter,
ProductionEvent.event_type == "task_measured",
ProductionEvent.task_id == task_id,
).limit(1)
)).first()
if closed is not None:
return []
rows = (await db.execute(
select(Measurement)
.join(RecipeSubtask, RecipeSubtask.id == Measurement.subtask_id)
.where(*measurement_filter, RecipeSubtask.task_id == task_id)
.order_by(Measurement.measured_at, Measurement.id)
)).scalars().all()
latest: dict[int, Measurement] = {}
for m in rows:
latest[m.subtask_id] = m
return [
{
"subtask_id": m.subtask_id,
"value": float(m.value),
"pass_fail": m.pass_fail,
"measured_at": m.measured_at,
}
for m in sorted(latest.values(), key=lambda m: (m.measured_at, m.id))
]
async def validate_run_for_version(
db: AsyncSession, run_id: int, version_id: int,
) -> "ProductionRun":
@@ -275,7 +344,7 @@ async def authorise_measurement(
) -> Measurement:
"""Let an out-of-tolerance value stand, on the supervisor's name."""
measurement.supervisor_id = supervisor.id
measurement.authorised_at = datetime.now()
measurement.authorised_at = utcnow()
await db.flush()
await db.refresh(measurement)
return measurement
@@ -14,6 +14,7 @@ import csv
import io
import logging
from datetime import datetime
from src.backend.utils.tempo import iso_utc, to_local, utcnow
from pathlib import Path
from sqlalchemy import select, update
@@ -169,9 +170,9 @@ async def build_statistics_csv(db: AsyncSession, run: ProductionRun) -> str:
measurement.input_method,
"" if measurement.input_duration_ms is None else measurement.input_duration_ms,
measurement.measured_by,
measurement.measured_at.isoformat() if measurement.measured_at else "",
iso_utc(measurement.measured_at),
measurement.supervisor_id or "",
measurement.authorised_at.isoformat() if measurement.authorised_at else "",
iso_utc(measurement.authorised_at),
])
return output.getvalue()
@@ -191,7 +192,7 @@ async def export_run_statistics(db: AsyncSession, run: ProductionRun) -> str | N
directory = Path(settings.upload_path) / EXPORT_SUBDIR
directory.mkdir(parents=True, exist_ok=True)
stamp = (run.closed_at or datetime.now()).strftime("%Y%m%d_%H%M%S")
stamp = to_local(run.closed_at or utcnow()).strftime("%Y%m%d_%H%M%S")
filename = f"production_{run.id}_{stamp}.csv"
# A large run's file can take a moment to write: off the event loop, so
# closing one run does not stall every other request being served.
+3 -2
View File
@@ -14,6 +14,7 @@ the countdown would be off by the UTC offset. The number is the contract; the
timestamp is only there for the audit trail.
"""
from datetime import datetime, timedelta
from src.backend.utils.tempo import utcnow
from typing import Optional
from fastapi import HTTPException, status
@@ -34,10 +35,10 @@ from src.backend.services import (
def _now() -> datetime:
"""Single source of time for runs.
Naive local time, matching what the database writes for ``func.now()`` defaults,
Naive UTC, matching what the database writes for ``func.now()`` defaults,
so a run's started_at and its deadline are comparable.
"""
return datetime.now()
return utcnow()
# ---------------------------------------------------------------------------
+13 -7
View File
@@ -2,6 +2,7 @@
import asyncio
import base64
from datetime import datetime
from src.backend.utils.tempo import to_local, utcnow
from pathlib import Path
import plotly.graph_objects as go
@@ -32,6 +33,11 @@ _jinja_env = Environment(
)
def _giorno_locale(dt: datetime) -> str:
"""UTC naive filter bound -> local calendar day (YYYY-MM-DD)."""
return to_local(dt).strftime("%Y-%m-%d")
async def _query_measurements(
db: AsyncSession,
recipe_id: int,
@@ -323,9 +329,9 @@ async def generate_spc_report(
if version_id:
filters_desc.append(f"Version: {version_id}")
if date_from:
filters_desc.append(f"From: {date_from.strftime('%Y-%m-%d')}")
filters_desc.append(f"From: {_giorno_locale(date_from)}")
if date_to:
filters_desc.append(f"To: {date_to.strftime('%Y-%m-%d')}")
filters_desc.append(f"To: {_giorno_locale(date_to)}")
if lot_number:
filters_desc.append(f"Lot: {lot_number}")
if serial_number:
@@ -342,7 +348,7 @@ async def generate_spc_report(
control_chart_svg=control_chart_svg,
histogram_svg=histogram_svg,
filters_desc=filters_desc,
generated_at=datetime.now(),
generated_at=to_local(utcnow()),
n_measurements=len(measurements),
)
@@ -395,7 +401,7 @@ async def generate_measurement_report(
"value": f"{float(m.value):.4f}",
"unit": st_info.get("unit", "mm"),
"pass_fail": m.pass_fail,
"measured_at": m.measured_at.strftime("%Y-%m-%d %H:%M"),
"measured_at": to_local(m.measured_at).strftime("%Y-%m-%d %H:%M"),
"operator": operator_map.get(m.measured_by, f"ID {m.measured_by}"),
"lot_number": m.lot_number or "—",
"serial_number": m.serial_number or "—",
@@ -414,9 +420,9 @@ async def generate_measurement_report(
if version_id:
filters_desc.append(f"Version: {version_id}")
if date_from:
filters_desc.append(f"From: {date_from.strftime('%Y-%m-%d')}")
filters_desc.append(f"From: {_giorno_locale(date_from)}")
if date_to:
filters_desc.append(f"To: {date_to.strftime('%Y-%m-%d')}")
filters_desc.append(f"To: {_giorno_locale(date_to)}")
if lot_number:
filters_desc.append(f"Lot: {lot_number}")
if serial_number:
@@ -430,7 +436,7 @@ async def generate_measurement_report(
rows=rows,
summary=summary,
filters_desc=filters_desc,
generated_at=datetime.now(),
generated_at=to_local(utcnow()),
n_measurements=len(measurements),
)
+4 -4
View File
@@ -40,7 +40,7 @@ from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.access_log import AccessLog
from src.backend.models.orm.setting import SystemSetting, RecipeVersionAudit
from src.backend.models.orm.station import Station, StationRecipeAssignment
from src.backend.services.auth_service import hash_password, generate_api_key
from src.backend.services.auth_service import hash_password, issue_api_key
# ---------------------------------------------------------------------------
# In-memory SQLite engine for tests
@@ -142,7 +142,6 @@ async def _create_user(
active: bool = True,
) -> User:
"""Insert a user into the test DB and return it with an API key set."""
api_key = generate_api_key()
user = User(
username=username,
password_hash=hash_password(password),
@@ -150,13 +149,14 @@ async def _create_user(
roles=roles or [],
is_admin=is_admin,
active=active,
api_key=api_key,
language_pref="en",
theme_pref="light",
)
session.add(user)
await session.flush()
await session.refresh(user)
# Clear key of this test session; the DB only keeps its hash.
user.plain_key = await issue_api_key(session, user)
return user
@@ -278,4 +278,4 @@ async def create_test_recipe(
def auth_headers(user: User) -> dict[str, str]:
"""Return headers dict with the user's API key for authenticated requests."""
return {"X-API-Key": user.api_key}
return {"X-API-Key": user.plain_key}
+151 -46
View File
@@ -78,75 +78,114 @@ class TestLogin:
assert len(api_key) >= 32
class TestLoginKeyReuse:
"""login_user reuses an existing api_key instead of rotating it (#25)."""
class TestLoginKeyPerSession:
"""Each login issues its own key; none revokes another (#25, #41)."""
async def test_two_logins_same_user_share_api_key(
async def test_two_logins_same_user_get_distinct_valid_keys(
self, client: AsyncClient, db_session
):
"""Two stations logging in as the same shift account both keep working."""
await _create_user(db_session, username="twostations", password="TwoStations1")
resp1 = await client.post(
"/api/auth/login",
json={"username": "twostations", "password": "TwoStations1"},
)
resp2 = await client.post(
"/api/auth/login",
json={"username": "twostations", "password": "TwoStations1"},
)
assert resp1.status_code == 200
assert resp2.status_code == 200
assert resp1.json()["api_key"] == resp2.json()["api_key"]
creds = {"username": "twostations", "password": "TwoStations1"}
resp1 = await client.post("/api/auth/login", json=creds)
resp2 = await client.post("/api/auth/login", json=creds)
k1, k2 = resp1.json()["api_key"], resp2.json()["api_key"]
assert k1 != k2
for k in (k1, k2):
resp = await client.get("/api/auth/me", headers={"X-API-Key": k})
assert resp.status_code == 200
async def test_login_generates_key_when_user_has_none(
async def test_db_stores_hash_never_clear_key(
self, client: AsyncClient, db_session
):
"""A user created without an api_key (e.g. via create_user) still gets one."""
from src.backend.services.auth_service import create_user
from sqlalchemy import select
from src.backend.models.orm.api_key import ApiKey
from src.backend.services.auth_service import hash_api_key
await create_user(
db_session, username="brandnew", password="BrandNew1",
display_name="Brand New",
)
await _create_user(db_session, username="hashed", password="HashedPass1")
resp = await client.post(
"/api/auth/login",
json={"username": "brandnew", "password": "BrandNew1"},
"/api/auth/login", json={"username": "hashed", "password": "HashedPass1"}
)
key = resp.json()["api_key"]
hashes = (await db_session.execute(select(ApiKey.key_hash))).scalars().all()
assert hash_api_key(key) in hashes
assert key not in hashes
assert all(len(h) == 64 for h in hashes)
async def test_expired_key_gives_401(self, client: AsyncClient, db_session):
from datetime import timedelta
from sqlalchemy import update
from src.backend.models.orm.api_key import ApiKey
from src.backend.services.auth_service import utcnow
user = await _create_user(db_session, username="expired", password="ExpiredPass1")
await db_session.execute(
update(ApiKey).values(expires_at=utcnow() - timedelta(minutes=1))
)
resp = await client.get("/api/auth/me", headers=auth_headers(user))
assert resp.status_code == 401
assert "expired" in resp.json()["detail"].lower()
async def test_use_renews_expiry_only_after_interval(
self, client: AsyncClient, db_session
):
from datetime import timedelta
from sqlalchemy import select, update
from src.backend.models.orm.api_key import ApiKey
from src.backend.services.auth_service import utcnow
user = await _create_user(db_session, username="renew", password="RenewPass1")
headers = auth_headers(user)
old = utcnow() - timedelta(hours=10)
# Recent use: no write, expiry untouched.
recent = utcnow() - timedelta(minutes=1)
await db_session.execute(
update(ApiKey).values(expires_at=old + timedelta(hours=24), last_used_at=recent)
)
await client.get("/api/auth/me", headers=headers)
row = (await db_session.execute(select(ApiKey))).scalar_one()
await db_session.refresh(row)
assert row.last_used_at == recent
# Stale last use: expiry is pushed forward.
await db_session.execute(
update(ApiKey).values(last_used_at=utcnow() - timedelta(minutes=10))
)
before = utcnow()
resp = await client.get("/api/auth/me", headers=headers)
assert resp.status_code == 200
assert resp.json()["api_key"]
await db_session.refresh(row)
assert row.expires_at >= before + timedelta(hours=23, minutes=59)
class TestLogout:
"""POST /api/auth/logout is a no-op on the shared api_key by default (#25)."""
"""Logout deletes only this session's key (#41)."""
async def test_logout_default_keeps_api_key_valid(
self, client: AsyncClient, db_session
):
user = await _create_user(
db_session, username="shared_logout", password="SharedPass1"
)
headers = auth_headers(user)
resp = await client.post("/api/auth/logout", headers=headers)
async def test_logout_removes_only_own_key(self, client: AsyncClient, db_session):
await _create_user(db_session, username="shared_logout", password="SharedPass1")
creds = {"username": "shared_logout", "password": "SharedPass1"}
k1 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
k2 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
resp = await client.post("/api/auth/logout", headers={"X-API-Key": k1})
assert resp.status_code == 204
# The key is still valid: another station on the same account keeps working.
resp = await client.get("/api/auth/me", headers=headers)
assert resp.status_code == 200
assert (await client.get("/api/auth/me", headers={"X-API-Key": k1})).status_code == 401
assert (await client.get("/api/auth/me", headers={"X-API-Key": k2})).status_code == 200
async def test_logout_all_sessions_invalidates_api_key(
async def test_logout_all_sessions_removes_every_key(
self, client: AsyncClient, db_session
):
user = await _create_user(
db_session, username="shared_logout_all", password="SharedPass1"
)
headers = auth_headers(user)
await _create_user(db_session, username="shared_logout_all", password="SharedPass1")
creds = {"username": "shared_logout_all", "password": "SharedPass1"}
k1 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
k2 = (await client.post("/api/auth/login", json=creds)).json()["api_key"]
resp = await client.post(
"/api/auth/logout", params={"all_sessions": "true"}, headers=headers
"/api/auth/logout", params={"all_sessions": "true"},
headers={"X-API-Key": k1},
)
assert resp.status_code == 204
resp = await client.get("/api/auth/me", headers=headers)
assert resp.status_code == 401
for k in (k1, k2):
assert (await client.get("/api/auth/me", headers={"X-API-Key": k})).status_code == 401
class TestAuthenticateUserTiming:
@@ -185,3 +224,69 @@ class TestHealthAndAuth:
resp = await client.get("/api/users")
assert resp.status_code == 401
assert "API key" in resp.json()["detail"] or "Missing" in resp.json()["detail"]
class TestRevokeAllKeys:
"""Password change, deactivation and regenerate invalidate every key (#41)."""
async def _two_keys(self, client, db_session, name):
user = await _create_user(db_session, username=name, password="KeysPass123")
creds = {"username": name, "password": "KeysPass123"}
keys = [
(await client.post("/api/auth/login", json=creds)).json()["api_key"]
for _ in range(2)
]
return user, keys
async def _all_401(self, client, keys):
for k in keys:
resp = await client.get("/api/auth/me", headers={"X-API-Key": k})
assert resp.status_code == 401
async def test_password_change_invalidates_all(self, client, db_session, admin_user):
user, keys = await self._two_keys(client, db_session, "pwkeys")
resp = await client.put(
f"/api/users/{user.id}/password",
headers=auth_headers(admin_user), json={"password": "NewSecurePass1"},
)
assert resp.status_code == 200
await self._all_401(client, keys)
async def test_deactivation_invalidates_all(self, client, db_session, admin_user):
user, keys = await self._two_keys(client, db_session, "offkeys")
resp = await client.delete(
f"/api/users/{user.id}", headers=auth_headers(admin_user)
)
assert resp.status_code == 204
await self._all_401(client, keys)
async def test_regenerate_invalidates_old_keys(self, client, db_session, admin_user):
user, keys = await self._two_keys(client, db_session, "regenkeys")
resp = await client.post(
f"/api/users/{user.id}/regenerate-key", headers=auth_headers(admin_user)
)
assert resp.status_code == 200
await self._all_401(client, keys)
new = resp.json()["api_key"]
assert (await client.get("/api/auth/me", headers={"X-API-Key": new})).status_code == 200
class TestExpiredKeyCleanup:
async def test_expired_key_removed_at_next_login(self, client, db_session):
from datetime import timedelta
from sqlalchemy import select, update
from src.backend.models.orm.api_key import ApiKey
from src.backend.services.auth_service import hash_api_key, utcnow
user = await _create_user(db_session, username="cleanup", password="CleanPass123")
old_hash = hash_api_key(user.plain_key)
await db_session.execute(
update(ApiKey).values(expires_at=utcnow() - timedelta(minutes=1))
)
resp = await client.post(
"/api/auth/login", json={"username": "cleanup", "password": "CleanPass123"}
)
assert resp.status_code == 200
hashes = (await db_session.execute(select(ApiKey.key_hash))).scalars().all()
assert old_hash not in hashes
assert len(hashes) == 1
+172
View File
@@ -0,0 +1,172 @@
"""#40: after a reload, the quotes already measured in the cycle come back.
The cycle has no column of its own: it opens at the latest start, cycle_completed
or remeasure-of-this-task event of the run, and a task_measured of the task after
that boundary means the task is closed in this cycle.
"""
from datetime import datetime, timedelta
from httpx import AsyncClient
from sqlalchemy import select
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.production import ProductionEvent, ProductionRun
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.station import Station
from src.backend.models.orm.task import RecipeSubtask, RecipeTask
from src.backend.tests.conftest import auth_headers, create_test_recipe
T0 = datetime(2026, 10, 1, 8, 0, 0)
async def _setup(db, user):
recipe = await create_test_recipe(db, user.id)
version = (await db.execute(
select(RecipeVersion).where(
RecipeVersion.recipe_id == recipe.id, RecipeVersion.is_current == True, # noqa: E712
)
)).scalar_one()
task = (await db.execute(
select(RecipeTask).where(RecipeTask.version_id == version.id)
)).scalars().first()
first = (await db.execute(
select(RecipeSubtask).where(RecipeSubtask.task_id == task.id)
)).scalars().first()
second = RecipeSubtask(
task_id=task.id, marker_number=2, description="Quota 2",
nominal=10.0, utl=10.5, uwl=10.3, lwl=9.7, ltl=9.5, unit="mm",
)
db.add(second)
station = Station(code="ST-CYC", name="St", active=True, created_by=user.id)
db.add(station)
await db.commit()
run = ProductionRun(
station_id=station.id, recipe_id=recipe.id, version_id=version.id,
operator_id=user.id, status="running", active_station_id=station.id,
)
db.add(run)
await db.commit()
return run, version, task, first, second
async def _event(db, run, user, kind, minutes, task_id=None):
db.add(ProductionEvent(
run_id=run.id, event_type=kind, user_id=user.id, task_id=task_id,
created_at=T0 + timedelta(minutes=minutes),
))
await db.commit()
async def _meas(db, run, version, subtask, user, minutes, value=10.0):
db.add(Measurement(
subtask_id=subtask.id, version_id=version.id, measured_by=user.id,
value=value, pass_fail="pass", production_run_id=run.id,
measured_at=T0 + timedelta(minutes=minutes),
))
await db.commit()
async def _get(client, user, run, task):
resp = await client.get(
"/api/measurements/current-cycle", headers=auth_headers(user),
params={"production_run_id": run.id, "task_id": task.id},
)
assert resp.status_code == 200
return resp.json()["measurements"]
async def test_only_second_cycle_after_cycle_completed(
client: AsyncClient, db_session, measurement_tec_user,
):
u = measurement_tec_user
run, version, task, q1, q2 = await _setup(db_session, u)
await _event(db_session, run, u, "start", 0)
await _meas(db_session, run, version, q1, u, 1, 10.1)
await _meas(db_session, run, version, q2, u, 2, 10.2)
await _event(db_session, run, u, "cycle_completed", 3)
await _meas(db_session, run, version, q1, u, 4, 10.3)
rows = await _get(client, u, run, task)
assert [(r["subtask_id"], r["value"]) for r in rows] == [(q1.id, 10.3)]
assert rows[0]["pass_fail"] == "pass"
async def test_last_reading_of_a_quote_wins(
client: AsyncClient, db_session, measurement_tec_user,
):
u = measurement_tec_user
run, version, task, q1, q2 = await _setup(db_session, u)
await _event(db_session, run, u, "start", 0)
await _meas(db_session, run, version, q1, u, 1, 10.1)
await _meas(db_session, run, version, q1, u, 2, 10.4)
rows = await _get(client, u, run, task)
assert [(r["subtask_id"], r["value"]) for r in rows] == [(q1.id, 10.4)]
async def test_remeasure_of_the_task_empties_the_list(
client: AsyncClient, db_session, measurement_tec_user,
):
u = measurement_tec_user
run, version, task, q1, q2 = await _setup(db_session, u)
await _event(db_session, run, u, "start", 0)
await _meas(db_session, run, version, q1, u, 1)
await _event(db_session, run, u, "remeasure", 2, task_id=task.id)
assert await _get(client, u, run, task) == []
async def test_remeasure_of_another_task_does_not_move_the_boundary(
client: AsyncClient, db_session, measurement_tec_user,
):
u = measurement_tec_user
run, version, task, q1, q2 = await _setup(db_session, u)
other = RecipeTask(
version_id=version.id, title="Altro", task_type=task.task_type, order_index=9,
)
db_session.add(other)
await db_session.commit()
await _event(db_session, run, u, "start", 0)
await _meas(db_session, run, version, q1, u, 1)
await _event(db_session, run, u, "remeasure", 2, task_id=other.id)
assert len(await _get(client, u, run, task)) == 1
async def test_task_measured_after_boundary_means_closed(
client: AsyncClient, db_session, measurement_tec_user,
):
u = measurement_tec_user
run, version, task, q1, q2 = await _setup(db_session, u)
await _event(db_session, run, u, "start", 0)
await _meas(db_session, run, version, q1, u, 1)
await _meas(db_session, run, version, q2, u, 2)
await _event(db_session, run, u, "task_measured", 3, task_id=task.id)
assert await _get(client, u, run, task) == []
async def test_task_measured_before_boundary_does_not_close_next_cycle(
client: AsyncClient, db_session, measurement_tec_user,
):
u = measurement_tec_user
run, version, task, q1, q2 = await _setup(db_session, u)
await _event(db_session, run, u, "start", 0)
await _event(db_session, run, u, "task_measured", 1, task_id=task.id)
await _event(db_session, run, u, "cycle_completed", 2)
await _meas(db_session, run, version, q1, u, 3)
assert len(await _get(client, u, run, task)) == 1
async def test_permissions(client: AsyncClient, db_session, measurement_tec_user, maker_user):
run, _v, task, _q1, _q2 = await _setup(db_session, measurement_tec_user)
params = {"production_run_id": run.id, "task_id": task.id}
no_key = await client.get("/api/measurements/current-cycle", params=params)
assert no_key.status_code in (401, 403)
forbidden = await client.get(
"/api/measurements/current-cycle", headers=auth_headers(maker_user), params=params,
)
assert forbidden.status_code == 403
+24
View File
@@ -107,3 +107,27 @@ class TestBaseMigrationNoopsOnExistingSchema:
assert "image_path" in {
c["name"] for c in insp.get_columns("recipes")
}
class TestApiKeyHashMigration:
"""015_api_key_hash: users.api_key -> api_keys table, and back (#41)."""
def test_015_upgrade_and_downgrade(self, sqlite_database_url):
db_path, _ = sqlite_database_url
cfg = _alembic_config()
command.upgrade(cfg, "head")
insp = _inspect(db_path)
assert "api_keys" in insp.get_table_names()
assert "api_key" not in {c["name"] for c in insp.get_columns("users")}
assert {c["name"] for c in insp.get_columns("api_keys")} >= {
"user_id", "key_hash", "created_at", "expires_at", "last_used_at",
}
command.downgrade(cfg, "014_vision_fk_ondelete")
insp = _inspect(db_path)
assert "api_keys" not in insp.get_table_names()
assert "api_key" in {c["name"] for c in insp.get_columns("users")}
command.upgrade(cfg, "head") # and up again
assert "api_keys" in _inspect(db_path).get_table_names()
@@ -0,0 +1,120 @@
"""#43: reference files removed with their task, animated images refused,
GET /api/measurements filtered by production run."""
import io
import pytest
from PIL import Image
from sqlalchemy import select
from src.backend.config import settings
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.vision import VisionReferenceImage
from src.backend.tests.conftest import auth_headers, create_test_recipe
from src.backend.tests.test_vision_upload_limits import a_png, recipe_parts
@pytest.fixture(autouse=True)
def redirect_uploads(monkeypatch, tmp_path):
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
async def _upload_reference(client, user, task_id):
resp = await client.post(
"/api/vision/reference-images", headers=auth_headers(user),
files={"image": ("p.png", a_png(), "image/png")},
data={"task_id": str(task_id)},
)
assert resp.status_code in (200, 201), resp.text
@pytest.mark.asyncio
async def test_delete_task_removes_the_reference_file(client, db_session, maker_user):
recipe = await create_test_recipe(db_session, maker_user.id)
_, task, _ = await recipe_parts(db_session, recipe.id)
await db_session.commit()
await _upload_reference(client, maker_user, task.id)
ref = (await db_session.execute(select(VisionReferenceImage))).scalars().one()
path = settings.upload_path / ref.path
assert path.exists()
resp = await client.delete(f"/api/tasks/{task.id}", headers=auth_headers(maker_user))
assert resp.status_code == 204
assert not path.exists()
@pytest.mark.asyncio
async def test_delete_task_keeps_a_file_another_task_still_references(
client, db_session, maker_user,
):
r1 = await create_test_recipe(db_session, maker_user.id)
r2 = await create_test_recipe(db_session, maker_user.id, code="REC-002")
_, t1, _ = await recipe_parts(db_session, r1.id)
_, t2, _ = await recipe_parts(db_session, r2.id)
await db_session.commit()
await _upload_reference(client, maker_user, t1.id)
ref = (await db_session.execute(select(VisionReferenceImage))).scalars().one()
db_session.add(VisionReferenceImage(task_id=t2.id, path=ref.path))
await db_session.commit()
path = settings.upload_path / ref.path
resp = await client.delete(f"/api/tasks/{t1.id}", headers=auth_headers(maker_user))
assert resp.status_code == 204
assert path.exists()
def _animated(fmt: str, frames: int) -> bytes:
imgs = [Image.new("L", (16, 16), color=i * 40) for i in range(frames)]
buf = io.BytesIO()
imgs[0].save(buf, format=fmt, save_all=True, append_images=imgs[1:], duration=50)
return buf.getvalue()
@pytest.mark.asyncio
@pytest.mark.parametrize("fmt,mime,frames,expected", [
("GIF", "image/gif", 2, 422),
("WEBP", "image/webp", 2, 422),
("GIF", "image/gif", 1, 201),
])
async def test_animated_images_are_refused(
client, db_session, maker_user, fmt, mime, frames, expected,
):
recipe = await create_test_recipe(db_session, maker_user.id)
_, task, _ = await recipe_parts(db_session, recipe.id)
await db_session.commit()
resp = await client.post(
"/api/vision/reference-images", headers=auth_headers(maker_user),
files={"image": ("a", _animated(fmt, frames), mime)},
data={"task_id": str(task.id)},
)
if expected == 422:
assert resp.status_code == 422
assert "animated" in resp.json()["detail"]
else:
assert resp.status_code in (200, 201), resp.text
@pytest.mark.asyncio
async def test_measurements_filter_by_production_run(client, db_session, maker_user):
recipe = await create_test_recipe(db_session, maker_user.id)
version, _, subtask = await recipe_parts(db_session, recipe.id)
for run in (7, 7, 8, None):
db_session.add(Measurement(
subtask_id=subtask.id, version_id=version.id, measured_by=maker_user.id,
value=1.0, pass_fail="pass", deviation=0.0, input_method="manual",
production_run_id=run,
))
await db_session.commit()
resp = await client.get(
"/api/measurements/", headers=auth_headers(maker_user),
params={"production_run_id": 7},
)
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 2
assert {m["production_run_id"] for m in body["items"]} == {7}
+113
View File
@@ -0,0 +1,113 @@
"""UTC in the DB, explicit +00:00 on the wire, local time only on presentation (#42)."""
from datetime import datetime, timezone
from unittest.mock import patch
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.models.orm.user import User
from src.backend.services import report_service
from src.backend.tests.conftest import auth_headers, create_test_recipe
from src.backend.utils.tempo import as_utc_naive, to_local, utcnow
def test_utcnow_naive_and_close_to_utc():
now = utcnow()
assert now.tzinfo is None
delta = datetime.now(timezone.utc).replace(tzinfo=None) - now
assert abs(delta.total_seconds()) < 5
def test_as_utc_naive():
parsed = datetime.fromisoformat("2026-10-06T10:00:00+02:00")
assert as_utc_naive(parsed) == datetime(2026, 10, 6, 8, 0)
assert as_utc_naive(parsed).tzinfo is None
naive = datetime(2026, 10, 6, 8, 0)
assert as_utc_naive(naive) is naive
assert as_utc_naive(None) is None
def test_to_local_is_rome():
assert to_local(datetime(2026, 10, 6, 8, 0)).strftime("%H:%M %z") == "10:00 +0200"
assert to_local(datetime(2026, 1, 6, 8, 0)).strftime("%H:%M %z") == "09:00 +0100"
async def _make_measurement(client, user, recipe_id) -> None:
tasks = (await client.get(f"/api/recipes/{recipe_id}/tasks", headers=auth_headers(user))).json()
await client.post(
"/api/measurements/",
headers=auth_headers(user),
json={
"subtask_id": tasks[0]["subtasks"][0]["id"],
"version_id": tasks[0]["version_id"],
"value": 10.0,
},
)
async def test_api_datetimes_carry_utc_offset_and_filter_equivalence(
client: AsyncClient,
measurement_tec_user: User,
metrologist_user: User,
db_session: AsyncSession,
):
recipe = await create_test_recipe(db_session, measurement_tec_user.id)
await _make_measurement(client, measurement_tec_user, recipe.id)
resp = await client.get("/api/measurements/", headers=auth_headers(metrologist_user))
item = resp.json()["items"][0]
assert item["measured_at"].endswith("+00:00")
# Same instant written with +02:00 and as UTC: identical results.
future_utc = "2099-01-01T08:00:00+00:00"
future_rome = "2099-01-01T10:00:00+02:00"
past_utc = "2000-01-01T08:00:00+00:00"
past_rome = "2000-01-01T10:00:00+02:00"
for frm, to in ((past_utc, future_utc), (past_rome, future_rome)):
r = await client.get(
"/api/measurements/",
headers=auth_headers(metrologist_user),
params={"date_from": frm, "date_to": to},
)
assert r.status_code == 200
assert r.json()["total"] == 1
# Boundary: measurement is "now" in UTC; a date_from 1h ahead in UTC,
# written as +02:00 (now+3h local wall clock), must exclude it.
ahead = (utcnow().replace(microsecond=0)).replace(tzinfo=timezone.utc)
from datetime import timedelta
ahead_utc = (ahead + timedelta(hours=1)).isoformat()
ahead_rome = (ahead + timedelta(hours=1)).astimezone(
timezone(timedelta(hours=2))
).isoformat()
totals = []
for frm in (ahead_utc, ahead_rome):
r = await client.get(
"/api/measurements/",
headers=auth_headers(metrologist_user),
params={"date_from": frm},
)
totals.append(r.json()["total"])
assert totals == [0, 0]
async def test_report_generated_at_is_local_time(
db_session: AsyncSession, metrologist_user: User
):
recipe = await create_test_recipe(db_session, metrologist_user.id)
captured: dict[str, str] = {}
def fake_render(html: str) -> bytes:
captured["html"] = html
return b"%PDF"
with patch.object(report_service, "utcnow", return_value=datetime(2026, 10, 6, 8, 0)), \
patch.object(report_service, "_render_pdf", side_effect=fake_render):
await report_service.generate_measurement_report(db_session, recipe.id)
assert "Generated 2026-10-06 10:00" in captured["html"]
def test_report_filter_day_is_local():
"""UTC naive 22:00 of Oct 5 is already Oct 6 in Europe/Rome (#42)."""
assert report_service._giorno_locale(datetime(2026, 10, 5, 22, 0, 0)) == "2026-10-06"
@@ -17,7 +17,7 @@ class TestChangeUserPassword:
the same as setup.py's change-user-password (setup.py:634).
"""
old_key = maker_user.api_key
old_key = maker_user.plain_key
old_username = maker_user.username
assert old_key is not None
@@ -31,7 +31,7 @@ class TestChangeUserPassword:
# The old key no longer authenticates. Read it before the request
# above: the client and app share one session (see conftest), so
# `maker_user.api_key` in this process reflects the mutation too.
# `maker_user.plain_key` in this process reflects the mutation too.
resp2 = await client.get("/api/auth/me", headers={"X-API-Key": old_key})
assert resp2.status_code == 401
View File
+34
View File
@@ -0,0 +1,34 @@
"""Time helpers: the DB holds naive UTC, presentation converts to display_timezone (#42)."""
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from src.backend.config import settings
def utcnow() -> datetime:
"""Naive UTC now (the whole DB moves to naive UTC, #42)."""
return datetime.now(timezone.utc).replace(tzinfo=None)
def as_utc_naive(dt: datetime | None) -> datetime | None:
"""Aware -> UTC naive; naive is taken as UTC already; None stays None."""
if dt is None or dt.tzinfo is None:
return dt
return dt.astimezone(timezone.utc).replace(tzinfo=None)
def to_utc_aware(dt: datetime) -> datetime:
"""Naive (UTC) or aware datetime -> aware UTC."""
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def iso_utc(dt: datetime | None) -> str:
"""ISO 8601 with explicit +00:00; "" for None (CSV cells)."""
return to_utc_aware(dt).isoformat() if dt else ""
def to_local(dt: datetime) -> datetime:
"""Naive UTC (or aware) datetime -> aware datetime in settings.display_timezone."""
return to_utc_aware(dt).astimezone(ZoneInfo(settings.display_timezone))
+22 -1
View File
@@ -2,7 +2,8 @@
import json
import os
import re
from datetime import date
from datetime import date, datetime, timezone
from zoneinfo import ZoneInfo
from urllib.parse import urlparse
from flask import Flask, flash, g, jsonify, redirect, url_for, session, request
@@ -154,6 +155,26 @@ def create_app() -> Flask:
bolded = _BOLD_RX.sub(r"<strong>\1</strong>", escaped)
return Markup(bolded.replace("\n", "<br>"))
@app.template_filter("local_dt")
def local_dt_filter(value, fmt="%d/%m/%Y %H:%M"):
"""Show a UTC datetime (ISO string or datetime) in DISPLAY_TIMEZONE.
A value without timezone is taken as UTC. None or empty gives "-".
"""
if value is None or value == "":
return "-"
if isinstance(value, str):
try:
value = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
except ValueError:
return value
if not isinstance(value, datetime):
return str(value)
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
tz = ZoneInfo(app.config.get("DISPLAY_TIMEZONE") or "Europe/Rome")
return value.astimezone(tz).strftime(fmt)
@app.template_filter("tojson_attr")
def tojson_attr_filter(value):
"""JSON encode safe for HTML attributes (x-data, etc.).
+39 -7
View File
@@ -386,14 +386,12 @@ def task_complete(recipe_id: int):
# Load measurements if version_id provided
measurements = []
if version_id:
# Scoped to this measurement session, not the whole version (#30): a
# second production run on the same recipe version used to pull in
# every other station's/day's readings too. GET /api/measurements has
# no production_run_id filter yet (that's lotto A3), so lot/serial +
# the current operator is the closest available identity - MANCA:
# once the backend accepts production_run_id, prefer it when the
# active run is known (see _current_run_id()).
# Scoped to this measurement session, not the whole version (#30): the
# active production run when known, else lot/serial + current operator.
params = {"version_id": version_id, "per_page": 500}
run_id = _current_run_id()
if run_id:
params["production_run_id"] = run_id
if lot_number:
params["lot_number"] = lot_number
if serial_number:
@@ -597,6 +595,40 @@ def api_task_progress(task_id: int):
return jsonify(row), 200
# ---------------------------------------------------------------------------
# Route: quotes already measured in the current cycle (#40)
# ---------------------------------------------------------------------------
@measure_bp.route("/api/current-cycle/<int:task_id>", methods=["GET"])
@login_required
@role_required("MeasurementTec")
def api_current_cycle(task_id: int):
"""Proxy: quotes of this task already measured in the cycle under way.
The run comes from _current_run_id(), like the rest of the page. Outside a
production there is no cycle to restore and the answer is an empty list.
"""
task_resp = api_client.get(f"/api/tasks/{task_id}")
if isinstance(task_resp, dict) and task_resp.get("error"):
return jsonify(task_resp), task_resp.get("status_code", 500)
recipe_id = task_resp.get("recipe_id")
if recipe_id and not _recipe_allowed_at_station(recipe_id):
abort(403, description=_("Ricetta non assegnata a questa stazione"))
run_id = _current_run_id()
if not run_id:
return jsonify({"measurements": []}), 200
resp = api_client.get(
"/api/measurements/current-cycle",
params={"production_run_id": run_id, "task_id": task_id},
)
if not isinstance(resp, dict) or resp.get("error"):
# Never fatal: without the restore the screen behaves as it always did.
return jsonify({"measurements": []}), 200
return jsonify(resp), 200
# ---------------------------------------------------------------------------
# Routes: Out-of-tolerance authorisation (point 5)
# ---------------------------------------------------------------------------
+2
View File
@@ -56,6 +56,8 @@ class Config:
# Babel i18n
BABEL_DEFAULT_LOCALE = "it"
BABEL_DEFAULT_TIMEZONE = "Europe/Rome"
# Il DB e le API parlano UTC; si converte solo alla presentazione.
DISPLAY_TIMEZONE: str = os.getenv("DISPLAY_TIMEZONE", "Europe/Rome")
LANGUAGES = {"it": "Italiano", "en": "English"}
@@ -271,7 +271,7 @@ function csvExport() {
*/
generateFilename(prefix) {
const now = new Date();
const dateStr = now.toISOString().slice(0, 10); // YYYY-MM-DD
const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; // YYYY-MM-DD locale
const timeStr = now.toTimeString().slice(0, 5).replace(':', ''); // HHMM
return `${prefix}_${dateStr}_${timeStr}.csv`;
@@ -183,7 +183,7 @@
{% for m in measurements|sort(attribute='subtask.marker_number')|sort(attribute='task_info.order_index') %}
<tr>
<td class="text-center font-mono text-xs" style="color: var(--text-secondary);">
{{ m.measured_at[:16]|replace('T', ' ') if m.measured_at else '-' }}
{{ m.measured_at|local_dt }}
</td>
<td style="color: var(--text-secondary);" class="text-sm">
{{ m.task_info.title if m.task_info else '-' }}
@@ -1100,6 +1100,7 @@ function taskExecute() {
this.inputStartedAt = Date.now();
// Rejoin whatever production is already open at this station.
await this.loadProductionRun();
await this.loadCurrentCycle();
await this.loadPendingAuthorisation();
// A tablet left to sleep drifts (#37.7): catch the countdown back up to
// the server's own clock, the same source adoptProductionRun already
@@ -1110,6 +1111,42 @@ function taskExecute() {
});
},
/* Quotes already measured in the cycle under way (#40).
A reload, a timer redirect or an auto-logout used to bring every quote back
and measuring them again put duplicates in the SPC statistics. Only inside
a production: outside one there is no cycle, and the screen starts empty as
it always did. */
async loadCurrentCycle() {
if (!this.isMeasureTask || !this.subtasks.length || !this.productionRun) return;
try {
const resp = await fetch(
'{{ url_for("measure.api_current_cycle", task_id=task.id) }}',
);
if (!resp.ok) return;
const data = await resp.json();
const known = new Set(this.subtasks.map(s => s.id));
const restored = (data.measurements || []).filter(m => known.has(m.subtask_id));
if (!restored.length) return;
this.measurements = restored.map(m => {
const st = this.subtasks.find(s => s.id === m.subtask_id);
const nominal = st ? (st.nominal || 0) : 0;
return {
subtask_id: m.subtask_id,
value: m.value,
pass_fail: m.pass_fail,
deviation: m.value - nominal,
};
});
const next = this.subtasks.findIndex(
s => !this.measurements.some(m => m.subtask_id === s.id),
);
this.currentIndex = next !== -1 ? next : this.subtasks.length - 1;
} catch (e) {
// Offline: the screen starts empty, as before.
}
},
/* Is a quote already waiting for the capoturno?
Reloading the page was the simplest way past the old gate. It is not one
@@ -312,8 +312,8 @@ function spcDashboard() {
const params = new URLSearchParams();
params.set('recipe_id', this.selectedRecipeId);
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
if (this.dateFrom) params.set('date_from', this.dateFrom + 'T00:00:00');
if (this.dateTo) params.set('date_to', this.dateTo + 'T23:59:59');
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
try {
// Parallel fetch of all endpoints
@@ -417,8 +417,8 @@ function spcDashboard() {
const params = new URLSearchParams();
params.set('recipe_id', this.selectedRecipeId);
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
if (this.dateFrom) params.set('date_from', this.dateFrom + 'T00:00:00');
if (this.dateTo) params.set('date_to', this.dateTo + 'T23:59:59');
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString());
if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
const url = '/statistics/api/report-' + type + '?' + params.toString();
const resp = await fetch(url);
@@ -0,0 +1,31 @@
"""Filtro local_dt: il backend parla UTC, la presentazione e' in Europe/Rome (#42)."""
from datetime import datetime, timezone
import pytest
@pytest.fixture
def local_dt(flask_app):
return flask_app.jinja_env.filters["local_dt"]
def test_summer_time(local_dt):
assert local_dt("2026-10-06T08:00:00+00:00") == "06/10/2026 10:00"
def test_winter_time(local_dt):
assert local_dt("2026-01-15T08:00:00+00:00") == "15/01/2026 09:00"
def test_naive_is_utc(local_dt):
assert local_dt("2026-10-06T08:00:00") == "06/10/2026 10:00"
def test_datetime_object_and_format(local_dt):
dt = datetime(2026, 10, 6, 8, 0, tzinfo=timezone.utc)
assert local_dt(dt, "%H:%M") == "10:00"
@pytest.mark.parametrize("empty", [None, ""])
def test_empty(local_dt, empty):
assert local_dt(empty) == "-"
+103 -5
View File
@@ -248,6 +248,67 @@ class TestApiTaskProgress:
assert resp.status_code in (302, 401)
class TestApiCurrentCycle:
"""GET /measure/api/current-cycle/<task_id> (gitea#40)."""
@pytest.fixture(autouse=True)
def _station_configured(self, monkeypatch):
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
def test_forwards_run_and_task(self, logged_in_client, mock_api_client):
mock_api_client.get_station_recipes.return_value = [{"id": 1}]
payload = {"measurements": [
{"subtask_id": 3, "value": 10.1, "pass_fail": "pass",
"measured_at": "2026-10-01T08:01:00"},
]}
mock_api_client.get.side_effect = api_get_router({
"/api/tasks/7": {"id": 7, "recipe_id": 1, "version_id": 42},
"/api/production-runs/current": {"id": 5},
"/api/measurements/current-cycle": payload,
})
resp = logged_in_client.get("/measure/api/current-cycle/7")
assert resp.status_code == 200
assert resp.get_json() == payload
call = next(
c for c in mock_api_client.get.call_args_list
if c.args[0] == "/api/measurements/current-cycle"
)
assert call.kwargs["params"] == {"production_run_id": 5, "task_id": 7}
def test_no_run_means_empty_and_no_backend_call(
self, logged_in_client, mock_api_client,
):
mock_api_client.get_station_recipes.return_value = [{"id": 1}]
mock_api_client.get.side_effect = api_get_router({
"/api/tasks/7": {"id": 7, "recipe_id": 1, "version_id": 42},
"/api/production-runs/current": None,
})
resp = logged_in_client.get("/measure/api/current-cycle/7")
assert resp.get_json() == {"measurements": []}
assert not any(
c.args[0] == "/api/measurements/current-cycle"
for c in mock_api_client.get.call_args_list
)
def test_refuses_a_recipe_not_assigned_to_this_station(
self, logged_in_client, mock_api_client,
):
mock_api_client.get_station_recipes.return_value = [{"id": 99}]
mock_api_client.get.side_effect = api_get_router({
"/api/tasks/7": {"id": 7, "recipe_id": 1, "version_id": 42},
})
assert logged_in_client.get("/measure/api/current-cycle/7").status_code == 403
def test_requires_login(self, client):
resp = client.get("/measure/api/current-cycle/7")
assert resp.status_code in (302, 401)
class TestStartSequence:
"""GET /measure/start/<recipe_id> — the operator's way in."""
@@ -338,6 +399,7 @@ class TestTaskComplete:
# The summary serialises the version number, so the recipe carries one.
{"id": 1, "code": "REC-001", "name": "Test Recipe", "version": 1},
[{"id": 1, "title": "Task 1", "order_index": 0, "subtasks": []}],
None, # current run
{"items": []}, # measurements
]
@@ -345,6 +407,21 @@ class TestTaskComplete:
assert resp.status_code == 200
assert b"productionClock(" in resp.data
def test_measured_at_shown_in_local_time(self, logged_in_client, mock_api_client):
"""#42: the API sends UTC; the summary shows Europe/Rome."""
mock_api_client.get.side_effect = [
{"id": 1, "code": "REC-001", "name": "Test Recipe", "version": 1},
[{"id": 1, "title": "Task 1", "order_index": 0, "subtasks": [
{"id": 1, "marker_number": 1, "description": "d", "nominal": 1.0,
"unit": "mm"}]}],
None,
{"items": [{"id": 1, "subtask_id": 1, "value": 1.0, "pass_fail": "pass",
"measured_at": "2026-10-06T08:00:00+00:00"}]},
]
resp = logged_in_client.get("/measure/complete/1?version_id=5")
assert resp.status_code == 200
assert "06/10/2026 10:00" in resp.get_data(as_text=True)
def test_measurements_filtered_by_lot_serial_and_operator(
self, logged_in_client, mock_api_client,
):
@@ -357,13 +434,14 @@ class TestTaskComplete:
mock_api_client.get.side_effect = [
{"id": 1, "code": "REC-001", "name": "Test Recipe", "version": 1},
[{"id": 1, "title": "Task 1", "order_index": 0, "subtasks": []}],
None, # current run
{"items": [], "pages": 1},
]
resp = logged_in_client.get("/measure/complete/1?version_id=5")
assert resp.status_code == 200
endpoint, kwargs = mock_api_client.get.call_args_list[2]
endpoint, kwargs = mock_api_client.get.call_args_list[3]
assert endpoint[0] == "/api/measurements"
params = kwargs["params"]
assert params["version_id"] == "5"
@@ -371,6 +449,24 @@ class TestTaskComplete:
assert params["serial_number"] == "S1"
assert params["measured_by"] == 1 # session["user_id"] from the fixture
def test_measurements_filtered_by_the_active_production_run(
self, logged_in_client, mock_api_client,
):
"""#43: when the station has a run open, the summary asks for that run."""
mock_api_client.get.side_effect = [
{"id": 1, "code": "REC-001", "name": "Test Recipe", "version": 1},
[{"id": 1, "title": "Task 1", "order_index": 0, "subtasks": []}],
{"id": 77}, # current run
{"items": [], "pages": 1},
]
resp = logged_in_client.get("/measure/complete/1?version_id=5")
assert resp.status_code == 200
endpoint, kwargs = mock_api_client.get.call_args_list[3]
assert endpoint[0] == "/api/measurements"
assert kwargs["params"]["production_run_id"] == 77
def test_measurements_without_lot_or_serial_still_scope_to_the_operator(
self, logged_in_client, mock_api_client,
):
@@ -379,13 +475,14 @@ class TestTaskComplete:
mock_api_client.get.side_effect = [
{"id": 1, "code": "REC-001", "name": "Test Recipe", "version": 1},
[{"id": 1, "title": "Task 1", "order_index": 0, "subtasks": []}],
None, # current run
{"items": [], "pages": 1},
]
resp = logged_in_client.get("/measure/complete/1?version_id=5")
assert resp.status_code == 200
endpoint, kwargs = mock_api_client.get.call_args_list[2]
endpoint, kwargs = mock_api_client.get.call_args_list[3]
params = kwargs["params"]
assert "lot_number" not in params
assert "serial_number" not in params
@@ -404,15 +501,16 @@ class TestTaskComplete:
mock_api_client.get.side_effect = [
{"id": 1, "code": "REC-001", "name": "Test Recipe", "version": 1},
[{"id": 1, "title": "Task 1", "order_index": 0, "subtasks": [subtask]}],
None, # current run
page1,
page2,
]
resp = logged_in_client.get("/measure/complete/1?version_id=5")
assert resp.status_code == 200
assert mock_api_client.get.call_count == 4
first_page_call = mock_api_client.get.call_args_list[2]
second_page_call = mock_api_client.get.call_args_list[3]
assert mock_api_client.get.call_count == 5
first_page_call = mock_api_client.get.call_args_list[3]
second_page_call = mock_api_client.get.call_args_list[4]
assert first_page_call.kwargs["params"]["page"] == 1
assert second_page_call.kwargs["params"]["page"] == 2
+117 -22
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import math
import os
import subprocess
import tempfile
import time
from dataclasses import dataclass, field
from functools import lru_cache
@@ -18,6 +19,9 @@ from typing import Any
import numpy as np
from visionsuite.core.image import Image
from visionsuite.task import Task
from visionsuite.task.graph import RecipeError # #46: errori di ricetta v2
from visionsuite.task.registry import load_tools
from visionsuite.task.session import Session
_SUBMODULE = Path(__file__).resolve().parents[2] / "vendor" / "visionsuite"
@@ -49,6 +53,10 @@ class RunOutcome:
# Kept apart from `failures`: the tool did not raise, it just produced a
# number the caller must not measure a part by. Value is the reason.
invalid_outputs: dict[str, str] = field(default_factory=dict)
# #46: contorni di tutti gli strumenti (px immagine, con tool_id) e esito
# del TaskReport (informativo: il verdetto quote lo dà il backend).
overlay: list[dict] = field(default_factory=list)
outcome: str | None = None
@lru_cache(maxsize=1)
@@ -97,12 +105,46 @@ def engine_version() -> str:
return _engine_version_from_git()
def _scale_calibration(calibration: dict | None):
"""Dict {"mm_per_px"|"px_per_mm"} -> ScaleCalibration (gli strumenti di
quota convertono con `in_millimetres`, che non accetta un dict)."""
if calibration is None:
return None
from visionsuite.calibration import ScaleCalibration
if not isinstance(calibration, dict) or not (
calibration.get("mm_per_px") or calibration.get("px_per_mm")
):
raise GraphBuildError(
"calibration must be {'mm_per_px': float} or {'px_per_mm': float}"
)
key = "mm_per_px" if calibration.get("mm_per_px") else "px_per_mm"
return ScaleCalibration(**{key: float(calibration[key])})
def _open_session(graph: dict, recipe_dir: Any, calibration: dict | None,
workdir: str) -> Session:
"""Costruisce la Session v2; RecipeError e grafo malformato -> GraphBuildError."""
cal = _scale_calibration(calibration)
try:
return Session.from_dict(
graph, Path(recipe_dir) if recipe_dir else Path(workdir), cal,
)
except RecipeError as exc:
raise GraphBuildError(f"malformed graph: {exc}") from exc
except (KeyError, TypeError, AttributeError, ValueError) as exc:
raise GraphBuildError(
f"malformed graph: {type(exc).__name__}: {exc}"
) from exc
def run_graph(
image: Any,
graph: dict,
calibration: dict | None = None,
recipe_dir: Any = None,
) -> RunOutcome:
"""Run a serialised vs-task graph against one image.
"""Run a serialised vs-task v2 graph against one image.
A tool that fails does not stop the others - on a crooked piece an edge may
simply not be there, and the other quotes are still wanted. Failures come
@@ -112,33 +154,35 @@ def run_graph(
wrapped into the `visionsuite.core.image.Image` that vs-measure's tools
actually read via `.pixels`; an `Image` passed in is used as-is.
Two phases, on purpose: building the task from `graph` can fail on
nothing but the caller's input (a bad schema version, a tool with no
"kind", a graph that is not a dict at all) - that is `GraphBuildError`,
a 422 for the worker. Once the task exists, `task.run()` is the engine's
own business; an exception from there is not the caller's mistake, and
the worker must not call it one.
`recipe_dir` is the folder of the recipe's files (meta.dxf, meta.models,
...); without it an empty temporary folder is used, so a recipe that needs
a file fails as a malformed graph instead of reading from the CWD.
Two phases, on purpose: building the session can fail on nothing but the
caller's input (a bad schema version, a tool with no "kind", a missing
file) - that is `GraphBuildError`, a 422 for the worker. Once it exists,
running is the engine's own business; an exception from there is not the
caller's mistake, and the worker must not call it one.
"""
try:
task = Task.from_dict(graph)
except (KeyError, TypeError, AttributeError, ValueError) as exc:
raise GraphBuildError(
f"malformed graph: {type(exc).__name__}: {exc}"
) from exc
wrapped_image = image if isinstance(image, Image) else Image(pixels=np.asarray(image))
context: dict[str, Any] = {"image": wrapped_image}
if calibration is not None:
context["calibration"] = calibration
started = time.monotonic()
report = task.run(context=context)
elapsed_ms = int((time.monotonic() - started) * 1000)
load_tools() # #46: registra gli strumenti degli entry point di vs-tools
with tempfile.TemporaryDirectory() as empty_dir:
session = _open_session(graph, recipe_dir, calibration, empty_dir)
wrapped_image = (
image if isinstance(image, Image) else Image(pixels=np.asarray(image))
)
started = time.monotonic()
report = session.run(wrapped_image)
elapsed_ms = int((time.monotonic() - started) * 1000)
outputs: dict[str, float] = {}
failures: list[ToolFailure] = []
invalid_outputs: dict[str, str] = {}
overlay: list[dict] = []
for result in report:
# #46: overlay di ogni strumento, con il tool_id di chi lo ha prodotto
for item in result.overlay or []:
if isinstance(item, dict):
overlay.append({**item, "tool_id": result.tool_id})
if not result.ok:
failures.append(ToolFailure(
tool_id=result.tool_id,
@@ -164,4 +208,55 @@ def run_graph(
engine_version=engine_version(),
duration_ms=elapsed_ms,
invalid_outputs=invalid_outputs,
overlay=overlay,
outcome=report.outcome,
)
def _is_number(value: Any) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool)
def validate_graph(
graph: dict, recipe_dir: Any = None
) -> tuple[bool, list[str], list[dict]]:
"""Validate a v2 recipe without an image: (ok, errors, numeric outputs).
Not a draft: a missing meta file is an error. `outputs` lists the numeric
outputs the tools declare (OUT properties with a numeric default, plus the
`values` of dimension tools), with quantity/unit when declared.
"""
load_tools()
if not isinstance(graph, dict):
return False, ["graph must be a JSON object"], []
try:
task = Task.from_dict(
graph, recipe_dir=Path(recipe_dir) if recipe_dir else None
)
except RecipeError as exc:
return False, list(exc.errors), []
except (KeyError, TypeError, AttributeError, ValueError) as exc:
return False, [f"{type(exc).__name__}: {exc}"], []
outputs: list[dict] = []
for tool in task.tools:
value_quantities = getattr(tool, "value_quantities", None) or {}
values = tuple(getattr(tool, "values", ()) or ())
for prop in tool.outputs():
if not (_is_number(prop.default) or prop.name in values):
continue
quantity = value_quantities.get(prop.name, getattr(tool, "quantity", None))
if not isinstance(quantity, str):
quantity = None
unit = None
if quantity == "length":
nominal_unit = getattr(tool, "nominal_unit", None)
unit = nominal_unit if isinstance(nominal_unit, str) else None
elif quantity == "angle":
unit = "deg"
outputs.append({
"key": f"{tool.id}.{prop.name}",
"quantity": quantity,
"unit": unit,
})
return True, [], outputs
+146 -13
View File
@@ -34,14 +34,16 @@ def a_disc(radius: int = 60, size: int = 400) -> np.ndarray:
def a_circle_graph() -> dict:
"""A two-tool graph, built with vs-task's own API."""
from visionsuite.task import Task
from visionsuite.task.tools import DetectCircleTool, DiameterTool
from visionsuite.tools.detect import DetectCircle
from visionsuite.tools.dimensions import Diameter
task = Task(name="prova")
found = task.add(DetectCircleTool(name="disco"))
found = task.add(DetectCircle(name="disco"))
found.center_row, found.center_col, found.radius = 200.0, 200.0, 60.0
found.scan_size = 30.0
quota = task.add(DiameterTool(name="diametro"))
quota = task.add(Diameter(name="diametro"))
quota.nominal_unit = "px" # #46: v2, senza calibrazione la quota resta in px
quota.link("circle", found, "circle")
return task.to_dict()
@@ -63,7 +65,7 @@ def test_a_broken_tool_does_not_sink_the_others():
graph = a_circle_graph()
graph["tools"].append(
{"id": "ghost", "kind": "DetectCircle", "name": "assente",
"properties": {"center_row": 5.0, "center_col": 5.0, "radius": 900.0}}
"values": {"center_row": 5.0, "center_col": 5.0, "radius": 900.0}}
)
outcome = run_graph(a_disc(), graph)
@@ -81,14 +83,21 @@ class _FakeResult:
self.ok = ok
self.outputs = outputs or {}
self.error = error
self.overlay = [] # #46: v2, ogni risultato porta l'overlay
class _FakeReport(list):
"""#46: run_graph ora legge `.outcome` dal report di Session.run."""
outcome = "pass"
def test_a_nan_output_is_dropped_not_measured(monkeypatch):
"""vs-measure emits NaN on an empty slice (caliper.py:112 per the
revisione) - a NaN must not become a quote's value (#16)."""
def fake_run(self, context=None):
return [_FakeResult("t1", "diametro", True, outputs={"diameter": float("nan")})]
monkeypatch.setattr(runner.Task, "run", fake_run)
def fake_run(self, image, tag=None):
return _FakeReport([_FakeResult("t1", "diametro", True, outputs={"diameter": float("nan")})])
monkeypatch.setattr(runner.Session, "run", fake_run)
outcome = run_graph(a_disc(), a_circle_graph())
@@ -97,9 +106,9 @@ def test_a_nan_output_is_dropped_not_measured(monkeypatch):
def test_an_infinite_output_is_dropped_not_measured(monkeypatch):
def fake_run(self, context=None):
return [_FakeResult("t1", "diametro", True, outputs={"diameter": float("inf")})]
monkeypatch.setattr(runner.Task, "run", fake_run)
def fake_run(self, image, tag=None):
return _FakeReport([_FakeResult("t1", "diametro", True, outputs={"diameter": float("inf")})])
monkeypatch.setattr(runner.Session, "run", fake_run)
outcome = run_graph(a_disc(), a_circle_graph())
@@ -109,9 +118,9 @@ def test_an_infinite_output_is_dropped_not_measured(monkeypatch):
def test_a_bool_output_is_not_treated_as_a_measurable_number(monkeypatch):
"""bool is a subclass of int - a flag output must not become a quote."""
def fake_run(self, context=None):
return [_FakeResult("t1", "flag", True, outputs={"present": True})]
monkeypatch.setattr(runner.Task, "run", fake_run)
def fake_run(self, image, tag=None):
return _FakeReport([_FakeResult("t1", "flag", True, outputs={"present": True})])
monkeypatch.setattr(runner.Session, "run", fake_run)
outcome = run_graph(a_disc(), a_circle_graph())
@@ -155,3 +164,127 @@ def test_engine_version_raises_rather_than_inventing_one(monkeypatch):
engine_version()
finally:
runner._engine_version_from_git.cache_clear()
# ---------------------------------------------------------------------------
# #46: ricette v2 complete - overlay, calibrazione, recipe_dir con DXF.
# ---------------------------------------------------------------------------
import hashlib
import shutil
from pathlib import Path
import cv2
_VS = Path(__file__).resolve().parents[3] / "vendor" / "visionsuite"
_SAMPLE_DXF = _VS / "packages" / "vs-dxf" / "tests" / "fixtures" / "sample.dxf"
# Il disegno di prova: rettangolo 200x100 mm con foro r=10 mm.
_PX_PER_MM = 4.0
def _dxf_scene() -> np.ndarray:
"""Il pezzo del disegno rasterizzato a 4 px/mm, margine 60 mm."""
margin = 60.0
w = int((200.0 + 2 * margin) * _PX_PER_MM)
h = int((100.0 + 2 * margin) * _PX_PER_MM)
img = np.full((h, w), 255, dtype=np.uint8)
cx, cy = w // 2, h // 2
hw, hh = 200.0 * _PX_PER_MM / 2, 100.0 * _PX_PER_MM / 2
cv2.rectangle(img, (int(cx - hw), int(cy - hh)), (int(cx + hw), int(cy + hh)), 0, -1)
cv2.circle(img, (cx, cy), int(10.0 * _PX_PER_MM), 255, -1)
return img
def _dxf_recipe() -> dict:
"""CompareDxf con ancora a due punti, collegati a due DetectPointScan.
Le due scansioni trovano i bordi sinistro e destro del pezzo una riga
sopra il lato in basso: origine del disegno = angolo in basso a sinistra,
direzione a -> b = asse x del disegno.
"""
h, w = _dxf_scene().shape
cx, cy = w // 2, h // 2
hw, hh = 200.0 * _PX_PER_MM / 2, 100.0 * _PX_PER_MM / 2
row = cy + hh - 1.0
def scan(tid: str, col: float) -> dict:
return {"id": tid, "name": tid, "kind": "DetectPointScan",
"values": {"start_row": row, "start_col": col - 30.0,
"end_row": row, "end_col": col + 30.0,
"width": 5.0, "select": "first"},
"links": {}}
return {
"schema_version": 2,
"name": "dxf",
"meta": {"dxf": {"disegno": { # sha256 obbligatorio in meta (v2)
"path": "disegno.dxf",
"sha256": hashlib.sha256(_SAMPLE_DXF.read_bytes()).hexdigest()}}},
"tools": [
scan("t-a", cx - hw),
scan("t-b", cx + hw),
{"id": "t-dxf", "name": "confronto", "kind": "CompareDxf",
"values": {"dxf": "disegno", "mm_per_px": 1.0 / _PX_PER_MM,
"anchor": "two_points",
"band_plus": 8.0, "band_minus": 8.0},
"links": {"point_a": {"tool": "t-a", "property": "point"},
"point_b": {"tool": "t-b", "property": "point"}}},
],
}
def test_a_circle_recipe_returns_overlay_and_outcome():
outcome = run_graph(a_disc(), a_circle_graph())
assert outcome.outcome in ("pass", "warning", "fail")
assert outcome.overlay, "DetectCircle must draw something"
assert all("tool_id" in item for item in outcome.overlay)
def test_calibration_dict_gives_mm_quotes():
graph = a_circle_graph()
for tool in graph["tools"]:
if tool["kind"] == "Diameter":
tool["values"]["nominal_unit"] = "mm"
outcome = run_graph(a_disc(), graph, calibration={"mm_per_px": 0.5})
assert outcome.failures == []
diameter = next(v for k, v in outcome.outputs.items() if k.endswith(".value"))
assert diameter == pytest.approx(60.0, abs=2.0) # 120 px * 0.5 mm/px
def test_compare_dxf_two_points_with_recipe_dir(tmp_path):
shutil.copy(_SAMPLE_DXF, tmp_path / "disegno.dxf")
outcome = run_graph(_dxf_scene(), _dxf_recipe(), recipe_dir=tmp_path)
assert outcome.failures == []
assert outcome.outputs["t-dxf.n_points"] > 1000
assert outcome.outputs["t-dxf.points_in_pct"] > 95.0
roles = {item.get("role") for item in outcome.overlay}
assert {"reference", "found"} <= roles
assert "t-dxf" in {item["tool_id"] for item in outcome.overlay}
def test_missing_meta_file_is_a_graph_build_error(tmp_path):
with pytest.raises(runner.GraphBuildError):
run_graph(_dxf_scene(), _dxf_recipe(), recipe_dir=tmp_path)
def test_validate_lists_numeric_outputs(tmp_path):
shutil.copy(_SAMPLE_DXF, tmp_path / "disegno.dxf")
graph = _dxf_recipe()
ok, errors, outputs = runner.validate_graph(graph, tmp_path)
assert ok, errors
keys = {o["key"] for o in outputs}
assert {"t-dxf.points_in_pct", "t-dxf.n_points"} <= keys
assert all(set(o) == {"key", "quantity", "unit"} for o in outputs)
def test_validate_reports_a_missing_file(tmp_path):
ok, errors, outputs = runner.validate_graph(_dxf_recipe(), tmp_path)
assert not ok and errors and outputs == []
+109 -17
View File
@@ -10,12 +10,20 @@ import asyncio
import io
import json
import os
import re
from pathlib import Path
import numpy as np
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, status
from PIL import Image, UnidentifiedImageError
from src.vision.runner import GraphBuildError, RunOutcome, engine_version, run_graph
from src.vision.runner import (
GraphBuildError,
RunOutcome,
engine_version,
run_graph,
validate_graph,
)
app = FastAPI(title="TieMeasureFlow Vision Worker", version="0.1.0")
@@ -31,6 +39,69 @@ _MAX_CONCURRENCY = int(os.environ.get("VISION_MAX_CONCURRENCY", "2"))
_run_slots = asyncio.Semaphore(_MAX_CONCURRENCY)
# #46: radice sotto cui vivono le cartelle delle ricette (volume uploads,
# montato in sola lettura nel worker). `recipe_dir` e' relativo a questa radice.
_RECIPES_ROOT_DEFAULT = "/app/uploads"
def _unprocessable(detail: str) -> HTTPException:
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=detail
)
def _resolve_recipe_dir(recipe_dir: str | None) -> Path | None:
"""Percorso RELATIVO sotto VISION_RECIPES_ROOT -> cartella assoluta.
Assoluto, `..` o fuori radice (anche via symlink) -> 422. Vuoto -> None.
"""
if recipe_dir is None or not recipe_dir.strip():
return None
rel = recipe_dir.strip()
# Letto a ogni richiesta (non a import) cosi' i test possono cambiarlo.
root = Path(os.environ.get("VISION_RECIPES_ROOT", _RECIPES_ROOT_DEFAULT)).resolve()
if (
Path(rel).is_absolute()
# #46: lettera di unita (C:/x) rifiutata su ogni OS; UNC coperto da startswith("\\")
or re.match(r"^[A-Za-z]:", rel)
or rel.startswith(("/", "\\"))
or ".." in Path(rel.replace("\\", "/")).parts
):
raise _unprocessable("recipe_dir must be a relative path under the recipes root")
resolved = (root / rel).resolve()
if resolved != root and root not in resolved.parents:
raise _unprocessable("recipe_dir is outside the recipes root")
return resolved
def _parse_graph(graph: str) -> dict:
try:
parsed = json.loads(graph)
except json.JSONDecodeError as exc:
raise _unprocessable(f"graph is not valid JSON: {exc}") from exc
if not isinstance(parsed, dict):
raise _unprocessable(
f"graph must be a JSON object, got {type(parsed).__name__}"
)
return parsed
def _parse_calibration(calibration: str | None) -> dict | None:
if calibration is None or not calibration.strip():
return None
try:
parsed = json.loads(calibration)
except json.JSONDecodeError as exc:
raise _unprocessable(f"calibration is not valid JSON: {exc}") from exc
if not isinstance(parsed, dict) or not (
parsed.get("mm_per_px") or parsed.get("px_per_mm")
):
raise _unprocessable(
"calibration must be {'mm_per_px': float} or {'px_per_mm': float}"
)
return parsed
class _ImageDecodeError(Exception):
"""The upload is not a decodable image - raised inside the worker thread
so it survives the trip through `asyncio.to_thread`."""
@@ -44,7 +115,12 @@ async def health() -> dict:
return {"status": "ok", "engine_version": engine_version()}
def _decode_and_run(content: bytes, graph: dict) -> RunOutcome:
def _decode_and_run(
content: bytes,
graph: dict,
calibration: dict | None = None,
recipe_dir: Path | None = None,
) -> RunOutcome:
"""The synchronous, CPU/IO-bound half of a request: PIL decode plus
`run_graph`. Runs in a worker thread (see `run` below) so the event loop -
and therefore `/health` - stays responsive while it executes. Once this
@@ -56,27 +132,19 @@ def _decode_and_run(content: bytes, graph: dict) -> RunOutcome:
frame = np.array(Image.open(io.BytesIO(content)).convert("L"))
except (UnidentifiedImageError, OSError) as exc:
raise _ImageDecodeError(str(exc)) from exc
return run_graph(frame, graph)
return run_graph(frame, graph, calibration=calibration, recipe_dir=recipe_dir)
@app.post("/run")
async def run(
image: UploadFile = File(...),
graph: str = Form(...),
recipe_dir: str | None = Form(None),
calibration: str | None = Form(None),
) -> dict:
try:
parsed_graph = json.loads(graph)
except json.JSONDecodeError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"graph is not valid JSON: {exc}",
) from exc
if not isinstance(parsed_graph, dict):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"graph must be a JSON object, got {type(parsed_graph).__name__}",
)
parsed_graph = _parse_graph(graph)
resolved_dir = _resolve_recipe_dir(recipe_dir)
parsed_calibration = _parse_calibration(calibration)
content = await image.read()
@@ -89,7 +157,13 @@ async def run(
async with _run_slots:
try:
outcome = await asyncio.wait_for(
asyncio.to_thread(_decode_and_run, content, parsed_graph),
asyncio.to_thread(
_decode_and_run,
content,
parsed_graph,
parsed_calibration,
resolved_dir,
),
timeout=_RUN_TIMEOUT_S,
)
except asyncio.TimeoutError as exc:
@@ -130,4 +204,22 @@ async def run(
"engine_version": outcome.engine_version,
"duration_ms": outcome.duration_ms,
"invalid_outputs": outcome.invalid_outputs,
# #46: contorni per l'overlay sull'immagine e esito VisionSuite.
"overlay": outcome.overlay,
"outcome": outcome.outcome,
}
@app.post("/validate")
async def validate(
graph: str = Form(...),
recipe_dir: str | None = Form(None),
) -> dict:
"""Validates a v2 recipe (not a draft: a missing meta file is an error)
and lists its numeric outputs. No image, so it is cheap: no run slot."""
parsed_graph = _parse_graph(graph)
resolved_dir = _resolve_recipe_dir(recipe_dir)
ok, errors, outputs = await asyncio.to_thread(
validate_graph, parsed_graph, resolved_dir
)
return {"ok": ok, "errors": errors, "outputs": outputs}
+94 -7
View File
@@ -45,13 +45,15 @@ def a_disc_png(radius: int = 60, size: int = 400) -> bytes:
def a_circle_graph() -> dict:
from visionsuite.task import Task
from visionsuite.task.tools import DetectCircleTool, DiameterTool
from visionsuite.tools.detect import DetectCircle
from visionsuite.tools.dimensions import Diameter
task = Task(name="prova")
found = task.add(DetectCircleTool(name="disco"))
found = task.add(DetectCircle(name="disco"))
found.center_row, found.center_col, found.radius = 200.0, 200.0, 60.0
found.scan_size = 30.0
quota = task.add(DiameterTool(name="diametro"))
quota = task.add(Diameter(name="diametro"))
quota.nominal_unit = "px" # #46: v2, senza calibrazione la quota resta in px
quota.link("circle", found, "circle")
return task.to_dict()
@@ -140,7 +142,7 @@ async def test_a_graph_that_is_not_an_object_is_refused():
@pytest.mark.asyncio
async def test_a_tool_without_a_kind_is_refused_not_crashed():
graph = {"schema_version": 1, "name": "p", "tools": [{"id": "a", "name": "x"}]}
graph = {"schema_version": 2, "name": "p", "tools": [{"id": "a", "name": "x"}]}
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://worker") as client:
response = await client.post(
@@ -151,7 +153,7 @@ async def test_a_tool_without_a_kind_is_refused_not_crashed():
assert response.status_code == 422
assert "malformed graph" in response.json()["detail"].lower()
assert "kind" in response.json()["detail"].lower()
assert "tipo sconosciuto" in response.json()["detail"].lower() # #46: messaggio v2
# ---------------------------------------------------------------------------
@@ -167,7 +169,7 @@ async def test_a_run_past_its_budget_answers_504_and_health_still_answers(
):
monkeypatch.setattr(worker_main, "_RUN_TIMEOUT_S", 0.1)
def slow_run_graph(frame, graph):
def slow_run_graph(frame, graph, **kwargs):
time.sleep(0.5) # off the event loop - it runs inside asyncio.to_thread
return runner.RunOutcome(engine_version="a" * 40)
@@ -197,7 +199,7 @@ async def test_a_third_concurrent_run_is_refused_not_queued(monkeypatch):
monkeypatch.setattr(worker_main, "_run_slots", asyncio.Semaphore(1))
monkeypatch.setattr(worker_main, "_RUN_TIMEOUT_S", 2.0)
def slow_run_graph(frame, graph):
def slow_run_graph(frame, graph, **kwargs):
time.sleep(0.3)
return runner.RunOutcome(engine_version="a" * 40)
@@ -222,3 +224,88 @@ async def test_a_third_concurrent_run_is_refused_not_queued(monkeypatch):
assert second_response.status_code == 503
assert first_response.status_code == 200
# ---------------------------------------------------------------------------
# #46: recipe_dir, calibration, overlay, /validate.
# ---------------------------------------------------------------------------
async def _post(path: str, **kwargs):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://worker") as client:
return await client.post(path, **kwargs)
@pytest.mark.asyncio
async def test_run_returns_overlay_and_outcome():
response = await _post(
"/run",
files={"image": ("piece.png", a_disc_png(), "image/png")},
data={"graph": json.dumps(a_circle_graph())},
)
body = response.json()
assert response.status_code == 200
assert body["overlay"] and "tool_id" in body["overlay"][0]
assert body["outcome"] in ("pass", "warning", "fail")
@pytest.mark.asyncio
async def test_run_accepts_a_calibration():
graph = a_circle_graph()
for tool in graph["tools"]:
if tool["kind"] == "Diameter":
tool["values"]["nominal_unit"] = "mm"
response = await _post(
"/run",
files={"image": ("piece.png", a_disc_png(), "image/png")},
data={"graph": json.dumps(graph),
"calibration": json.dumps({"px_per_mm": 2.0})},
)
body = response.json()
assert response.status_code == 200, body
assert body["failures"] == []
@pytest.mark.asyncio
@pytest.mark.parametrize("bad", ["../etc", "a/../../b", "/abs/path", "C:/x/y"])
async def test_a_recipe_dir_outside_the_root_is_refused(bad, monkeypatch, tmp_path):
monkeypatch.setenv("VISION_RECIPES_ROOT", str(tmp_path))
response = await _post(
"/run",
files={"image": ("piece.png", a_disc_png(), "image/png")},
data={"graph": json.dumps(a_circle_graph()), "recipe_dir": bad},
)
assert response.status_code == 422
response = await _post(
"/validate", data={"graph": json.dumps(a_circle_graph()), "recipe_dir": bad}
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_validate_ok_and_with_a_missing_file(monkeypatch, tmp_path):
monkeypatch.setenv("VISION_RECIPES_ROOT", str(tmp_path))
response = await _post("/validate", data={"graph": json.dumps(a_circle_graph())})
body = response.json()
assert response.status_code == 200 and body["ok"] is True
assert any(o["key"].endswith(".diameter") for o in body["outputs"])
(tmp_path / "r1").mkdir()
graph = {
"schema_version": 2, "name": "d",
"meta": {"dxf": {"d": {"path": "mancante.dxf"}}},
"tools": [{"id": "t", "name": "c", "kind": "CompareDxf",
"values": {"dxf": "d", "mm_per_px": 0.25,
"band_plus": 8.0, "band_minus": 8.0},
"links": {}}],
}
response = await _post(
"/validate", data={"graph": json.dumps(graph), "recipe_dir": "r1"}
)
body = response.json()
assert response.status_code == 200
assert body["ok"] is False and body["errors"] and body["outputs"] == []
Generated
+142 -22
View File
@@ -626,6 +626,45 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/21/0e/8459ca4413e1a21a06c97d134bfaf18adfd27cea068813dc0faae06cbf00/cssselect2-0.9.0-py3-none-any.whl", hash = "sha256:6a99e5f91f9a016a304dd929b0966ca464bcfda15177b6fb4a118fc0fb5d9563", size = 15453, upload-time = "2026-02-12T17:16:38.317Z" },
]
[[package]]
name = "ezdxf"
version = "1.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fonttools" },
{ name = "numpy" },
{ name = "pyparsing" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/d7/1b7be8db364f1c4838dfc1a40ca96577aba405deabf896a4eb3aaeb15a62/ezdxf-1.4.4.tar.gz", hash = "sha256:da5a5e0e6bdbb6656f9c017b47edc7eafceb419d61a2b5de64ffb344c168e593", size = 1866886, upload-time = "2026-05-14T09:19:19.511Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/d0/bc515b7f5596bc3d55b93fc9f2c2b4926e2587af6bfae0486ef7cc7ddf43/ezdxf-1.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:39aadab5bc7a05deddbd0db9b984c8a4bd28bd4c1ac2c5ee03f0d1b096272a88", size = 3553590, upload-time = "2026-05-14T09:25:28.042Z" },
{ url = "https://files.pythonhosted.org/packages/a7/73/207132f13a5280d186f2441d9125639fdc7ddeabf56c2d5da4d691e81ce6/ezdxf-1.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2ed65b191113a82133ba47daa6ebdd540b487b17394b1e327e9511dde87ad924", size = 3008276, upload-time = "2026-05-14T09:25:29.424Z" },
{ url = "https://files.pythonhosted.org/packages/82/73/b01af87269726e3719abc5ff694b843b710ef8fc345d21013e90d70e44d8/ezdxf-1.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7c3c8f2c2b62fe5b6c9b62ab8257b697535d62b0c79acf3a5040bd95edfab451", size = 3001750, upload-time = "2026-05-14T09:25:31.146Z" },
{ url = "https://files.pythonhosted.org/packages/dd/92/284d7ca6a9da107966c7bd9d81c92245985235bc432fe0837982ef80630a/ezdxf-1.4.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d68d5ab9e1071319030da3af27590f34c38c8544a5ea638d9a4a800a96abb3c2", size = 5802382, upload-time = "2026-05-14T09:27:28.462Z" },
{ url = "https://files.pythonhosted.org/packages/7d/fe/e4f37730ca5b5bf7de3a968a26126b404f60324d0b8ae9d59d11d6e55740/ezdxf-1.4.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f75a4f2924ebdda0f5b2779ff2135ba92de2596c95a8fa9b1d9ebcabea1be41", size = 5794671, upload-time = "2026-05-14T09:27:09.867Z" },
{ url = "https://files.pythonhosted.org/packages/fe/3f/c3e7ab74f58cb69ef985fb71eda859b5c287cc506c65cbffe7e55c7cf2d4/ezdxf-1.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0eec8a2095b645420a67560e99041c0d25afefd39eab5bff8c736fdb32211a75", size = 5767366, upload-time = "2026-05-14T09:27:30.108Z" },
{ url = "https://files.pythonhosted.org/packages/66/7a/b7070bf05e75b3e709a6fe5ae5bcd3bd899e987ef84ff25cf28327519c03/ezdxf-1.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d87a85052e92b9c28e54605dbad65f779a3b61e6288e992d6ef00df7bde4bed3", size = 5830221, upload-time = "2026-05-14T09:27:12.074Z" },
{ url = "https://files.pythonhosted.org/packages/78/cf/a990289808c261c52f5b219484d136a8748773c99a6d25c84f55a0353fea/ezdxf-1.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:473d68c8f2b04ace513efa290a1cd0d3a4a589cdff48124f015f0e51034668b2", size = 2957599, upload-time = "2026-05-14T09:23:46.571Z" },
{ url = "https://files.pythonhosted.org/packages/18/3d/98648d221ffa047b5ba898c8e152010861dc71a66b531dcf15053075010e/ezdxf-1.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d723d35d08223318c17080a32a05192a1449e544dee867c9d9c6f25998fac303", size = 3563916, upload-time = "2026-05-14T09:25:32.879Z" },
{ url = "https://files.pythonhosted.org/packages/2c/f1/84bbaa571412f840c22deeae185e54d4a67aa52f03e1728fd8425748c548/ezdxf-1.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6b0c73b46085521bdbd4a3e7d88998da7bec5713ba89bbb502fdeffe6b42fc7e", size = 3016857, upload-time = "2026-05-14T09:25:34.607Z" },
{ url = "https://files.pythonhosted.org/packages/b0/ff/f2eb77d22d26aa98abbe0961470871d8b85ef4e73e5527f544d25f958d6e/ezdxf-1.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ac3e83a70c1f866dd5114192a6fab6e75cc83b75e907bc08c5d94b98cce14cc0", size = 3003336, upload-time = "2026-05-14T09:25:35.947Z" },
{ url = "https://files.pythonhosted.org/packages/65/7d/2a6ddd6beef5d9a8593078619f3920a0cd76b1db4c6a974ac37045543219/ezdxf-1.4.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ebe09717ab6636bf48780eabdab51e3ab2135f7963bb52452444b6d95e03d12", size = 5788485, upload-time = "2026-05-14T09:27:32.176Z" },
{ url = "https://files.pythonhosted.org/packages/e7/29/2094fd7719146ca7bb236be9751b5929d431f643cbab669aed89cf121883/ezdxf-1.4.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44af248af0860fe8b1a39ca40e1cd2fa8ac37b22142b14b20c6a0caf1b50f932", size = 5816781, upload-time = "2026-05-14T09:27:13.96Z" },
{ url = "https://files.pythonhosted.org/packages/19/8d/669b8047cca162204c53b874183d73b5f6c4b1d5d48fa1e515a32d56f5ac/ezdxf-1.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02a097794625e9fc948e637dfe07057e98167b4fe215357a59645759fef6db3f", size = 5754006, upload-time = "2026-05-14T09:27:33.858Z" },
{ url = "https://files.pythonhosted.org/packages/5b/44/b753997173327250e43ae50f11f0c69016b1e78482e0bd3857c3495236e7/ezdxf-1.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f093bd2228f5782d483aed3bd89cdb74b672b5f90c677c4125cd236ee5649151", size = 5861860, upload-time = "2026-05-14T09:27:15.758Z" },
{ url = "https://files.pythonhosted.org/packages/0b/99/2e81cb168e0f09adfb05fb40e5f890ff87ac2c00e98079cf09837dd76330/ezdxf-1.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:3e518d9ed4dcbe525d57f14c25c5e592f82660a690fdec39b03b092e6cdc4771", size = 2963238, upload-time = "2026-05-14T09:23:47.924Z" },
{ url = "https://files.pythonhosted.org/packages/be/ed/97aa3ba1ba923e4098169de5bca380ee1430e2b34d0dd85b73007e34df16/ezdxf-1.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:278a09a845d67a4f893aebe9ef8dcd8894ece9f255d7d8bb21719ffd902109b8", size = 3555283, upload-time = "2026-05-14T09:25:37.346Z" },
{ url = "https://files.pythonhosted.org/packages/4e/b1/8ca9408a2b382a806837e72d35f10931fdd9f53fe66cf208a6340358fbc5/ezdxf-1.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b0a787f8ebcbdcb9798c6fcfe90c23afd1a0993c8d4302b6cce5d8f19ae052ae", size = 3012646, upload-time = "2026-05-14T09:25:39.01Z" },
{ url = "https://files.pythonhosted.org/packages/8b/aa/1e8d3130eaabb780c5e8adfdc1ead023bb04468779b18169d60f7ce5b291/ezdxf-1.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c451d1c20f08b30c735ca4d650fee5b44147402f4b5662f8550053ed8a3009c2", size = 2999048, upload-time = "2026-05-14T09:25:40.348Z" },
{ url = "https://files.pythonhosted.org/packages/cd/ca/1dd390b79df9c104c2d4b5964e80db25362edf704ac742c9db9b279658a5/ezdxf-1.4.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d3d9a6663993fc644751647fd7843057b51742ffac27c48ce28ea9f81239613", size = 5747890, upload-time = "2026-05-14T09:27:36.028Z" },
{ url = "https://files.pythonhosted.org/packages/8f/1e/226a6e636ae533e0bc0afe4435d1058fac173564e4af4102862f055a46d9/ezdxf-1.4.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82c022cf094d21ad3db68557aa83628e4152355e8a8bef0fffd4f71a9ea325df", size = 5774707, upload-time = "2026-05-14T09:27:17.625Z" },
{ url = "https://files.pythonhosted.org/packages/24/a3/7e33c9036de944b5446982bb629d698356e48b00c4d173768f52bdeeff4b/ezdxf-1.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3be59c2a3a93585412a2a37af29b3ad7574fa3e429e624924c3285d0573388b3", size = 5713753, upload-time = "2026-05-14T09:27:37.55Z" },
{ url = "https://files.pythonhosted.org/packages/36/9e/ac0cdc3a8623fc38aa16670f6537aa810bc1381217a51ac3481299bbc986/ezdxf-1.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:44a9716860b59dddd49a3708c04ff4580ad1e7de92ac93bb3213d85fba5ac93f", size = 5813895, upload-time = "2026-05-14T09:27:19.514Z" },
{ url = "https://files.pythonhosted.org/packages/dd/22/b511a8d9ea8f23447a67fe8977213fac5e4e69df30130a3ef31043613eaf/ezdxf-1.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:207eed544417464ffaf2570880d58a792ecd2534c9c2dede45e895f3200b77e6", size = 2960937, upload-time = "2026-05-14T09:23:49.361Z" },
{ url = "https://files.pythonhosted.org/packages/a0/09/5ecb6f82d35a2f4a4334d0a608fcf764827fa69dccdf5987ce309c16b6c1/ezdxf-1.4.4-py3-none-any.whl", hash = "sha256:666edda631ba717270293b734f5d58dd97a1d1aba4787187f09d0cc584645865", size = 1331217, upload-time = "2026-05-14T09:19:26.601Z" },
]
[[package]]
name = "fastapi"
version = "0.136.1"
@@ -1079,8 +1118,8 @@ name = "numba"
version = "0.67.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "llvmlite", marker = "python_full_version >= '3.13'" },
{ name = "numpy", marker = "python_full_version >= '3.13'" },
{ name = "llvmlite" },
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7a/90/2544f4e3a61e501d6c9a5418fd4b905323222693d54a02cab0106a0af865/numba-0.67.0.tar.gz", hash = "sha256:cd75aa535b33fa05d9d930b1ae8af9f97a2881e96d72dfb38ec9b78284d9f851", size = 2836515, upload-time = "2026-08-11T23:04:00.174Z" }
wheels = [
@@ -1185,7 +1224,7 @@ name = "opencv-python"
version = "5.0.0.93"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy", marker = "python_full_version >= '3.13'" },
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/79/4c/a438d23e09ce2033c09f7b784ad2fbdb0adf529e434101ed28f142226f98/opencv_python-5.0.0.93.tar.gz", hash = "sha256:66aac3e5b5faa48d4025816592f3af19e4bfc2c68dec067bae2dbb4ca10aa9e2", size = 81802749, upload-time = "2026-07-02T06:59:53.815Z" }
wheels = [
@@ -1570,6 +1609,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pyparsing"
version = "3.3.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/11/b213bebff182584360cb8d17c72c1677fec5c5c228de439e63bcf8ab1c8f/pyparsing-3.3.3.tar.gz", hash = "sha256:928ae7e20211f3b6f3915a72f06a0cfd29ab9d24279dd6346b6b1a7146397d36", size = 1050487, upload-time = "2026-09-20T20:59:05.609Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/bb/d215ee7c73b61497b28a5503f9f53523f294fcc936762b7caf90e0c1c2b5/pyparsing-3.3.3-py3-none-any.whl", hash = "sha256:ece8c00a69cf01b45d0b1dedabb469c90d8caf996d4fda40f147627a122849a4", size = 126420, upload-time = "2026-09-20T20:59:04.025Z" },
]
[[package]]
name = "pypdfium2"
version = "5.8.0"
@@ -1751,7 +1799,7 @@ name = "scipy"
version = "1.18.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy", marker = "python_full_version >= '3.13'" },
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" }
wheels = [
@@ -1980,6 +2028,7 @@ vision = [
{ name = "vs-measure", marker = "python_full_version >= '3.13'" },
{ name = "vs-pm2d", marker = "python_full_version >= '3.13'" },
{ name = "vs-task", marker = "python_full_version >= '3.13'" },
{ name = "vs-tools", extra = ["dxf", "locate"], marker = "python_full_version >= '3.13'" },
]
vision-worker = [
{ name = "fastapi", marker = "python_full_version >= '3.13'" },
@@ -1991,6 +2040,7 @@ vision-worker = [
{ name = "vs-measure", marker = "python_full_version >= '3.13'" },
{ name = "vs-pm2d", marker = "python_full_version >= '3.13'" },
{ name = "vs-task", marker = "python_full_version >= '3.13'" },
{ name = "vs-tools", extra = ["dxf", "locate"], marker = "python_full_version >= '3.13'" },
]
[package.metadata]
@@ -2032,6 +2082,7 @@ requires-dist = [
{ name = "vs-measure", marker = "python_full_version >= '3.13' and extra == 'vision'", editable = "vendor/visionsuite/packages/vs-measure" },
{ name = "vs-pm2d", marker = "python_full_version >= '3.13' and extra == 'vision'", editable = "vendor/visionsuite/packages/vs-pm2d" },
{ name = "vs-task", marker = "python_full_version >= '3.13' and extra == 'vision'", editable = "vendor/visionsuite/packages/vs-task" },
{ name = "vs-tools", extras = ["locate", "dxf"], marker = "python_full_version >= '3.13' and extra == 'vision'", editable = "vendor/visionsuite/packages/vs-tools" },
{ name = "weasyprint", marker = "extra == 'server'", specifier = ">=62.0" },
]
provides-extras = ["server", "client", "dev", "vision", "vision-worker"]
@@ -2157,8 +2208,14 @@ name = "visionsuite"
version = "0.0.1"
source = { editable = "vendor/visionsuite/packages/vs-core" }
dependencies = [
{ name = "numpy", marker = "python_full_version >= '3.13'" },
{ name = "opencv-python", marker = "python_full_version >= '3.13'" },
{ name = "numpy" },
{ name = "opencv-python" },
{ name = "pillow" },
]
[package.optional-dependencies]
dxf = [
{ name = "ezdxf" },
]
[package.metadata]
@@ -2168,42 +2225,70 @@ requires-dist = [
{ name = "numba", marker = "extra == 'fast'", specifier = ">=0.59" },
{ name = "numpy", specifier = ">=1.24" },
{ name = "opencv-python", specifier = ">=4.8" },
{ name = "pillow", specifier = ">=12.2.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7" },
]
provides-extras = ["dev", "fast", "dxf"]
[[package]]
name = "vs-dxf"
version = "0.1.0"
source = { editable = "vendor/visionsuite/packages/vs-dxf" }
dependencies = [
{ name = "numpy" },
{ name = "opencv-python" },
{ name = "pydantic" },
{ name = "scipy" },
{ name = "visionsuite", extra = ["dxf"] },
{ name = "vs-measure" },
]
[package.metadata]
requires-dist = [
{ name = "numpy", specifier = ">=1.26" },
{ name = "opencv-python", specifier = ">=4.9" },
{ name = "pydantic", specifier = ">=2.6" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1" },
{ name = "scipy", specifier = ">=1.12" },
{ name = "visionsuite", extras = ["dxf"], editable = "vendor/visionsuite/packages/vs-core" },
{ name = "vs-measure", editable = "vendor/visionsuite/packages/vs-measure" },
]
provides-extras = ["dev"]
[[package]]
name = "vs-measure"
version = "0.1.0"
source = { editable = "vendor/visionsuite/packages/vs-measure" }
dependencies = [
{ name = "numpy", marker = "python_full_version >= '3.13'" },
{ name = "scipy", marker = "python_full_version >= '3.13'" },
{ name = "visionsuite", marker = "python_full_version >= '3.13'" },
{ name = "numpy" },
{ name = "scipy" },
{ name = "visionsuite" },
]
[package.metadata]
requires-dist = [
{ name = "numba", marker = "extra == 'fast'", specifier = ">=0.59" },
{ name = "numpy", specifier = ">=1.24" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7" },
{ name = "scipy", specifier = ">=1.11" },
{ name = "visionsuite", editable = "vendor/visionsuite/packages/vs-core" },
]
provides-extras = ["dev"]
provides-extras = ["dev", "fast"]
[[package]]
name = "vs-pm2d"
version = "0.1.0"
source = { editable = "vendor/visionsuite/packages/vs-pm2d" }
dependencies = [
{ name = "fastapi", marker = "python_full_version >= '3.13'" },
{ name = "numba", marker = "python_full_version >= '3.13'" },
{ name = "numpy", marker = "python_full_version >= '3.13'" },
{ name = "opencv-python", marker = "python_full_version >= '3.13'" },
{ name = "pillow", marker = "python_full_version >= '3.13'" },
{ name = "python-multipart", marker = "python_full_version >= '3.13'" },
{ name = "uvicorn", extra = ["standard"], marker = "python_full_version >= '3.13'" },
{ name = "visionsuite", marker = "python_full_version >= '3.13'" },
{ name = "fastapi" },
{ name = "numba" },
{ name = "numpy" },
{ name = "opencv-python" },
{ name = "pillow" },
{ name = "python-multipart" },
{ name = "uvicorn", extra = ["standard"] },
{ name = "visionsuite" },
]
[package.metadata]
@@ -2226,19 +2311,54 @@ name = "vs-task"
version = "0.1.0"
source = { editable = "vendor/visionsuite/packages/vs-task" }
dependencies = [
{ name = "numpy", marker = "python_full_version >= '3.13'" },
{ name = "visionsuite", marker = "python_full_version >= '3.13'" },
{ name = "vs-measure", marker = "python_full_version >= '3.13'" },
{ name = "numpy" },
{ name = "visionsuite" },
]
[package.metadata]
requires-dist = [
{ name = "fastapi", marker = "extra == 'server'", specifier = ">=0.132" },
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" },
{ name = "numpy", specifier = ">=1.24" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7" },
{ name = "uvicorn", marker = "extra == 'server'", specifier = ">=0.34" },
{ name = "visionsuite", editable = "vendor/visionsuite/packages/vs-core" },
{ name = "vs-stat", marker = "extra == 'stat'", editable = "vendor/visionsuite/packages/vs-stat" },
]
provides-extras = ["dev", "stat", "server"]
[[package]]
name = "vs-tools"
version = "0.1.0"
source = { editable = "vendor/visionsuite/packages/vs-tools" }
dependencies = [
{ name = "numpy" },
{ name = "visionsuite" },
{ name = "vs-measure" },
{ name = "vs-task" },
]
[package.optional-dependencies]
dxf = [
{ name = "vs-dxf" },
]
locate = [
{ name = "vs-pm2d" },
]
[package.metadata]
requires-dist = [
{ name = "ezdxf", marker = "extra == 'dev'", specifier = ">=1.0" },
{ name = "numpy", specifier = ">=1.24" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7" },
{ name = "visionsuite", editable = "vendor/visionsuite/packages/vs-core" },
{ name = "vs-dxf", marker = "extra == 'dxf'", editable = "vendor/visionsuite/packages/vs-dxf" },
{ name = "vs-measure", editable = "vendor/visionsuite/packages/vs-measure" },
{ name = "vs-pm2d", marker = "extra == 'locate'", editable = "vendor/visionsuite/packages/vs-pm2d" },
{ name = "vs-task", editable = "vendor/visionsuite/packages/vs-task" },
{ name = "vs-tools", extras = ["locate", "dxf"], marker = "extra == 'dev'", editable = "vendor/visionsuite/packages/vs-tools" },
]
provides-extras = ["dev"]
provides-extras = ["locate", "dxf", "dev"]
[[package]]
name = "watchfiles"