Compare commits

..

4 Commits

5 changed files with 193 additions and 11 deletions
+13 -5
View File
@@ -40,14 +40,22 @@ _IMAGE_EXTENSIONS = {
"image/png": "png", "image/png": "png",
"image/gif": "gif", "image/gif": "gif",
"image/webp": "webp", "image/webp": "webp",
"image/bmp": "bmp", # #99
"image/x-ms-bmp": "bmp", # #99
} }
# #99: the Opto bench cameras save BMP and the worker decodes it (PIL), so the
# vision routes accept it on top of files.py's list. files.py is untouched: its
# attachments are served by extension and BMP is not meant to be shown there.
_VISION_IMAGE_TYPES = ALLOWED_IMAGE_TYPES | {"image/bmp", "image/x-ms-bmp"}
# Sniffed from the first bytes, independent of the declared Content-Type: a # Sniffed from the first bytes, independent of the declared Content-Type: a
# text file uploaded as "image/png" passes the header check above and must # text file uploaded as "image/png" passes the header check above and must
# still be caught before it reaches the worker as a bare 500 (#15). BMP and # still be caught before it reaches the worker as a bare 500 (#15). TIFF is
# TIFF are included even though `_IMAGE_EXTENSIONS` does not accept them, # included even though the allow-list does not accept it (PIL, and so the
# because PIL - and so the worker - can decode both; this check only refuses # worker, can decode it; browsers do not show it in the result); BMP is now
# what is not a real image at all, it does not narrow the allow-list above. # also allowed (#99). This check only refuses what is not a real image at all,
# it does not narrow the allow-list above.
_IMAGE_SIGNATURES: tuple[tuple[bytes, ...], ...] = ( _IMAGE_SIGNATURES: tuple[tuple[bytes, ...], ...] = (
(b"\x89PNG\r\n\x1a\n",), # PNG (b"\x89PNG\r\n\x1a\n",), # PNG
(b"\xff\xd8\xff",), # JPEG (b"\xff\xd8\xff",), # JPEG
@@ -83,7 +91,7 @@ async def _read_image(image: UploadFile) -> bytes:
is in hand - the same two-step convention `files.py::upload_file` already is in hand - the same two-step convention `files.py::upload_file` already
uses, reused rather than reinvented here. uses, reused rather than reinvented here.
""" """
if image.content_type not in ALLOWED_IMAGE_TYPES: if image.content_type not in _VISION_IMAGE_TYPES: # #99
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File type {image.content_type} not allowed. Must be an image.", detail=f"File type {image.content_type} not allowed. Must be an image.",
+109
View File
@@ -0,0 +1,109 @@
"""#99: the vision routes accept BMP (the Opto bench cameras save it); TIFF and
non-images stay refused. files.py (attachments) is not touched by this."""
import io
import pytest
from PIL import Image
from sqlalchemy import select
from src.backend.config import settings
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 ( # noqa: F401 - fixtures
recipe_parts, redirect_uploads, worker_says,
)
def a_bmp() -> bytes:
buffer = io.BytesIO()
Image.new("L", (64, 64), color=0).save(buffer, format="BMP")
return buffer.getvalue()
def a_tiff() -> bytes:
buffer = io.BytesIO()
Image.new("L", (64, 64), color=0).save(buffer, format="TIFF")
return buffer.getvalue()
async def a_camera_task(db_session, user):
recipe = await create_test_recipe(db_session, user.id)
_, task, _ = await recipe_parts(db_session, recipe.id)
task.task_type = "camera_measure"
task.vision_json = {"schema_version": 1, "name": "p", "tools": []}
await db_session.commit()
return task
@pytest.mark.asyncio
@pytest.mark.parametrize("content_type", ["image/bmp", "image/x-ms-bmp"])
async def test_preview_accepts_a_bmp(
client, db_session, maker_user, worker_says, content_type,
):
task = await a_camera_task(db_session, maker_user)
worker_says({"t1.diameter": 10.0})
response = await client.post(
"/api/vision/preview",
headers=auth_headers(maker_user),
files={"image": ("p.bmp", a_bmp(), content_type)},
data={"task_id": str(task.id)},
)
assert response.status_code != 400, response.text
assert response.status_code != 422, response.text
@pytest.mark.asyncio
async def test_preview_still_rejects_a_tiff(
client, db_session, maker_user, worker_says,
):
task = await a_camera_task(db_session, maker_user)
worker_says({"t1.diameter": 10.0})
response = await client.post(
"/api/vision/preview",
headers=auth_headers(maker_user),
files={"image": ("p.tif", a_tiff(), "image/tiff")},
data={"task_id": str(task.id)},
)
assert response.status_code == 400
@pytest.mark.asyncio
async def test_a_text_file_declared_bmp_is_refused_on_its_bytes(
client, db_session, maker_user, worker_says,
):
task = await a_camera_task(db_session, maker_user)
worker_says({"t1.diameter": 10.0})
response = await client.post(
"/api/vision/preview",
headers=auth_headers(maker_user),
files={"image": ("p.bmp", b"i am not an image", "image/bmp")},
data={"task_id": str(task.id)},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_reference_image_bmp_is_saved_with_the_bmp_extension(
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()
response = await client.post(
"/api/vision/reference-images",
headers=auth_headers(maker_user),
files={"image": ("p.bmp", a_bmp(), "image/x-ms-bmp")},
data={"task_id": str(task.id)},
)
assert response.status_code == 200, response.text
saved = (await db_session.execute(select(VisionReferenceImage))).scalars().one()
assert saved.path.endswith(".bmp")
assert (settings.upload_path / saved.path).exists()
@@ -51,7 +51,9 @@
<!-- Subtask --> <!-- Subtask -->
<div> <div>
<label class="block text-xs font-medium text-steel mb-1">{{ _("Punto di misura") }}</label> <label class="block text-xs font-medium text-steel mb-1">{{ _("Punto di misura") }}</label>
<!-- #100: il cambio di punto ricarica subito, senza «Applica filtri» -->
<select x-model="selectedSubtaskId" <select x-model="selectedSubtaskId"
@change="loadData()"
:disabled="subtasks.length === 0" :disabled="subtasks.length === 0"
class="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50"> class="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50">
<option value="">{{ _("Tutti i punti") }}</option> <option value="">{{ _("Tutti i punti") }}</option>
@@ -287,20 +289,52 @@
</div> </div>
</div> </div>
</template> </template>
<template x-if="!capability || capability.cp == null"> <!-- #100: con «Tutti i punti» tabella con una riga per punto di misura -->
<template x-if="!loadedSubtaskId && capabilityAll.length > 0">
<div>
<table class="w-full text-sm">
<thead>
<tr class="text-xs text-steel text-left">
<th class="py-1 font-medium">{{ _("Punto di misura") }}</th>
<th class="py-1 font-medium text-right">n</th>
<th class="py-1 font-medium text-right">Cp</th>
<th class="py-1 font-medium text-right">Cpk</th>
</tr>
</thead>
<tbody>
<template x-for="row in capabilityAll" :key="row.subtask.id">
<tr class="cursor-pointer hover:bg-[var(--bg-primary)] border-t border-[var(--border-color)]"
@click="selectedSubtaskId = String(row.subtask.id); loadData()">
<td class="py-1.5 pr-2" x-text="'#' + row.subtask.marker_number + ' — ' + row.subtask.description"></td>
<td class="py-1.5 text-right font-mono" x-text="row.data && row.data.n != null ? row.data.n : '—'"></td>
<td class="py-1.5 text-right font-mono font-bold"
:class="row.data && row.data.cp != null ? capabilityColor(row.data.cp) : 'text-steel'"
x-text="row.data && row.data.cp != null ? row.data.cp : '—'"></td>
<td class="py-1.5 text-right font-mono font-bold"
:class="row.data && row.data.cpk != null ? capabilityColor(row.data.cpk) : 'text-steel'"
x-text="row.data && row.data.cpk != null ? row.data.cpk : '—'"></td>
</tr>
</template>
</tbody>
</table>
<p class="text-xs text-steel mt-3">{{ _("Scegli un punto per carta di controllo e istogramma") }}</p>
</div>
</template>
<template x-if="(!capability || capability.cp == null) && (loadedSubtaskId || capabilityAll.length === 0)">
<p class="text-center text-steel py-8">{{ _("Seleziona un punto di misura per calcolare gli indici") }}</p> <p class="text-center text-steel py-8">{{ _("Seleziona un punto di misura per calcolare gli indici") }}</p>
</template> </template>
</div> </div>
</div> </div>
<!-- Row 2: Carta di Controllo --> <!-- Row 2: Carta di Controllo -->
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6"> <!-- #100: x-show (non x-if) perche' renderCharts() cerca gli id nel DOM; nascosta senza punto -->
<div x-show="loadedSubtaskId" class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6">
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Carta di Controllo") }}</h2> <h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Carta di Controllo") }}</h2>
<div id="control-chart" style="min-height: 350px;"></div> <div id="control-chart" style="min-height: 350px;"></div>
</div> </div>
<!-- Row 3: Istogramma --> <!-- Row 3: Istogramma -->
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6"> <div x-show="loadedSubtaskId" class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6">
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Istogramma") }}</h2> <h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Istogramma") }}</h2>
<div id="histogram-chart" style="min-height: 350px;"></div> <div id="histogram-chart" style="min-height: 350px;"></div>
</div> </div>
@@ -346,6 +380,8 @@ function spcDashboard() {
subtasks: [], subtasks: [],
// Data state // Data state
loadedSubtaskId: '', // #100 punto con cui sono stati caricati i dati (i pannelli dipendono da questo)
capabilityAll: [], // #100 con «Tutti i punti»: [{subtask, data}] una riga per punto
summary: null, summary: null,
capability: null, capability: null,
controlChart: null, controlChart: null,
@@ -408,6 +444,8 @@ function spcDashboard() {
async onRecipeChange() { async onRecipeChange() {
this.selectedSubtaskId = ''; this.selectedSubtaskId = '';
this.loadedSubtaskId = ''; // #100
this.capabilityAll = []; // #100
this.selectedRunId = ''; // #58 this.selectedRunId = ''; // #58
this.runs = []; this.runs = [];
this.subtasks = []; this.subtasks = [];
@@ -459,10 +497,11 @@ function spcDashboard() {
if (this.selectedRunId) params.set('production_run_id', this.selectedRunId); // #58 if (this.selectedRunId) params.set('production_run_id', this.selectedRunId); // #58
if (this.dateFrom) params.set('date_from', new Date(this.dateFrom + 'T00:00:00').toISOString()); 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()); if (this.dateTo) params.set('date_to', new Date(this.dateTo + 'T23:59:59.999').toISOString());
const requestedSubtaskId = this.selectedSubtaskId; // #100 punto richiesto da questa chiamata
try { try {
// Parallel fetch of all endpoints // Parallel fetch of all endpoints
const [summaryResp, capabilityResp, controlResp, histogramResp] = await Promise.allSettled([ const [summaryResp, capabilityResp, controlResp, histogramResp, allResp] = await Promise.allSettled([
fetch('/statistics/api/summary?' + params.toString()).then(r => r.json()), fetch('/statistics/api/summary?' + params.toString()).then(r => r.json()),
this.selectedSubtaskId this.selectedSubtaskId
? fetch('/statistics/api/capability?' + params.toString()).then(r => r.json()) ? fetch('/statistics/api/capability?' + params.toString()).then(r => r.json())
@@ -473,6 +512,17 @@ function spcDashboard() {
this.selectedSubtaskId this.selectedSubtaskId
? fetch('/statistics/api/histogram?' + params.toString()).then(r => r.json()) ? fetch('/statistics/api/histogram?' + params.toString()).then(r => r.json())
: Promise.resolve(null), : Promise.resolve(null),
// #100: senza punto scelto, capability di ogni punto della ricetta
requestedSubtaskId
? Promise.resolve([])
: Promise.all(this.subtasks.map(st => {
const p = new URLSearchParams(params.toString());
p.set('subtask_id', st.id);
return fetch('/statistics/api/capability?' + p.toString())
.then(r => r.json())
.then(d => ({ subtask: st, data: (d && !d.error) ? d : null }))
.catch(() => ({ subtask: st, data: null }));
})),
]); ]);
// Summary (always available) // Summary (always available)
@@ -505,6 +555,9 @@ function spcDashboard() {
this.histogram = null; this.histogram = null;
} }
// #100: lista per-punto solo con «Tutti i punti»; con un punto resta il flusso di prima
this.capabilityAll = (allResp.status === 'fulfilled' && Array.isArray(allResp.value)) ? allResp.value : [];
this.loadedSubtaskId = requestedSubtaskId;
this.hasData = true; this.hasData = true;
// Render charts after DOM update // Render charts after DOM update
@@ -522,7 +575,7 @@ function spcDashboard() {
renderCharts() { renderCharts() {
// Control chart // Control chart
if (this.controlChart && this.selectedSubtaskId) { if (this.controlChart && this.loadedSubtaskId) { // #100
renderControlChart('control-chart', this.controlChart); renderControlChart('control-chart', this.controlChart);
} else { } else {
const el = document.getElementById('control-chart'); const el = document.getElementById('control-chart');
@@ -530,7 +583,7 @@ function spcDashboard() {
} }
// Histogram // Histogram
if (this.histogram && this.selectedSubtaskId) { if (this.histogram && this.loadedSubtaskId) { // #100
const tol = this.capability ? { const tol = this.capability ? {
utl: this.capability.utl, utl: this.capability.utl,
ltl: this.capability.ltl, ltl: this.capability.ltl,
@@ -167,3 +167,11 @@ class TestRunTaskResultsProxy:
assert 'id="run-task-results"' in html assert 'id="run-task-results"' in html
assert "loadTaskResults" in html assert "loadTaskResults" in html
assert "/statistics/api/runs/" in html assert "/statistics/api/runs/" in html
def test_dashboard_all_points_overview(self, logged_in_client, mock_api_client):
# #100: con «Tutti i punti» la pagina ha tabella per-punto e select che ricarica da solo
mock_api_client.get.return_value = {"items": [], "total": 0, "pages": 1}
html = logged_in_client.get("/statistics/dashboard").get_data(as_text=True)
assert "capabilityAll" in html
assert "loadedSubtaskId" in html
assert '@change="loadData()"' in html
@@ -3485,3 +3485,7 @@ msgstr "Invalid camera address"
#: templates/admin/stations.html blueprints/admin.py (#88) #: templates/admin/stations.html blueprints/admin.py (#88)
msgid "Risposta non valida dal servizio camera" msgid "Risposta non valida dal servizio camera"
msgstr "Invalid response from the camera service" msgstr "Invalid response from the camera service"
#: templates/statistics/dashboard.html (#100)
msgid "Scegli un punto per carta di controllo e istogramma"
msgstr "Choose a point for the control chart and histogram"