410 lines
14 KiB
Python
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],
|
|
})
|