Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vunz4VLxAYDBnU5tLciLDC
331 lines
13 KiB
Python
331 lines
13 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 physical 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
|
|
|
|
A "physical label" is not the same as a PartLabel row: a drawer holding
|
|
two part types prints as one 40 x 20mm label. tape_printer.plan_groups
|
|
works that out, and everything here follows its plan.
|
|
|
|
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),
|
|
'size_mode': SiteSetting.get('tape_size_mode',
|
|
tape_printer.DEFAULT_SIZE_MODE),
|
|
}
|
|
|
|
|
|
def _group_spec():
|
|
"""
|
|
The manual grouping from the page: "1,2|3" means parts 1 and 2 share a
|
|
label and part 3 is on its own. Empty means "work it out from drawers".
|
|
"""
|
|
return (request.values.get('groups') or '').strip()
|
|
|
|
|
|
def _options_query(options, labels, group_spec=None):
|
|
"""
|
|
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,
|
|
'size_mode': options.size_mode,
|
|
'copies': options.copies,
|
|
'font_scale': options.font_scale,
|
|
'image_percent': options.image_percent,
|
|
'padding_mm': options.padding_mm,
|
|
'align': options.align,
|
|
'fields': options.fields,
|
|
}
|
|
if group_spec:
|
|
query['groups'] = group_spec
|
|
# Checkboxes: only present when on, which is what the parser expects.
|
|
if options.rotate:
|
|
query['rotate'] = '1'
|
|
if options.group_drawers:
|
|
query['group_drawers'] = '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
|
|
# The *_default values only apply when the form was not submitted, so an
|
|
# explicit choice on the page always wins over the saved preference.
|
|
options = tape_printer.options_from_form(
|
|
source, rotate_default=config['rotate'],
|
|
size_default=config['size_mode'], tape_default=config['tape'])
|
|
|
|
group_spec = _group_spec()
|
|
plan = tape_printer.plan_groups(labels, options, group_spec)
|
|
tape = tape_printer.get_tape(options.tape)
|
|
|
|
return render_template(
|
|
'admin/labels/tape.html',
|
|
labels=labels,
|
|
plan=plan,
|
|
group_spec=tape_printer.spec_from_groups(
|
|
[item['labels'] for item in plan]),
|
|
ids=','.join(str(l.id) for l in labels),
|
|
options=options,
|
|
tapes=tape_printer.TAPE_PRESETS,
|
|
tape=tape,
|
|
size_modes=tape_printer.SIZE_MODES,
|
|
label_width_mm=tape_printer.LABEL_WIDTH_MM,
|
|
tape_fits=tape_printer.tape_fits_label(options.tape),
|
|
tape_used_mm=tape_printer.tape_used_mm(plan, options.copies),
|
|
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,
|
|
font_info=tape_printer.font_report(),
|
|
)
|
|
|
|
|
|
@tape_bp.route('/preview.png')
|
|
@login_required
|
|
@admin_required
|
|
def preview():
|
|
"""
|
|
One physical label. `ids` is every part on it, because a drawer holding
|
|
two part types prints as a single 40 x 20mm label, not two labels.
|
|
"""
|
|
raw = request.args.get('ids') or request.args.get('id') or ''
|
|
wanted = [int(v) for v in str(raw).split(',') if v.strip().isdigit()]
|
|
if not wanted:
|
|
abort(400, 'preview.png needs ?ids= of one or more labels.')
|
|
|
|
found = {row.id: row for row in
|
|
PartLabel.query.filter(PartLabel.id.in_(wanted)).all()}
|
|
group = [found[i] for i in wanted if i in found]
|
|
if not group:
|
|
abort(404)
|
|
|
|
config = printer_config()
|
|
options = tape_printer.options_from_form(
|
|
request.args, rotate_default=config['rotate'],
|
|
size_default=config['size_mode'], tape_default=config['tape'])
|
|
buffer = tape_printer.preview_png(
|
|
group, 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'],
|
|
size_default=config['size_mode'], tape_default=config['tape'])
|
|
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,
|
|
_group_spec())))
|
|
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,
|
|
_group_spec())))
|
|
|
|
upload_folder = current_app.config['UPLOAD_FOLDER']
|
|
group_spec = _group_spec()
|
|
plan = tape_printer.plan_groups(labels, options, group_spec)
|
|
|
|
if options.is_fixed and not tape_printer.tape_fits_label(options.tape):
|
|
flash(f'{tape_printer.LABEL_WIDTH_MM:.0f}mm labels need 50mm tape - '
|
|
f'{tape_printer.get_tape(options.tape).name} is narrower, so '
|
|
'they were scaled down to fit.', 'warning')
|
|
|
|
sent = 0
|
|
failures = []
|
|
|
|
for item in plan:
|
|
group = item['labels']
|
|
name = ' + '.join(l.display_title for l in group)
|
|
try:
|
|
image = tape_printer.render_group(group, 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.
|
|
#
|
|
# Paced rather than fired in a burst: the VC-500W takes one job
|
|
# at a time and answers "busy" to anything arriving while the
|
|
# tape is still moving, so each job waits for the printer to go
|
|
# idle and retries while it is still working.
|
|
for _ in range(options.copies):
|
|
ipp_client.print_job_paced(
|
|
host, document, mime_type,
|
|
job_name=f'LIM: {name}'[:120],
|
|
port=config['port'], path=config['path'])
|
|
sent += 1
|
|
except ipp_client.IPPError as exc:
|
|
failures.append(f'{name}: {exc}')
|
|
except Exception as exc:
|
|
logger.exception('Tape print failed for %s', name)
|
|
failures.append(f'{name}: {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, group_spec)))
|
|
|
|
|
|
@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'))
|
|
|
|
config = printer_config()
|
|
source = request.form if request.method == 'POST' else request.args
|
|
options = tape_printer.options_from_form(
|
|
source, rotate_default=config['rotate'],
|
|
size_default=config['size_mode'], tape_default=config['tape'])
|
|
buffer = tape_printer.render_batch_pdf(
|
|
labels, options, current_app.config['UPLOAD_FOLDER'], _group_spec())
|
|
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')
|