From ce53291aa1fe257a545a23fc7859499d2fac889b Mon Sep 17 00:00:00 2001 From: FmaresWGU Date: Wed, 22 Apr 2026 18:44:43 -0700 Subject: [PATCH] Add label preview before full generation - POST /preview endpoint returns base64 first-sheet PDF without storing anything in Supabase (10/hour rate limit, same validation as /upload) - PDFSheetGenerator.generate_preview_sheet() renders to BytesIO instead of disk, calibrates on all labels for accurate bar widths - generate_label_preview() in label_service mirrors upload parsing but skips zip creation, DB writes, and storage upload - New LabelPreview component decodes base64 -> Blob URL, renders PDF in an iframe with loading state, exposes Generate All and Close actions - labelUploader splits into side-by-side layout when preview is active; preview panel is sticky so it stays visible while scrolling the form Co-Authored-By: Claude Sonnet 4.6 --- backend/routes/uploads.py | 72 +++++++++++++- backend/services/label_service.py | 81 +++++++++++++++- backend/utils/PDFSheetGenerator.py | 22 +++++ .../react-ts/src/components/LabelPreview.tsx | 83 ++++++++++++++++ .../react-ts/src/components/labelUploader.tsx | 95 ++++++++++++++++++- 5 files changed, 348 insertions(+), 5 deletions(-) create mode 100644 frontend-vite/react-ts/src/components/LabelPreview.tsx diff --git a/backend/routes/uploads.py b/backend/routes/uploads.py index 7b1a8c4..7c93814 100644 --- a/backend/routes/uploads.py +++ b/backend/routes/uploads.py @@ -1,7 +1,7 @@ import json from flask import current_app, Blueprint, request, jsonify from auth.decorators import verify_session_auth -from services.label_service import process_label_file +from services.label_service import process_label_file, generate_label_preview from utils.security import validate_file_security, rate_limit, sanitize_input import logging @@ -83,3 +83,73 @@ def upload_file(): except Exception as e: logger.exception("Error processing upload for user %s", request.user_id) return jsonify({'error': 'An error occurred processing your file'}), 500 + + +@uploads_bp.route('/preview', methods=['POST']) +@rate_limit("10 per hour") +@verify_session_auth +def preview_labels(): + try: + if 'file' not in request.files: + return jsonify({'error': 'No file provided'}), 400 + + file = request.files['file'] + user_id = request.user_id + + file_size_mb = len(file.read()) / (1024 * 1024) + file.seek(0) + if file_size_mb > 25: + return jsonify({'error': 'File too large. Maximum 25MB.'}), 400 + + is_valid, result = validate_file_security(file) + if not is_valid: + return jsonify({'error': result}), 400 + + secure_filename_result = sanitize_input(result) + + template_id = request.form.get('template_id', current_app.config['DEFAULT_TEMPLATE']) + templates = current_app.config['LABEL_TEMPLATES'] + if template_id not in templates: + valid = ', '.join(templates.keys()) + return jsonify({'error': f"Invalid template_id '{template_id}'. Valid templates: {valid}"}), 400 + + column_mapping = None + column_mapping_raw = request.form.get('column_mapping') + if column_mapping_raw: + try: + mapping = json.loads(column_mapping_raw) + except json.JSONDecodeError: + return jsonify({'error': 'Invalid column_mapping JSON'}), 400 + + barcode_col = mapping.get('barcode_column') + if barcode_col is None or not isinstance(barcode_col, int) or barcode_col < 1 or barcode_col > 100: + return jsonify({'error': 'barcode_column must be an integer between 1 and 100'}), 400 + + column_mapping = { + 'barcode_column': barcode_col - 1, + 'text_columns': [ + {'column': tc['column'] - 1, 'label': str(tc.get('label', f'Column {tc["column"]}'))[:50]} + for tc in mapping.get('text_columns', []) + if isinstance(tc.get('column'), int) and 1 <= tc['column'] <= 100 + ], + 'has_header_row': bool(mapping.get('has_header_row', False)) + } + + barcode_type = request.form.get('barcode_type', 'code128') + if barcode_type not in ('code128', 'qr'): + return jsonify({'error': f"Invalid barcode_type '{barcode_type}'. Valid types: code128, qr"}), 400 + + result = generate_label_preview( + file, user_id, secure_filename_result, + template_id=template_id, + column_mapping=column_mapping, + barcode_type=barcode_type + ) + return jsonify(result) + + except ValueError as e: + logger.warning("Preview validation error for user %s", request.user_id) + return jsonify({'error': str(e)}), 400 + except Exception as e: + logger.exception("Error generating preview for user %s", request.user_id) + return jsonify({'error': 'An error occurred generating the preview'}), 500 diff --git a/backend/services/label_service.py b/backend/services/label_service.py index e8af25b..73afe1d 100644 --- a/backend/services/label_service.py +++ b/backend/services/label_service.py @@ -1,3 +1,4 @@ +import base64 import pandas as pd import uuid import time @@ -152,4 +153,82 @@ def process_label_file(file, user_id, secure_filename, template_id=None, column_ logger.error("Processing failed, cleaning up temp files.") if os.path.exists(filepath): os.remove(filepath) - raise + raise + + +def generate_label_preview(file, user_id, secure_filename, template_id=None, column_mapping=None, barcode_type='code128'): + logger.info("Starting generate_label_preview for user %s", user_id) + + if not column_mapping: + column_mapping = { + 'barcode_column': 1, + 'text_columns': [ + {'column': 0, 'label': 'Location'}, + {'column': 3, 'label': 'Unit'} + ], + 'has_header_row': False + } + + ext = os.path.splitext(secure_filename)[1] + saved_name = f"{uuid.uuid4().hex}{ext}" + filepath = os.path.join(current_app.config['UPLOAD_FOLDER'], saved_name) + file.save(filepath) + + try: + header_arg = 0 if column_mapping.get('has_header_row') else None + if secure_filename.endswith(('.xlsx', '.xls')): + df = pd.read_excel(filepath, header=header_arg) + else: + df = pd.read_csv(filepath, header=header_arg) + + barcode_col = column_mapping['barcode_column'] + text_cols = column_mapping.get('text_columns', []) + + labels = [] + for _, row in df.iterrows(): + if barcode_col >= len(row): + continue + barcode_value = str(row.iloc[barcode_col]).strip()[:100] + if not barcode_value or barcode_value == 'nan': + continue + text_lines = [] + for tc in text_cols: + col_idx = tc['column'] + if col_idx < len(row): + val = str(row.iloc[col_idx]).strip()[:100] + if val and val != 'nan': + text_lines.append((tc['label'], val)) + labels.append((barcode_value, text_lines)) + + if not labels: + raise ValueError('No valid data found in file') + + label_count = len(labels) + if label_count > current_app.config['MAX_LABELS']: + raise ValueError(f'Too many labels. Maximum: {current_app.config["MAX_LABELS"]}') + + template = None + if template_id: + template = current_app.config['LABEL_TEMPLATES'][template_id] + sheet_gen = PDFSheetGenerator(current_app.config['SHEET_FOLDER'], template) + pdf_bytes = sheet_gen.generate_preview_sheet(labels, barcode_type=barcode_type) + + os.remove(filepath) + + labels_per_sheet = sheet_gen.rows * sheet_gen.columns + total_sheets = (label_count + labels_per_sheet - 1) // labels_per_sheet + + logger.info("Preview generated for user %s: %d labels, %d sheets", user_id, label_count, total_sheets) + + return { + 'preview_pdf': base64.b64encode(pdf_bytes).decode('utf-8'), + 'label_count': label_count, + 'total_sheets': total_sheets, + 'labels_on_first_sheet': min(label_count, labels_per_sheet), + } + + except Exception as e: + logger.error("Preview generation failed, cleaning up temp files.") + if os.path.exists(filepath): + os.remove(filepath) + raise diff --git a/backend/utils/PDFSheetGenerator.py b/backend/utils/PDFSheetGenerator.py index d84cff6..43e018a 100644 --- a/backend/utils/PDFSheetGenerator.py +++ b/backend/utils/PDFSheetGenerator.py @@ -1,3 +1,4 @@ +import io from reportlab.pdfgen import canvas from reportlab.lib.pagesizes import LETTER from reportlab.lib.units import inch @@ -74,6 +75,27 @@ def _draw_debug_grid(self, c): y = LETTER[1] - self.margin_top - (row + 1) * self.label_height - row * self.y_gap c.rect(x, y, self.label_width, self.label_height) + def generate_preview_sheet(self, labels, barcode_type='code128'): + labels_per_sheet = self.rows * self.columns + preview_labels = labels[:labels_per_sheet] + + barcode_gen = BarcodeGenerator(self.template, barcode_type=barcode_type) + barcode_gen.calibrate([bv for bv, _ in labels]) + + buf = io.BytesIO() + c = canvas.Canvas(buf, pagesize=LETTER) + + for i, (barcode_value, text_lines) in enumerate(preview_labels): + row = i // self.columns + col = i % self.columns + x = self.margin_left + col * (self.label_width + self.x_gap) + y = LETTER[1] - self.margin_top - (row + 1) * self.label_height - row * self.y_gap + barcode_gen.draw_label_to_canvas(c, x, y, barcode_value, text_lines) + + c.save() + buf.seek(0) + return buf.read() + def _get_default_template(self): from config.settings import Config return Config.LABEL_TEMPLATES[Config.DEFAULT_TEMPLATE] \ No newline at end of file diff --git a/frontend-vite/react-ts/src/components/LabelPreview.tsx b/frontend-vite/react-ts/src/components/LabelPreview.tsx new file mode 100644 index 0000000..59977e4 --- /dev/null +++ b/frontend-vite/react-ts/src/components/LabelPreview.tsx @@ -0,0 +1,83 @@ +import { useEffect, useRef, useState } from 'react'; +import { X } from 'lucide-react'; + +interface LabelPreviewProps { + pdfBase64: string; + labelCount: number; + totalSheets: number; + labelsOnFirstSheet: number; + onGenerateAll: () => void; + onClose: () => void; +} + +function LabelPreview({ pdfBase64, labelCount, totalSheets, labelsOnFirstSheet, onGenerateAll, onClose }: LabelPreviewProps) { + const [pdfUrl, setPdfUrl] = useState(null); + const [loaded, setLoaded] = useState(false); + const activeUrlRef = useRef(null); + + useEffect(() => { + setLoaded(false); + const bytes = Uint8Array.from(atob(pdfBase64), c => c.charCodeAt(0)); + const blob = new Blob([bytes], { type: 'application/pdf' }); + const url = URL.createObjectURL(blob); + activeUrlRef.current = url; + setPdfUrl(url); + + return () => { + URL.revokeObjectURL(url); + activeUrlRef.current = null; + }; + }, [pdfBase64]); + + const sheetLabel = totalSheets === 1 ? 'sheet' : 'sheets'; + const labelLabel = labelCount === 1 ? 'label' : 'labels'; + + return ( +
+ {/* Header */} +
+
+

First Sheet Preview

+

+ {labelsOnFirstSheet} of {labelCount} {labelLabel} ยท {totalSheets} {sheetLabel} total +

+
+
+ + +
+
+ + {/* PDF Viewer */} +
+ {!loaded && ( +
+
+
+ )} + {pdfUrl && ( +