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

5
app/models/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
from app.models.user import User
from app.models.set import Set
from app.models.instruction import Instruction
__all__ = ['User', 'Set', 'Instruction']

123
app/models/extra_file.py Normal file
View File

@@ -0,0 +1,123 @@
from datetime import datetime
from app import db
class ExtraFile(db.Model):
"""Model for extra files attached to sets (BrickLink XML, Stud.io, box art, etc)."""
__tablename__ = 'extra_files'
id = db.Column(db.Integer, primary_key=True)
set_id = db.Column(db.Integer, db.ForeignKey('sets.id', ondelete='CASCADE'), nullable=False)
# File information
file_name = db.Column(db.String(255), nullable=False)
original_filename = db.Column(db.String(255), nullable=False) # Original name before hashing
file_path = db.Column(db.String(500), nullable=False)
file_type = db.Column(db.String(50), nullable=False) # Extension
file_size = db.Column(db.Integer, nullable=False) # Size in bytes
# Metadata
description = db.Column(db.Text)
category = db.Column(db.String(50)) # 'bricklink', 'studio', 'box_art', 'document', 'photo', 'other'
# Tracking
uploaded_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
uploaded_by = db.Column(db.Integer, db.ForeignKey('users.id'))
# Relationships
lego_set = db.relationship('Set', back_populates='extra_files')
uploader = db.relationship('User', backref='uploaded_files')
def __repr__(self):
return f'<ExtraFile {self.file_name} for Set {self.set_id}>'
@property
def file_size_formatted(self):
"""Return human-readable file size."""
size = self.file_size
for unit in ['B', 'KB', 'MB', 'GB']:
if size < 1024.0:
return f"{size:.1f} {unit}"
size /= 1024.0
return f"{size:.1f} TB"
@property
def file_icon(self):
"""Return Bootstrap icon class based on file type."""
icon_map = {
# Images
'jpg': 'file-image',
'jpeg': 'file-image',
'png': 'file-image',
'gif': 'file-image',
'webp': 'file-image',
'bmp': 'file-image',
# Documents
'pdf': 'file-pdf',
'doc': 'file-word',
'docx': 'file-word',
'txt': 'file-text',
'rtf': 'file-text',
# Spreadsheets
'xls': 'file-excel',
'xlsx': 'file-excel',
'csv': 'file-spreadsheet',
# Data files
'xml': 'file-code',
'json': 'file-code',
# 3D/CAD files
'ldr': 'box-seam', # LDraw
'mpd': 'box-seam', # LDraw
'io': 'box-seam', # Stud.io
'lxf': 'box-seam', # LEGO Digital Designer
'lxfml': 'box-seam',
# Archives
'zip': 'file-zip',
'rar': 'file-zip',
'7z': 'file-zip',
'tar': 'file-zip',
'gz': 'file-zip',
# Other
'default': 'file-earmark'
}
return icon_map.get(self.file_type.lower(), icon_map['default'])
@property
def is_image(self):
"""Check if file is an image."""
image_types = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp']
return self.file_type.lower() in image_types
@property
def can_preview(self):
"""Check if file can be previewed in browser."""
preview_types = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf', 'txt']
return self.file_type.lower() in preview_types
def to_dict(self):
"""Convert to dictionary for JSON responses."""
return {
'id': self.id,
'set_id': self.set_id,
'file_name': self.file_name,
'original_filename': self.original_filename,
'file_path': self.file_path.replace('\\', '/'),
'file_type': self.file_type,
'file_size': self.file_size,
'file_size_formatted': self.file_size_formatted,
'description': self.description,
'category': self.category,
'uploaded_at': self.uploaded_at.isoformat(),
'is_image': self.is_image,
'can_preview': self.can_preview,
'file_icon': self.file_icon,
'download_url': f'/extra-files/download/{self.id}'
}

60
app/models/instruction.py Normal file
View File

@@ -0,0 +1,60 @@
from datetime import datetime
from app import db
class Instruction(db.Model):
"""Model for instruction files (PDFs or images)."""
__tablename__ = 'instructions'
id = db.Column(db.Integer, primary_key=True)
set_id = db.Column(db.Integer, db.ForeignKey('sets.id'), nullable=False, index=True)
# File information
file_type = db.Column(db.String(10), nullable=False) # 'PDF' or 'IMAGE'
file_path = db.Column(db.String(500), nullable=False)
file_name = db.Column(db.String(200), nullable=False)
file_size = db.Column(db.Integer) # Size in bytes
thumbnail_path = db.Column(db.String(500)) # Thumbnail preview (especially for PDFs)
# For image sequences
page_number = db.Column(db.Integer, default=1)
# Metadata
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
uploaded_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
def __repr__(self):
return f'<Instruction {self.file_name} for Set {self.set_id}>'
def to_dict(self):
"""Convert instruction to dictionary."""
# Ensure forward slashes for web URLs
clean_path = self.file_path.replace('\\', '/')
thumbnail_clean = self.thumbnail_path.replace('\\', '/') if self.thumbnail_path else None
return {
'id': self.id,
'set_id': self.set_id,
'file_type': self.file_type,
'file_name': self.file_name,
'file_size': self.file_size,
'page_number': self.page_number,
'file_url': f'/static/uploads/{clean_path}',
'thumbnail_url': f'/static/uploads/{thumbnail_clean}' if thumbnail_clean else None,
'uploaded_at': self.uploaded_at.isoformat()
}
@property
def file_size_mb(self):
"""Return file size in MB."""
if self.file_size:
return round(self.file_size / (1024 * 1024), 2)
return 0
@staticmethod
def allowed_file(filename):
"""Check if file extension is allowed."""
from flask import current_app
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in current_app.config['ALLOWED_EXTENSIONS']

99
app/models/set.py Normal file
View File

@@ -0,0 +1,99 @@
from datetime import datetime
from app import db
class Set(db.Model):
"""Model for LEGO sets."""
__tablename__ = 'sets'
id = db.Column(db.Integer, primary_key=True)
set_number = db.Column(db.String(20), unique=True, nullable=False, index=True)
set_name = db.Column(db.String(200), nullable=False)
theme = db.Column(db.String(100), nullable=False, index=True)
year_released = db.Column(db.Integer, nullable=False, index=True)
piece_count = db.Column(db.Integer)
# MOC (My Own Creation) support
is_moc = db.Column(db.Boolean, default=False, nullable=False, index=True)
moc_designer = db.Column(db.String(100), nullable=True) # Designer/creator name
moc_description = db.Column(db.Text, nullable=True) # Detailed description
# Brickset integration
brickset_id = db.Column(db.Integer, unique=True, nullable=True)
image_url = db.Column(db.String(500)) # External URL (Brickset, etc.)
cover_image = db.Column(db.String(500)) # Uploaded cover image path
# Metadata
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow,
onupdate=datetime.utcnow)
# Relationships
instructions = db.relationship('Instruction', backref='set', lazy='dynamic',
cascade='all, delete-orphan')
extra_files = db.relationship('ExtraFile', back_populates='lego_set', lazy='dynamic',
cascade='all, delete-orphan', order_by='ExtraFile.uploaded_at.desc()')
def __repr__(self):
return f'<Set {self.set_number}: {self.set_name}>'
def get_image(self):
"""Get the best available image (uploaded cover takes priority)."""
if self.cover_image:
# Ensure forward slashes for web URLs
clean_path = self.cover_image.replace('\\', '/')
return f'/static/uploads/{clean_path}'
return self.image_url
@property
def has_cover_image(self):
"""Check if set has an uploaded cover image."""
return bool(self.cover_image)
@property
def cover_image_url(self):
"""Get the uploaded cover image URL."""
if self.cover_image:
clean_path = self.cover_image.replace('\\', '/')
return f'/static/uploads/{clean_path}'
return None
def to_dict(self):
"""Convert set to dictionary."""
return {
'id': self.id,
'set_number': self.set_number,
'set_name': self.set_name,
'theme': self.theme,
'year_released': self.year_released,
'piece_count': self.piece_count,
'image_url': self.image_url,
'is_moc': self.is_moc,
'moc_designer': self.moc_designer,
'moc_description': self.moc_description,
'instruction_count': self.instructions.count(),
'created_at': self.created_at.isoformat(),
'updated_at': self.updated_at.isoformat()
}
@property
def instruction_files(self):
"""Get all instruction files for this set."""
return self.instructions.order_by(Instruction.page_number).all()
@property
def pdf_instructions(self):
"""Get only PDF instructions."""
return self.instructions.filter_by(file_type='PDF').all()
@property
def image_instructions(self):
"""Get only image instructions."""
return self.instructions.filter_by(file_type='IMAGE').order_by(
Instruction.page_number).all()
# Import here to avoid circular imports
from app.models.instruction import Instruction

42
app/models/user.py Normal file
View File

@@ -0,0 +1,42 @@
from datetime import datetime
from flask_login import UserMixin
from app import db, bcrypt
class User(UserMixin, db.Model):
"""User model for authentication."""
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False, index=True)
email = db.Column(db.String(120), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(128), nullable=False)
is_admin = db.Column(db.Boolean, default=False, nullable=False, index=True)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
# Relationships
sets = db.relationship('Set', backref='added_by', lazy='dynamic',
foreign_keys='Set.user_id')
instructions = db.relationship('Instruction', backref='uploaded_by',
lazy='dynamic')
def __repr__(self):
return f'<User {self.username}>'
def set_password(self, password):
"""Hash and set the user's password."""
self.password_hash = bcrypt.generate_password_hash(password).decode('utf-8')
def check_password(self, password):
"""Check if the provided password matches the hash."""
return bcrypt.check_password_hash(self.password_hash, password)
def to_dict(self):
"""Convert user to dictionary."""
return {
'id': self.id,
'username': self.username,
'email': self.email,
'created_at': self.created_at.isoformat()
}