diff --git a/app/routes/labels_tape.py b/app/routes/labels_tape.py new file mode 100644 index 0000000..2862c3a --- /dev/null +++ b/app/routes/labels_tape.py @@ -0,0 +1,232 @@ +""" +Tape printing - admin only. Drives the Brother VC-500W over the network. + +Routes (all under /admin/labels/tape): + / choose labels, set tape options, see a live preview + /preview.png rendered preview of one label + /print send the job to the printer over IPP + /download.pdf the same labels as a PDF, for when you'd rather not + /test probe the printer: model, state, accepted formats + +Created by: Jess Rogerson (yelling commands at Claude.AI) +""" + +import logging + +from flask import (Blueprint, abort, current_app, flash, jsonify, redirect, + render_template, request, send_file, url_for) +from flask_login import login_required + +from app import db +from app.models.part_label import PartCategory, PartLabel +from app.models.site_setting import SiteSetting +from app.routes.admin import admin_required +from app.services import ipp_client, tape_printer + +logger = logging.getLogger(__name__) + +tape_bp = Blueprint('tape', __name__, url_prefix='/admin/labels/tape') + + +def printer_config(): + return { + 'host': (SiteSetting.get('tape_printer_host', '') or '').strip(), + 'port': int(SiteSetting.get('tape_printer_port', '631') or 631), + 'path': (SiteSetting.get('tape_printer_path', '/ipp/print') + or '/ipp/print'), + 'format': (SiteSetting.get('tape_printer_format', '') or '').strip(), + 'tape': SiteSetting.get('tape_default', tape_printer.DEFAULT_TAPE), + } + + +def _selected_labels(): + """Resolve the selection from ids= or category=, same as sheet printing.""" + ids = request.values.getlist('ids') + if len(ids) == 1 and ',' in ids[0]: + ids = ids[0].split(',') + ids = [int(v) for v in ids if str(v).strip().isdigit()] + + if ids: + found = {row.id: row for row in + PartLabel.query.filter(PartLabel.id.in_(ids)).all()} + return [found[i] for i in ids if i in found] + + category_id = request.values.get('category', type=int) + if category_id: + category = PartCategory.query.get_or_404(category_id) + return (PartLabel.query + .filter(PartLabel.category_id.in_(category.descendant_ids())) + .order_by(PartLabel.location, PartLabel.name).all()) + return [] + + +@tape_bp.route('/', methods=['GET', 'POST']) +@login_required +@admin_required +def index(): + labels = _selected_labels() + if not labels: + flash('Select some labels to print first.', 'warning') + return redirect(url_for('labels.index')) + + source = request.form if request.method == 'POST' else request.args + options = tape_printer.options_from_form(source) + if not source.get('tape'): + options.tape = printer_config()['tape'] + + return render_template( + 'admin/labels/tape.html', + labels=labels, + ids=','.join(str(l.id) for l in labels), + options=options, + tapes=tape_printer.TAPE_PRESETS, + tape=tape_printer.get_tape(options.tape), + field_options=[ + ('image', 'Part image'), ('name', 'Part name'), + ('dimensions', 'Dimensions'), ('part_number', 'Part number'), + ('alt_numbers', 'Alternate numbers'), ('category', 'Category'), + ('category_code', 'Category code'), ('location', 'Drawer'), + ('colour', 'Colour name'), ('accent', 'Colour stripe'), + ], + printer=printer_config(), + ) + + +@tape_bp.route('/preview.png') +@login_required +@admin_required +def preview(): + label_id = request.args.get('id', type=int) + if not label_id: + abort(400, 'preview.png needs an ?id= of a label.') + label = PartLabel.query.get_or_404(label_id) + options = tape_printer.options_from_form(request.args) + buffer = tape_printer.preview_png( + label, options, current_app.config['UPLOAD_FOLDER']) + return send_file(buffer, mimetype='image/png') + + +@tape_bp.route('/test', methods=['POST']) +@login_required +@admin_required +def test_printer(): + """Ask the printer what it is. Always answers; never 500s on a timeout.""" + host = (request.form.get('host') + or printer_config()['host'] or '').strip() + if not host: + return jsonify({'ok': False, + 'error': 'No printer address set.'}) + try: + port = int(request.form.get('port') or printer_config()['port']) + except ValueError: + port = 631 + path = request.form.get('path') or printer_config()['path'] + + try: + info = ipp_client.get_printer_attributes(host, port, path) + except ipp_client.IPPError as exc: + return jsonify({'ok': False, 'error': str(exc)}) + except Exception as exc: + logger.exception('Printer probe blew up') + return jsonify({'ok': False, + 'error': f'{type(exc).__name__}: {exc}'}) + + if info.get('usable_format'): + info['advice'] = (f'Ready. Jobs will be sent as ' + f'{info["usable_format"]}.') + else: + info['advice'] = ( + 'Reached the printer, but it did not list a format LIM can ' + 'produce (JPEG, PNG or PDF). Use Download PDF and print from ' + 'the Brother app instead.') + return jsonify(info) + + +@tape_bp.route('/print', methods=['POST']) +@login_required +@admin_required +def print_tape(): + labels = _selected_labels() + if not labels: + flash('Nothing selected to print.', 'warning') + return redirect(url_for('labels.index')) + + options = tape_printer.options_from_form(request.form) + config = printer_config() + host = config['host'] + if not host: + flash('No tape printer address configured. Set it in Label ' + 'Settings.', 'warning') + return redirect(url_for('labels.settings')) + + # Work out what the printer will take. A stored choice wins; otherwise + # probe, so a firmware change can't silently break printing. + mime_type = config['format'] + if not mime_type: + try: + info = ipp_client.get_printer_attributes( + host, config['port'], config['path']) + mime_type = info.get('usable_format') + except ipp_client.IPPError as exc: + flash(f'Could not reach the printer: {exc}', 'danger') + return redirect(url_for('tape.index', ids=','.join( + str(l.id) for l in labels))) + if not mime_type: + flash('The printer did not offer a format LIM can produce. Use ' + 'Download PDF instead.', 'warning') + return redirect(url_for('tape.index', + ids=','.join(str(l.id) for l in labels))) + + upload_folder = current_app.config['UPLOAD_FOLDER'] + sent = 0 + failures = [] + + for label in labels: + try: + image = tape_printer.render_label(label, options, upload_folder) + document = tape_printer.image_to_bytes(image, mime_type) + # copies is handled by repeating the job rather than the IPP + # copies attribute: tape printers vary wildly in honouring it. + for _ in range(options.copies): + ipp_client.print_job( + host, document, mime_type, + job_name=f'LIM: {label.display_title}'[:120], + port=config['port'], path=config['path']) + sent += 1 + except ipp_client.IPPError as exc: + failures.append(f'{label.display_title}: {exc}') + except Exception as exc: + logger.exception('Tape print failed for label %s', label.id) + failures.append(f'{label.display_title}: {exc}') + + if sent: + flash(f'Sent {sent} label{"" if sent == 1 else "s"} to the printer.', + 'success') + for problem in failures[:5]: + flash(problem, 'danger') + + return redirect(url_for('tape.index', + ids=','.join(str(l.id) for l in labels), + tape=options.tape)) + + +@tape_bp.route('/download.pdf', methods=['GET', 'POST']) +@login_required +@admin_required +def download_pdf(): + """Every selected label as one PDF, one tape label per page.""" + labels = _selected_labels() + if not labels: + flash('Nothing selected.', 'warning') + return redirect(url_for('labels.index')) + + source = request.form if request.method == 'POST' else request.args + options = tape_printer.options_from_form(source) + buffer = tape_printer.render_batch_pdf( + labels, options, current_app.config['UPLOAD_FOLDER']) + if buffer is None: + flash('Nothing to render.', 'warning') + return redirect(url_for('labels.index')) + + return send_file(buffer, mimetype='application/pdf', as_attachment=True, + download_name='lego-tape-labels.pdf')