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

410 lines
14 KiB
Python

from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, send_file
from flask_login import login_required, current_user
from werkzeug.utils import secure_filename
from app import db
from app.models.set import Set
from app.models.instruction import Instruction
from app.services.file_handler import FileHandler
from app.services import page_order
import os
instructions_bp = Blueprint('instructions', __name__, url_prefix='/instructions')
@instructions_bp.route('/upload/<int:set_id>', methods=['GET', 'POST'])
@login_required
def upload(set_id):
"""Upload instruction files for a specific set."""
lego_set = Set.query.get_or_404(set_id)
if request.method == 'POST':
# Check if files were uploaded
if 'files[]' not in request.files:
flash('No files selected.', 'danger')
return redirect(request.url)
files = request.files.getlist('files[]')
uploaded_count = 0
for file in files:
if file and file.filename and FileHandler.allowed_file(file.filename):
try:
# Determine file type
file_type = FileHandler.get_file_type(file.filename)
# Save file and generate thumbnail
file_path, file_size, thumbnail_path = FileHandler.save_file(
file,
lego_set.set_number,
file_type
)
# Determine page number for images
page_number = 1
if file_type == 'IMAGE':
page_number = page_order.next_page_number(set_id)
# Create instruction record
instruction = Instruction(
set_id=set_id,
file_type=file_type,
file_path=file_path,
file_name=secure_filename(file.filename),
file_size=file_size,
page_number=page_number,
thumbnail_path=thumbnail_path,
user_id=current_user.id
)
db.session.add(instruction)
uploaded_count += 1
except Exception as e:
flash(f'Error uploading {file.filename}: {str(e)}', 'danger')
continue
if uploaded_count > 0:
db.session.commit()
flash(f'Successfully uploaded {uploaded_count} file(s)!', 'success')
else:
flash('No files were uploaded.', 'warning')
return redirect(url_for('sets.view_set', set_id=set_id))
return render_template('instructions/upload.html', set=lego_set)
@instructions_bp.route('/<int:instruction_id>/delete', methods=['POST'])
@login_required
def delete(instruction_id):
"""Delete an instruction file."""
instruction = Instruction.query.get_or_404(instruction_id)
set_id = instruction.set_id
# Delete the physical file
FileHandler.delete_file(instruction.file_path)
# Delete thumbnail if exists
if instruction.thumbnail_path:
FileHandler.delete_file(instruction.thumbnail_path)
# Delete the database record
db.session.delete(instruction)
db.session.commit()
flash('Instruction file deleted successfully.', 'success')
return redirect(url_for('sets.view_set', set_id=set_id))
@instructions_bp.route('/delete-all-images/<int:set_id>', methods=['POST'])
@login_required
def delete_all_images(set_id):
"""Delete all image instructions for a set."""
lego_set = Set.query.get_or_404(set_id)
# Get all image instructions
image_instructions = Instruction.query.filter_by(
set_id=set_id,
file_type='IMAGE'
).all()
count = len(image_instructions)
# Delete each one
for instruction in image_instructions:
# Delete physical file
FileHandler.delete_file(instruction.file_path)
# Delete thumbnail if exists
if instruction.thumbnail_path:
FileHandler.delete_file(instruction.thumbnail_path)
# Delete database record
db.session.delete(instruction)
db.session.commit()
flash(f'Successfully deleted {count} image instruction(s).', 'success')
return redirect(url_for('sets.view_set', set_id=set_id))
@instructions_bp.route('/<int:instruction_id>/view')
@login_required
def view(instruction_id):
"""View a specific instruction file."""
instruction = Instruction.query.get_or_404(instruction_id)
from flask import current_app
file_path = os.path.join(
current_app.config['UPLOAD_FOLDER'],
instruction.file_path
)
if not os.path.exists(file_path):
flash('File not found.', 'danger')
return redirect(url_for('sets.view_set', set_id=instruction.set_id))
return send_file(file_path)
@instructions_bp.route('/<int:set_id>/reorder', methods=['POST'])
@login_required
def reorder(set_id):
"""Save a new page order for a set's image instructions."""
Set.query.get_or_404(set_id)
payload = request.get_json(silent=True) or {}
new_order = payload.get('order', [])
if not new_order:
return jsonify({'error': 'No order provided'}), 400
try:
pages = page_order.apply_order(set_id, new_order)
return jsonify({
'success': True,
'pages': [p.to_dict() for p in pages],
'audit': page_order.audit(set_id),
})
except ValueError as e:
db.session.rollback()
return jsonify({'error': str(e)}), 400
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 500
@instructions_bp.route('/bulk-upload/<int:set_id>', methods=['POST'])
@login_required
def bulk_upload(set_id):
"""Handle bulk upload via AJAX."""
lego_set = Set.query.get_or_404(set_id)
if 'file' not in request.files:
return jsonify({'error': 'No file provided'}), 400
file = request.files['file']
if not file or not file.filename:
return jsonify({'error': 'Invalid file'}), 400
if not FileHandler.allowed_file(file.filename):
return jsonify({'error': 'File type not allowed'}), 400
try:
# Determine file type
file_type = FileHandler.get_file_type(file.filename)
# Save file (save_file returns three values; the thumbnail was being
# dropped here, which blew up every bulk upload with a tuple-unpack
# error before it ever reached the database)
file_path, file_size, thumbnail_path = FileHandler.save_file(
file,
lego_set.set_number,
file_type
)
# Determine page number for images
page_number = 1
if file_type == 'IMAGE':
page_number = page_order.next_page_number(set_id)
# Create instruction record
instruction = Instruction(
set_id=set_id,
file_type=file_type,
file_path=file_path,
file_name=secure_filename(file.filename),
file_size=file_size,
page_number=page_number,
thumbnail_path=thumbnail_path,
user_id=current_user.id
)
db.session.add(instruction)
db.session.commit()
return jsonify({
'success': True,
'instruction': instruction.to_dict()
})
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 500
@instructions_bp.route('/viewer/<int:set_id>')
@login_required
def image_viewer(set_id):
"""View image instructions in a scrollable PDF-like viewer."""
lego_set = Set.query.get_or_404(set_id)
# Get all image instructions sorted by page number
image_instructions = lego_set.image_instructions
if not image_instructions:
flash('No image instructions available for this set.', 'info')
return redirect(url_for('sets.view_set', set_id=set_id))
return render_template('instructions/viewer.html',
set=lego_set,
images=image_instructions)
@instructions_bp.route('/debug/<int:set_id>')
@login_required
def debug_paths(set_id):
"""Debug endpoint to check instruction paths."""
from flask import current_app
lego_set = Set.query.get_or_404(set_id)
debug_info = {
'set_number': lego_set.set_number,
'set_name': lego_set.set_name,
'upload_folder': current_app.config['UPLOAD_FOLDER'],
'instructions': []
}
for instruction in lego_set.instructions:
file_path = instruction.file_path.replace('\\', '/')
full_path = os.path.join(current_app.config['UPLOAD_FOLDER'], instruction.file_path)
info = {
'id': instruction.id,
'file_name': instruction.file_name,
'file_type': instruction.file_type,
'page_number': instruction.page_number,
'db_path': instruction.file_path,
'clean_path': file_path,
'full_disk_path': full_path,
'file_exists': os.path.exists(full_path),
'web_url': f'/static/uploads/{file_path}'
}
if instruction.thumbnail_path:
thumb_clean = instruction.thumbnail_path.replace('\\', '/')
thumb_full = os.path.join(current_app.config['UPLOAD_FOLDER'], instruction.thumbnail_path)
info['thumbnail_db'] = instruction.thumbnail_path
info['thumbnail_clean'] = thumb_clean
info['thumbnail_full'] = thumb_full
info['thumbnail_exists'] = os.path.exists(thumb_full)
info['thumbnail_url'] = f'/static/uploads/{thumb_clean}'
debug_info['instructions'].append(info)
return jsonify(debug_info)
# ---------------------------------------------------------------------------
# Page organiser: fixing the order of image instructions after upload.
# ---------------------------------------------------------------------------
@instructions_bp.route('/<int:set_id>/organise')
@login_required
def organise(set_id):
"""Drag-and-drop page organiser for a set's image instructions."""
lego_set = Set.query.get_or_404(set_id)
pages = page_order.image_pages(set_id)
if not pages:
flash('There are no image pages to organise for this set.', 'info')
return redirect(url_for('sets.view_set', set_id=set_id))
return render_template(
'instructions/organise.html',
set=lego_set,
pages=pages,
page_hints={p.id: page_order.page_hint(p.file_name or '') for p in pages},
strategies=page_order.strategy_choices(),
audit=page_order.audit(set_id),
)
@instructions_bp.route('/<int:set_id>/auto-sort', methods=['POST'])
@login_required
def auto_sort(set_id):
"""Sort a set's pages by filename, detected page number, or upload order."""
Set.query.get_or_404(set_id)
payload = request.get_json(silent=True) or request.form
strategy = (payload.get('strategy') or 'filename').strip()
reverse = str(payload.get('reverse', '')).lower() in ('1', 'true', 'yes', 'on')
try:
if strategy == page_order.REVERSIBLE:
pages = page_order.reverse_order(set_id)
message = 'Page order reversed.'
else:
pages = page_order.auto_sort(set_id, strategy, reverse=reverse)
label = dict(page_order.strategy_choices()).get(strategy, strategy)
message = f'Sorted {len(pages)} pages by {label.lower()}.'
except ValueError as e:
if request.is_json:
return jsonify({'error': str(e)}), 400
flash(str(e), 'danger')
return redirect(url_for('instructions.organise', set_id=set_id))
except Exception as e:
db.session.rollback()
if request.is_json:
return jsonify({'error': str(e)}), 500
flash(f'Could not sort pages: {e}', 'danger')
return redirect(url_for('instructions.organise', set_id=set_id))
if request.is_json:
return jsonify({
'success': True,
'message': message,
'pages': [p.to_dict() for p in pages],
})
flash(message, 'success')
return redirect(url_for('instructions.organise', set_id=set_id))
@instructions_bp.route('/<int:set_id>/renumber', methods=['POST'])
@login_required
def renumber(set_id):
"""Close gaps and clear duplicate page numbers without moving anything."""
Set.query.get_or_404(set_id)
try:
pages = page_order.renumber(set_id)
except Exception as e:
db.session.rollback()
if request.is_json:
return jsonify({'error': str(e)}), 500
flash(f'Could not renumber pages: {e}', 'danger')
return redirect(url_for('instructions.organise', set_id=set_id))
message = f'Renumbered {len(pages)} pages 1 to {len(pages)}.'
if request.is_json:
return jsonify({'success': True, 'message': message,
'pages': [p.to_dict() for p in pages]})
flash(message, 'success')
return redirect(url_for('instructions.organise', set_id=set_id))
@instructions_bp.route('/<int:instruction_id>/move', methods=['POST'])
@login_required
def move(instruction_id):
"""Move one page to a given position, shuffling the rest around it."""
payload = request.get_json(silent=True) or request.form
position = payload.get('position')
try:
pages = page_order.move_page(instruction_id, int(position))
except (TypeError, ValueError) as e:
db.session.rollback()
return jsonify({'error': str(e)}), 400
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 500
return jsonify({
'success': True,
'pages': [p.to_dict() for p in pages],
})