From 900edea065cd10bdc3dbaa9b474e3b8358c28ae0 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Sat, 3 Oct 2026 00:55:15 +1000 Subject: [PATCH] Add admin-only parts labels blueprint --- app/routes/labels.py | 436 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 436 insertions(+) create mode 100644 app/routes/labels.py diff --git a/app/routes/labels.py b/app/routes/labels.py new file mode 100644 index 0000000..e120120 --- /dev/null +++ b/app/routes/labels.py @@ -0,0 +1,436 @@ +""" +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 and print defaults + +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 +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') + 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(), + )