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/', 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('//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/', 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('//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('//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/', 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/') @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/') @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('//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('//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('//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('//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], })