Initial commit - LEGO Instructions Manager v1.5.0
This commit is contained in:
6
app/routes/__init__.py
Normal file
6
app/routes/__init__.py
Normal 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
409
app/routes/admin.py
Normal 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
102
app/routes/auth.py
Normal 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
273
app/routes/extra_files.py
Normal 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
305
app/routes/instructions.py
Normal 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
48
app/routes/main.py
Normal 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
274
app/routes/sets.py
Normal 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)
|
||||
Reference in New Issue
Block a user