Files
lego-instructions-manager/app/routes/labels.py
T

473 lines
19 KiB
Python

"""
Parts drawer labelling - admin only.
Everything in this blueprint sits behind admin_required, which is reused from
the admin blueprint so there is exactly one definition of "is an admin" in
the app.
Routes:
/admin/labels/ browse categories and labels
/admin/labels/new create a label
/admin/labels/<id>/edit edit
/admin/labels/<id>/delete delete
/admin/labels/lookup AJAX Rebrickable search
/admin/labels/import create a label from a Rebrickable hit
/admin/labels/print the printable sheet
/admin/labels/print.pdf same sheet as a PDF (ReportLab only)
/admin/labels/categories manage the taxonomy
/admin/labels/settings API key, print defaults, tape printer
Created by: Jess Rogerson (yelling commands at Claude.AI)
"""
import os
from flask import (Blueprint, current_app, flash, jsonify, redirect,
render_template, request, send_file, url_for)
from flask_login import current_user, login_required
from sqlalchemy import or_
from werkzeug.utils import secure_filename
from app import db
from app.models.part_label import PartCategory, PartLabel, slugify
from app.models.site_setting import SiteSetting
from app.routes.admin import admin_required
from app.services import dymo_sheet, label_render, tape_printer
from app.services.part_catalog import (RebrickableAPI, cache_part_image,
ensure_seeded, seed_categories)
labels_bp = Blueprint('labels', __name__, url_prefix='/admin/labels')
IMAGE_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
def _save_uploaded_image(file_storage, part_number):
"""Store an uploaded part image under uploads/parts. Returns rel path."""
if not file_storage or not file_storage.filename:
return None
extension = file_storage.filename.rsplit('.', 1)[-1].lower()
if extension not in IMAGE_EXTENSIONS:
flash('Part images must be PNG, JPG, GIF or WEBP.', 'warning')
return None
folder = os.path.join(current_app.config['UPLOAD_FOLDER'], 'parts')
os.makedirs(folder, exist_ok=True)
stem = secure_filename(part_number or os.path.splitext(
file_storage.filename)[0]) or 'part'
filename = f'{stem}.{extension}'
destination = os.path.join(folder, filename)
counter = 1
while os.path.exists(destination):
filename = f'{stem}-{counter}.{extension}'
destination = os.path.join(folder, filename)
counter += 1
file_storage.save(destination)
try:
from PIL import Image
with Image.open(destination) as img:
if max(img.size) > 600:
img.thumbnail((600, 600))
img.save(destination)
except Exception:
pass
return f'parts/{filename}'
def _label_from_form(label, form, files):
label.part_number = (form.get('part_number') or '').strip() or None
label.alt_part_numbers = (form.get('alt_part_numbers') or '').strip() or None
label.name = (form.get('name') or '').strip()
label.dimensions = (form.get('dimensions') or '').strip() or None
label.colour_name = (form.get('colour_name') or '').strip() or None
label.location = (form.get('location') or '').strip() or None
label.notes = (form.get('notes') or '').strip() or None
category_id = form.get('category_id', type=int)
label.category_id = category_id or None
remote = (form.get('image_url') or '').strip() or None
stored = _save_uploaded_image(files.get('image'), label.part_number)
if stored:
label.image = stored
label.image_url = None
elif remote and remote != label.image_url:
label.image_url = remote
if form.get('cache_image'):
cached = cache_part_image(remote, label.part_number)
if cached:
label.image = cached
elif form.get('clear_image'):
label.image = None
label.image_url = None
return label
# ------------------------------------------------------------------ browse
@labels_bp.route('/')
@login_required
@admin_required
def index():
ensure_seeded()
category_id = request.args.get('category', type=int)
search = (request.args.get('q') or '').strip()
page = request.args.get('page', 1, type=int)
query = PartLabel.query
active_category = None
if category_id:
active_category = PartCategory.query.get_or_404(category_id)
query = query.filter(
PartLabel.category_id.in_(active_category.descendant_ids()))
if search:
pattern = f'%{search}%'
query = query.filter(or_(
PartLabel.name.ilike(pattern),
PartLabel.part_number.ilike(pattern),
PartLabel.alt_part_numbers.ilike(pattern),
PartLabel.location.ilike(pattern),
PartLabel.dimensions.ilike(pattern),
))
pagination = (query.order_by(PartLabel.location, PartLabel.name)
.paginate(page=page, per_page=60, error_out=False))
return render_template(
'admin/labels/index.html',
roots=PartCategory.roots(),
labels=pagination.items,
pagination=pagination,
active_category=active_category,
search=search,
total_labels=PartLabel.query.count(),
presets=label_render.SHEET_PRESETS,
default_preset=SiteSetting.get('label_default_preset',
label_render.DEFAULT_PRESET),
rebrickable_ready=RebrickableAPI.is_configured(),
)
# ------------------------------------------------------------------- CRUD
@labels_bp.route('/new', methods=['GET', 'POST'])
@login_required
@admin_required
def new_label():
ensure_seeded()
if request.method == 'POST':
if not (request.form.get('name') or '').strip():
flash('A label needs a name.', 'danger')
else:
label = _label_from_form(PartLabel(), request.form, request.files)
label.source = request.form.get('source') or 'manual'
label.created_by_id = current_user.id
db.session.add(label)
db.session.commit()
flash(f'Label "{label.name}" created.', 'success')
if request.form.get('save_and_new'):
return redirect(url_for('labels.new_label',
category=label.category_id))
return redirect(url_for('labels.index',
category=label.category_id))
preset_label = PartLabel()
preset_label.category_id = request.args.get('category', type=int)
return render_template('admin/labels/form.html', label=preset_label,
categories=PartCategory.choices(),
rebrickable_ready=RebrickableAPI.is_configured(),
is_new=True)
@labels_bp.route('/<int:label_id>/edit', methods=['GET', 'POST'])
@login_required
@admin_required
def edit_label(label_id):
label = PartLabel.query.get_or_404(label_id)
if request.method == 'POST':
if not (request.form.get('name') or '').strip():
flash('A label needs a name.', 'danger')
else:
_label_from_form(label, request.form, request.files)
db.session.commit()
flash('Label updated.', 'success')
return redirect(url_for('labels.index',
category=label.category_id))
return render_template('admin/labels/form.html', label=label,
categories=PartCategory.choices(),
rebrickable_ready=RebrickableAPI.is_configured(),
is_new=False)
@labels_bp.route('/<int:label_id>/delete', methods=['POST'])
@login_required
@admin_required
def delete_label(label_id):
label = PartLabel.query.get_or_404(label_id)
category_id = label.category_id
db.session.delete(label)
db.session.commit()
flash('Label deleted.', 'info')
return redirect(url_for('labels.index', category=category_id))
# ------------------------------------------------------------- rebrickable
@labels_bp.route('/lookup')
@login_required
@admin_required
def lookup():
"""AJAX part search. Always 200s with a reason, so the UI can say why."""
query = (request.args.get('q') or '').strip()
if not RebrickableAPI.is_configured():
return jsonify({'ok': False, 'results': [],
'reason': 'No Rebrickable API key configured.'})
if not query:
return jsonify({'ok': True, 'results': []})
api = RebrickableAPI()
exact = api.get_part(query) if query.replace('-', '').isalnum() else None
results = api.search_parts(query)
if exact and not any(r['part_number'] == exact['part_number']
for r in results):
results.insert(0, exact)
return jsonify({'ok': True, 'results': results[:25]})
@labels_bp.route('/import', methods=['POST'])
@login_required
@admin_required
def import_part():
"""Create a label straight from a Rebrickable search hit."""
part_number = (request.form.get('part_number') or '').strip()
if not part_number:
flash('No part number supplied.', 'warning')
return redirect(url_for('labels.index'))
data = RebrickableAPI().get_part(part_number)
if not data:
flash(f'Rebrickable had nothing for part {part_number}.', 'warning')
return redirect(url_for('labels.index'))
label = PartLabel(
part_number=data['part_number'],
name=data['name'],
dimensions=data.get('dimensions'),
image_url=data.get('image_url'),
category_id=request.form.get('category_id', type=int) or None,
location=(request.form.get('location') or '').strip() or None,
source='rebrickable',
created_by_id=current_user.id,
)
cached = cache_part_image(data.get('image_url'), data['part_number'])
if cached:
label.image = cached
db.session.add(label)
db.session.commit()
flash(f'Imported {label.part_number} - {label.name}.', 'success')
return redirect(url_for('labels.edit_label', label_id=label.id))
# ------------------------------------------------------------------ print
def _labels_for_printing():
"""Resolve the print selection from ids=, category= or everything."""
ids = request.values.getlist('ids')
if len(ids) == 1 and ',' in ids[0]:
ids = ids[0].split(',')
ids = [int(value) for value in ids if str(value).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 (PartLabel.query.order_by(PartLabel.location, PartLabel.name)
.all())
@labels_bp.route('/print', methods=['GET', 'POST'])
@login_required
@admin_required
def print_sheet():
labels = _labels_for_printing()
if not labels:
flash('Nothing selected to print.', 'warning')
return redirect(url_for('labels.index'))
source = request.form if request.method == 'POST' else request.args
options = label_render.options_from_form(source)
layout = label_render.build_pages(labels, options)
return render_template('admin/labels/print.html', layout=layout,
labels=labels,
ids=','.join(str(l.id) for l in labels),
field_options=label_render.FIELD_OPTIONS,
presets=label_render.SHEET_PRESETS,
pdf_available=label_render.reportlab_available())
@labels_bp.route('/print.pdf', methods=['GET', 'POST'])
@login_required
@admin_required
def print_pdf():
if not label_render.reportlab_available():
flash('PDF export needs ReportLab: pip install reportlab. '
'Printing from the browser works without it.', 'info')
return redirect(url_for('labels.print_sheet', **request.args))
labels = _labels_for_printing()
if not labels:
flash('Nothing selected to print.', 'warning')
return redirect(url_for('labels.index'))
source = request.form if request.method == 'POST' else request.args
layout = label_render.build_pages(
labels, label_render.options_from_form(source))
buffer = label_render.render_pdf(layout,
current_app.config['UPLOAD_FOLDER'])
return send_file(buffer, mimetype='application/pdf',
as_attachment=True, download_name='lego-part-labels.pdf')
# ------------------------------------------------------------- categories
@labels_bp.route('/categories', methods=['GET', 'POST'])
@login_required
@admin_required
def categories():
ensure_seeded()
if request.method == 'POST':
action = request.form.get('action')
if action == 'create':
name = (request.form.get('name') or '').strip()
if not name:
flash('Categories need a name.', 'danger')
else:
parent_id = request.form.get('parent_id', type=int) or None
parent = PartCategory.query.get(parent_id) if parent_id else None
base = f'{parent.name}-{name}' if parent else name
slug = slugify(base)
suffix = 1
while PartCategory.query.filter_by(slug=slug).first():
suffix += 1
slug = f'{slugify(base)}-{suffix}'
db.session.add(PartCategory(
name=name, slug=slug, parent_id=parent_id,
code=(request.form.get('code') or '').strip() or None,
colour=(request.form.get('colour') or '').strip() or None,
sort_order=request.form.get('sort_order', 0, type=int)))
db.session.commit()
flash(f'Category "{name}" added.', 'success')
elif action == 'update':
category = PartCategory.query.get_or_404(
request.form.get('category_id', type=int))
category.name = (request.form.get('name') or category.name).strip()
category.code = (request.form.get('code') or '').strip() or None
category.colour = (request.form.get('colour') or '').strip() or None
category.sort_order = request.form.get('sort_order', 0, type=int)
db.session.commit()
flash('Category updated.', 'success')
elif action == 'delete':
category = PartCategory.query.get_or_404(
request.form.get('category_id', type=int))
if category.label_count(recursive=True):
flash('That category still has labels in it. Move or delete '
'them first.', 'warning')
else:
db.session.delete(category)
db.session.commit()
flash('Category deleted.', 'info')
elif action == 'reseed':
created = seed_categories(force=True)
flash(f'Seed taxonomy applied: {created} categories added. '
'Nothing existing was changed.', 'success')
return redirect(url_for('labels.categories'))
return render_template('admin/labels/categories.html',
roots=PartCategory.roots(),
choices=PartCategory.choices())
# --------------------------------------------------------------- settings
@labels_bp.route('/settings', methods=['GET', 'POST'])
@login_required
@admin_required
def settings():
if request.method == 'POST':
SiteSetting.set('rebrickable_api_key',
(request.form.get('rebrickable_api_key') or '').strip())
SiteSetting.set('label_default_preset',
request.form.get('label_default_preset')
or label_render.DEFAULT_PRESET)
SiteSetting.set('label_offset_x', request.form.get('offset_x') or '0')
SiteSetting.set('label_offset_y', request.form.get('offset_y') or '0')
# Brother VC-500W (or any AirPrint label printer) over IPP
SiteSetting.set('tape_printer_host',
(request.form.get('tape_printer_host') or '').strip())
SiteSetting.set('tape_printer_port',
(request.form.get('tape_printer_port') or '631').strip())
SiteSetting.set('tape_printer_path',
(request.form.get('tape_printer_path')
or '/ipp/print').strip())
SiteSetting.set('tape_printer_format',
(request.form.get('tape_printer_format') or '').strip())
SiteSetting.set('tape_default',
request.form.get('tape_default') or tape_printer.DEFAULT_TAPE)
# A full settings POST always carries every field, so an absent
# checkbox here genuinely means unticked.
SiteSetting.set('tape_rotate_default',
bool(request.form.get('tape_rotate_default')))
SiteSetting.set('tape_size_mode',
request.form.get('tape_size_mode')
or tape_printer.DEFAULT_SIZE_MODE)
SiteSetting.set('dymo_stock',
request.form.get('dymo_stock')
or dymo_sheet.DEFAULT_STOCK)
db.session.commit()
flash('Label settings saved.', 'success')
return redirect(url_for('labels.settings'))
return render_template(
'admin/labels/settings.html',
presets=label_render.SHEET_PRESETS,
api_key=SiteSetting.get('rebrickable_api_key', '') or '',
env_key_present=bool(current_app.config.get('REBRICKABLE_API_KEY')
or os.environ.get('REBRICKABLE_API_KEY')),
default_preset=SiteSetting.get('label_default_preset',
label_render.DEFAULT_PRESET),
offset_x=SiteSetting.get('label_offset_x', '0'),
offset_y=SiteSetting.get('label_offset_y', '0'),
pdf_available=label_render.reportlab_available(),
tapes=tape_printer.TAPE_PRESETS,
tape_printer_host=SiteSetting.get('tape_printer_host', '') or '',
tape_printer_port=SiteSetting.get('tape_printer_port', '631') or '631',
tape_printer_path=SiteSetting.get('tape_printer_path', '/ipp/print') or '/ipp/print',
tape_printer_format=SiteSetting.get('tape_printer_format', '') or '',
tape_default=SiteSetting.get('tape_default', tape_printer.DEFAULT_TAPE),
tape_rotate_default=SiteSetting.get_bool('tape_rotate_default', True),
size_modes=tape_printer.SIZE_MODES,
tape_size_mode=SiteSetting.get('tape_size_mode',
tape_printer.DEFAULT_SIZE_MODE),
dymo_stocks=dymo_sheet.STOCKS,
dymo_stock=SiteSetting.get('dymo_stock', dymo_sheet.DEFAULT_STOCK),
font_info=tape_printer.font_report(),
)