Initial commit - LEGO Instructions Manager v1.5.0

This commit is contained in:
2025-12-09 17:20:41 +11:00
commit 63496b1ccd
68 changed files with 9131 additions and 0 deletions

6
app/routes/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
from app.routes.auth import auth_bp
from app.routes.main import main_bp
from app.routes.sets import sets_bp
from app.routes.instructions import instructions_bp
__all__ = ['auth_bp', 'main_bp', 'sets_bp', 'instructions_bp']

409
app/routes/admin.py Normal file
View File

@@ -0,0 +1,409 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify
from flask_login import login_required, current_user
from functools import wraps
from app import db
from app.models.user import User
from app.models.set import Set
from app.models.instruction import Instruction
from sqlalchemy import func
from datetime import datetime, timedelta
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
def admin_required(f):
"""Decorator to require admin access."""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
flash('Please log in to access this page.', 'warning')
return redirect(url_for('auth.login'))
if not current_user.is_admin:
flash('You do not have permission to access this page.', 'danger')
return redirect(url_for('main.index'))
return f(*args, **kwargs)
return decorated_function
@admin_bp.route('/')
@login_required
@admin_required
def dashboard():
"""Admin dashboard with site statistics."""
# Get statistics
total_users = User.query.count()
total_sets = Set.query.count()
total_instructions = Instruction.query.count()
total_mocs = Set.query.filter_by(is_moc=True).count()
# Recent activity
recent_users = User.query.order_by(User.created_at.desc()).limit(5).all()
recent_sets = Set.query.order_by(Set.created_at.desc()).limit(10).all()
# Storage statistics
total_storage = db.session.query(
func.sum(Instruction.file_size)
).scalar() or 0
total_storage_mb = round(total_storage / (1024 * 1024), 2)
# Get users with most sets
top_contributors = db.session.query(
User, func.count(Set.id).label('set_count')
).join(Set).group_by(User.id).order_by(
func.count(Set.id).desc()
).limit(5).all()
# Theme statistics
theme_stats = db.session.query(
Set.theme, func.count(Set.id).label('count')
).group_by(Set.theme).order_by(
func.count(Set.id).desc()
).limit(10).all()
return render_template('admin/dashboard.html',
total_users=total_users,
total_sets=total_sets,
total_instructions=total_instructions,
total_mocs=total_mocs,
total_storage_mb=total_storage_mb,
recent_users=recent_users,
recent_sets=recent_sets,
top_contributors=top_contributors,
theme_stats=theme_stats)
@admin_bp.route('/users')
@login_required
@admin_required
def users():
"""User management page."""
page = request.args.get('page', 1, type=int)
per_page = 20
# Search functionality
search = request.args.get('search', '')
query = User.query
if search:
query = query.filter(
(User.username.ilike(f'%{search}%')) |
(User.email.ilike(f'%{search}%'))
)
pagination = query.order_by(User.created_at.desc()).paginate(
page=page, per_page=per_page, error_out=False
)
# Get set counts for each user
user_stats = {}
for user in pagination.items:
user_stats[user.id] = {
'sets': user.sets.count(),
'instructions': user.instructions.count()
}
return render_template('admin/users.html',
users=pagination.items,
pagination=pagination,
user_stats=user_stats,
search=search)
@admin_bp.route('/users/<int:user_id>/toggle-admin', methods=['POST'])
@login_required
@admin_required
def toggle_admin(user_id):
"""Toggle admin status for a user."""
if user_id == current_user.id:
return jsonify({'error': 'Cannot change your own admin status'}), 400
user = User.query.get_or_404(user_id)
user.is_admin = not user.is_admin
db.session.commit()
status = 'granted' if user.is_admin else 'revoked'
flash(f'Admin access {status} for {user.username}', 'success')
return jsonify({'success': True, 'is_admin': user.is_admin})
@admin_bp.route('/users/<int:user_id>/delete', methods=['POST'])
@login_required
@admin_required
def delete_user(user_id):
"""Delete a user and optionally their data."""
if user_id == current_user.id:
flash('Cannot delete your own account from admin panel', 'danger')
return redirect(url_for('admin.users'))
user = User.query.get_or_404(user_id)
username = user.username
# Check if should delete user's data
delete_data = request.form.get('delete_data') == 'on'
if delete_data:
# Delete user's sets and instructions
Set.query.filter_by(user_id=user_id).delete()
Instruction.query.filter_by(user_id=user_id).delete()
else:
# Reassign to admin (current user)
Set.query.filter_by(user_id=user_id).update({'user_id': current_user.id})
Instruction.query.filter_by(user_id=user_id).update({'user_id': current_user.id})
db.session.delete(user)
db.session.commit()
flash(f'User {username} has been deleted', 'success')
return redirect(url_for('admin.users'))
@admin_bp.route('/sets')
@login_required
@admin_required
def sets():
"""View all sets in the system."""
page = request.args.get('page', 1, type=int)
per_page = 20
# Filter options
filter_type = request.args.get('type', 'all')
search = request.args.get('search', '')
query = Set.query
# Apply filters
if filter_type == 'mocs':
query = query.filter_by(is_moc=True)
elif filter_type == 'official':
query = query.filter_by(is_moc=False)
if search:
query = query.filter(
(Set.set_number.ilike(f'%{search}%')) |
(Set.set_name.ilike(f'%{search}%')) |
(Set.theme.ilike(f'%{search}%'))
)
pagination = query.order_by(Set.created_at.desc()).paginate(
page=page, per_page=per_page, error_out=False
)
return render_template('admin/sets.html',
sets=pagination.items,
pagination=pagination,
filter_type=filter_type,
search=search)
@admin_bp.route('/sets/<int:set_id>/delete', methods=['POST'])
@login_required
@admin_required
def delete_set(set_id):
"""Delete a set and its instructions."""
lego_set = Set.query.get_or_404(set_id)
set_number = lego_set.set_number
# Delete instructions (cascade should handle this)
db.session.delete(lego_set)
db.session.commit()
flash(f'Set {set_number} has been deleted', 'success')
return redirect(url_for('admin.sets'))
@admin_bp.route('/site-settings', methods=['GET', 'POST'])
@login_required
@admin_required
def site_settings():
"""Site-wide settings configuration."""
if request.method == 'POST':
# This is a placeholder for future site settings
# You could store settings in a database table or config file
flash('Settings updated successfully', 'success')
return redirect(url_for('admin.site_settings'))
# Get current stats for display
stats = {
'total_users': User.query.count(),
'total_sets': Set.query.count(),
'total_instructions': Instruction.query.count(),
'total_storage': db.session.query(func.sum(Instruction.file_size)).scalar() or 0
}
return render_template('admin/settings.html', stats=stats)
@admin_bp.route('/api/stats')
@login_required
@admin_required
def api_stats():
"""API endpoint for real-time statistics."""
# Get stats for the last 30 days
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
new_users = User.query.filter(User.created_at >= thirty_days_ago).count()
new_sets = Set.query.filter(Set.created_at >= thirty_days_ago).count()
return jsonify({
'total_users': User.query.count(),
'total_sets': Set.query.count(),
'total_instructions': Instruction.query.count(),
'new_users_30d': new_users,
'new_sets_30d': new_sets
})
@admin_bp.route('/bulk-import', methods=['GET', 'POST'])
@login_required
@admin_required
def bulk_import():
"""Bulk import sets from Brickset."""
from app.services.brickset_api import BricksetAPI
import time
if request.method == 'POST':
# Get form data
set_numbers_text = request.form.get('set_numbers', '')
user_id = request.form.get('user_id')
throttle_delay = float(request.form.get('throttle_delay', '0.5')) # Default 0.5 seconds
# Parse set numbers (split by newlines, commas, or spaces)
import re
set_numbers = re.split(r'[,\s\n]+', set_numbers_text.strip())
set_numbers = [s.strip() for s in set_numbers if s.strip()]
if not set_numbers:
flash('Please enter at least one set number.', 'warning')
return redirect(url_for('admin.bulk_import'))
if not user_id:
flash('Please select a user.', 'warning')
return redirect(url_for('admin.bulk_import'))
# Warn if trying to import too many at once
if len(set_numbers) > 50:
flash('Warning: Importing more than 50 sets may take a while. Consider splitting into smaller batches.', 'warning')
# Check if Brickset is configured
if not BricksetAPI.is_configured():
flash('Brickset API is not configured. Please add credentials to .env file.', 'danger')
return redirect(url_for('admin.bulk_import'))
# Import sets with throttling
api = BricksetAPI()
results = {
'success': [],
'failed': [],
'already_exists': [],
'rate_limited': []
}
for index, set_number in enumerate(set_numbers, 1):
try:
# Check if set already exists
existing_set = Set.query.filter_by(set_number=set_number).first()
if existing_set:
results['already_exists'].append({
'set_number': set_number,
'name': existing_set.set_name
})
continue
# Add delay between requests to respect rate limits
if index > 1: # Don't delay on first request
time.sleep(throttle_delay)
# Fetch from Brickset
set_data = api.get_set_by_number(set_number)
if not set_data:
results['failed'].append({
'set_number': set_number,
'reason': 'Not found in Brickset'
})
continue
# Create set in database
new_set = Set(
set_number=set_number,
set_name=set_data.get('name', 'Unknown'),
theme=set_data.get('theme', 'Unknown'),
year_released=set_data.get('year', datetime.now().year),
piece_count=set_data.get('pieces', 0),
image_url=set_data.get('image', {}).get('imageURL'),
user_id=user_id,
is_moc=False
)
db.session.add(new_set)
results['success'].append({
'set_number': set_number,
'name': new_set.set_name,
'theme': new_set.theme
})
current_app.logger.info(f"Imported set {index}/{len(set_numbers)}: {set_number}")
except Exception as e:
error_msg = str(e)
if 'API limit exceeded' in error_msg or 'rate limit' in error_msg.lower():
results['rate_limited'].append({
'set_number': set_number,
'reason': 'API rate limit exceeded'
})
current_app.logger.warning(f"Rate limit hit on set {set_number}, stopping import")
# Stop processing remaining sets
break
else:
results['failed'].append({
'set_number': set_number,
'reason': error_msg
})
current_app.logger.error(f"Failed to import {set_number}: {error_msg}")
# Commit all successful imports
if results['success']:
db.session.commit()
flash(f"Successfully imported {len(results['success'])} set(s)!", 'success')
if results['rate_limited']:
remaining = len(set_numbers) - len(results['success']) - len(results['failed']) - len(results['already_exists']) - len(results['rate_limited'])
flash(f"API rate limit reached after {len(results['success'])} imports. "
f"{len(results['rate_limited'])} set(s) not processed due to rate limit. "
f"Try again in a few minutes or increase the throttle delay.", 'warning')
if results['failed']:
flash(f"Failed to import {len(results['failed'])} set(s).", 'warning')
if results['already_exists']:
flash(f"{len(results['already_exists'])} set(s) already exist in database.", 'info')
# Store results in session for display
from flask import session
session['import_results'] = results
return redirect(url_for('admin.bulk_import_results'))
# GET request - show form
users = User.query.order_by(User.username).all()
brickset_configured = BricksetAPI.is_configured()
return render_template('admin/bulk_import.html',
users=users,
brickset_configured=brickset_configured)
@admin_bp.route('/bulk-import/results')
@login_required
@admin_required
def bulk_import_results():
"""Display bulk import results."""
from flask import session
results = session.pop('import_results', None)
if not results:
flash('No import results to display.', 'info')
return redirect(url_for('admin.bulk_import'))
return render_template('admin/bulk_import_results.html', results=results)

102
app/routes/auth.py Normal file
View File

@@ -0,0 +1,102 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_user, logout_user, login_required, current_user
from app import db
from app.models.user import User
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
@auth_bp.route('/register', methods=['GET', 'POST'])
def register():
"""User registration."""
if current_user.is_authenticated:
return redirect(url_for('main.index'))
if request.method == 'POST':
username = request.form.get('username')
email = request.form.get('email')
password = request.form.get('password')
confirm_password = request.form.get('confirm_password')
# Validation
if not all([username, email, password, confirm_password]):
flash('All fields are required.', 'danger')
return render_template('auth/register.html')
if password != confirm_password:
flash('Passwords do not match.', 'danger')
return render_template('auth/register.html')
if len(password) < 6:
flash('Password must be at least 6 characters long.', 'danger')
return render_template('auth/register.html')
# Check if user already exists
if User.query.filter_by(username=username).first():
flash('Username already exists.', 'danger')
return render_template('auth/register.html')
if User.query.filter_by(email=email).first():
flash('Email already registered.', 'danger')
return render_template('auth/register.html')
# Create new user
user = User(username=username, email=email)
user.set_password(password)
db.session.add(user)
db.session.commit()
flash('Registration successful! Please log in.', 'success')
return redirect(url_for('auth.login'))
return render_template('auth/register.html')
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
"""User login."""
if current_user.is_authenticated:
return redirect(url_for('main.index'))
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
remember = request.form.get('remember', False)
if not username or not password:
flash('Please provide both username and password.', 'danger')
return render_template('auth/login.html')
user = User.query.filter_by(username=username).first()
if user and user.check_password(password):
login_user(user, remember=bool(remember))
next_page = request.args.get('next')
flash(f'Welcome back, {user.username}!', 'success')
return redirect(next_page) if next_page else redirect(url_for('main.index'))
else:
flash('Invalid username or password.', 'danger')
return render_template('auth/login.html')
@auth_bp.route('/logout')
@login_required
def logout():
"""User logout."""
logout_user()
flash('You have been logged out.', 'info')
return redirect(url_for('auth.login'))
@auth_bp.route('/profile')
@login_required
def profile():
"""User profile page."""
set_count = current_user.sets.count()
instruction_count = current_user.instructions.count()
return render_template('auth/profile.html',
set_count=set_count,
instruction_count=instruction_count)

273
app/routes/extra_files.py Normal file
View File

@@ -0,0 +1,273 @@
import os
from flask import Blueprint, render_template, redirect, url_for, flash, request, send_file, current_app, jsonify
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.extra_file import ExtraFile
import uuid
extra_files_bp = Blueprint('extra_files', __name__)
ALLOWED_EXTENSIONS = {
# Images
'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg',
# Documents
'pdf', 'doc', 'docx', 'txt', 'rtf', 'odt',
# Spreadsheets
'xls', 'xlsx', 'csv', 'ods',
# Data files
'xml', 'json', 'yaml', 'yml',
# 3D/CAD files
'ldr', 'mpd', 'io', 'lxf', 'lxfml', 'stl', 'obj',
# Archives
'zip', 'rar', '7z', 'tar', 'gz',
# Other
'md', 'html', 'css', 'js'
}
# File categories
FILE_CATEGORIES = {
'bricklink': ['xml'],
'studio': ['io'],
'ldraw': ['ldr', 'mpd'],
'ldd': ['lxf', 'lxfml'],
'box_art': ['jpg', 'jpeg', 'png', 'gif', 'webp'],
'document': ['pdf', 'doc', 'docx', 'txt', 'rtf'],
'photo': ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'],
'data': ['xml', 'json', 'csv', 'xlsx', 'xls'],
'archive': ['zip', 'rar', '7z', 'tar', 'gz'],
'other': []
}
def allowed_file(filename):
"""Check if file extension is allowed."""
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def get_file_category(file_extension):
"""Determine file category based on extension."""
ext = file_extension.lower()
# Check each category
for category, extensions in FILE_CATEGORIES.items():
if ext in extensions:
return category
return 'other'
@extra_files_bp.route('/upload/<int:set_id>', methods=['GET', 'POST'])
@login_required
def upload(set_id):
"""Upload extra files for a set."""
lego_set = Set.query.get_or_404(set_id)
# Check permission
if lego_set.user_id != current_user.id and not current_user.is_admin:
flash('You do not have permission to upload files to this set.', 'danger')
return redirect(url_for('sets.view_set', set_id=set_id))
if request.method == 'POST':
# Check if files were uploaded
if 'files' not in request.files:
flash('No files selected.', 'warning')
return redirect(request.url)
files = request.files.getlist('files')
description = request.form.get('description', '').strip()
category = request.form.get('category', 'other')
if not files or all(file.filename == '' for file in files):
flash('No files selected.', 'warning')
return redirect(request.url)
uploaded_count = 0
failed_files = []
for file in files:
if file and file.filename:
if not allowed_file(file.filename):
failed_files.append(f"{file.filename} (unsupported file type)")
continue
try:
# Secure the filename
original_filename = secure_filename(file.filename)
file_extension = original_filename.rsplit('.', 1)[1].lower()
# Generate unique filename
unique_filename = f"{uuid.uuid4().hex}.{file_extension}"
# Create directory for extra files
extra_files_dir = os.path.join(current_app.config['UPLOAD_FOLDER'],
'extra_files',
str(lego_set.set_number))
os.makedirs(extra_files_dir, exist_ok=True)
# Save file
file_path = os.path.join(extra_files_dir, unique_filename)
file.save(file_path)
# Get file size
file_size = os.path.getsize(file_path)
# Determine category if auto
if category == 'auto':
category = get_file_category(file_extension)
# Create database record
relative_path = os.path.join('extra_files',
str(lego_set.set_number),
unique_filename)
extra_file = ExtraFile(
set_id=lego_set.id,
file_name=unique_filename,
original_filename=original_filename,
file_path=relative_path,
file_type=file_extension,
file_size=file_size,
description=description if description else None,
category=category,
uploaded_by=current_user.id
)
db.session.add(extra_file)
uploaded_count += 1
except Exception as e:
failed_files.append(f"{file.filename} ({str(e)})")
current_app.logger.error(f"Error uploading file {file.filename}: {str(e)}")
# Commit all successful uploads
if uploaded_count > 0:
db.session.commit()
flash(f'Successfully uploaded {uploaded_count} file(s)!', 'success')
if failed_files:
flash(f"Failed to upload {len(failed_files)} file(s): {', '.join(failed_files)}", 'warning')
return redirect(url_for('sets.view_set', set_id=set_id))
# GET request - show upload form
return render_template('extra_files/upload.html',
lego_set=lego_set,
file_categories=FILE_CATEGORIES)
@extra_files_bp.route('/download/<int:file_id>')
@login_required
def download(file_id):
"""Download an extra file."""
extra_file = ExtraFile.query.get_or_404(file_id)
# Check permission
if extra_file.lego_set.user_id != current_user.id and not current_user.is_admin:
flash('You do not have permission to download this file.', 'danger')
return redirect(url_for('main.index'))
# Get full file path
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], extra_file.file_path)
if not os.path.exists(file_path):
flash('File not found.', 'danger')
return redirect(url_for('sets.view_set', set_id=extra_file.set_id))
return send_file(
file_path,
as_attachment=True,
download_name=extra_file.original_filename
)
@extra_files_bp.route('/preview/<int:file_id>')
@login_required
def preview(file_id):
"""Preview a file (for images and PDFs)."""
extra_file = ExtraFile.query.get_or_404(file_id)
# Check permission
if extra_file.lego_set.user_id != current_user.id and not current_user.is_admin:
flash('You do not have permission to view this file.', 'danger')
return redirect(url_for('main.index'))
if not extra_file.can_preview:
flash('This file type cannot be previewed.', 'info')
return redirect(url_for('extra_files.download', file_id=file_id))
# Get full file path
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], extra_file.file_path)
if not os.path.exists(file_path):
flash('File not found.', 'danger')
return redirect(url_for('sets.view_set', set_id=extra_file.set_id))
return send_file(file_path)
@extra_files_bp.route('/delete/<int:file_id>', methods=['POST'])
@login_required
def delete(file_id):
"""Delete an extra file."""
extra_file = ExtraFile.query.get_or_404(file_id)
set_id = extra_file.set_id
# Check permission
if extra_file.lego_set.user_id != current_user.id and not current_user.is_admin:
flash('You do not have permission to delete this file.', 'danger')
return redirect(url_for('sets.view_set', set_id=set_id))
try:
# Delete physical file
file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], extra_file.file_path)
if os.path.exists(file_path):
os.remove(file_path)
# Delete database record
db.session.delete(extra_file)
db.session.commit()
flash('File deleted successfully!', 'success')
except Exception as e:
db.session.rollback()
flash(f'Error deleting file: {str(e)}', 'danger')
current_app.logger.error(f"Error deleting extra file {file_id}: {str(e)}")
return redirect(url_for('sets.view_set', set_id=set_id))
@extra_files_bp.route('/edit/<int:file_id>', methods=['POST'])
@login_required
def edit(file_id):
"""Edit file description and category."""
extra_file = ExtraFile.query.get_or_404(file_id)
# Check permission
if extra_file.lego_set.user_id != current_user.id and not current_user.is_admin:
return jsonify({'success': False, 'error': 'Permission denied'}), 403
try:
extra_file.description = request.form.get('description', '').strip() or None
extra_file.category = request.form.get('category', 'other')
db.session.commit()
if request.is_json or request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'success': True, 'message': 'File updated successfully'})
else:
flash('File updated successfully!', 'success')
return redirect(url_for('sets.view_set', set_id=extra_file.set_id))
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error updating extra file {file_id}: {str(e)}")
if request.is_json or request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'success': False, 'error': str(e)}), 500
else:
flash(f'Error updating file: {str(e)}', 'danger')
return redirect(url_for('sets.view_set', set_id=extra_file.set_id))

305
app/routes/instructions.py Normal file
View File

@@ -0,0 +1,305 @@
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
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':
# Get the highest page number for this set
max_page = db.session.query(
db.func.max(Instruction.page_number)
).filter_by(
set_id=set_id,
file_type='IMAGE'
).scalar()
page_number = (max_page or 0) + 1
# 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):
"""Reorder image instructions for a set."""
lego_set = Set.query.get_or_404(set_id)
# Get new order from request
new_order = request.json.get('order', [])
if not new_order:
return jsonify({'error': 'No order provided'}), 400
try:
# Update page numbers
for index, instruction_id in enumerate(new_order, start=1):
instruction = Instruction.query.get(instruction_id)
if instruction and instruction.set_id == set_id:
instruction.page_number = index
db.session.commit()
return jsonify({'success': True})
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
file_path, file_size = FileHandler.save_file(
file,
lego_set.set_number,
file_type
)
# Determine page number for images
page_number = 1
if file_type == 'IMAGE':
max_page = db.session.query(
db.func.max(Instruction.page_number)
).filter_by(
set_id=set_id,
file_type='IMAGE'
).scalar()
page_number = (max_page or 0) + 1
# 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,
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)

48
app/routes/main.py Normal file
View File

@@ -0,0 +1,48 @@
from flask import Blueprint, render_template, redirect, url_for
from flask_login import login_required
from app.models.set import Set
from app.models.instruction import Instruction
from sqlalchemy import func
main_bp = Blueprint('main', __name__)
@main_bp.route('/')
def index():
"""Homepage."""
return render_template('index.html')
@main_bp.route('/dashboard')
@login_required
def dashboard():
"""User dashboard with statistics and recent sets."""
# Get statistics
total_sets = Set.query.count()
total_instructions = Instruction.query.count()
# Get theme statistics
theme_stats = db.session.query(
Set.theme,
func.count(Set.id).label('count')
).group_by(Set.theme).order_by(func.count(Set.id).desc()).limit(5).all()
# Get recent sets
recent_sets = Set.query.order_by(Set.created_at.desc()).limit(6).all()
# Get sets by year
year_stats = db.session.query(
Set.year_released,
func.count(Set.id).label('count')
).group_by(Set.year_released).order_by(Set.year_released.desc()).limit(10).all()
return render_template('dashboard.html',
total_sets=total_sets,
total_instructions=total_instructions,
theme_stats=theme_stats,
year_stats=year_stats,
recent_sets=recent_sets)
# Import here to avoid circular imports at module level
from app import db

274
app/routes/sets.py Normal file
View File

@@ -0,0 +1,274 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, current_app
from flask_login import login_required, current_user
import os
from app import db
from app.models.set import Set
from app.models.instruction import Instruction
from app.services.brickset_api import BricksetAPI
from app.services.file_handler import FileHandler
sets_bp = Blueprint('sets', __name__, url_prefix='/sets')
@sets_bp.route('/debug/<int:set_id>')
@login_required
def debug_set_image(set_id):
"""Debug route to check image paths."""
lego_set = Set.query.get_or_404(set_id)
debug_info = {
'set_number': lego_set.set_number,
'set_name': lego_set.set_name,
'cover_image_db': lego_set.cover_image,
'image_url_db': lego_set.image_url,
'get_image_result': lego_set.get_image(),
'cover_image_exists': False,
'cover_image_full_path': None
}
# Check if file actually exists
if lego_set.cover_image:
from flask import current_app
full_path = os.path.join(current_app.config['UPLOAD_FOLDER'], lego_set.cover_image)
debug_info['cover_image_full_path'] = full_path
debug_info['cover_image_exists'] = os.path.exists(full_path)
return jsonify(debug_info)
@sets_bp.route('/')
@login_required
def list_sets():
"""List all LEGO sets with sorting and filtering."""
page = request.args.get('page', 1, type=int)
sort_by = request.args.get('sort', 'set_number')
theme_filter = request.args.get('theme', '')
year_filter = request.args.get('year', type=int)
search_query = request.args.get('q', '')
# Build query
query = Set.query
# Apply filters
if theme_filter:
query = query.filter(Set.theme == theme_filter)
if year_filter:
query = query.filter(Set.year_released == year_filter)
if search_query:
query = query.filter(
db.or_(
Set.set_name.ilike(f'%{search_query}%'),
Set.set_number.ilike(f'%{search_query}%')
)
)
# Apply sorting
if sort_by == 'set_number':
query = query.order_by(Set.set_number)
elif sort_by == 'name':
query = query.order_by(Set.set_name)
elif sort_by == 'theme':
query = query.order_by(Set.theme, Set.set_number)
elif sort_by == 'year':
query = query.order_by(Set.year_released.desc(), Set.set_number)
elif sort_by == 'newest':
query = query.order_by(Set.created_at.desc())
# Paginate
from flask import current_app
per_page = current_app.config.get('SETS_PER_PAGE', 20)
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
# Get unique themes and years for filters
themes = db.session.query(Set.theme).distinct().order_by(Set.theme).all()
themes = [t[0] for t in themes]
years = db.session.query(Set.year_released).distinct().order_by(Set.year_released.desc()).all()
years = [y[0] for y in years]
return render_template('sets/list.html',
sets=pagination.items,
pagination=pagination,
themes=themes,
years=years,
current_theme=theme_filter,
current_year=year_filter,
current_sort=sort_by,
search_query=search_query)
@sets_bp.route('/<int:set_id>')
@login_required
def view_set(set_id):
"""View detailed information about a specific set."""
lego_set = Set.query.get_or_404(set_id)
# Get instructions grouped by type
pdf_instructions = lego_set.pdf_instructions
image_instructions = lego_set.image_instructions
return render_template('sets/detail.html',
set=lego_set,
pdf_instructions=pdf_instructions,
image_instructions=image_instructions)
@sets_bp.route('/add', methods=['GET', 'POST'])
@login_required
def add_set():
"""Add a new LEGO set or MOC."""
if request.method == 'POST':
set_number = request.form.get('set_number', '').strip()
set_name = request.form.get('set_name', '').strip()
theme = request.form.get('theme', '').strip()
year_released = request.form.get('year_released', type=int)
piece_count = request.form.get('piece_count', type=int)
image_url = request.form.get('image_url', '').strip()
# MOC fields
is_moc = request.form.get('is_moc') == 'on'
moc_designer = request.form.get('moc_designer', '').strip() if is_moc else None
moc_description = request.form.get('moc_description', '').strip() if is_moc else None
# Validation
if not all([set_number, set_name, theme, year_released]):
flash('Set number, name, theme, and year are required.', 'danger')
return render_template('sets/add.html')
# Check if set already exists
if Set.query.filter_by(set_number=set_number).first():
flash(f'Set {set_number} already exists in the database.', 'warning')
return redirect(url_for('sets.list_sets'))
# Handle cover image upload
cover_image_path = None
if 'cover_image' in request.files:
file = request.files['cover_image']
if file and file.filename and FileHandler.allowed_file(file.filename):
try:
cover_image_path, _ = FileHandler.save_cover_image(file, set_number)
except Exception as e:
flash(f'Error uploading cover image: {str(e)}', 'warning')
# Create new set
new_set = Set(
set_number=set_number,
set_name=set_name,
theme=theme,
year_released=year_released,
piece_count=piece_count,
image_url=image_url,
cover_image=cover_image_path,
is_moc=is_moc,
moc_designer=moc_designer,
moc_description=moc_description,
user_id=current_user.id
)
db.session.add(new_set)
db.session.commit()
set_type = "MOC" if is_moc else "Set"
flash(f'{set_type} {set_number}: {set_name} added successfully!', 'success')
return redirect(url_for('sets.view_set', set_id=new_set.id))
return render_template('sets/add.html')
@sets_bp.route('/<int:set_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_set(set_id):
"""Edit an existing LEGO set or MOC."""
lego_set = Set.query.get_or_404(set_id)
if request.method == 'POST':
lego_set.set_name = request.form.get('set_name', '').strip()
lego_set.theme = request.form.get('theme', '').strip()
lego_set.year_released = request.form.get('year_released', type=int)
lego_set.piece_count = request.form.get('piece_count', type=int)
lego_set.image_url = request.form.get('image_url', '').strip()
# Update MOC fields
lego_set.is_moc = request.form.get('is_moc') == 'on'
lego_set.moc_designer = request.form.get('moc_designer', '').strip() if lego_set.is_moc else None
lego_set.moc_description = request.form.get('moc_description', '').strip() if lego_set.is_moc else None
# Handle cover image upload
if 'cover_image' in request.files:
file = request.files['cover_image']
if file and file.filename and FileHandler.allowed_file(file.filename):
try:
# Delete old cover image if exists
if lego_set.cover_image:
old_path = os.path.join(current_app.config['UPLOAD_FOLDER'], lego_set.cover_image)
if os.path.exists(old_path):
os.remove(old_path)
# Save new cover image
cover_image_path, _ = FileHandler.save_cover_image(file, lego_set.set_number)
lego_set.cover_image = cover_image_path
except Exception as e:
flash(f'Error uploading cover image: {str(e)}', 'warning')
# Option to remove cover image
if request.form.get('remove_cover_image') == 'on':
if lego_set.cover_image:
old_path = os.path.join(current_app.config['UPLOAD_FOLDER'], lego_set.cover_image)
if os.path.exists(old_path):
os.remove(old_path)
lego_set.cover_image = None
db.session.commit()
flash('Set updated successfully!', 'success')
return redirect(url_for('sets.view_set', set_id=set_id))
return render_template('sets/edit.html', set=lego_set)
@sets_bp.route('/<int:set_id>/delete', methods=['POST'])
@login_required
def delete_set(set_id):
"""Delete a LEGO set and all its instructions."""
lego_set = Set.query.get_or_404(set_id)
# Delete associated files
from app.services.file_handler import FileHandler
for instruction in lego_set.instructions:
FileHandler.delete_file(instruction.file_path)
db.session.delete(lego_set)
db.session.commit()
flash(f'Set {lego_set.set_number} deleted successfully.', 'success')
return redirect(url_for('sets.list_sets'))
@sets_bp.route('/search-brickset')
@login_required
def search_brickset():
"""Search for sets using Brickset API."""
query = request.args.get('q', '').strip()
if not query:
return jsonify({'error': 'Search query is required'}), 400
api = BricksetAPI()
# Try to search by set number first, then by name
results = api.search_sets(set_number=query)
if not results:
results = api.search_sets(query=query)
# Format results for JSON response
formatted_results = []
for result in results[:10]: # Limit to 10 results
formatted_results.append({
'setNumber': result.get('number'),
'name': result.get('name'),
'theme': result.get('theme'),
'year': result.get('year'),
'pieces': result.get('pieces'),
'imageUrl': result.get('image', {}).get('imageURL') if result.get('image') else None
})
return jsonify(formatted_results)