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:
@@ -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")
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -80,47 +80,90 @@ 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);
|
||||||
const container = this.canvas.parentElement;
|
|
||||||
if (!container) {
|
|
||||||
console.error('AnnotationViewer: parent container not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const containerWidth = container.clientWidth;
|
|
||||||
const containerHeight = container.clientHeight || 500; // fallback height
|
|
||||||
|
|
||||||
this.scale = Math.min(
|
|
||||||
containerWidth / img.width,
|
|
||||||
containerHeight / img.height,
|
|
||||||
1 // non ingrandire oltre dimensione originale
|
|
||||||
);
|
|
||||||
|
|
||||||
this.canvas.width = img.width * this.scale;
|
|
||||||
this.canvas.height = img.height * this.scale;
|
|
||||||
|
|
||||||
// Draw image
|
|
||||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
||||||
this.ctx.drawImage(img, 0, 0, this.canvas.width, this.canvas.height);
|
|
||||||
this.imageLoaded = true;
|
|
||||||
|
|
||||||
// Draw annotations on top
|
|
||||||
this.drawAnnotations();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
img.onerror = () => {
|
img.onerror = function () {
|
||||||
console.error('AnnotationViewer: failed to load image:', this.imageUrl);
|
console.error('AnnotationViewer: failed to load image:', self.imageUrl);
|
||||||
};
|
};
|
||||||
|
|
||||||
img.src = this.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;
|
||||||
|
if (!container) {
|
||||||
|
console.error('AnnotationViewer: parent container not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerWidth = container.clientWidth;
|
||||||
|
const containerHeight = container.clientHeight || 500;
|
||||||
|
|
||||||
|
this.scale = Math.min(
|
||||||
|
containerWidth / srcWidth,
|
||||||
|
containerHeight / srcHeight,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
this.canvas.width = srcWidth * this.scale;
|
||||||
|
this.canvas.height = srcHeight * this.scale;
|
||||||
|
|
||||||
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
this.ctx.drawImage(source, 0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
this.imageLoaded = true;
|
||||||
|
|
||||||
|
this.drawAnnotations();
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Disegna tutti i markers sulle annotazioni
|
* Disegna tutti i markers sulle annotazioni
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user