Plan the run as physical labels, not one per part

A drawer with two part types is one label, so the page, the preview, the
print loop and the PDF all work from plan_groups rather than iterating
the selection. The preview takes the parts that share a label, and the
page reports how much tape the run will use.
This commit is contained in:
jessikitty committed 2026-10-12 22:42:49 +11:00
1 parent d86a71de67
commit b5a639c737
1 file changed
+62 -21
+62 -21
View File
@@ -3,11 +3,15 @@ Tape printing - admin only. Drives the Brother VC-500W over the network.
Routes (all under /admin/labels/tape): Routes (all under /admin/labels/tape):
/ choose labels, set tape options, see a live preview / choose labels, set tape options, see a live preview
/preview.png rendered preview of one label /preview.png rendered preview of one physical label
/print send the job to the printer over IPP /print send the job to the printer over IPP
/download.pdf the same labels as a PDF, for when you'd rather not /download.pdf the same labels as a PDF, for when you'd rather not
/test probe the printer: model, state, accepted formats /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) Created by: Jess Rogerson (yelling commands at Claude.AI)
""" """
@@ -37,6 +41,8 @@ def printer_config():
'format': (SiteSetting.get('tape_printer_format', '') or '').strip(), 'format': (SiteSetting.get('tape_printer_format', '') or '').strip(),
'tape': SiteSetting.get('tape_default', tape_printer.DEFAULT_TAPE), 'tape': SiteSetting.get('tape_default', tape_printer.DEFAULT_TAPE),
'rotate': SiteSetting.get_bool('tape_rotate_default', True), 'rotate': SiteSetting.get_bool('tape_rotate_default', True),
'size_mode': SiteSetting.get('tape_size_mode',
tape_printer.DEFAULT_SIZE_MODE),
} }
@@ -51,6 +57,7 @@ def _options_query(options, labels):
'ids': ','.join(str(l.id) for l in labels), 'ids': ','.join(str(l.id) for l in labels),
tape_printer.FORM_MARKER: '1', tape_printer.FORM_MARKER: '1',
'tape': options.tape, 'tape': options.tape,
'size_mode': options.size_mode,
'copies': options.copies, 'copies': options.copies,
'font_scale': options.font_scale, 'font_scale': options.font_scale,
'image_percent': options.image_percent, 'image_percent': options.image_percent,
@@ -61,6 +68,8 @@ def _options_query(options, labels):
# Checkboxes: only present when on, which is what the parser expects. # Checkboxes: only present when on, which is what the parser expects.
if options.rotate: if options.rotate:
query['rotate'] = '1' query['rotate'] = '1'
if options.group_drawers:
query['group_drawers'] = '1'
if options.outline: if options.outline:
query['outline'] = '1' query['outline'] = '1'
if options.fixed_length_mm: if options.fixed_length_mm:
@@ -100,20 +109,27 @@ def index():
config = printer_config() config = printer_config()
source = request.form if request.method == 'POST' else request.args source = request.form if request.method == 'POST' else request.args
# rotate_default only applies when the form was not submitted, so an # The *_default values only apply when the form was not submitted, so an
# explicit untick on the page always wins over the saved preference. # explicit choice on the page always wins over the saved preference.
options = tape_printer.options_from_form( options = tape_printer.options_from_form(
source, rotate_default=config['rotate']) source, rotate_default=config['rotate'],
if not source.get('tape'): size_default=config['size_mode'], tape_default=config['tape'])
options.tape = config['tape']
plan = tape_printer.plan_groups(labels, options)
tape = tape_printer.get_tape(options.tape)
return render_template( return render_template(
'admin/labels/tape.html', 'admin/labels/tape.html',
labels=labels, labels=labels,
plan=plan,
ids=','.join(str(l.id) for l in labels), ids=','.join(str(l.id) for l in labels),
options=options, options=options,
tapes=tape_printer.TAPE_PRESETS, tapes=tape_printer.TAPE_PRESETS,
tape=tape_printer.get_tape(options.tape), 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=[ field_options=[
('image', 'Part image'), ('name', 'Part name'), ('image', 'Part image'), ('name', 'Part name'),
('dimensions', 'Dimensions'), ('part_number', 'Part number'), ('dimensions', 'Dimensions'), ('part_number', 'Part number'),
@@ -129,14 +145,27 @@ def index():
@login_required @login_required
@admin_required @admin_required
def preview(): def preview():
label_id = request.args.get('id', type=int) """
if not label_id: One physical label. `ids` is every part on it, because a drawer holding
abort(400, 'preview.png needs an ?id= of a label.') two part types prints as a single 40 x 20mm label, not two labels.
label = PartLabel.query.get_or_404(label_id) """
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( options = tape_printer.options_from_form(
request.args, rotate_default=printer_config()['rotate']) request.args, rotate_default=config['rotate'],
size_default=config['size_mode'], tape_default=config['tape'])
buffer = tape_printer.preview_png( buffer = tape_printer.preview_png(
label, options, current_app.config['UPLOAD_FOLDER']) group, options, current_app.config['UPLOAD_FOLDER'])
return send_file(buffer, mimetype='image/png') return send_file(buffer, mimetype='image/png')
@@ -187,7 +216,8 @@ def print_tape():
config = printer_config() config = printer_config()
options = tape_printer.options_from_form( options = tape_printer.options_from_form(
request.form, rotate_default=config['rotate']) request.form, rotate_default=config['rotate'],
size_default=config['size_mode'], tape_default=config['tape'])
host = config['host'] host = config['host']
if not host: if not host:
flash('No tape printer address configured. Set it in Label ' flash('No tape printer address configured. Set it in Label '
@@ -213,26 +243,35 @@ def print_tape():
**_options_query(options, labels))) **_options_query(options, labels)))
upload_folder = current_app.config['UPLOAD_FOLDER'] upload_folder = current_app.config['UPLOAD_FOLDER']
plan = tape_printer.plan_groups(labels, options)
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 sent = 0
failures = [] failures = []
for label in labels: for item in plan:
group = item['labels']
name = ' + '.join(l.display_title for l in group)
try: try:
image = tape_printer.render_label(label, options, upload_folder) image = tape_printer.render_group(group, options, upload_folder)
document = tape_printer.image_to_bytes(image, mime_type) document = tape_printer.image_to_bytes(image, mime_type)
# copies is handled by repeating the job rather than the IPP # copies is handled by repeating the job rather than the IPP
# copies attribute: tape printers vary wildly in honouring it. # copies attribute: tape printers vary wildly in honouring it.
for _ in range(options.copies): for _ in range(options.copies):
ipp_client.print_job( ipp_client.print_job(
host, document, mime_type, host, document, mime_type,
job_name=f'LIM: {label.display_title}'[:120], job_name=f'LIM: {name}'[:120],
port=config['port'], path=config['path']) port=config['port'], path=config['path'])
sent += 1 sent += 1
except ipp_client.IPPError as exc: except ipp_client.IPPError as exc:
failures.append(f'{label.display_title}: {exc}') failures.append(f'{name}: {exc}')
except Exception as exc: except Exception as exc:
logger.exception('Tape print failed for label %s', label.id) logger.exception('Tape print failed for %s', name)
failures.append(f'{label.display_title}: {exc}') failures.append(f'{name}: {exc}')
if sent: if sent:
flash(f'Sent {sent} label{"" if sent == 1 else "s"} to the printer.', flash(f'Sent {sent} label{"" if sent == 1 else "s"} to the printer.',
@@ -253,9 +292,11 @@ def download_pdf():
flash('Nothing selected.', 'warning') flash('Nothing selected.', 'warning')
return redirect(url_for('labels.index')) return redirect(url_for('labels.index'))
config = printer_config()
source = request.form if request.method == 'POST' else request.args source = request.form if request.method == 'POST' else request.args
options = tape_printer.options_from_form( options = tape_printer.options_from_form(
source, rotate_default=printer_config()['rotate']) source, rotate_default=config['rotate'],
size_default=config['size_mode'], tape_default=config['tape'])
buffer = tape_printer.render_batch_pdf( buffer = tape_printer.render_batch_pdf(
labels, options, current_app.config['UPLOAD_FOLDER']) labels, options, current_app.config['UPLOAD_FOLDER'])
if buffer is None: if buffer is None: