Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 351153e78a | |||
| 842ceca007 | |||
| b78f2422d8 | |||
| 444dc0a6d1 |
@@ -40,14 +40,22 @@ _IMAGE_EXTENSIONS = {
|
||||
"image/png": "png",
|
||||
"image/gif": "gif",
|
||||
"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
|
||||
# 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
|
||||
# TIFF are included even though `_IMAGE_EXTENSIONS` does not accept them,
|
||||
# because PIL - and so the worker - can decode both; this check only refuses
|
||||
# what is not a real image at all, it does not narrow the allow-list above.
|
||||
# still be caught before it reaches the worker as a bare 500 (#15). TIFF is
|
||||
# included even though the allow-list does not accept it (PIL, and so the
|
||||
# worker, can decode it; browsers do not show it in the result); BMP is now
|
||||
# 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, ...], ...] = (
|
||||
(b"\x89PNG\r\n\x1a\n",), # PNG
|
||||
(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
|
||||
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(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File type {image.content_type} not allowed. Must be an image.",
|
||||
|
||||
@@ -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 -->
|
||||
<div>
|
||||
<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"
|
||||
@change="loadData()"
|
||||
: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">
|
||||
<option value="">{{ _("Tutti i punti") }}</option>
|
||||
@@ -287,20 +289,52 @@
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<div id="control-chart" style="min-height: 350px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<div id="histogram-chart" style="min-height: 350px;"></div>
|
||||
</div>
|
||||
@@ -346,6 +380,8 @@ function spcDashboard() {
|
||||
subtasks: [],
|
||||
|
||||
// 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,
|
||||
capability: null,
|
||||
controlChart: null,
|
||||
@@ -408,6 +444,8 @@ function spcDashboard() {
|
||||
|
||||
async onRecipeChange() {
|
||||
this.selectedSubtaskId = '';
|
||||
this.loadedSubtaskId = ''; // #100
|
||||
this.capabilityAll = []; // #100
|
||||
this.selectedRunId = ''; // #58
|
||||
this.runs = [];
|
||||
this.subtasks = [];
|
||||
@@ -459,10 +497,11 @@ function spcDashboard() {
|
||||
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.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 {
|
||||
// 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()),
|
||||
this.selectedSubtaskId
|
||||
? fetch('/statistics/api/capability?' + params.toString()).then(r => r.json())
|
||||
@@ -473,6 +512,17 @@ function spcDashboard() {
|
||||
this.selectedSubtaskId
|
||||
? fetch('/statistics/api/histogram?' + params.toString()).then(r => r.json())
|
||||
: 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)
|
||||
@@ -505,6 +555,9 @@ function spcDashboard() {
|
||||
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;
|
||||
|
||||
// Render charts after DOM update
|
||||
@@ -522,7 +575,7 @@ function spcDashboard() {
|
||||
|
||||
renderCharts() {
|
||||
// Control chart
|
||||
if (this.controlChart && this.selectedSubtaskId) {
|
||||
if (this.controlChart && this.loadedSubtaskId) { // #100
|
||||
renderControlChart('control-chart', this.controlChart);
|
||||
} else {
|
||||
const el = document.getElementById('control-chart');
|
||||
@@ -530,7 +583,7 @@ function spcDashboard() {
|
||||
}
|
||||
|
||||
// Histogram
|
||||
if (this.histogram && this.selectedSubtaskId) {
|
||||
if (this.histogram && this.loadedSubtaskId) { // #100
|
||||
const tol = this.capability ? {
|
||||
utl: this.capability.utl,
|
||||
ltl: this.capability.ltl,
|
||||
|
||||
@@ -167,3 +167,11 @@ class TestRunTaskResultsProxy:
|
||||
assert 'id="run-task-results"' in html
|
||||
assert "loadTaskResults" 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)
|
||||
msgid "Risposta non valida dal servizio camera"
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user