Files
lego-instructions-manager/app/routes/labels_tape.py
T
jessikitty 9406de237d Keep tape options across the print redirect, honour saved rotation
Printing redirected back to a bare tape page, so every option - rotation
most visibly - reverted to the default the moment you pressed Print.
Carry the chosen options through the redirect instead.

Also read tape_rotate_default so an untick can be made permanent in
Settings; it only applies to a fresh arrival, never over an explicit
choice on the page.
2026-10-12 22:11:09 +11:00

267 lines
9.8 KiB
Python

"""
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),
'rotate': SiteSetting.get_bool('tape_rotate_default', True),
}
def _options_query(options, labels):
"""
The chosen options as query args, so a redirect back to the tape page
comes back exactly as the user left it. Without this, printing bounced
you to a fresh page and every choice - rotation especially - reverted
to the default.
"""
query = {
'ids': ','.join(str(l.id) for l in labels),
tape_printer.FORM_MARKER: '1',
'tape': options.tape,
'copies': options.copies,
'font_scale': options.font_scale,
'image_percent': options.image_percent,
'padding_mm': options.padding_mm,
'align': options.align,
'fields': options.fields,
}
# Checkboxes: only present when on, which is what the parser expects.
if options.rotate:
query['rotate'] = '1'
if options.outline:
query['outline'] = '1'
if options.fixed_length_mm:
query['fixed_length_mm'] = options.fixed_length_mm
return query
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'))
config = printer_config()
source = request.form if request.method == 'POST' else request.args
# rotate_default only applies when the form was not submitted, so an
# explicit untick on the page always wins over the saved preference.
options = tape_printer.options_from_form(
source, rotate_default=config['rotate'])
if not source.get('tape'):
options.tape = 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=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, rotate_default=printer_config()['rotate'])
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'))
config = printer_config()
options = tape_printer.options_from_form(
request.form, rotate_default=config['rotate'])
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',
**_options_query(options, 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',
**_options_query(options, 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', **_options_query(options, labels)))
@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, rotate_default=printer_config()['rotate'])
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')