467 lines
19 KiB
Python
467 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 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)
|
|
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),
|
|
)
|