Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 71 additions & 1 deletion backend/routes/uploads.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
81 changes: 80 additions & 1 deletion backend/services/label_service.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import base64
import pandas as pd
import uuid
import time
Expand Down Expand Up @@ -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
22 changes: 22 additions & 0 deletions backend/utils/PDFSheetGenerator.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import io
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import LETTER
from reportlab.lib.units import inch
Expand Down Expand Up @@ -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]
83 changes: 83 additions & 0 deletions frontend-vite/react-ts/src/components/LabelPreview.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);
const [loaded, setLoaded] = useState(false);
const activeUrlRef = useRef<string | null>(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 (
<div className="flex flex-col bg-card border border-border rounded-[16px] overflow-hidden" style={{ height: 'calc(100vh - 6rem)' }}>
{/* Header */}
<div className="px-6 py-4 border-b border-border flex items-center justify-between flex-shrink-0">
<div>
<h2 className="font-heading text-sm font-semibold text-foreground">First Sheet Preview</h2>
<p className="text-xs text-muted-foreground mt-0.5">
{labelsOnFirstSheet} of {labelCount} {labelLabel} · {totalSheets} {sheetLabel} total
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={onGenerateAll}
className="h-9 px-5 bg-primary text-primary-foreground font-heading text-sm font-medium rounded-full hover:bg-primary/90 transition-colors cursor-pointer"
>
Generate All
</button>
<button
onClick={onClose}
className="p-2 rounded-[10px] text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors cursor-pointer bg-transparent border-none"
title="Close preview"
>
<X className="w-4 h-4" />
</button>
</div>
</div>

{/* PDF Viewer */}
<div className="flex-1 bg-[#3A3A3A] flex items-center justify-center p-6 relative overflow-hidden">
{!loaded && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-white" />
</div>
)}
{pdfUrl && (
<iframe
src={pdfUrl}
title="Label Preview"
className={`w-full h-full rounded shadow-2xl transition-opacity duration-200 ${loaded ? 'opacity-100' : 'opacity-0'}`}
style={{ border: 'none' }}
onLoad={() => setLoaded(true)}
/>
)}
</div>
</div>
);
}

export default LabelPreview;
Loading
Loading