fix: file display, persistence, PDF support and save error handling

- Add file proxy route in maker blueprint (X-API-Key auth for browser requests)
- Persist file_path/annotations_json to DB via RecipeCreate/RecipeUpdate schemas
- Fix canvas sizing using grandparent container instead of Fabric.js wrapper div
- Defer canvas init with requestAnimationFrame for x-show timing
- Add PDF.js support in annotation-editor and annotation-viewer
- Fix annotations_json double-serialization (parse string to object before send)
- Handle FastAPI 422 validation error arrays in api_client and JS error display
- Update template URLs to use /maker/api/files/ proxy path

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Adriano
2026-02-07 22:04:45 +01:00
parent d262ef68af
commit b075115cef
8 changed files with 315 additions and 65 deletions
+23 -1
View File
@@ -1,8 +1,11 @@
"""Maker blueprint - recipe creation and editing.""" """Maker blueprint - recipe creation and editing."""
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for import requests as http_requests
from flask import Blueprint, Response, flash, jsonify, redirect, render_template, request, session, url_for
from flask_babel import gettext as _ from flask_babel import gettext as _
from blueprints.auth import login_required, role_required from blueprints.auth import login_required, role_required
from config import Config
from services.api_client import api_client from services.api_client import api_client
maker_bp = Blueprint("maker", __name__, url_prefix="/maker") maker_bp = Blueprint("maker", __name__, url_prefix="/maker")
@@ -349,6 +352,25 @@ def api_upload_file():
return jsonify(resp), 201 return jsonify(resp), 201
@maker_bp.route("/api/files/<path:file_path>", methods=["GET"])
@login_required
def api_get_file(file_path: str):
"""Proxy: Serve file from API server (browser can't send X-API-Key)."""
api_key = session.get("api_key", "")
base_url = Config.API_SERVER_URL.rstrip("/")
resp = http_requests.get(
f"{base_url}/api/files/{file_path}",
headers={"X-API-Key": api_key},
timeout=30,
)
if resp.status_code != 200:
return Response(resp.text, status=resp.status_code)
return Response(
resp.content,
content_type=resp.headers.get("content-type", "application/octet-stream"),
)
@maker_bp.route("/api/files/<path:file_path>", methods=["DELETE"]) @maker_bp.route("/api/files/<path:file_path>", methods=["DELETE"])
@login_required @login_required
@role_required("Maker") @role_required("Maker")
+5
View File
@@ -40,6 +40,11 @@ class APIClient:
try: try:
error_body = response.json() error_body = response.json()
detail = error_body.get("detail", str(error_body)) detail = error_body.get("detail", str(error_body))
# FastAPI 422 returns detail as a list of validation errors
if isinstance(detail, list):
detail = "; ".join(
e.get("msg", str(e)) for e in detail if isinstance(e, dict)
) or str(detail)
except Exception: except Exception:
detail = response.text or f"HTTP {response.status_code}" detail = response.text or f"HTTP {response.status_code}"
+132 -27
View File
@@ -90,7 +90,6 @@ function annotationEditor() {
this.setupEvents(); this.setupEvents();
this.setupKeyboard(); this.setupKeyboard();
this.resizeCanvas();
this._debouncedResize = debounce(this.resizeCanvas.bind(this), 200); this._debouncedResize = debounce(this.resizeCanvas.bind(this), 200);
window.addEventListener('resize', this._debouncedResize); window.addEventListener('resize', this._debouncedResize);
@@ -126,26 +125,63 @@ function annotationEditor() {
this._onImageLoaded = function (e) { this._onImageLoaded = function (e) {
if (e.detail && e.detail.path) { if (e.detail && e.detail.path) {
// Wait for x-show transition to reveal the canvas // Wait for x-show transition to reveal the canvas and DOM layout
setTimeout(function () { self._deferredLoad(
self.resizeCanvas(); '/maker/api/files/' + e.detail.path,
self.loadBackgroundImage('/api/files/' + e.detail.path); e.detail.annotations || null
if (e.detail.annotations) { );
self.loadAnnotationsJson(e.detail.annotations);
}
}, 150);
} }
}; };
window.addEventListener('image-loaded', this._onImageLoaded); window.addEventListener('image-loaded', this._onImageLoaded);
// ---- Initial load: check parent scope for existing file ---- // ---- Initial load: check parent scope for existing file ----
// Deferred to requestAnimationFrame so the browser has laid out the
// container (x-cloak removed, x-show applied) before we read dimensions.
var filePath = this.currentFilePath; // from parent recipeEditor scope var filePath = this.currentFilePath; // from parent recipeEditor scope
if (filePath) { if (filePath) {
var annoJson = this.annotationsJson; // from parent recipeEditor scope this._deferredLoad(
this.loadBackgroundImage('/api/files/' + filePath); '/maker/api/files/' + filePath,
if (annoJson) { this.annotationsJson || null
this.loadAnnotationsJson(annoJson); );
} else {
// No file yet — still resize so canvas is ready when image is uploaded
requestAnimationFrame(function () {
self.resizeCanvas();
});
} }
},
/**
* Deferred load: waits for the DOM to be fully laid out (via
* requestAnimationFrame) then resizes the canvas and loads the image.
* Falls back to a second attempt after 300ms if the container still
* has zero width (e.g. Alpine x-show transition not yet complete).
*/
_deferredLoad(imageUrl, annotationsJson) {
var self = this;
requestAnimationFrame(function () {
self.resizeCanvas();
// If container is still hidden / zero-width, retry after transition
if (!self.canvas.width) {
setTimeout(function () {
self.resizeCanvas();
self._loadImageAndAnnotations(imageUrl, annotationsJson);
}, 350);
} else {
self._loadImageAndAnnotations(imageUrl, annotationsJson);
}
});
},
/**
* Internal: load background image and optionally annotations.
*/
_loadImageAndAnnotations(imageUrl, annotationsJson) {
this.loadBackgroundImage(imageUrl);
if (annotationsJson) {
this.loadAnnotationsJson(annotationsJson);
} }
}, },
@@ -154,32 +190,93 @@ function annotationEditor() {
// ---------------------------------------------------------------- // ----------------------------------------------------------------
/** /**
* Load an image as the canvas background. * Load an image or PDF as the canvas background.
* Scales the image to fit within the canvas while maintaining aspect ratio * For PDFs, renders the first page via PDF.js then uses it as background.
* and never exceeding the original size (scale <= 1). * Scales to fit within the canvas while maintaining aspect ratio.
* *
* @param {string} imageUrl - URL of the image to load * @param {string} imageUrl - URL of the image or PDF to load
*/ */
loadBackgroundImage(imageUrl) { loadBackgroundImage(imageUrl) {
if (!this.canvas) return; if (!this.canvas) return;
if (!this.canvas.width) {
this.resizeCanvas();
}
var isPdf = imageUrl.toLowerCase().replace(/\?.*$/, '').endsWith('.pdf');
if (isPdf && typeof pdfjsLib !== 'undefined') {
this._loadPdfBackground(imageUrl);
} else {
this._loadImageBackground(imageUrl);
}
},
/**
* Render first page of a PDF via PDF.js and set as canvas background.
*/
_loadPdfBackground(pdfUrl) {
var self = this;
var loadingTask = pdfjsLib.getDocument(pdfUrl);
loadingTask.promise.then(function (pdf) {
return pdf.getPage(1);
}).then(function (page) {
// Render at 2x scale for crisp display
var scale = 2;
var viewport = page.getViewport({ scale: scale });
// Off-screen canvas for rendering
var offCanvas = document.createElement('canvas');
offCanvas.width = viewport.width;
offCanvas.height = viewport.height;
var ctx = offCanvas.getContext('2d');
var renderContext = {
canvasContext: ctx,
viewport: viewport,
};
page.render(renderContext).promise.then(function () {
// Convert rendered PDF page to data URL, then load as Fabric image
var dataUrl = offCanvas.toDataURL('image/png');
self._loadImageBackground(dataUrl);
});
}).catch(function (err) {
console.error('AnnotationEditor: failed to load PDF:', err);
});
},
/**
* Load a raster image URL as the canvas background.
*/
_loadImageBackground(imageUrl) {
var self = this;
fabric.Image.fromURL( fabric.Image.fromURL(
imageUrl, imageUrl,
function (img) { function (img) {
if (!img || !img.width || !img.height) { if (!img || !img.width || !img.height) {
console.error('AnnotationEditor: failed to load background image'); console.error('AnnotationEditor: failed to load background image from', imageUrl);
return; return;
} }
if (!self.canvas.width) {
self.resizeCanvas();
}
var cw = self.canvas.width || 800;
var ch = self.canvas.height || 480;
var scale = Math.min( var scale = Math.min(
this.canvas.width / img.width, cw / img.width,
this.canvas.height / img.height, ch / img.height,
1 // never upscale 1 // never upscale
); );
this.canvas.setBackgroundImage( self.canvas.setBackgroundImage(
img, img,
this.canvas.renderAll.bind(this.canvas), self.canvas.renderAll.bind(self.canvas),
{ {
scaleX: scale, scaleX: scale,
scaleY: scale, scaleY: scale,
@@ -188,9 +285,8 @@ function annotationEditor() {
} }
); );
this.imageLoaded = true; self.imageLoaded = true;
}.bind(this), }
{ crossOrigin: 'anonymous' }
); );
}, },
@@ -670,16 +766,25 @@ function annotationEditor() {
/** /**
* Resize the Fabric.js canvas to fill its parent container. * Resize the Fabric.js canvas to fill its parent container.
* Maintains a minimum 5:3 aspect ratio (height = width * 0.6). * Maintains a minimum 5:3 aspect ratio (height = width * 0.6).
*
* Note: Fabric.js wraps the <canvas> in its own div, so parentElement
* is the Fabric wrapper (which has a fixed pixel width from creation).
* We need the .canvas-container grandparent for the real layout width.
*/ */
resizeCanvas() { resizeCanvas() {
var container = this.canvasEl ? this.canvasEl.parentElement : null; if (!this.canvasEl || !this.canvas) return;
if (!container || !this.canvas) return;
// Fabric wrapper → .canvas-container (or whatever holds the canvas)
var fabricWrapper = this.canvasEl.parentElement;
var container = fabricWrapper ? fabricWrapper.parentElement : null;
if (!container) return;
var width = container.clientWidth; var width = container.clientWidth;
var height = Math.max(400, Math.round(width * 0.6)); var height = Math.max(400, Math.round(width * 0.6));
this.canvas.setWidth(width); this.canvas.setWidth(width);
this.canvas.setHeight(height); this.canvas.setHeight(height);
this.canvas.calcOffset();
this.canvas.renderAll(); this.canvas.renderAll();
}, },
+62 -19
View File
@@ -80,13 +80,65 @@ function annotationViewer() {
}, },
/** /**
* Carica e renderizza l'immagine con scaling * Carica e renderizza l'immagine (o la prima pagina PDF) con scaling
*/ */
loadImage() { loadImage() {
var isPdf = this.imageUrl.toLowerCase().replace(/\?.*$/, '').endsWith('.pdf');
if (isPdf && typeof pdfjsLib !== 'undefined') {
this._loadPdf();
} else {
this._loadRasterImage();
}
},
/**
* Render first page of a PDF via PDF.js
*/
_loadPdf() {
const self = this;
pdfjsLib.getDocument(this.imageUrl).promise.then(function (pdf) {
return pdf.getPage(1);
}).then(function (page) {
const pdfScale = 2; // render at 2x for clarity
const viewport = page.getViewport({ scale: pdfScale });
const offCanvas = document.createElement('canvas');
offCanvas.width = viewport.width;
offCanvas.height = viewport.height;
const offCtx = offCanvas.getContext('2d');
page.render({ canvasContext: offCtx, viewport: viewport }).promise.then(function () {
// Use rendered PDF page as if it were an image
self._drawImageOnCanvas(offCanvas, viewport.width, viewport.height);
});
}).catch(function (err) {
console.error('AnnotationViewer: failed to load PDF:', err);
});
},
/**
* Load a raster image (PNG, JPG, etc.)
*/
_loadRasterImage() {
const self = this;
const img = new Image(); const img = new Image();
img.onload = () => { img.onload = function () {
// Calculate scale to fit container self._drawImageOnCanvas(img, img.width, img.height);
};
img.onerror = function () {
console.error('AnnotationViewer: failed to load image:', self.imageUrl);
};
img.src = this.imageUrl;
},
/**
* Draw an image source (Image or Canvas) scaled to fit the container
*/
_drawImageOnCanvas(source, srcWidth, srcHeight) {
const container = this.canvas.parentElement; const container = this.canvas.parentElement;
if (!container) { if (!container) {
console.error('AnnotationViewer: parent container not found'); console.error('AnnotationViewer: parent container not found');
@@ -94,31 +146,22 @@ function annotationViewer() {
} }
const containerWidth = container.clientWidth; const containerWidth = container.clientWidth;
const containerHeight = container.clientHeight || 500; // fallback height const containerHeight = container.clientHeight || 500;
this.scale = Math.min( this.scale = Math.min(
containerWidth / img.width, containerWidth / srcWidth,
containerHeight / img.height, containerHeight / srcHeight,
1 // non ingrandire oltre dimensione originale 1
); );
this.canvas.width = img.width * this.scale; this.canvas.width = srcWidth * this.scale;
this.canvas.height = img.height * this.scale; this.canvas.height = srcHeight * this.scale;
// Draw image
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.ctx.drawImage(img, 0, 0, this.canvas.width, this.canvas.height); this.ctx.drawImage(source, 0, 0, this.canvas.width, this.canvas.height);
this.imageLoaded = true; this.imageLoaded = true;
// Draw annotations on top
this.drawAnnotations(); this.drawAnnotations();
};
img.onerror = () => {
console.error('AnnotationViewer: failed to load image:', this.imageUrl);
};
img.src = this.imageUrl;
}, },
/** /**
+11 -4
View File
@@ -580,7 +580,11 @@
{% block extra_js %} {% block extra_js %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/5.3.1/fabric.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/5.3.1/fabric.min.js"></script>
<script src="{{ url_for('static', filename='js/annotation-editor.js') }}"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script>
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
</script>
<script src="{{ url_for('static', filename='js/annotation-editor.js') }}?v=5"></script>
<script> <script>
function recipeEditor() { function recipeEditor() {
return { return {
@@ -649,9 +653,11 @@ function recipeEditor() {
payload.change_notes = this.changeNotes.trim(); payload.change_notes = this.changeNotes.trim();
} }
// Include annotations if available // Include annotations if available (may be a JSON string or an object)
if (this.annotationsJson) { if (this.annotationsJson) {
payload.annotations_json = this.annotationsJson; payload.annotations_json = (typeof this.annotationsJson === 'string')
? JSON.parse(this.annotationsJson)
: this.annotationsJson;
} }
// Include file path if uploaded // Include file path if uploaded
@@ -677,7 +683,8 @@ function recipeEditor() {
const data = await resp.json(); const data = await resp.json();
if (data.error) { if (data.error) {
this.errorMessage = data.detail || data.error || '{{ _("Errore nel salvataggio") }}'; var detail = data.detail || data.error || '{{ _("Errore nel salvataggio") }}';
this.errorMessage = (typeof detail === 'string') ? detail : JSON.stringify(detail);
this.saving = false; this.saving = false;
return; return;
} }
+7 -3
View File
@@ -207,7 +207,7 @@
<div class="annotation-container" <div class="annotation-container"
x-data="annotationViewer()" x-data="annotationViewer()"
x-init=" x-init="
imageUrl = '/api/files/{{ task.file_path }}'; imageUrl = '/maker/api/files/{{ task.file_path }}';
annotations = {{ task.annotations_json|default('null')|tojson }}; annotations = {{ task.annotations_json|default('null')|tojson }};
$nextTick(() => init()); $nextTick(() => init());
"> ">
@@ -225,7 +225,7 @@
<p class="text-sm font-medium text-[var(--text-primary)]">{{ _('Documento PDF allegato') }}</p> <p class="text-sm font-medium text-[var(--text-primary)]">{{ _('Documento PDF allegato') }}</p>
<p class="text-xs text-[var(--text-muted)]">{{ task.file_path }}</p> <p class="text-xs text-[var(--text-muted)]">{{ task.file_path }}</p>
</div> </div>
<a href="/api/files/{{ task.file_path }}" <a href="/maker/api/files/{{ task.file_path }}"
target="_blank" target="_blank"
class="btn btn-secondary text-xs gap-1.5"> class="btn btn-secondary text-xs gap-1.5">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
@@ -424,7 +424,11 @@
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script>
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
</script>
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=2"></script>
<script> <script>
/** /**
* Recipe Preview - Minimal Alpine.js component * Recipe Preview - Minimal Alpine.js component
+8
View File
@@ -13,6 +13,10 @@ class RecipeCreate(BaseModel):
code: str = Field(..., min_length=1, max_length=100) code: str = Field(..., min_length=1, max_length=100)
name: str = Field(..., min_length=1, max_length=255) name: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None description: Optional[str] = None
# Optional task-level fields for the initial technical drawing
file_path: Optional[str] = Field(None, max_length=500)
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
annotations_json: Optional[dict] = None
class RecipeUpdate(BaseModel): class RecipeUpdate(BaseModel):
@@ -20,6 +24,10 @@ class RecipeUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=255) name: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None description: Optional[str] = None
change_notes: Optional[str] = None change_notes: Optional[str] = None
# Task-level fields: saved to the first task of the new version
file_path: Optional[str] = Field(None, max_length=500)
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
annotations_json: Optional[dict] = None
class RecipeVersionResponse(BaseModel): class RecipeVersionResponse(BaseModel):
+56
View File
@@ -147,6 +147,22 @@ async def create_recipe(
db.add(version) db.add(version)
await db.flush() await db.flush()
# Create initial task with technical drawing if file was uploaded
if data.file_path:
initial_task = RecipeTask(
version_id=version.id,
order_index=0,
title="Technical Drawing",
file_path=data.file_path,
file_type=data.file_type or (
"pdf" if data.file_path.lower().endswith(".pdf")
else "image"
),
annotations_json=data.annotations_json,
)
db.add(initial_task)
await db.flush()
await _write_audit( await _write_audit(
db, db,
recipe_id=recipe.id, recipe_id=recipe.id,
@@ -223,6 +239,46 @@ async def create_new_version(
# Deep-copy tasks + subtasks # Deep-copy tasks + subtasks
await _copy_tasks_to_version(db, source_version=current, target_version=new_version) await _copy_tasks_to_version(db, source_version=current, target_version=new_version)
# Apply task-level updates (file_path, annotations_json) to the first task
if data.file_path is not None or data.annotations_json is not None:
# Reload new version's tasks
result_tasks = await db.execute(
select(RecipeTask)
.where(RecipeTask.version_id == new_version.id)
.order_by(RecipeTask.order_index)
)
tasks = list(result_tasks.scalars().all())
if tasks:
first_task = tasks[0]
if data.file_path is not None:
first_task.file_path = data.file_path
# Auto-detect file_type from extension
if data.file_type:
first_task.file_type = data.file_type
elif data.file_path.lower().endswith(".pdf"):
first_task.file_type = "pdf"
else:
first_task.file_type = "image"
if data.annotations_json is not None:
first_task.annotations_json = data.annotations_json
else:
# No tasks yet — create a default task to hold the drawing
default_task = RecipeTask(
version_id=new_version.id,
order_index=0,
title="Technical Drawing",
file_path=data.file_path,
file_type=data.file_type or (
"pdf" if data.file_path and data.file_path.lower().endswith(".pdf")
else "image"
),
annotations_json=data.annotations_json,
)
db.add(default_task)
await db.flush()
# Apply header updates # Apply header updates
update_fields: dict = {} update_fields: dict = {}
if data.name is not None: if data.name is not None: