""" 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//edit edit /admin/labels//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 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('//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('//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) 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), )