Initial commit - LEGO Instructions Manager v1.5.0
This commit is contained in:
5
app/models/__init__.py
Normal file
5
app/models/__init__.py
Normal 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
123
app/models/extra_file.py
Normal 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
60
app/models/instruction.py
Normal 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
99
app/models/set.py
Normal 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
42
app/models/user.py
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user