From 63496b1ccd5f91e3b937eeb8df00348ebb4fc587 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Tue, 9 Dec 2025 17:20:41 +1100 Subject: [PATCH] Initial commit - LEGO Instructions Manager v1.5.0 --- .env.example | 21 + .gitignore | 42 ++ 0.6.0 | 9 + 1.0.0 | 1 + 1.2.0 | 10 + 1.23.0 | 1 + 2.31.0 | 5 + 3.0.0 | 1 + 3.1.0 | 12 + PROJECT_SUMMARY.md | 380 +++++++++++++++++ QUICK_REFERENCE.md | 235 +++++++++++ README.md | 254 ++++++++++++ SETUP_GUIDE.md | 324 +++++++++++++++ add_admin_column.py | 82 ++++ add_extra_files_table.py | 109 +++++ app/__init__.py | 78 ++++ app/config.py | 59 +++ app/models/__init__.py | 5 + app/models/extra_file.py | 123 ++++++ app/models/instruction.py | 60 +++ app/models/set.py | 99 +++++ app/models/user.py | 42 ++ app/routes/__init__.py | 6 + app/routes/admin.py | 409 ++++++++++++++++++ app/routes/auth.py | 102 +++++ app/routes/extra_files.py | 273 ++++++++++++ app/routes/instructions.py | 305 ++++++++++++++ app/routes/main.py | 48 +++ app/routes/sets.py | 274 ++++++++++++ app/services/__init__.py | 4 + app/services/brickset_api.py | 198 +++++++++ app/services/file_handler.py | 317 ++++++++++++++ app/static/css/style.css | 146 +++++++ app/static/favicon.ico | Bin 0 -> 16958 bytes app/static/js/.gitkeep | 0 app/templates/admin/bulk_import.html | 260 ++++++++++++ app/templates/admin/bulk_import_results.html | 255 ++++++++++++ app/templates/admin/dashboard.html | 284 +++++++++++++ app/templates/admin/sets.html | 99 +++++ app/templates/admin/settings.html | 63 +++ app/templates/admin/users.html | 211 ++++++++++ app/templates/auth/login.html | 49 +++ app/templates/auth/profile.html | 71 ++++ app/templates/auth/register.html | 54 +++ app/templates/base.html | 133 ++++++ app/templates/dashboard.html | 209 ++++++++++ app/templates/extra_files/upload.html | 162 ++++++++ app/templates/index.html | 112 +++++ app/templates/instructions/upload.html | 244 +++++++++++ app/templates/instructions/viewer.html | 380 +++++++++++++++++ app/templates/sets/add.html | 324 +++++++++++++++ app/templates/sets/detail.html | 415 +++++++++++++++++++ app/templates/sets/edit.html | 211 ++++++++++ app/templates/sets/list.html | 192 +++++++++ check_admin.py | 135 ++++++ cleanup_folders.py | 173 ++++++++ fix_paths.py | 178 ++++++++ fresh_git_upload.bat | 232 +++++++++++ install.bat | 53 +++ migrate_add_cover_image.py | 93 +++++ migrate_add_moc_support.py | 124 ++++++ migrate_v1.3.0.py | 156 +++++++ poo.py | 13 + push_to_gitea.bat | 79 ++++ requirements-flexible.txt | 40 ++ requirements.txt | 40 ++ run.py | 53 +++ tests/__init__.py | 0 68 files changed, 9131 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 0.6.0 create mode 100644 1.0.0 create mode 100644 1.2.0 create mode 100644 1.23.0 create mode 100644 2.31.0 create mode 100644 3.0.0 create mode 100644 3.1.0 create mode 100644 PROJECT_SUMMARY.md create mode 100644 QUICK_REFERENCE.md create mode 100644 README.md create mode 100644 SETUP_GUIDE.md create mode 100644 add_admin_column.py create mode 100644 add_extra_files_table.py create mode 100644 app/__init__.py create mode 100644 app/config.py create mode 100644 app/models/__init__.py create mode 100644 app/models/extra_file.py create mode 100644 app/models/instruction.py create mode 100644 app/models/set.py create mode 100644 app/models/user.py create mode 100644 app/routes/__init__.py create mode 100644 app/routes/admin.py create mode 100644 app/routes/auth.py create mode 100644 app/routes/extra_files.py create mode 100644 app/routes/instructions.py create mode 100644 app/routes/main.py create mode 100644 app/routes/sets.py create mode 100644 app/services/__init__.py create mode 100644 app/services/brickset_api.py create mode 100644 app/services/file_handler.py create mode 100644 app/static/css/style.css create mode 100644 app/static/favicon.ico create mode 100644 app/static/js/.gitkeep create mode 100644 app/templates/admin/bulk_import.html create mode 100644 app/templates/admin/bulk_import_results.html create mode 100644 app/templates/admin/dashboard.html create mode 100644 app/templates/admin/sets.html create mode 100644 app/templates/admin/settings.html create mode 100644 app/templates/admin/users.html create mode 100644 app/templates/auth/login.html create mode 100644 app/templates/auth/profile.html create mode 100644 app/templates/auth/register.html create mode 100644 app/templates/base.html create mode 100644 app/templates/dashboard.html create mode 100644 app/templates/extra_files/upload.html create mode 100644 app/templates/index.html create mode 100644 app/templates/instructions/upload.html create mode 100644 app/templates/instructions/viewer.html create mode 100644 app/templates/sets/add.html create mode 100644 app/templates/sets/detail.html create mode 100644 app/templates/sets/edit.html create mode 100644 app/templates/sets/list.html create mode 100644 check_admin.py create mode 100644 cleanup_folders.py create mode 100644 fix_paths.py create mode 100644 fresh_git_upload.bat create mode 100644 install.bat create mode 100644 migrate_add_cover_image.py create mode 100644 migrate_add_moc_support.py create mode 100644 migrate_v1.3.0.py create mode 100644 poo.py create mode 100644 push_to_gitea.bat create mode 100644 requirements-flexible.txt create mode 100644 requirements.txt create mode 100644 run.py create mode 100644 tests/__init__.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4e8cf90 --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# Flask Configuration +FLASK_APP=run.py +FLASK_ENV=development +SECRET_KEY=your-secret-key-change-this-in-production + +# Database Configuration +DATABASE_URL=sqlite:///lego_instructions.db +# For PostgreSQL: postgresql://username:password@localhost/lego_instructions + +# Brickset API Configuration +BRICKSET_API_KEY=your-brickset-api-key-here +BRICKSET_USERNAME=your-brickset-username +BRICKSET_PASSWORD=your-brickset-password + +# Upload Configuration +UPLOAD_FOLDER=app/static/uploads +MAX_CONTENT_LENGTH=52428800 # 50MB max file size +ALLOWED_EXTENSIONS=pdf,png,jpg,jpeg,gif + +# Application Configuration +SETS_PER_PAGE=20 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..64dcf47 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.Python +env/ +venv/ +ENV/ +build/ +dist/ +*.egg-info/ + +# Flask +instance/ +.webassets-cache + +# Environment +.env +.venv + +# Database +*.db +*.sqlite +*.sqlite3 + +# Uploads - user data +app/static/uploads/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log diff --git a/0.6.0 b/0.6.0 new file mode 100644 index 0000000..663b453 --- /dev/null +++ b/0.6.0 @@ -0,0 +1,9 @@ +Requirement already satisfied: Flask-Login in e:\lim\venv\lib\site-packages (0.6.3) +Requirement already satisfied: Flask>=1.0.4 in e:\lim\venv\lib\site-packages (from Flask-Login) (3.1.2) +Requirement already satisfied: Werkzeug>=1.0.1 in e:\lim\venv\lib\site-packages (from Flask-Login) (3.1.4) +Requirement already satisfied: blinker>=1.9.0 in e:\lim\venv\lib\site-packages (from Flask>=1.0.4->Flask-Login) (1.9.0) +Requirement already satisfied: click>=8.1.3 in e:\lim\venv\lib\site-packages (from Flask>=1.0.4->Flask-Login) (8.3.1) +Requirement already satisfied: itsdangerous>=2.2.0 in e:\lim\venv\lib\site-packages (from Flask>=1.0.4->Flask-Login) (2.2.0) +Requirement already satisfied: jinja2>=3.1.2 in e:\lim\venv\lib\site-packages (from Flask>=1.0.4->Flask-Login) (3.1.6) +Requirement already satisfied: markupsafe>=2.1.1 in e:\lim\venv\lib\site-packages (from Flask>=1.0.4->Flask-Login) (3.0.3) +Requirement already satisfied: colorama in e:\lim\venv\lib\site-packages (from click>=8.1.3->Flask>=1.0.4->Flask-Login) (0.4.6) diff --git a/1.0.0 b/1.0.0 new file mode 100644 index 0000000..48b7bf5 --- /dev/null +++ b/1.0.0 @@ -0,0 +1 @@ +Requirement already satisfied: python-dotenv in e:\lim\venv\lib\site-packages (1.2.1) diff --git a/1.2.0 b/1.2.0 new file mode 100644 index 0000000..1aebd92 --- /dev/null +++ b/1.2.0 @@ -0,0 +1,10 @@ +Requirement already satisfied: Flask-WTF in e:\lim\venv\lib\site-packages (1.2.2) +Requirement already satisfied: flask in e:\lim\venv\lib\site-packages (from Flask-WTF) (3.1.2) +Requirement already satisfied: itsdangerous in e:\lim\venv\lib\site-packages (from Flask-WTF) (2.2.0) +Requirement already satisfied: wtforms in e:\lim\venv\lib\site-packages (from Flask-WTF) (3.2.1) +Requirement already satisfied: blinker>=1.9.0 in e:\lim\venv\lib\site-packages (from flask->Flask-WTF) (1.9.0) +Requirement already satisfied: click>=8.1.3 in e:\lim\venv\lib\site-packages (from flask->Flask-WTF) (8.3.1) +Requirement already satisfied: jinja2>=3.1.2 in e:\lim\venv\lib\site-packages (from flask->Flask-WTF) (3.1.6) +Requirement already satisfied: markupsafe>=2.1.1 in e:\lim\venv\lib\site-packages (from flask->Flask-WTF) (3.0.3) +Requirement already satisfied: werkzeug>=3.1.0 in e:\lim\venv\lib\site-packages (from flask->Flask-WTF) (3.1.4) +Requirement already satisfied: colorama in e:\lim\venv\lib\site-packages (from click>=8.1.3->flask->Flask-WTF) (0.4.6) diff --git a/1.23.0 b/1.23.0 new file mode 100644 index 0000000..bf552db --- /dev/null +++ b/1.23.0 @@ -0,0 +1 @@ +Requirement already satisfied: PyMuPDF in e:\lim\venv\lib\site-packages (1.26.6) diff --git a/2.31.0 b/2.31.0 new file mode 100644 index 0000000..6fc253d --- /dev/null +++ b/2.31.0 @@ -0,0 +1,5 @@ +Requirement already satisfied: requests in e:\lim\venv\lib\site-packages (2.32.5) +Requirement already satisfied: charset_normalizer<4,>=2 in e:\lim\venv\lib\site-packages (from requests) (3.4.4) +Requirement already satisfied: idna<4,>=2.5 in e:\lim\venv\lib\site-packages (from requests) (3.11) +Requirement already satisfied: urllib3<3,>=1.21.1 in e:\lim\venv\lib\site-packages (from requests) (2.6.0) +Requirement already satisfied: certifi>=2017.4.17 in e:\lim\venv\lib\site-packages (from requests) (2025.11.12) diff --git a/3.0.0 b/3.0.0 new file mode 100644 index 0000000..d7af75d --- /dev/null +++ b/3.0.0 @@ -0,0 +1 @@ +Requirement already satisfied: PyPDF2 in e:\lim\venv\lib\site-packages (3.0.1) diff --git a/3.1.0 b/3.1.0 new file mode 100644 index 0000000..bf7fde0 --- /dev/null +++ b/3.1.0 @@ -0,0 +1,12 @@ +Requirement already satisfied: Flask-SQLAlchemy in e:\lim\venv\lib\site-packages (3.1.1) +Requirement already satisfied: flask>=2.2.5 in e:\lim\venv\lib\site-packages (from Flask-SQLAlchemy) (3.1.2) +Requirement already satisfied: sqlalchemy>=2.0.16 in e:\lim\venv\lib\site-packages (from Flask-SQLAlchemy) (2.0.44) +Requirement already satisfied: blinker>=1.9.0 in e:\lim\venv\lib\site-packages (from flask>=2.2.5->Flask-SQLAlchemy) (1.9.0) +Requirement already satisfied: click>=8.1.3 in e:\lim\venv\lib\site-packages (from flask>=2.2.5->Flask-SQLAlchemy) (8.3.1) +Requirement already satisfied: itsdangerous>=2.2.0 in e:\lim\venv\lib\site-packages (from flask>=2.2.5->Flask-SQLAlchemy) (2.2.0) +Requirement already satisfied: jinja2>=3.1.2 in e:\lim\venv\lib\site-packages (from flask>=2.2.5->Flask-SQLAlchemy) (3.1.6) +Requirement already satisfied: markupsafe>=2.1.1 in e:\lim\venv\lib\site-packages (from flask>=2.2.5->Flask-SQLAlchemy) (3.0.3) +Requirement already satisfied: werkzeug>=3.1.0 in e:\lim\venv\lib\site-packages (from flask>=2.2.5->Flask-SQLAlchemy) (3.1.4) +Requirement already satisfied: colorama in e:\lim\venv\lib\site-packages (from click>=8.1.3->flask>=2.2.5->Flask-SQLAlchemy) (0.4.6) +Requirement already satisfied: greenlet>=1 in e:\lim\venv\lib\site-packages (from sqlalchemy>=2.0.16->Flask-SQLAlchemy) (3.3.0) +Requirement already satisfied: typing-extensions>=4.6.0 in e:\lim\venv\lib\site-packages (from sqlalchemy>=2.0.16->Flask-SQLAlchemy) (4.15.0) diff --git a/PROJECT_SUMMARY.md b/PROJECT_SUMMARY.md new file mode 100644 index 0000000..e856fff --- /dev/null +++ b/PROJECT_SUMMARY.md @@ -0,0 +1,380 @@ +# šŸŽ‰ LEGO Instructions Manager - Project Summary + +## āœ… What We've Built + +Congratulations! You now have a complete, production-ready LEGO Instructions Manager application with all the core features implemented. + +--- + +## šŸ“¦ Complete Feature Set + +### āœ… User Authentication +- User registration with email validation +- Secure login/logout with bcrypt password hashing +- User profile pages with statistics +- Session management with Flask-Login + +### āœ… Set Management +- Add new sets (manual or Brickset-assisted) +- View all sets with pagination +- Search and filter by: + - Set number + - Set name + - Theme + - Year + - General text search +- Sort by multiple criteria +- Edit set details +- Delete sets (with cascading instruction deletion) +- Set detail pages with comprehensive information + +### āœ… Instruction Management +- Upload PDF instructions +- Upload image instructions (PNG, JPG, GIF) +- Automatic file organization by set number +- Image thumbnail generation +- Page number tracking for image sequences +- Bulk upload support +- File deletion with cleanup +- View/download instructions + +### āœ… Brickset API Integration +- Search Brickset catalog +- Auto-populate set details +- Theme suggestions +- Instruction availability checking +- Proper error handling and fallbacks + +### āœ… User Interface +- Modern, responsive Bootstrap 5 design +- Mobile-friendly interface +- Interactive dashboard with statistics +- Image galleries +- Search/filter interface +- Upload progress indicators +- Flash messages for user feedback + +### āœ… Backend Architecture +- Clean MVC architecture +- SQLAlchemy ORM +- Database migrations with Flask-Migrate +- Service layer for business logic +- Proper separation of concerns +- Type hints for better code quality + +--- + +## šŸ“ Project Structure Overview + +``` +lego-instructions-manager/ +ā”œā”€ā”€ app/ +│ ā”œā”€ā”€ __init__.py # Flask app factory +│ ā”œā”€ā”€ config.py # Configuration management +│ ā”œā”€ā”€ models/ # Database models +│ │ ā”œā”€ā”€ user.py # User authentication +│ │ ā”œā”€ā”€ set.py # LEGO set data +│ │ └── instruction.py # Instruction files +│ ā”œā”€ā”€ routes/ # URL routes & views +│ │ ā”œā”€ā”€ auth.py # Login/register/logout +│ │ ā”œā”€ā”€ main.py # Homepage/dashboard +│ │ ā”œā”€ā”€ sets.py # Set CRUD operations +│ │ └── instructions.py # File uploads +│ ā”œā”€ā”€ services/ # Business logic +│ │ ā”œā”€ā”€ brickset_api.py # Brickset integration +│ │ └── file_handler.py # File management +│ ā”œā”€ā”€ templates/ # HTML templates +│ │ ā”œā”€ā”€ base.html # Base layout +│ │ ā”œā”€ā”€ index.html # Homepage +│ │ ā”œā”€ā”€ dashboard.html # User dashboard +│ │ ā”œā”€ā”€ auth/ # Auth templates +│ │ ā”œā”€ā”€ sets/ # Set templates +│ │ └── instructions/ # Upload templates +│ └── static/ # Static assets +│ ā”œā”€ā”€ css/ # Custom styles +│ ā”œā”€ā”€ js/ # JavaScript files +│ └── uploads/ # User uploads +│ ā”œā”€ā”€ pdfs/ # PDF storage +│ └── images/ # Image storage +ā”œā”€ā”€ migrations/ # Database migrations +ā”œā”€ā”€ tests/ # Unit tests +ā”œā”€ā”€ requirements.txt # Python dependencies +ā”œā”€ā”€ run.py # Application entry point +ā”œā”€ā”€ .env.example # Environment template +ā”œā”€ā”€ README.md # Main documentation +└── SETUP_GUIDE.md # Setup instructions +``` + +--- + +## šŸš€ Ready to Deploy + +The application is production-ready with: +- āœ… Secure authentication +- āœ… Input validation +- āœ… Error handling +- āœ… File upload security +- āœ… CSRF protection +- āœ… SQL injection prevention +- āœ… XSS protection + +--- + +## šŸ”œ Suggested Next Steps (Optional Enhancements) + +### Phase 1: Polish & Testing +1. **Write Unit Tests** + - Test models, routes, and services + - Add integration tests + - Set up continuous integration + +2. **Additional Templates** + - Create `sets/list.html` (set listing page) + - Create `sets/detail.html` (set detail page) + - Create `sets/edit.html` (edit set form) + - Create `instructions/upload.html` (upload interface) + +3. **Enhanced UI** + - Add drag-and-drop file upload + - Implement image lightbox viewer + - Add set comparison feature + - Create print-friendly instruction views + +### Phase 2: Advanced Features +1. **User Features** + - User roles (admin, regular user) + - Shared collections + - Collection export (PDF, Excel) + - Wishlist functionality + +2. **Enhanced Search** + - Full-text search + - Advanced filtering + - Saved searches + - Tag system + +3. **Analytics** + - Collection value tracking + - Completion statistics + - Theme trends + - Visual charts and graphs + +### Phase 3: Integration & Automation +1. **External Integrations** + - BrickLink API integration + - BrickOwl integration + - Rebrickable integration + - LEGO Pick-a-Brick + +2. **Automation** + - Automatic backup scheduling + - Batch import from CSV + - OCR for instruction scanning + - Automatic theme classification + +3. **Social Features** + - User profiles + - Collection sharing + - Comments and ratings + - Community themes + +--- + +## šŸ› ļø Technical Debt & Improvements + +### High Priority +- [ ] Add comprehensive error logging +- [ ] Implement rate limiting for API calls +- [ ] Add database connection pooling +- [ ] Set up automated backups + +### Medium Priority +- [ ] Add caching for Brickset API responses +- [ ] Implement lazy loading for images +- [ ] Add WebP image format support +- [ ] Optimize database queries + +### Low Priority +- [ ] Add dark mode toggle +- [ ] Implement keyboard shortcuts +- [ ] Add multilingual support +- [ ] Create mobile app (React Native/Flutter) + +--- + +## šŸ“Š Current Statistics + +### Lines of Code +- Python: ~1,500 lines +- HTML/Templates: ~1,000 lines +- CSS: ~200 lines +- JavaScript: ~100 lines + +### Files Created +- Python modules: 15 files +- HTML templates: 10 files +- Configuration: 4 files +- Documentation: 3 files + +### Features Implemented +- Core features: 100% +- Authentication: 100% +- File management: 100% +- API integration: 100% +- UI/UX: 80% (some advanced templates pending) + +--- + +## šŸŽÆ Deployment Checklist + +Before deploying to production: + +### Security +- [ ] Change SECRET_KEY to a secure random value +- [ ] Use PostgreSQL instead of SQLite +- [ ] Set up HTTPS/SSL certificates +- [ ] Enable CSRF protection (already configured) +- [ ] Review file upload size limits +- [ ] Set up firewall rules + +### Performance +- [ ] Use Gunicorn with multiple workers +- [ ] Set up Nginx as reverse proxy +- [ ] Enable gzip compression +- [ ] Implement CDN for static files +- [ ] Set up database connection pooling +- [ ] Add Redis for caching (optional) + +### Monitoring +- [ ] Set up error logging (Sentry, etc.) +- [ ] Implement health check endpoint +- [ ] Monitor disk space for uploads +- [ ] Set up uptime monitoring +- [ ] Track user analytics (optional) + +### Backup & Recovery +- [ ] Automated database backups +- [ ] Backup uploaded files regularly +- [ ] Test restore procedures +- [ ] Document recovery process + +--- + +## šŸ“š Documentation Files + +1. **README.md** - Main project documentation +2. **SETUP_GUIDE.md** - Step-by-step setup instructions +3. **THIS FILE** - Project summary and roadmap +4. **.env.example** - Environment configuration template + +--- + +## šŸŽ“ Learning Resources + +### Flask +- Official Flask Documentation: https://flask.palletsprojects.com +- Flask Mega-Tutorial: https://blog.miguelgrinberg.com/post/the-flask-mega-tutorial-part-i-hello-world + +### SQLAlchemy +- SQLAlchemy Documentation: https://docs.sqlalchemy.org +- SQLAlchemy Tutorial: https://docs.sqlalchemy.org/en/14/tutorial/ + +### Brickset API +- API v3 Documentation: https://brickset.com/article/52664/api-version-3-documentation + +### Web Development +- Bootstrap 5: https://getbootstrap.com +- JavaScript/jQuery: https://jquery.com +- MDN Web Docs: https://developer.mozilla.org + +--- + +## šŸ’” Tips for Success + +1. **Start Small**: Begin with the core features, then expand +2. **Test Often**: Test each feature as you build it +3. **Backup Regularly**: Don't lose your data! +4. **Document Changes**: Keep notes on customizations +5. **Stay Organized**: Maintain a consistent file structure +6. **Ask for Help**: Use the Gitea issue tracker + +--- + +## šŸ† Achievements Unlocked + +- āœ… Complete Flask application +- āœ… Database design and implementation +- āœ… User authentication system +- āœ… File upload handling +- āœ… API integration +- āœ… Responsive web design +- āœ… Production-ready codebase + +--- + +## šŸŽØ Customization Ideas + +1. **Branding** + - Change color scheme in `style.css` + - Add your own logo + - Customize navbar colors + +2. **Features** + - Add favorite sets + - Implement set notes/comments + - Create custom themes/categories + - Add MOC (My Own Creation) support + +3. **Integration** + - Connect to cloud storage (Dropbox, Google Drive) + - Add email notifications + - Integrate with inventory systems + - Create API endpoints for mobile apps + +--- + +## šŸ”„ Quick Start Commands + +```bash +# Activate virtual environment +source venv/bin/activate + +# Run development server +python run.py + +# Initialize database +flask --app run.py init-db + +# Create admin user +flask --app run.py create-admin + +# Access Flask shell +flask --app run.py shell +``` + +--- + +## šŸ“ž Support & Community + +- **Repository**: https://gitea.hideawaygaming.com.au/jessikitty/lego-instructions-manager +- **Issues**: Use Gitea issue tracker +- **Discussions**: Consider setting up a discussion board + +--- + +## šŸŽŠ Congratulations! + +You now have a fully functional LEGO Instructions Manager! The foundation is solid, and you can expand it in any direction you choose. Whether you want to: + +- Keep it simple for personal use +- Add advanced features +- Deploy it for multiple users +- Integrate with other systems + +The choice is yours. Happy building! 🧱 + +--- + +**Last Updated**: December 2024 +**Version**: 1.0.0 +**Status**: Production Ready diff --git a/QUICK_REFERENCE.md b/QUICK_REFERENCE.md new file mode 100644 index 0000000..0020c05 --- /dev/null +++ b/QUICK_REFERENCE.md @@ -0,0 +1,235 @@ +# šŸ“‹ LEGO Instructions Manager - Quick Reference + +## šŸš€ Quick Start + +```bash +# 1. Setup +python3 -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate +pip install -r requirements.txt +cp .env.example .env + +# 2. Configure (edit .env) +# Add your SECRET_KEY and optionally Brickset credentials + +# 3. Initialize +flask --app run.py init-db +flask --app run.py create-admin + +# 4. Run +python run.py +# Visit: http://localhost:5000 +``` + +## šŸ“ Key Files + +| File | Purpose | +|------|---------| +| `run.py` | Application entry point | +| `.env` | Configuration (SECRET_KEY, API keys) | +| `requirements.txt` | Python dependencies | +| `app/__init__.py` | Flask app factory | +| `app/config.py` | App configuration | + +## šŸ”‘ Environment Variables + +```env +SECRET_KEY=your-secret-key-here +DATABASE_URL=sqlite:///lego_instructions.db +BRICKSET_API_KEY=your-api-key +BRICKSET_USERNAME=your-username +BRICKSET_PASSWORD=your-password +``` + +## šŸ› ļø Flask Commands + +```bash +# Initialize database +flask --app run.py init-db + +# Create admin user +flask --app run.py create-admin + +# Flask shell +flask --app run.py shell + +# Database migrations +flask --app run.py db init +flask --app run.py db migrate -m "message" +flask --app run.py db upgrade +``` + +## šŸ“‚ Directory Structure + +``` +app/ +ā”œā”€ā”€ models/ # Database models (User, Set, Instruction) +ā”œā”€ā”€ routes/ # URL routes (auth, main, sets, instructions) +ā”œā”€ā”€ services/ # Business logic (Brickset API, file handling) +ā”œā”€ā”€ templates/ # HTML templates +└── static/ # CSS, JS, uploads +``` + +## 🌐 Main Routes + +| Route | Purpose | +|-------|---------| +| `/` | Homepage | +| `/auth/login` | Login page | +| `/auth/register` | Registration | +| `/dashboard` | User dashboard | +| `/sets/` | List all sets | +| `/sets/add` | Add new set | +| `/sets/` | View set details | +| `/instructions/upload/` | Upload instructions | + +## šŸ” Common Tasks + +### Add a New Set +1. Navigate to `/sets/add` +2. Search Brickset OR enter manually +3. Click "Add Set" + +### Upload Instructions +1. Go to set detail page +2. Click "Upload Instructions" +3. Select files (PDF or images) +4. Click "Upload" + +### Search Sets +1. Go to `/sets/` +2. Use search bar for text search +3. Use filters for theme/year +4. Click column headers to sort + +## šŸ› Troubleshooting + +### Module Not Found +```bash +pip install -r requirements.txt +``` + +### Permission Denied +```bash +chmod -R 755 app/static/uploads +``` + +### Database Locked +```bash +# Restart the application +# Or switch to PostgreSQL +``` + +### Port Already in Use +```python +# In run.py, change: +app.run(port=5001) # Use different port +``` + +## šŸ“Š Database Models + +### User +- username, email, password_hash +- Relationships: sets, instructions + +### Set +- set_number, set_name, theme, year_released +- piece_count, brickset_id, image_url + +### Instruction +- set_id, file_type, file_path, file_name +- file_size, page_number + +## šŸ” Security Features + +- āœ… Password hashing (bcrypt) +- āœ… CSRF protection +- āœ… SQL injection prevention +- āœ… XSS protection +- āœ… Secure file uploads +- āœ… Session management + +## šŸ“± Tech Stack + +- **Backend**: Flask 3.0, SQLAlchemy +- **Frontend**: Bootstrap 5, jQuery +- **Database**: SQLite (dev) / PostgreSQL (prod) +- **Auth**: Flask-Login, bcrypt +- **API**: Brickset API v3 + +## šŸŽØ Customization + +### Change Colors +Edit `app/static/css/style.css`: +```css +:root { + --lego-red: #d11013; + --lego-yellow: #ffd700; + --lego-blue: #0055bf; +} +``` + +### Add Custom Routes +1. Create route in `app/routes/` +2. Register blueprint in `app/__init__.py` + +### Modify Models +1. Edit model in `app/models/` +2. Create migration +3. Apply migration + +## šŸ“¦ Dependencies + +Main packages: +- Flask 3.0.0 +- Flask-SQLAlchemy 3.1.1 +- Flask-Login 0.6.3 +- Flask-Bcrypt 1.0.1 +- requests 2.31.0 +- Pillow 10.1.0 + +## šŸ”„ Update Workflow + +```bash +# Pull latest changes +git pull origin main + +# Update dependencies +pip install -r requirements.txt --upgrade + +# Apply database migrations +flask --app run.py db upgrade + +# Restart application +python run.py +``` + +## šŸ“ž Help & Resources + +- **README.md** - Full documentation +- **SETUP_GUIDE.md** - Detailed setup +- **PROJECT_SUMMARY.md** - Features & roadmap +- **Brickset API**: https://brickset.com/article/52664 + +## ⚔ Performance Tips + +1. Use PostgreSQL for production +2. Enable gzip compression +3. Set up CDN for static files +4. Use Redis for caching +5. Monitor upload folder size + +## šŸŽÆ Production Checklist + +- [ ] Change SECRET_KEY +- [ ] Use PostgreSQL +- [ ] Set FLASK_ENV=production +- [ ] Use gunicorn +- [ ] Set up nginx +- [ ] Enable HTTPS +- [ ] Set up backups +- [ ] Configure logging + +--- + +**Quick Help**: For detailed information, see README.md and SETUP_GUIDE.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..d0b7c21 --- /dev/null +++ b/README.md @@ -0,0 +1,254 @@ +# 🧱 LEGO Instructions Manager + +A Python web application for organizing and managing your LEGO instruction manuals. Upload PDFs and images, search by theme, set number, or year, and integrate with Brickset API for automatic set details. + +![LEGO Instructions Manager](https://img.shields.io/badge/Python-3.8+-blue.svg) +![Flask](https://img.shields.io/badge/Flask-3.0.0-green.svg) +![License](https://img.shields.io/badge/license-MIT-orange.svg) + +## ✨ Features + +- šŸ“¤ **Upload & Organize**: Upload instruction PDFs and images for your LEGO sets +- šŸ” **Advanced Search**: Search and filter by theme, year, set number, or name +- šŸŽØ **Theme Organization**: Organize sets by LEGO themes automatically +- šŸ”— **Brickset Integration**: Auto-populate set details using Brickset API v3 +- šŸ‘¤ **User Authentication**: Secure login and user management +- šŸ“± **Responsive Design**: Works on desktop, tablet, and mobile +- šŸ“Š **Statistics Dashboard**: View collection statistics and insights +- šŸ–¼ļø **Image Gallery**: View instruction images in an organized gallery +- šŸ“„ **PDF Support**: View and download PDF instructions directly + +## šŸš€ Quick Start + +### Prerequisites + +- Python 3.8 or higher +- pip (Python package manager) +- Git + +### Installation + +1. **Clone the repository** +```bash +git clone https://gitea.hideawaygaming.com.au/jessikitty/lego-instructions-manager.git +cd lego-instructions-manager +``` + +2. **Create a virtual environment** +```bash +python3 -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate +``` + +3. **Install dependencies** +```bash +pip install -r requirements.txt +``` + +4. **Configure environment variables** +```bash +cp .env.example .env +# Edit .env with your settings +``` + +5. **Initialize the database** +```bash +flask --app run.py init-db +``` + +6. **Create an admin user** +```bash +flask --app run.py create-admin +``` + +7. **Run the application** +```bash +python run.py +``` + +The application will be available at `http://localhost:5000` + +## āš™ļø Configuration + +### Environment Variables + +Edit the `.env` file with your configuration: + +```env +# Flask Configuration +SECRET_KEY=your-secret-key-here +FLASK_ENV=development + +# Database (SQLite by default) +DATABASE_URL=sqlite:///lego_instructions.db + +# Brickset API (optional but recommended) +BRICKSET_API_KEY=your-api-key +BRICKSET_USERNAME=your-username +BRICKSET_PASSWORD=your-password + +# Upload Settings +MAX_CONTENT_LENGTH=52428800 # 50MB +SETS_PER_PAGE=20 +``` + +### Brickset API Setup + +1. Register for a free account at [Brickset.com](https://brickset.com) +2. Request an API key at [Brickset API Documentation](https://brickset.com/article/52664/api-version-3-documentation) +3. Add your credentials to the `.env` file + +The application works without Brickset, but you'll miss out on automatic set data population. + +## šŸ“– Usage + +### Adding a Set + +1. Click "Add Set" in the navigation +2. Enter set details manually OR search using Brickset +3. Save the set to your collection + +### Uploading Instructions + +1. Navigate to a set's detail page +2. Click "Upload Instructions" +3. Select PDF or image files +4. Files are automatically organized by set number + +### Searching & Filtering + +- Use the search bar to find sets by name or number +- Filter by theme or year using the dropdowns +- Sort by set number, name, theme, year, or newest first + +## šŸ—‚ļø Project Structure + +``` +lego-instructions-manager/ +ā”œā”€ā”€ app/ +│ ā”œā”€ā”€ models/ # Database models +│ ā”œā”€ā”€ routes/ # Application routes/views +│ ā”œā”€ā”€ services/ # Business logic (API, file handling) +│ ā”œā”€ā”€ templates/ # HTML templates +│ └── static/ # CSS, JS, uploaded files +ā”œā”€ā”€ migrations/ # Database migrations +ā”œā”€ā”€ tests/ # Unit tests +ā”œā”€ā”€ requirements.txt # Python dependencies +ā”œā”€ā”€ run.py # Application entry point +└── README.md # This file +``` + +## šŸ› ļø Development + +### Database Migrations + +When you modify database models: + +```bash +flask --app run.py db init # Initialize migrations (first time only) +flask --app run.py db migrate -m "Description" # Create migration +flask --app run.py db upgrade # Apply migration +``` + +### Running Tests + +```bash +pytest tests/ +``` + +### Flask Shell + +Access the Flask shell for debugging: + +```bash +flask --app run.py shell +``` + +## šŸ“Š Database Schema + +### User +- id, username, email, password_hash, created_at + +### Set +- id, set_number, set_name, theme, year_released, piece_count +- brickset_id, image_url, user_id, created_at, updated_at + +### Instruction +- id, set_id, file_type, file_path, file_name, file_size +- page_number, user_id, uploaded_at + +## šŸ”’ Security Notes + +- Passwords are hashed using bcrypt +- File uploads are validated and sanitized +- User authentication required for all operations +- CSRF protection enabled on all forms + +## 🚢 Deployment + +### Production Considerations + +1. **Use PostgreSQL** instead of SQLite: +```env +DATABASE_URL=postgresql://user:password@localhost/lego_db +``` + +2. **Set a strong SECRET_KEY**: +```python +import secrets +print(secrets.token_hex(32)) +``` + +3. **Use gunicorn** for production: +```bash +gunicorn -w 4 -b 0.0.0.0:8000 "run:app" +``` + +4. **Set up nginx** as a reverse proxy +5. **Enable HTTPS** with Let's Encrypt +6. **Regular backups** of database and uploads folder + +## šŸ› Troubleshooting + +### Database Errors +```bash +# Reset database +rm lego_instructions.db +flask --app run.py init-db +``` + +### File Upload Issues +```bash +# Check permissions +chmod -R 755 app/static/uploads +``` + +### Brickset Connection Issues +- Verify API credentials in `.env` +- Check Brickset API status +- Review application logs + +## šŸ“ License + +This project is licensed under the MIT License. + +## šŸ¤ Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## šŸ“§ Support + +For issues and questions: +- Open an issue on Gitea +- Check existing documentation +- Review Brickset API docs + +## šŸ™ Acknowledgments + +- [Brickset](https://brickset.com) for the excellent LEGO database API +- [Bootstrap](https://getbootstrap.com) for the UI framework +- [Flask](https://flask.palletsprojects.com) for the web framework + +--- + +**Built with ā¤ļø for LEGO enthusiasts** diff --git a/SETUP_GUIDE.md b/SETUP_GUIDE.md new file mode 100644 index 0000000..81c19fb --- /dev/null +++ b/SETUP_GUIDE.md @@ -0,0 +1,324 @@ +# šŸš€ LEGO Instructions Manager - Setup Guide + +Complete step-by-step guide to get your LEGO Instructions Manager up and running. + +## šŸ“‹ Prerequisites Checklist + +Before you begin, ensure you have: +- [ ] Python 3.8 or higher installed +- [ ] Git installed +- [ ] Terminal/Command Prompt access +- [ ] Gitea repository access (https://gitea.hideawaygaming.com.au) +- [ ] (Optional) Brickset account for API access + +--- + +## šŸ”§ Step-by-Step Installation + +### Step 1: Clone the Repository + +```bash +# Clone from your Gitea instance +git clone https://gitea.hideawaygaming.com.au/jessikitty/lego-instructions-manager.git + +# Navigate into the project +cd lego-instructions-manager +``` + +### Step 2: Set Up Python Virtual Environment + +**On Linux/Mac:** +```bash +python3 -m venv venv +source venv/bin/activate +``` + +**On Windows:** +```bash +python -m venv venv +venv\Scripts\activate +``` + +You should see `(venv)` in your terminal prompt when activated. + +### Step 3: Install Dependencies + +```bash +pip install --upgrade pip +pip install -r requirements.txt +``` + +This will install all required packages including Flask, SQLAlchemy, and more. + +### Step 4: Configure Environment Variables + +```bash +# Copy the example environment file +cp .env.example .env + +# Edit .env with your preferred text editor +nano .env # or vim, code, etc. +``` + +**Required settings to change:** +```env +SECRET_KEY=CHANGE_THIS_TO_A_RANDOM_STRING +``` + +Generate a secure secret key: +```bash +python3 -c "import secrets; print(secrets.token_hex(32))" +``` + +**Optional Brickset settings:** +```env +BRICKSET_API_KEY=your-api-key-here +BRICKSET_USERNAME=your-username +BRICKSET_PASSWORD=your-password +``` + +### Step 5: Initialize the Database + +```bash +# Create the database and tables +flask --app run.py init-db +``` + +You should see: "Database initialized successfully!" + +### Step 6: Create Your Admin User + +```bash +flask --app run.py create-admin +``` + +Follow the prompts to create your admin account: +- Username: Choose your username +- Email: Your email address +- Password: Choose a secure password (min 6 characters) + +### Step 7: Run the Application + +```bash +python run.py +``` + +You should see output like: +``` + * Running on http://0.0.0.0:5000 + * Debug mode: on +``` + +### Step 8: Access the Application + +Open your web browser and navigate to: +``` +http://localhost:5000 +``` + +You should see the LEGO Instructions Manager homepage! + +--- + +## šŸŽÆ First Time Setup Tasks + +### 1. Login +- Click "Login" in the navigation +- Enter the admin credentials you created +- You'll be redirected to the dashboard + +### 2. Add Your First Set +- Click "Add Set" in the navigation +- If Brickset is configured, try searching for a set +- Otherwise, manually enter set details +- Click "Add Set" to save + +### 3. Upload Instructions +- From the set detail page, click "Upload Instructions" +- Select PDF or image files +- Click "Upload" to save + +--- + +## šŸ”‘ Getting Brickset API Access + +### Step 1: Create Brickset Account +1. Go to [https://brickset.com](https://brickset.com) +2. Click "Register" and create a free account +3. Verify your email address + +### Step 2: Request API Key +1. Visit [API Documentation](https://brickset.com/article/52664/api-version-3-documentation) +2. Fill out the API key request form +3. Wait for approval (usually within 24 hours) +4. You'll receive your API key via email + +### Step 3: Configure API Credentials +1. Open your `.env` file +2. Add your credentials: +```env +BRICKSET_API_KEY=your-api-key-from-email +BRICKSET_USERNAME=your-brickset-username +BRICKSET_PASSWORD=your-brickset-password +``` +3. Save the file and restart the application + +--- + +## šŸ—„ļø Database Management + +### Using SQLite (Default) +The default database is SQLite, stored in `lego_instructions.db`. + +**Backup your database:** +```bash +cp lego_instructions.db lego_instructions.db.backup +``` + +**Reset database:** +```bash +rm lego_instructions.db +flask --app run.py init-db +flask --app run.py create-admin +``` + +### Upgrading to PostgreSQL (Production) + +1. **Install PostgreSQL** +```bash +# Ubuntu/Debian +sudo apt-get install postgresql postgresql-contrib + +# macOS +brew install postgresql +``` + +2. **Create Database** +```bash +sudo -u postgres psql +CREATE DATABASE lego_instructions; +CREATE USER lego_user WITH PASSWORD 'your-password'; +GRANT ALL PRIVILEGES ON DATABASE lego_instructions TO lego_user; +\q +``` + +3. **Update .env** +```env +DATABASE_URL=postgresql://lego_user:your-password@localhost/lego_instructions +``` + +4. **Install PostgreSQL Python driver** +```bash +pip install psycopg2-binary +``` + +5. **Initialize database** +```bash +flask --app run.py init-db +``` + +--- + +## šŸ› Troubleshooting + +### Issue: "ModuleNotFoundError" +**Solution:** Make sure virtual environment is activated and dependencies are installed +```bash +source venv/bin/activate # or venv\Scripts\activate on Windows +pip install -r requirements.txt +``` + +### Issue: "Database is locked" +**Solution:** SQLite can only handle one write at a time. For production, use PostgreSQL +```bash +# Quick fix: restart the application +# Long-term: upgrade to PostgreSQL +``` + +### Issue: "Permission denied" on uploads +**Solution:** Check upload directory permissions +```bash +chmod -R 755 app/static/uploads +``` + +### Issue: "Brickset API not responding" +**Solution:** Verify your credentials and check Brickset status +- Check your API key, username, and password in `.env` +- Verify your Brickset account is active +- Check if Brickset is having issues: https://brickset.com + +### Issue: "Port 5000 already in use" +**Solution:** Change the port in `run.py` +```python +app.run(host='0.0.0.0', port=5001, debug=True) +``` + +--- + +## šŸ“± Development vs Production + +### Development Mode (Current Setup) +- Debug mode enabled +- SQLite database +- Runs on localhost:5000 +- Auto-reloads on code changes + +### Production Deployment +For production, you'll need: +1. PostgreSQL database +2. Gunicorn WSGI server +3. Nginx reverse proxy +4. SSL/TLS certificates +5. Proper SECRET_KEY +6. Regular backups + +**Quick production start:** +```bash +export FLASK_ENV=production +gunicorn -w 4 -b 0.0.0.0:8000 "run:app" +``` + +--- + +## šŸŽ“ Next Steps + +1. āœ… Explore the dashboard and statistics +2. āœ… Add multiple sets to your collection +3. āœ… Upload various instruction formats (PDF, images) +4. āœ… Try searching and filtering +5. āœ… Test Brickset integration +6. āœ… Customize themes and categories +7. āœ… Regular backups of your database + +--- + +## šŸ“š Additional Resources + +- **Flask Documentation**: https://flask.palletsprojects.com +- **Brickset API Docs**: https://brickset.com/article/52664 +- **SQLAlchemy Docs**: https://docs.sqlalchemy.org +- **Bootstrap 5 Docs**: https://getbootstrap.com + +--- + +## šŸ’” Pro Tips + +1. **Regular Backups**: Set up automated database backups +2. **Organize Early**: Establish naming conventions for sets +3. **Use Brickset**: It saves time on data entry +4. **Image Quality**: Higher resolution images work better +5. **PDF Organization**: Name PDFs clearly before upload + +--- + +## šŸ¤ Getting Help + +If you encounter issues: +1. Check this guide's troubleshooting section +2. Review application logs +3. Check the main README.md +4. Create an issue on Gitea + +--- + +**Happy Building! 🧱** diff --git a/add_admin_column.py b/add_admin_column.py new file mode 100644 index 0000000..863626c --- /dev/null +++ b/add_admin_column.py @@ -0,0 +1,82 @@ +""" +Quick Migration - Add is_admin column to users table +""" + +import sqlite3 +import os + +def find_database(): + """Find the database file.""" + script_dir = os.path.dirname(os.path.abspath(__file__)) + possible_paths = [ + os.path.join(script_dir, 'instance', 'lego_instructions.db'), + os.path.join(script_dir, 'lego_instructions.db'), + os.path.join(script_dir, 'app', 'lego_instructions.db'), + ] + + for path in possible_paths: + if os.path.exists(path): + return path + + return None + +def main(): + db_path = find_database() + + if not db_path: + print("=" * 70) + print("āŒ Database not found!") + print("=" * 70) + return + + print("=" * 70) + print("Quick Migration - Adding is_admin Column") + print("=" * 70) + print() + print(f"Database: {db_path}") + print() + + try: + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # Check if column exists + cursor.execute("PRAGMA table_info(users)") + columns = [col[1] for col in cursor.fetchall()] + + if 'is_admin' in columns: + print("āœ… Column 'is_admin' already exists!") + print(" No migration needed.") + else: + print("Adding 'is_admin' column...") + cursor.execute(""" + ALTER TABLE users + ADD COLUMN is_admin BOOLEAN DEFAULT 0 NOT NULL + """) + conn.commit() + print("āœ… Successfully added 'is_admin' column!") + + # Try to add index + try: + cursor.execute("CREATE INDEX idx_users_is_admin ON users(is_admin)") + conn.commit() + print("āœ… Created index on is_admin column!") + except sqlite3.OperationalError: + print(" (Index already exists)") + + conn.close() + + print() + print("=" * 70) + print("Migration complete!") + print("=" * 70) + print() + print("Next step: Run check_admin.py to make jessikitty an admin") + print() + + except Exception as e: + print(f"āŒ Error: {e}") + return + +if __name__ == "__main__": + main() diff --git a/add_extra_files_table.py b/add_extra_files_table.py new file mode 100644 index 0000000..3a339aa --- /dev/null +++ b/add_extra_files_table.py @@ -0,0 +1,109 @@ +""" +Database Migration - Add Extra Files Table +Creates the extra_files table for storing additional set files +""" + +import sqlite3 +import os + +def find_database(): + """Find the database file.""" + script_dir = os.path.dirname(os.path.abspath(__file__)) + possible_paths = [ + os.path.join(script_dir, 'instance', 'lego_instructions.db'), + os.path.join(script_dir, 'lego_instructions.db'), + os.path.join(script_dir, 'app', 'lego_instructions.db'), + ] + + for path in possible_paths: + if os.path.exists(path): + return path + + return None + +def main(): + db_path = find_database() + + if not db_path: + print("=" * 70) + print("āŒ Database not found!") + print("=" * 70) + return + + print("=" * 70) + print("Database Migration - Extra Files Table") + print("=" * 70) + print() + print(f"Database: {db_path}") + print() + + try: + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # Check if table exists + cursor.execute(""" + SELECT name FROM sqlite_master + WHERE type='table' AND name='extra_files' + """) + + if cursor.fetchone(): + print("āœ… Table 'extra_files' already exists!") + print(" No migration needed.") + else: + print("Creating 'extra_files' table...") + + cursor.execute(""" + CREATE TABLE extra_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + set_id INTEGER NOT NULL, + file_name VARCHAR(255) NOT NULL, + original_filename VARCHAR(255) NOT NULL, + file_path VARCHAR(500) NOT NULL, + file_type VARCHAR(50) NOT NULL, + file_size INTEGER NOT NULL, + description TEXT, + category VARCHAR(50), + uploaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + uploaded_by INTEGER, + FOREIGN KEY (set_id) REFERENCES sets(id) ON DELETE CASCADE, + FOREIGN KEY (uploaded_by) REFERENCES users(id) + ) + """) + + # Create indexes + cursor.execute(""" + CREATE INDEX idx_extra_files_set_id ON extra_files(set_id) + """) + + cursor.execute(""" + CREATE INDEX idx_extra_files_category ON extra_files(category) + """) + + cursor.execute(""" + CREATE INDEX idx_extra_files_uploaded_at ON extra_files(uploaded_at) + """) + + conn.commit() + print("āœ… Successfully created 'extra_files' table!") + print("āœ… Created indexes!") + + conn.close() + + print() + print("=" * 70) + print("Migration complete!") + print("=" * 70) + print() + print("What you can do now:") + print(" 1. Upload extra files to your sets") + print(" 2. Store BrickLink XMLs, Stud.io files, box art, etc.") + print(" 3. Keep everything organized in one place!") + print() + + except Exception as e: + print(f"āŒ Error: {e}") + return + +if __name__ == "__main__": + main() diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..74b2410 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,78 @@ +import os +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate +from flask_login import LoginManager +from flask_bcrypt import Bcrypt +from app.config import config + +# Initialize extensions +db = SQLAlchemy() +migrate = Migrate() +login_manager = LoginManager() +bcrypt = Bcrypt() + + +def create_app(config_name='default'): + """Application factory pattern.""" + app = Flask(__name__) + + # Load configuration + app.config.from_object(config[config_name]) + + # Initialize extensions + db.init_app(app) + migrate.init_app(app, db) + bcrypt.init_app(app) + + # Configure Flask-Login + login_manager.init_app(app) + login_manager.login_view = 'auth.login' + login_manager.login_message = 'Please log in to access this page.' + login_manager.login_message_category = 'info' + + # User loader for Flask-Login + from app.models.user import User + + @login_manager.user_loader + def load_user(user_id): + return User.query.get(int(user_id)) + + # Register blueprints + 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 + from app.routes.admin import admin_bp + from app.routes.extra_files import extra_files_bp + + app.register_blueprint(auth_bp) + app.register_blueprint(main_bp) + app.register_blueprint(sets_bp) + app.register_blueprint(instructions_bp) + app.register_blueprint(admin_bp) + app.register_blueprint(extra_files_bp) + + # Import models to ensure they're registered with SQLAlchemy + from app.models.user import User + from app.models.set import Set + from app.models.instruction import Instruction + from app.models.extra_file import ExtraFile + + # Create upload directories + os.makedirs(os.path.join(app.config['UPLOAD_FOLDER'], 'pdfs'), exist_ok=True) + os.makedirs(os.path.join(app.config['UPLOAD_FOLDER'], 'images'), exist_ok=True) + os.makedirs(os.path.join(app.config['UPLOAD_FOLDER'], 'covers'), exist_ok=True) + os.makedirs(os.path.join(app.config['UPLOAD_FOLDER'], 'thumbnails'), exist_ok=True) + os.makedirs(os.path.join(app.config['UPLOAD_FOLDER'], 'extra_files'), exist_ok=True) + + # Context processor for global template variables + @app.context_processor + def inject_global_vars(): + from app.services.brickset_api import BricksetAPI + return { + 'app_name': 'LEGO Instructions Manager', + 'brickset_available': BricksetAPI.is_configured() + } + + return app diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..7e8a338 --- /dev/null +++ b/app/config.py @@ -0,0 +1,59 @@ +import os +from dotenv import load_dotenv + +basedir = os.path.abspath(os.path.dirname(__file__)) +load_dotenv(os.path.join(basedir, '.env')) + + +class Config: + """Base configuration.""" + + # Flask + SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production' + + # Database + SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or \ + 'sqlite:///' + os.path.join(basedir, 'lego_instructions.db') + SQLALCHEMY_TRACK_MODIFICATIONS = False + + # File Upload + # basedir is E:\LIM\app, so we just need 'static/uploads' to get E:\LIM\app\static\uploads + UPLOAD_FOLDER = os.path.join(basedir, 'static', 'uploads') + MAX_CONTENT_LENGTH = int(os.environ.get('MAX_CONTENT_LENGTH', 52428800)) # 50MB + ALLOWED_EXTENSIONS = {'pdf', 'png', 'jpg', 'jpeg', 'gif', 'webp'} + + # Brickset API + BRICKSET_API_KEY = os.environ.get('BRICKSET_API_KEY') + BRICKSET_USERNAME = os.environ.get('BRICKSET_USERNAME') + BRICKSET_PASSWORD = os.environ.get('BRICKSET_PASSWORD') + BRICKSET_API_URL = 'https://brickset.com/api/v3.asmx' + + # Pagination + SETS_PER_PAGE = int(os.environ.get('SETS_PER_PAGE', 20)) + + +class DevelopmentConfig(Config): + """Development configuration.""" + DEBUG = True + TESTING = False + + +class ProductionConfig(Config): + """Production configuration.""" + DEBUG = False + TESTING = False + + +class TestingConfig(Config): + """Testing configuration.""" + TESTING = True + SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:' + WTF_CSRF_ENABLED = False + + +config = { + 'development': DevelopmentConfig, + 'production': ProductionConfig, + 'testing': TestingConfig, + 'default': DevelopmentConfig +} diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..54e8928 --- /dev/null +++ b/app/models/__init__.py @@ -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'] diff --git a/app/models/extra_file.py b/app/models/extra_file.py new file mode 100644 index 0000000..dffdd31 --- /dev/null +++ b/app/models/extra_file.py @@ -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'' + + @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}' + } diff --git a/app/models/instruction.py b/app/models/instruction.py new file mode 100644 index 0000000..7cb9b9d --- /dev/null +++ b/app/models/instruction.py @@ -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'' + + 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'] diff --git a/app/models/set.py b/app/models/set.py new file mode 100644 index 0000000..330ba70 --- /dev/null +++ b/app/models/set.py @@ -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'' + + 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 diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..342ff36 --- /dev/null +++ b/app/models/user.py @@ -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'' + + 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() + } diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..1316c77 --- /dev/null +++ b/app/routes/__init__.py @@ -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'] diff --git a/app/routes/admin.py b/app/routes/admin.py new file mode 100644 index 0000000..d721257 --- /dev/null +++ b/app/routes/admin.py @@ -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//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//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//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) diff --git a/app/routes/auth.py b/app/routes/auth.py new file mode 100644 index 0000000..6202f62 --- /dev/null +++ b/app/routes/auth.py @@ -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) diff --git a/app/routes/extra_files.py b/app/routes/extra_files.py new file mode 100644 index 0000000..6c4e006 --- /dev/null +++ b/app/routes/extra_files.py @@ -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/', 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/') +@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/') +@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/', 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/', 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)) diff --git a/app/routes/instructions.py b/app/routes/instructions.py new file mode 100644 index 0000000..3027f08 --- /dev/null +++ b/app/routes/instructions.py @@ -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/', 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('//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/', 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('//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('//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/', 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/') +@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/') +@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) diff --git a/app/routes/main.py b/app/routes/main.py new file mode 100644 index 0000000..0478d9d --- /dev/null +++ b/app/routes/main.py @@ -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 diff --git a/app/routes/sets.py b/app/routes/sets.py new file mode 100644 index 0000000..63e1497 --- /dev/null +++ b/app/routes/sets.py @@ -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/') +@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('/') +@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('//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('//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) diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..5159b5c --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1,4 @@ +from app.services.brickset_api import BricksetAPI +from app.services.file_handler import FileHandler + +__all__ = ['BricksetAPI', 'FileHandler'] diff --git a/app/services/brickset_api.py b/app/services/brickset_api.py new file mode 100644 index 0000000..c6fdcc9 --- /dev/null +++ b/app/services/brickset_api.py @@ -0,0 +1,198 @@ +import requests +from flask import current_app +from typing import Optional, List, Dict, Any + + +class BricksetAPI: + """Service for interacting with the Brickset API v3.""" + + BASE_URL = 'https://brickset.com/api/v3.asmx' + + def __init__(self): + self.api_key = current_app.config.get('BRICKSET_API_KEY') + self.username = current_app.config.get('BRICKSET_USERNAME') + self.password = current_app.config.get('BRICKSET_PASSWORD') + self._user_hash = None + + @staticmethod + def is_configured() -> bool: + """Check if Brickset API is properly configured.""" + return bool( + current_app.config.get('BRICKSET_API_KEY') and + current_app.config.get('BRICKSET_USERNAME') and + current_app.config.get('BRICKSET_PASSWORD') + ) + + def _get_user_hash(self) -> Optional[str]: + """Authenticate and get user hash token.""" + if self._user_hash: + return self._user_hash + + if not all([self.api_key, self.username, self.password]): + current_app.logger.warning('Brickset API credentials not configured') + return None + + try: + response = requests.get( + f'{self.BASE_URL}/login', + params={ + 'apiKey': self.api_key, + 'username': self.username, + 'password': self.password + }, + timeout=10 + ) + response.raise_for_status() + data = response.json() + + if data.get('status') == 'success': + self._user_hash = data.get('hash') + return self._user_hash + else: + current_app.logger.error(f"Brickset login failed: {data.get('message')}") + return None + + except Exception as e: + current_app.logger.error(f'Brickset API authentication error: {str(e)}') + return None + + def search_sets(self, + set_number: Optional[str] = None, + theme: Optional[str] = None, + year: Optional[int] = None, + query: Optional[str] = None) -> List[Dict[str, Any]]: + """ + Search for LEGO sets using various parameters. + + Args: + set_number: Specific set number to search for + theme: Theme name to filter by + year: Year released + query: General search query + + Returns: + List of set dictionaries + """ + user_hash = self._get_user_hash() + if not user_hash: + return [] + + params = { + 'apiKey': self.api_key, + 'userHash': user_hash, + 'params': '{}' # JSON params object + } + + # Build search parameters + search_params = {} + if set_number: + search_params['setNumber'] = set_number + if theme: + search_params['theme'] = theme + if year: + search_params['year'] = year + if query: + search_params['query'] = query + + params['params'] = str(search_params) + + try: + response = requests.get( + f'{self.BASE_URL}/getSets', + params=params, + timeout=15 + ) + response.raise_for_status() + data = response.json() + + if data.get('status') == 'success': + return data.get('sets', []) + else: + current_app.logger.error(f"Brickset search failed: {data.get('message')}") + return [] + + except Exception as e: + current_app.logger.error(f'Brickset API search error: {str(e)}') + return [] + + def get_set_by_number(self, set_number: str) -> Optional[Dict[str, Any]]: + """ + Get detailed information for a specific set by its number. + + Args: + set_number: The LEGO set number (e.g., "10497") + + Returns: + Dictionary with set information or None + """ + results = self.search_sets(set_number=set_number) + return results[0] if results else None + + def get_themes(self) -> List[str]: + """ + Get list of all available LEGO themes. + + Returns: + List of theme names + """ + user_hash = self._get_user_hash() + if not user_hash: + return [] + + try: + response = requests.get( + f'{self.BASE_URL}/getThemes', + params={ + 'apiKey': self.api_key, + 'userHash': user_hash + }, + timeout=10 + ) + response.raise_for_status() + data = response.json() + + if data.get('status') == 'success': + themes = data.get('themes', []) + return [theme.get('theme') for theme in themes if theme.get('theme')] + else: + return [] + + except Exception as e: + current_app.logger.error(f'Brickset API themes error: {str(e)}') + return [] + + def get_instructions(self, set_number: str) -> List[Dict[str, Any]]: + """ + Get instruction information for a specific set. + + Args: + set_number: The LEGO set number + + Returns: + List of instruction dictionaries + """ + user_hash = self._get_user_hash() + if not user_hash: + return [] + + try: + response = requests.get( + f'{self.BASE_URL}/getInstructions', + params={ + 'apiKey': self.api_key, + 'userHash': user_hash, + 'setNumber': set_number + }, + timeout=10 + ) + response.raise_for_status() + data = response.json() + + if data.get('status') == 'success': + return data.get('instructions', []) + else: + return [] + + except Exception as e: + current_app.logger.error(f'Brickset API instructions error: {str(e)}') + return [] diff --git a/app/services/file_handler.py b/app/services/file_handler.py new file mode 100644 index 0000000..bfba332 --- /dev/null +++ b/app/services/file_handler.py @@ -0,0 +1,317 @@ +import os +import uuid +from werkzeug.utils import secure_filename +from flask import current_app +from PIL import Image +from typing import Tuple, Optional + + +class FileHandler: + """Service for handling file uploads and storage.""" + + @staticmethod + def allowed_file(filename: str) -> bool: + """Check if file extension is allowed.""" + return '.' in filename and \ + filename.rsplit('.', 1)[1].lower() in current_app.config['ALLOWED_EXTENSIONS'] + + @staticmethod + def get_file_type(filename: str) -> str: + """Determine file type based on extension.""" + ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else '' + return 'PDF' if ext == 'pdf' else 'IMAGE' + + @staticmethod + def generate_unique_filename(original_filename: str) -> str: + """Generate a unique filename while preserving the extension.""" + ext = secure_filename(original_filename).rsplit('.', 1)[1].lower() + unique_name = f"{uuid.uuid4().hex}.{ext}" + return unique_name + + @staticmethod + def save_cover_image(file, set_number: str) -> Tuple[str, int]: + """ + Save cover image for a set. + + Args: + file: FileStorage object from request + set_number: Set number for organizing files + + Returns: + Tuple of (relative_path, file_size_bytes) + """ + # Create covers directory if it doesn't exist + covers_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'covers') + os.makedirs(covers_dir, exist_ok=True) + + # Generate unique filename + filename = FileHandler.generate_unique_filename(file.filename) + + # Create set-specific subdirectory + set_dir = os.path.join(covers_dir, secure_filename(set_number)) + os.makedirs(set_dir, exist_ok=True) + + # Save file + file_path = os.path.join(set_dir, filename) + file.save(file_path) + + # Get file size + file_size = os.path.getsize(file_path) + + # Create thumbnail and optimize + try: + img = Image.open(file_path) + + # Convert RGBA to RGB if necessary + if img.mode == 'RGBA': + background = Image.new('RGB', img.size, (255, 255, 255)) + background.paste(img, mask=img.split()[3]) + img = background + + # Resize if too large (max 800px on longest side) + max_size = 800 + if max(img.size) > max_size: + ratio = max_size / max(img.size) + new_size = tuple(int(dim * ratio) for dim in img.size) + img = img.resize(new_size, Image.Resampling.LANCZOS) + img.save(file_path, optimize=True, quality=85) + file_size = os.path.getsize(file_path) + + except Exception as e: + current_app.logger.error(f"Error processing cover image: {e}") + + # Return relative path from uploads folder + relative_path = os.path.join('covers', secure_filename(set_number), filename) + return relative_path.replace('\\', '/'), file_size + + @staticmethod + def save_file(file, set_number: str, file_type: str) -> Tuple[str, int, Optional[str]]: + """ + Save uploaded file to appropriate directory. + + Args: + file: FileStorage object from request + set_number: LEGO set number for organizing files + file_type: 'PDF' or 'IMAGE' + + Returns: + Tuple of (relative_path, file_size, thumbnail_path) + """ + # Generate unique filename + unique_filename = FileHandler.generate_unique_filename(file.filename) + + # Determine subdirectory + subdir = 'pdfs' if file_type == 'PDF' else 'images' + + # Create set-specific directory + set_dir = os.path.join( + current_app.config['UPLOAD_FOLDER'], + subdir, + secure_filename(set_number) + ) + os.makedirs(set_dir, exist_ok=True) + + # Full file path + file_path = os.path.join(set_dir, unique_filename) + + # Save the file + file.save(file_path) + + # Get file size + file_size = os.path.getsize(file_path) + + # Generate thumbnail + thumbnail_rel_path = None + if file_type == 'IMAGE': + thumb_path = FileHandler.create_thumbnail(file_path) + if thumb_path: + # Get relative path for thumbnail + thumbnail_rel_path = os.path.join(subdir, secure_filename(set_number), + os.path.basename(thumb_path)) + thumbnail_rel_path = thumbnail_rel_path.replace('\\', '/') + elif file_type == 'PDF': + thumb_path = FileHandler.create_pdf_thumbnail(file_path, set_number) + if thumb_path: + thumbnail_rel_path = thumb_path.replace('\\', '/') + + # Return relative path for database storage + relative_path = os.path.join(subdir, secure_filename(set_number), unique_filename) + + return relative_path.replace('\\', '/'), file_size, thumbnail_rel_path + + @staticmethod + def create_thumbnail(image_path: str, size: Tuple[int, int] = (300, 300)) -> Optional[str]: + """ + Create a thumbnail for an image. + + Args: + image_path: Path to the original image + size: Thumbnail size (width, height) + + Returns: + Path to thumbnail or None if failed + """ + try: + img = Image.open(image_path) + img.thumbnail(size, Image.Resampling.LANCZOS) + + # Generate thumbnail filename + base, ext = os.path.splitext(image_path) + thumb_path = f"{base}_thumb{ext}" + + img.save(thumb_path, optimize=True) + return thumb_path + + except Exception as e: + current_app.logger.error(f'Thumbnail creation failed: {str(e)}') + return None + + @staticmethod + def create_pdf_thumbnail(pdf_path: str, set_number: str, size: Tuple[int, int] = (300, 400)) -> Optional[str]: + """ + Create a thumbnail from the first page of a PDF. + + Args: + pdf_path: Path to the PDF file + set_number: Set number for organizing thumbnails + size: Thumbnail size (width, height) + + Returns: + Relative path to thumbnail or None if failed + """ + try: + # Try using PyMuPDF (fitz) first + try: + import fitz # PyMuPDF + + # Open PDF + doc = fitz.open(pdf_path) + + # Get first page + page = doc[0] + + # Render page to pixmap (image) + # Use matrix for scaling to desired size + zoom = 2 # Higher zoom for better quality + mat = fitz.Matrix(zoom, zoom) + pix = page.get_pixmap(matrix=mat) + + # Create thumbnails directory + thumbnails_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'thumbnails', secure_filename(set_number)) + os.makedirs(thumbnails_dir, exist_ok=True) + + # Generate thumbnail filename + pdf_filename = os.path.basename(pdf_path) + thumb_filename = os.path.splitext(pdf_filename)[0] + '_thumb.png' + thumb_path = os.path.join(thumbnails_dir, thumb_filename) + + # Save as PNG + pix.save(thumb_path) + + # Close document + doc.close() + + # Resize to target size using PIL + img = Image.open(thumb_path) + img.thumbnail(size, Image.Resampling.LANCZOS) + img.save(thumb_path, optimize=True) + + # Return relative path + rel_path = os.path.join('thumbnails', secure_filename(set_number), thumb_filename) + return rel_path + + except ImportError: + # Try pdf2image as fallback + try: + from pdf2image import convert_from_path + + # Convert first page only + images = convert_from_path(pdf_path, first_page=1, last_page=1, dpi=150) + + if images: + # Create thumbnails directory + thumbnails_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], 'thumbnails', secure_filename(set_number)) + os.makedirs(thumbnails_dir, exist_ok=True) + + # Generate thumbnail filename + pdf_filename = os.path.basename(pdf_path) + thumb_filename = os.path.splitext(pdf_filename)[0] + '_thumb.png' + thumb_path = os.path.join(thumbnails_dir, thumb_filename) + + # Resize and save + img = images[0] + img.thumbnail(size, Image.Resampling.LANCZOS) + img.save(thumb_path, 'PNG', optimize=True) + + # Return relative path + rel_path = os.path.join('thumbnails', secure_filename(set_number), thumb_filename) + return rel_path + + except ImportError: + current_app.logger.warning('Neither PyMuPDF nor pdf2image available for PDF thumbnails') + return None + + except Exception as e: + current_app.logger.error(f'PDF thumbnail creation failed: {str(e)}') + return None + + @staticmethod + def delete_file(file_path: str) -> bool: + """ + Delete a file from storage. + + Args: + file_path: Relative path to the file + + Returns: + True if successful, False otherwise + """ + try: + full_path = os.path.join(current_app.config['UPLOAD_FOLDER'], file_path) + + if os.path.exists(full_path): + os.remove(full_path) + + # Also delete thumbnail if it exists + base, ext = os.path.splitext(full_path) + thumb_path = f"{base}_thumb{ext}" + if os.path.exists(thumb_path): + os.remove(thumb_path) + + return True + return False + + except Exception as e: + current_app.logger.error(f'File deletion failed: {str(e)}') + return False + + @staticmethod + def get_directory_size(set_number: str) -> Tuple[int, int]: + """ + Get total size of all files for a specific set. + + Args: + set_number: LEGO set number + + Returns: + Tuple of (total_size_bytes, file_count) + """ + total_size = 0 + file_count = 0 + + for subdir in ['pdfs', 'images']: + set_dir = os.path.join( + current_app.config['UPLOAD_FOLDER'], + subdir, + secure_filename(set_number) + ) + + if os.path.exists(set_dir): + for filename in os.listdir(set_dir): + if not filename.endswith('_thumb'): + filepath = os.path.join(set_dir, filename) + if os.path.isfile(filepath): + total_size += os.path.getsize(filepath) + file_count += 1 + + return total_size, file_count diff --git a/app/static/css/style.css b/app/static/css/style.css new file mode 100644 index 0000000..010a605 --- /dev/null +++ b/app/static/css/style.css @@ -0,0 +1,146 @@ +/* Custom styles for LEGO Instructions Manager */ + +:root { + --lego-red: #d11013; + --lego-yellow: #ffd700; + --lego-blue: #0055bf; +} + +body { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +main { + flex: 1; +} + +/* Navbar customization */ +.navbar-brand { + font-weight: bold; + font-size: 1.5rem; +} + +/* Card hover effects */ +.card { + transition: transform 0.2s, box-shadow 0.2s; +} + +.card:hover { + transform: translateY(-5px); + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important; +} + +/* Set card image */ +.set-image { + height: 200px; + object-fit: contain; + background-color: #f8f9fa; +} + +/* Dashboard clickable thumbnails */ +.dashboard a .set-image, +.dashboard a .card-img-top { + transition: opacity 0.3s, transform 0.3s; +} + +.dashboard a:hover .set-image, +.dashboard a:hover .card-img-top { + opacity: 0.85; + transform: scale(1.02); +} + +.dashboard a:hover img { + filter: brightness(1.05); +} + +/* Instruction thumbnails */ +.instruction-thumbnail { + width: 150px; + height: 150px; + object-fit: cover; + cursor: pointer; + transition: transform 0.2s; +} + +.instruction-thumbnail:hover { + transform: scale(1.05); +} + +/* File upload area */ +.upload-area { + border: 2px dashed #dee2e6; + border-radius: 0.5rem; + padding: 3rem; + text-align: center; + transition: all 0.3s; +} + +.upload-area:hover { + border-color: var(--lego-red); + background-color: #f8f9fa; +} + +.upload-area.dragover { + border-color: var(--lego-red); + background-color: #fff3cd; +} + +/* Badge styling */ +.badge-theme { + background-color: var(--lego-blue); +} + +.badge-year { + background-color: var(--lego-yellow); + color: #000; +} + +/* Stats cards */ +.stat-card { + border-left: 4px solid var(--lego-red); +} + +/* Search bar */ +.search-container { + max-width: 600px; + margin: 0 auto 2rem; +} + +/* Pagination */ +.pagination .page-link { + color: var(--lego-red); +} + +.pagination .page-item.active .page-link { + background-color: var(--lego-red); + border-color: var(--lego-red); +} + +/* Loading spinner */ +.spinner-wrapper { + display: flex; + justify-content: center; + align-items: center; + min-height: 200px; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .set-image { + height: 150px; + } + + .instruction-thumbnail { + width: 100px; + height: 100px; + } +} + +/* Print styles for instructions */ +@media print { + .navbar, .btn, footer { + display: none; + } +} diff --git a/app/static/favicon.ico b/app/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..b1dba6590978a5570e558091ff25fdef464010d3 GIT binary patch literal 16958 zcmeHPZ)g-p6yJE_iHH%AP(tv6h=d@eh!iQ3hy)QSQi_D)hZ01jlv0XF@k8-~NYf8R z#Sc=7C`b_zks=}@60RaDB1S}li0D;|ks>A4AW_bn{(iIfE_1VQFPl(>%R2JQ+kNlN zpWmA|v%3>D2%7L`?b-l8+k=jELC_KeK?eX;&<{{Qy<%nsfmsXe1FiuN8U!5c0RYh; zhv^j4i@L*rX>i=d_`gUV0katXXB(WeHfQI+3Eb)xvjjejmsWo!=mvDi2f19Lfr{PB!rF`X&oE;+CQ+;^s!|1NMp znPUE@!M%`T{@nMjq?rG8a3^FxNSnL~?l)4*{}#A225Fmj;d>k03As<3ypwYN{1##i z(l+nHm*2x__~sS9zI@OB{rqd^T_voGLjccD`Hl1u&;#@XMZnLaOVDiwcz(73Xa_a| z2Z2$*_kRN2_rOY^r2<~_TZ}pB`@8pS?Q1z$gRwkD|=_7SO@lt1E;q}cVbD~V_5V~${4 zmy`R`(`*13GmiZ=uqb(b`}+%xv#ZnQ|LO3>UKcSyzzO6d#)eHAGxiSnthyOJ{I+|`#r}hoLq_LfM4u>|J?Dv znYyyNUasv?-E&2)XAzILEII9rCd1qpg81$1ZJ=|2;)7 zc5(z)t<=oPbjlyx_;nc-+wR$`+fHWl6J(>FZO#u>JIu=)ZtvX zais5L{ON;S*NXivcH2XI{bIjMU!x5fmn*;Xz2Ub6aPt^rc8mR99*56GR~ug2J>puW zuQ5n%AMx`m34g|XUbH`IYJP8iHpb^m_;J730~`T{0mgxQsFnkWOEq4d(KgK~*ctbg zifvuR#`c#gcIKTs2TAzTe@C<}YHIdfj%l^-=WFdZI_Cm0(Te#Dwx5B8Q7hkpEQh#w z_rT7W&r<5L&*|{zb5|Qn8}#HYZSom2JKC?Nv1c62e zU-D5n#L0txi{X9sy-w=w*8O~~`LM#{5u}o~QN&g+J>tUy%p) zEEH?K`aa{fDc1$qHv_G}902RW$`;6-z)L`UMP7ie4_F3Z&$VcSTn8Km*uVI?k}*FA zYyp-7?4up%0l1I&F_XGeT_5tl^OpOV?%3W%J@UVu zV*bP6ek;ZNnfo_W%%8b`BgOog``1&Zu3nv) zWK+&)?D;9r7H1Mqjmv3pj5(XJ=Mz{LGd2d~rksnrE5YUg&u4g6p~bHeb>u=$G4t;7 zmuvgGh(o9ocXreWap2g00osMt)TKRx`xI^Lt+d7WMZPy32TlOYDefCwS7#iY#q~J{ z7jW(4B6NdwK)YY(Jap17`(U5H1MHt;`3?9H*aXa3Qw|;TcrdW!Mnl?jFq-f;mm4=0fg&4@7_1 eVkGW`P>;yik`*cQh(lMFZkcEPtG6J?nEwFu5%V +
+

+ Bulk Import Sets from Brickset +

+

Import multiple official LEGO sets at once using Brickset data

+
+ + + +{% if not brickset_configured %} +
+ + Brickset API Not Configured +

+ Please add your Brickset API credentials to the .env file: +

+
+BRICKSET_API_KEY=your_api_key_here
+BRICKSET_USERNAME=your_username
+BRICKSET_PASSWORD=your_password
+

+ Get your API key at: https://brickset.com/tools/webservices/requestkey +

+
+{% endif %} + +
+
+
+
+
Import Sets
+
+
+
+ +
+ + + + Enter LEGO set numbers (e.g., 8860, 10497-1, 42100). Variants like -1 are supported. + +
+ + +
+ + + + Sets will be added to this user's collection + +
+ + +
+ + + + + Brickset has API rate limits. Increase delay if you get rate limit errors. + +
+ + +
+ +
+
+
+
+
+ +
+ +
+
+
How It Works
+
+
+
    +
  1. Enter set numbers (one per line)
  2. +
  3. Select which user to assign them to
  4. +
  5. Choose throttle delay
  6. +
  7. Click "Import Sets"
  8. +
  9. System fetches data from Brickset
  10. +
  11. Sets are added to database!
  12. +
+
+
+ + +
+
+
API Rate Limits
+
+
+

+ Brickset has API rate limits! +

+
    +
  • + Recommended: Import 10-20 sets at a time +
  • +
  • + Throttle: Use 0.5s-1.0s delay between requests +
  • +
  • + If rate limited: Wait 5-10 minutes and retry +
  • +
  • + Large batches: Split into multiple smaller imports +
  • +
+
+
+ + +
+
+
What Gets Imported
+
+
+
    +
  • Set Number
  • +
  • Set Name
  • +
  • Theme
  • +
  • Year Released
  • +
  • Piece Count
  • +
  • Cover Image (from Brickset)
  • +
+
+ + + You can upload instructions separately later! + +
+
+ + +
+
+
Pro Tips
+
+
+
    +
  • + Start Small: Try 5-10 sets first to test +
  • +
  • + Duplicates: Sets already in database will be skipped +
  • +
  • + Not Found: Invalid set numbers will be reported +
  • +
  • + Formats: Works with variants like 10497-1 +
  • +
+
+
+
+
+ + +
+
+
+
+
Example Sets You Can Try
+
+
+
+
+ Technic:
+ 8860, 8880, 42100, 42110 +
+
+ Creator Expert:
+ 10497, 10294, 10283 +
+
+ Ideas:
+ 21318, 21330, 21341 +
+
+ Star Wars:
+ 75192, 75313, 75331 +
+
+
+ +
+
+
+
+ + + +{% endblock %} diff --git a/app/templates/admin/bulk_import_results.html b/app/templates/admin/bulk_import_results.html new file mode 100644 index 0000000..83b136b --- /dev/null +++ b/app/templates/admin/bulk_import_results.html @@ -0,0 +1,255 @@ +{% extends "base.html" %} + +{% block title %}Import Results - Admin - {{ app_name }}{% endblock %} + +{% block content %} +
+
+

+ Bulk Import Results +

+
+ +
+ + +
+
+
+
+

{{ results.success|length }}

+
Successfully Imported
+
+
+
+
+
+
+

{{ results.already_exists|length }}

+
Already Existed
+
+
+
+
+
+
+

{{ results.failed|length }}

+
Failed to Import
+
+
+
+
+
+
+

{{ results.rate_limited|length }}

+
Rate Limited
+
+
+
+
+ + +{% if results.success %} +
+
+
+ + Successfully Imported ({{ results.success|length }}) +
+
+
+
+ + + + + + + + + + + {% for set in results.success %} + + + + + + + {% endfor %} + +
Set NumberNameThemeActions
{{ set.set_number }}{{ set.name }}{{ set.theme }} + + + View + +
+
+
+
+{% endif %} + + +{% if results.already_exists %} +
+
+
+ + Already in Database ({{ results.already_exists|length }}) +
+
+
+
+ + + + + + + + + + {% for set in results.already_exists %} + + + + + + {% endfor %} + +
Set NumberNameStatus
{{ set.set_number }}{{ set.name }}Skipped - Already exists
+
+
+
+{% endif %} + + +{% if results.failed %} +
+
+
+ + Failed to Import ({{ results.failed|length }}) +
+
+
+
+ + + + + + + + + {% for set in results.failed %} + + + + + {% endfor %} + +
Set NumberReason
{{ set.set_number }} + {{ set.reason }} +
+
+
+ +
+{% endif %} + + +{% if results.rate_limited %} +
+
+
+ + Rate Limited ({{ results.rate_limited|length }}) +
+
+
+
+
API Rate Limit Reached
+

+ Brickset's API has rate limits to prevent abuse. Your import was stopped after + {{ results.success|length }} successful import(s) to avoid hitting the limit. +

+

+ To import these remaining sets: +

+
    +
  1. Wait 5-10 minutes for the rate limit to reset
  2. +
  3. Use a longer throttle delay (1.0s or 2.0s)
  4. +
  5. Import in smaller batches (10-15 sets at a time)
  6. +
+
+ +
+ + + + + + + + + {% for set in results.rate_limited %} + + + + + {% endfor %} + +
Set NumberStatus
{{ set.set_number }}{{ set.reason }}
+
+
+ +
+{% endif %} + + +
+
+
What's Next?
+ + + {% if results.success %} +
+

+ + Don't forget to upload instructions for the newly imported sets! +

+
+ {% endif %} +
+
+ +{% endblock %} diff --git a/app/templates/admin/dashboard.html b/app/templates/admin/dashboard.html new file mode 100644 index 0000000..39efa9b --- /dev/null +++ b/app/templates/admin/dashboard.html @@ -0,0 +1,284 @@ +{% extends "base.html" %} + +{% block title %}Admin Dashboard - {{ app_name }}{% endblock %} + +{% block content %} +
+
+

+ Admin Dashboard +

+

System overview and management

+
+
+ + +
+
+
+
+
+
+
Total Users
+

{{ total_users }}

+
+ +
+
+ +
+
+ +
+
+
+
+
+
Total Sets
+

{{ total_sets }}

+
+ +
+
+ +
+
+ +
+
+
+
+
+
MOC Builds
+

{{ total_mocs }}

+
+ +
+
+ +
+
+ +
+
+
+
+
+
Instructions
+

{{ total_instructions }}

+ {{ total_storage_mb }} MB +
+ +
+
+ +
+
+
+ +
+ +
+
+
+
Recent Users
+ View All +
+
+ {% if recent_users %} +
+ + + + + + + + + + + {% for user in recent_users %} + + + + + + + {% endfor %} + +
UsernameEmailJoinedAdmin
+ {{ user.username }} + {{ user.email }}{{ user.created_at.strftime('%Y-%m-%d') }} + {% if user.is_admin %} + Admin + {% else %} + User + {% endif %} +
+
+ {% else %} +

No users yet

+ {% endif %} +
+
+
+ + +
+
+
+
Top Contributors
+
+
+ {% if top_contributors %} +
+ + + + + + + + + {% for user, count in top_contributors %} + + + + + {% endfor %} + +
UserSets Added
+ {{ user.username }} + + {{ count }} +
+
+ {% else %} +

No data yet

+ {% endif %} +
+
+
+
+ +
+ +
+
+
+
Popular Themes
+
+
+ {% if theme_stats %} + {% for theme, count in theme_stats %} +
+
+ {{ theme }} + {{ count }} +
+
+ {% set percentage = (count / total_sets * 100) | int %} +
+ {{ percentage }}% +
+
+
+ {% endfor %} + {% else %} +

No theme data yet

+ {% endif %} +
+
+
+ + +
+
+
+
Recently Added Sets
+ View All +
+
+ {% if recent_sets %} + + {% else %} +

No sets yet

+ {% endif %} +
+
+
+
+ + +
+
+
+
+
Quick Actions
+
+ +
+
+
+ +{% endblock %} diff --git a/app/templates/admin/sets.html b/app/templates/admin/sets.html new file mode 100644 index 0000000..1aa18ce --- /dev/null +++ b/app/templates/admin/sets.html @@ -0,0 +1,99 @@ +{% extends "base.html" %} + +{% block title %}Set Management - Admin{% endblock %} + +{% block content %} +
+
+

Set Management

+
+ +
+ +
+
+
+
+
+ +
+
+ +
+
+ +
+
+
+
+
+ +
+
+ {% if sets %} +
+ + + + + + + + + + + + + {% for set in sets %} + + + + + + + + + {% endfor %} + +
Set NumberNameThemeYearTypeActions
{{ set.set_number }}{{ set.set_name }}{{ set.theme }}{{ set.year_released }} + {% if set.is_moc %} + + MOC + + {% else %} + Official + {% endif %} + +
+ + + +
+ +
+
+
+
+ {% else %} +

No sets found

+ {% endif %} +
+
+ +{% endblock %} diff --git a/app/templates/admin/settings.html b/app/templates/admin/settings.html new file mode 100644 index 0000000..f9c968b --- /dev/null +++ b/app/templates/admin/settings.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} + +{% block title %}Site Settings - Admin{% endblock %} + +{% block content %} +
+
+

Site Settings

+
+ +
+ +
+
+
+
+
System Information
+
+
+ + + + + + + + + + + + + + + + + +
Total Users:{{ stats.total_users }}
Total Sets:{{ stats.total_sets }}
Total Instructions:{{ stats.total_instructions }}
Storage Used:{{ (stats.total_storage / 1024 / 1024) | round(2) }} MB
+
+
+
+ +
+
+
+
Settings
+
+
+

+ Site settings configuration will be available in future updates. +

+

+ For now, modify settings in config.py +

+
+
+
+
+ +{% endblock %} diff --git a/app/templates/admin/users.html b/app/templates/admin/users.html new file mode 100644 index 0000000..1b0347b --- /dev/null +++ b/app/templates/admin/users.html @@ -0,0 +1,211 @@ +{% extends "base.html" %} + +{% block title %}User Management - Admin - {{ app_name }}{% endblock %} + +{% block content %} +
+
+

+ User Management +

+

Manage users and permissions

+
+ +
+ + +
+
+
+
+ + + {% if search %} + + Clear + + {% endif %} +
+
+
+
+ + +
+
+
+ Users ({{ pagination.total }}) +
+
+
+ {% if users %} +
+ + + + + + + + + + + + + + {% for user in users %} + + + + + + + + + + + + + {% endfor %} + +
UsernameEmailJoinedSetsInstructionsStatusActions
+ + {{ user.username }} + {% if user.id == current_user.id %} + You + {% endif %} + {{ user.email }}{{ user.created_at.strftime('%Y-%m-%d') }} + {{ user_stats[user.id]['sets'] }} + + {{ user_stats[user.id]['instructions'] }} + + {% if user.is_admin %} + + Admin + + {% else %} + User + {% endif %} + +
+ {% if user.id != current_user.id %} + + + {% else %} + Cannot modify yourself + {% endif %} +
+
+
+ + + {% if pagination.pages > 1 %} + + {% endif %} + {% else %} +
+ +

No users found

+
+ {% endif %} +
+
+ + + +{% endblock %} diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html new file mode 100644 index 0000000..dc36ca9 --- /dev/null +++ b/app/templates/auth/login.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} + +{% block title %}Login - {{ app_name }}{% endblock %} + +{% block content %} +
+
+
+
+

+ Login +

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+ +
+ +

+ Don't have an account? + Register here +

+
+
+
+
+{% endblock %} diff --git a/app/templates/auth/profile.html b/app/templates/auth/profile.html new file mode 100644 index 0000000..404b184 --- /dev/null +++ b/app/templates/auth/profile.html @@ -0,0 +1,71 @@ +{% extends "base.html" %} + +{% block title %}Profile - {{ app_name }}{% endblock %} + +{% block content %} +
+
+
+
+

+ User Profile +

+
+
+
+
+
Account Information
+ + + + + + + + + + + + + +
Username:{{ current_user.username }}
Email:{{ current_user.email }}
Member Since:{{ current_user.created_at.strftime('%B %d, %Y') }}
+
+ +
+
Statistics
+
+
+
+
+

{{ set_count }}

+ Sets Added +
+
+
+
+
+
+

{{ instruction_count }}

+ Instructions +
+
+
+
+
+
+ +
+ + +
+
+
+
+{% endblock %} diff --git a/app/templates/auth/register.html b/app/templates/auth/register.html new file mode 100644 index 0000000..729fca5 --- /dev/null +++ b/app/templates/auth/register.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} + +{% block title %}Register - {{ app_name }}{% endblock %} + +{% block content %} +
+
+
+
+

+ Register +

+ +
+
+ + +
Choose a unique username (letters, numbers, underscore).
+
+ +
+ + +
+ +
+ + +
Must be at least 6 characters long.
+
+ +
+ + +
+ +
+ +
+
+ +
+ +

+ Already have an account? + Login here +

+
+
+
+
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..b90a1be --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,133 @@ + + + + + + {% block title %}{{ app_name }}{% endblock %} + + + + + + + + + + + + + + + + + {% block extra_css %}{% endblock %} + + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} +
+ + +
+ {% block content %}{% endblock %} +
+ + +
+
+

+ LEGO Instructions Manager © 2024 + {% if brickset_available %} + + Brickset Connected + + {% endif %} +

+
+
+ + + + + + + + {% block extra_js %}{% endblock %} + + diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html new file mode 100644 index 0000000..faf9d6d --- /dev/null +++ b/app/templates/dashboard.html @@ -0,0 +1,209 @@ +{% extends "base.html" %} + +{% block title %}Dashboard - {{ app_name }}{% endblock %} + +{% block content %} +
+
+

+ Dashboard +

+

Welcome back, {{ current_user.username }}!

+
+
+ + +
+
+
+
+
+
+
Total Sets
+

{{ total_sets }}

+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
Instructions
+

{{ total_instructions }}

+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
Themes
+

{{ theme_stats|length }}

+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
Years Collected
+

{{ year_stats|length }}

+
+
+ +
+
+
+
+
+
+ +
+ +
+
+
+
+ Top Themes +
+
+
+ {% if theme_stats %} +
    + {% for theme, count in theme_stats %} +
  • + {{ theme }} + {{ count }} +
  • + {% endfor %} +
+ {% else %} +

No themes yet. Add your first set!

+ {% endif %} +
+
+
+ + +
+
+
+
+ Sets by Year +
+
+
+ {% if year_stats %} +
    + {% for year, count in year_stats %} +
  • + {{ year }} + {{ count }} +
  • + {% endfor %} +
+ {% else %} +

No sets yet.

+ {% endif %} +
+
+
+
+ + +
+
+
+
+
+ Recently Added Sets +
+ + View All + +
+
+ {% if recent_sets %} +
+ {% for set in recent_sets %} +
+
+ + {% if set.cover_image %} + {{ set.set_name }} + {% elif set.image_url %} + {{ set.set_name }} + {% else %} +
+ +
+ {% endif %} +
+
+
+ {{ set.set_number }} + {% if set.is_moc %} + + + + {% endif %} +
+

{{ set.set_name }}

+
+ {{ set.theme }} + {{ set.year_released }} +
+
+ +
+
+ {% endfor %} +
+ {% else %} +
+ +

No sets in your collection yet.

+ +
+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/app/templates/extra_files/upload.html b/app/templates/extra_files/upload.html new file mode 100644 index 0000000..3e07e29 --- /dev/null +++ b/app/templates/extra_files/upload.html @@ -0,0 +1,162 @@ +{% extends "base.html" %} + +{% block title %}Upload Extra Files - {{ lego_set.set_number }}{% endblock %} + +{% block content %} +
+
+

+ Upload Extra Files +

+

+ {{ lego_set.set_number }}: {{ lego_set.set_name }} +

+
+ +
+ +
+
+
+
+
Upload Files
+
+
+
+ +
+ + + + Select one or more files to upload. Multiple files can be selected at once. + +
+ + +
+ + +
+ + +
+ + + + This description will apply to all uploaded files. + +
+ + +
+ +
+
+
+
+
+ +
+ +
+
+
Supported Files
+
+
+ Images: +

JPG, PNG, GIF, WebP, BMP, SVG

+ + Documents: +

PDF, DOC, DOCX, TXT, RTF

+ + Data Files: +

XML, JSON, CSV, XLSX, XLS

+ + 3D/CAD: +

+ LDR, MPD (LDraw)
+ IO (Stud.io)
+ LXF, LXFML (LDD)
+ STL, OBJ +

+ + Archives: +

ZIP, RAR, 7Z, TAR, GZ

+
+
+ + +
+
+
Tips
+
+
+
    +
  • + BrickLink XML: Part lists for ordering +
  • +
  • + Stud.io Files: Digital building models +
  • +
  • + Box Art: High-res images of the box +
  • +
  • + Photos: Your built model pictures +
  • +
  • + Archives: Zip multiple files together +
  • +
+
+
+
+
+ + + + +{% endblock %} diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..7db353d --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,112 @@ +{% extends "base.html" %} + +{% block title %}Home - {{ app_name }}{% endblock %} + +{% block content %} +
+
+
+

+ LEGO Instructions Manager +

+

+ Organize, manage, and access all your LEGO instruction manuals in one place. + Upload PDFs and images, search by theme, set number, or year, and integrate with Brickset for automatic set details. +

+ + {% if not current_user.is_authenticated %} + + {% else %} + + {% endif %} +
+
+
+ +
+
+
+
+ +

Upload & Organize

+

+ Upload instruction PDFs and images for your LEGO sets. Keep everything organized by theme, year, and set number. +

+
+
+
+ +
+
+
+ +

Easy Search

+

+ Quickly find any instruction manual using powerful search and filtering. Sort by theme, year, or set number. +

+
+
+
+ +
+
+
+ +

Brickset Integration

+

+ Connect with Brickset API to automatically populate set details and access official instructions when available. +

+
+
+
+
+ +
+
+
+
+

+ Features +

+
+
+
    +
  • Upload PDF and image instructions
  • +
  • Organize by theme and year
  • +
  • Search and filter capabilities
  • +
  • User authentication & profiles
  • +
+
+
+
    +
  • Brickset API integration
  • +
  • Automatic set detail population
  • +
  • Image gallery view
  • +
  • Responsive design
  • +
+
+
+
+
+
+
+{% endblock %} diff --git a/app/templates/instructions/upload.html b/app/templates/instructions/upload.html new file mode 100644 index 0000000..190cdb4 --- /dev/null +++ b/app/templates/instructions/upload.html @@ -0,0 +1,244 @@ +{% extends "base.html" %} + +{% block title %}Upload Instructions - {{ app_name }}{% endblock %} + +{% block content %} + + +
+
+
+
+

+ Upload Instructions +

+

{{ set.set_number }}: {{ set.set_name }}

+
+
+ +
+
+
+
{{ set.set_name }}
+

+ Set: {{ set.set_number }} | + Theme: {{ set.theme }} | + Year: {{ set.year_released }} +

+
+
+

+ Current Instructions:
+ {{ set.instructions.count() }} file(s) +

+
+
+
+ + +
+ +
+ + +
+ Accepted formats: PDF, PNG, JPG, JPEG, GIF (Max 50MB per file) +
+
+ + +
+ +

Drag & Drop Files Here

+

or click to browse

+

+ You can upload multiple files at once +

+
+ + + + + +
+
Upload Tips:
+
    +
  • PDFs: Upload complete instruction manuals as single PDF files
  • +
  • Images: Upload individual pages as separate images (they will be numbered automatically)
  • +
  • Quality: Higher resolution images provide better viewing experience
  • +
  • Organization: Files are automatically organized by set number
  • +
+
+ +
+ +
+ + Cancel + + +
+
+
+
+ + + {% if set.instructions.count() > 0 %} +
+
+
Current Instructions
+
+
+
+
+
PDFs: {{ set.pdf_instructions|length }}
+ {% if set.pdf_instructions %} +
    + {% for pdf in set.pdf_instructions %} +
  • {{ pdf.file_name }}
  • + {% endfor %} +
+ {% else %} +

No PDFs uploaded yet

+ {% endif %} +
+
+
Images: {{ set.image_instructions|length }}
+ {% if set.image_instructions %} +

{{ set.image_instructions|length }} page(s)

+ {% else %} +

No images uploaded yet

+ {% endif %} +
+
+
+
+ {% endif %} +
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} + +{% block extra_css %} + +{% endblock %} diff --git a/app/templates/instructions/viewer.html b/app/templates/instructions/viewer.html new file mode 100644 index 0000000..8304d11 --- /dev/null +++ b/app/templates/instructions/viewer.html @@ -0,0 +1,380 @@ +{% extends "base.html" %} + +{% block title %}Instructions Viewer - {{ set.set_number }}: {{ set.set_name }}{% endblock %} + +{% block content %} + + +
+
+
+

+ {{ set.set_number }}: {{ set.set_name }} + {% if set.is_moc %} + + MOC + + {% endif %} +

+ {{ images|length }} page(s) +
+
+ + + + Close + +
+
+ +
+ +
+ {% for image in images %} +
+
+ Page {{ image.page_number }} / {{ images|length }} + Page {{ image.page_number }} +
+
+ {% endfor %} +
+
+ + +
+ + +
+ + Page 1 / {{ images|length }} + + | + + +
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/templates/sets/add.html b/app/templates/sets/add.html new file mode 100644 index 0000000..66cc9a2 --- /dev/null +++ b/app/templates/sets/add.html @@ -0,0 +1,324 @@ +{% extends "base.html" %} + +{% block title %}Add Set - {{ app_name }}{% endblock %} + +{% block content %} +
+
+
+
+

+ Add New LEGO Set or MOC +

+
+
+ +
+
What are you adding?
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + {% if brickset_available %} + +
+
Search Brickset
+

Search for a set to auto-populate details

+
+ + +
+
+
+
+ {% endif %} + + +
+
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
Enter a URL to an image of the set (e.g., from Brickset)
+
+ +
+ + +
+ + Upload your own photo of the set or MOC (JPG, PNG, GIF). Max 800px, optimized automatically. +
+ +
+ + + + +
+ +
+ + Cancel + + +
+
+
+
+
+
+{% endblock %} + +{% block extra_js %} +{% if brickset_available %} + +{% endif %} + + +{% endblock %} diff --git a/app/templates/sets/detail.html b/app/templates/sets/detail.html new file mode 100644 index 0000000..ab44cf2 --- /dev/null +++ b/app/templates/sets/detail.html @@ -0,0 +1,415 @@ +{% extends "base.html" %} + +{% block title %}{{ set.set_number }}: {{ set.set_name }} - {{ app_name }}{% endblock %} + +{% block content %} + + +
+ +
+
+ {% if set.cover_image %} + {{ set.set_name }} + {% elif set.image_url %} + {{ set.set_name }} + {% else %} +
+ +
+ {% endif %} + +
+
+ {{ set.set_number }} + {% if set.is_moc %} + + MOC + + {% endif %} +
+
{{ set.set_name }}
+ + + + + + + + + + + {% if set.is_moc %} + + + + + {% if set.moc_designer %} + + + + + {% endif %} + {% endif %} + {% if set.piece_count %} + + + + + {% endif %} + + + + + + + + +
Theme:{{ set.theme }}
Year:{{ set.year_released }}
Type:My Own Creation
Designer:{{ set.moc_designer }}
Pieces:{{ set.piece_count }}
Instructions:{{ set.instructions.count() }} file(s)
Added:{{ set.created_at.strftime('%b %d, %Y') }}
+
+ + +
+
+ + +
+
+
+
Instructions
+ + Upload + +
+
+ {% if pdf_instructions or image_instructions %} + + + {% if pdf_instructions %} +
PDF Instruction Books
+
+ {% for instruction in pdf_instructions %} +
+
+ {% if instruction.thumbnail_path %} + + {{ instruction.file_name }} + + {% else %} + +
+ +
+
+ {% endif %} +
+
{{ instruction.file_name }}
+

+ {{ instruction.file_size_mb }} MB
+ {{ instruction.uploaded_at.strftime('%b %d, %Y') }} +

+
+ +
+
+ {% endfor %} +
+ {% endif %} + + + {% if image_instructions %} +
Scanned Instructions
+
+
+
+ {% set first_image = image_instructions[0] %} + + Instructions Preview + +
+
+ Image Instructions +
+

+ {{ image_instructions|length }} page(s)
+ Uploaded {{ first_image.uploaded_at.strftime('%b %d, %Y') }} +

+
+ +
+
+
+ + + + {% endif %} + + {% else %} +
+ +
No Instructions Yet
+

Upload PDF or image files to get started.

+ + Upload Instructions + +
+ {% endif %} +
+
+ + +
+
+
Extra Files
+ + Upload Files + +
+
+ {% set extra_files_list = set.extra_files.all() %} + {% if extra_files_list %} + +
+ {% for file in extra_files_list %} +
+
+ + {% if file.is_image %} + + {{ file.original_filename }} + + {% else %} +
+ +
+ {% endif %} + +
+
+ + {{ file.original_filename }} +
+ + {% if file.category and file.category != 'other' %} + + {{ file.category|replace('_', ' ')|title }} + + {% endif %} + +

+ {{ file.file_size_formatted }} +
+ {{ file.uploaded_at.strftime('%b %d, %Y') }} +

+ + {% if file.description %} +

+ {{ file.description|truncate(60) }} +

+ {% endif %} +
+ + +
+
+ {% endfor %} +
+ {% else %} +
+ +

No extra files yet

+

+ Upload BrickLink XMLs, Stud.io files, box art, photos, or any other related files +

+ + Upload Files + +
+ {% endif %} +
+
+ + +
+
+
Additional Information
+
+
+
+
Set Number:
+
{{ set.set_number }}
+ +
Set Name:
+
{{ set.set_name }}
+ +
Theme:
+
{{ set.theme }}
+ +
Year Released:
+
{{ set.year_released }}
+ + {% if set.piece_count %} +
Piece Count:
+
{{ set.piece_count }} pieces
+ {% endif %} + + {% if set.is_moc %} +
Type:
+
+ + My Own Creation (MOC) + +
+ + {% if set.moc_designer %} +
Designer:
+
{{ set.moc_designer }}
+ {% endif %} + + {% if set.moc_description %} +
Description:
+
{{ set.moc_description }}
+ {% endif %} + {% endif %} + + {% if set.brickset_id %} +
Brickset ID:
+
{{ set.brickset_id }}
+ {% endif %} + +
Added By:
+
{{ set.added_by.username }}
+ +
Date Added:
+
{{ set.created_at.strftime('%B %d, %Y at %I:%M %p') }}
+ +
Last Updated:
+
{{ set.updated_at.strftime('%B %d, %Y at %I:%M %p') }}
+
+
+
+
+
+ + +{% endblock %} + +{% block extra_css %} + +{% endblock %} diff --git a/app/templates/sets/edit.html b/app/templates/sets/edit.html new file mode 100644 index 0000000..4260494 --- /dev/null +++ b/app/templates/sets/edit.html @@ -0,0 +1,211 @@ +{% extends "base.html" %} + +{% block title %}Edit Set - {{ app_name }}{% endblock %} + +{% block content %} +
+
+
+
+

+ Edit LEGO Set +

+
+
+
+
+
+ + +
Set number cannot be changed
+
+ +
+ + +
+
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
Enter a URL to an image of the set
+
+ + {% if set.image_url %} +
+ +
+ {{ set.set_name }} +
+
+ {% endif %} + + +
+
+
Cover Picture
+
+
+ {% if set.cover_image %} +
+ +
+ {{ set.set_name }} +
+
+ + +
+
+ {% endif %} + +
+ + +
+ Upload your own photo of the set or MOC (JPG, PNG, GIF). Max 800px, optimized automatically. +
+ +
+
+
+ + +
+
+
+ + +
+
+
+
+ + +
Who designed this MOC?
+
+ +
+ + +
Optional notes about your custom creation
+
+
+
+ +
+ +
+ + Cancel + + +
+
+
+
+ +
+
+
Set Information
+
+
+

Added by: {{ set.added_by.username }}

+

Created: {{ set.created_at.strftime('%B %d, %Y at %I:%M %p') }}

+

Last updated: {{ set.updated_at.strftime('%B %d, %Y at %I:%M %p') }}

+

Instructions: {{ set.instructions.count() }} file(s)

+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/app/templates/sets/list.html b/app/templates/sets/list.html new file mode 100644 index 0000000..bd1bbc3 --- /dev/null +++ b/app/templates/sets/list.html @@ -0,0 +1,192 @@ +{% extends "base.html" %} + +{% block title %}My Sets - {{ app_name }}{% endblock %} + +{% block content %} +
+
+

My LEGO Sets

+

{{ pagination.total }} sets in your collection

+
+ +
+ + +
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+
+ + +{% if sets %} +
+ {% for set in sets %} +
+
+ + {% if set.cover_image %} + {{ set.set_name }} + {% elif set.image_url %} + {{ set.set_name }} + {% else %} +
+ +
+ {% endif %} +
+ +
+
+ + {{ set.set_number }} + + {% if set.is_moc %} + + + + {% endif %} +
+

+ {{ set.set_name }} +

+ +
+ {{ set.theme }} + {{ set.year_released }} +
+ + {% if set.piece_count %} +

+ {{ set.piece_count }} pieces +

+ {% endif %} + +

+ {{ set.instructions.count() }} instruction(s) +

+
+ + +
+
+ {% endfor %} +
+ + + {% if pagination.pages > 1 %} + + {% endif %} + +{% else %} +
+ +

No Sets Found

+

+ {% if search_query or current_theme or current_year %} + Try adjusting your filters or search terms. + {% else %} + Start by adding your first LEGO set or MOC to your collection! + {% endif %} +

+ + +
+{% endif %} +{% endblock %} diff --git a/check_admin.py b/check_admin.py new file mode 100644 index 0000000..c21c486 --- /dev/null +++ b/check_admin.py @@ -0,0 +1,135 @@ +""" +Admin Status Check & Set Script +Checks if user 'jessikitty' is an admin, and can set admin status +""" + +import sys +import os + +# Add parent directory to path so we can import app +script_dir = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, script_dir) + +def find_database(): + """Find the database file in common locations.""" + possible_paths = [ + os.path.join(script_dir, 'instance', 'lego_instructions.db'), + os.path.join(script_dir, 'lego_instructions.db'), + os.path.join(script_dir, 'app', 'lego_instructions.db'), + ] + + for path in possible_paths: + if os.path.exists(path): + return path + + return None + +def main(): + # Find database + db_path = find_database() + + if not db_path: + print("=" * 70) + print("āŒ Database not found!") + print("=" * 70) + print() + print("Searched in:") + print(" - E:\\LIM\\instance\\lego_instructions.db") + print(" - E:\\LIM\\lego_instructions.db") + print(" - E:\\LIM\\app\\lego_instructions.db") + print() + print("Please make sure:") + print(" 1. You've run the app at least once to create the database") + print(" 2. You're in the correct directory (E:\\LIM)") + print() + return + + print(f"Found database: {db_path}") + print() + + # Set database URL to the found path + db_uri = 'sqlite:///' + db_path.replace('\\', '/') + os.environ['DATABASE_URL'] = db_uri + + from app import create_app, db + from app.models.user import User + + app = create_app() + + with app.app_context(): + print("=" * 70) + print("LEGO Instructions Manager - Admin Status Check") + print("=" * 70) + print() + + # Find user + user = User.query.filter_by(username='jessikitty').first() + + if not user: + print("āŒ User 'jessikitty' not found!") + print() + print("Available users:") + all_users = User.query.all() + for u in all_users: + admin_badge = "šŸ›”ļø ADMIN" if u.is_admin else "šŸ‘¤ User" + print(f" - {u.username} ({u.email}) - {admin_badge}") + print() + return + + print(f"Found user: {user.username}") + print(f"Email: {user.email}") + print(f"Member since: {user.created_at.strftime('%B %d, %Y')}") + print() + + if user.is_admin: + print("āœ… Status: ADMIN (Already has admin privileges)") + print() + print("Admin capabilities:") + print(" āœ“ Access admin panel at /admin/") + print(" āœ“ Manage all users") + print(" āœ“ Manage all sets") + print(" āœ“ View site statistics") + print(" āœ“ Delete any content") + else: + print("āš ļø Status: Regular User (Not an admin)") + print() + response = input("Would you like to grant admin privileges? (yes/no): ") + + if response.lower() in ['yes', 'y']: + user.is_admin = True + db.session.commit() + print() + print("=" * 70) + print("āœ… SUCCESS! Admin privileges granted to jessikitty!") + print("=" * 70) + print() + print("Admin capabilities:") + print(" āœ“ Access admin panel at /admin/") + print(" āœ“ Manage all users") + print(" āœ“ Manage all sets") + print(" āœ“ View site statistics") + print(" āœ“ Delete any content") + print() + print("Next steps:") + print(" 1. Restart Flask (if running)") + print(" 2. Login as jessikitty") + print(" 3. Look for 'Admin' dropdown in navbar") + print(" 4. Click Admin → Dashboard") + else: + print() + print("No changes made.") + + print() + print("=" * 70) + print("Current Admin Users:") + print("=" * 70) + admins = User.query.filter_by(is_admin=True).all() + if admins: + for admin in admins: + print(f" šŸ›”ļø {admin.username} ({admin.email})") + else: + print(" No admin users found!") + print() + +if __name__ == "__main__": + main() diff --git a/cleanup_folders.py b/cleanup_folders.py new file mode 100644 index 0000000..3eba028 --- /dev/null +++ b/cleanup_folders.py @@ -0,0 +1,173 @@ +""" +Cleanup and Verification Script +This script will: +1. Check for duplicate upload folders +2. Move files to correct location +3. Clean up duplicate folders +4. Verify everything is in the right place +""" + +import os +import shutil +import glob + +def cleanup_and_verify(): + """Clean up duplicate folders and verify correct structure.""" + + print("="*70) + print("LEGO Instructions Manager - Cleanup & Verification") + print("="*70) + print() + + base_dir = os.path.dirname(os.path.abspath(__file__)) + + # Correct location + correct_uploads = os.path.join(base_dir, 'app', 'static', 'uploads') + + # Duplicate location (the problem) + duplicate_uploads = os.path.join(base_dir, 'app', 'app', 'static', 'uploads') + + print("Checking for duplicate folders...") + print(f"Correct location: {correct_uploads}") + print(f"Duplicate location: {duplicate_uploads}") + print() + + # Check if duplicate exists + if os.path.exists(duplicate_uploads): + print("āš ļø DUPLICATE FOLDER FOUND!") + print(f"Found: {duplicate_uploads}") + print() + + # Count files in duplicate + covers_in_duplicate = os.path.join(duplicate_uploads, 'covers') + if os.path.exists(covers_in_duplicate): + file_count = 0 + for root, dirs, files in os.walk(covers_in_duplicate): + file_count += len(files) + + print(f"Files in duplicate location: {file_count}") + + if file_count > 0: + print() + print("šŸ”„ Moving files to correct location...") + + # Ensure correct location exists + os.makedirs(correct_uploads, exist_ok=True) + correct_covers = os.path.join(correct_uploads, 'covers') + os.makedirs(correct_covers, exist_ok=True) + + # Move all set folders + for set_folder in os.listdir(covers_in_duplicate): + src_folder = os.path.join(covers_in_duplicate, set_folder) + dst_folder = os.path.join(correct_covers, set_folder) + + if os.path.isdir(src_folder): + if os.path.exists(dst_folder): + print(f" Merging: {set_folder}") + # Merge folders + for file in os.listdir(src_folder): + src_file = os.path.join(src_folder, file) + dst_file = os.path.join(dst_folder, file) + if not os.path.exists(dst_file): + shutil.copy2(src_file, dst_file) + print(f" Moved: {file}") + else: + print(f" Moving: {set_folder}") + shutil.copytree(src_folder, dst_folder) + + print("āœ… Files moved successfully!") + + print() + print("šŸ—‘ļø Removing duplicate folder structure...") + + # Remove the duplicate app/app folder + duplicate_app_folder = os.path.join(base_dir, 'app', 'app') + try: + shutil.rmtree(duplicate_app_folder) + print(f"āœ… Removed: {duplicate_app_folder}") + except Exception as e: + print(f"āš ļø Could not remove: {e}") + print(f" Please manually delete: {duplicate_app_folder}") + else: + print("āœ… No duplicate folder found - structure is correct!") + + print() + print("="*70) + print("Verifying correct structure...") + print("="*70) + print() + + # Verify correct structure + if os.path.exists(correct_uploads): + print(f"āœ… Uploads folder exists: {correct_uploads}") + + # Check subdirectories + covers = os.path.join(correct_uploads, 'covers') + images = os.path.join(correct_uploads, 'images') + pdfs = os.path.join(correct_uploads, 'pdfs') + + for folder, name in [(covers, 'covers'), (images, 'images'), (pdfs, 'pdfs')]: + if os.path.exists(folder): + file_count = sum([len(files) for _, _, files in os.walk(folder)]) + print(f"āœ… {name:8} folder exists - {file_count} file(s)") + else: + print(f"āš ļø {name:8} folder missing - creating...") + os.makedirs(folder, exist_ok=True) + print(f"āœ… Created: {folder}") + + # List cover images + print() + print("Cover images found:") + covers_path = os.path.join(correct_uploads, 'covers') + if os.path.exists(covers_path): + set_folders = [f for f in os.listdir(covers_path) if os.path.isdir(os.path.join(covers_path, f))] + if set_folders: + for set_folder in sorted(set_folders): + files = os.listdir(os.path.join(covers_path, set_folder)) + print(f" {set_folder}: {len(files)} image(s)") + else: + print(" No cover images uploaded yet") + + else: + print(f"āŒ Uploads folder does NOT exist: {correct_uploads}") + print("Creating it now...") + os.makedirs(correct_uploads, exist_ok=True) + os.makedirs(os.path.join(correct_uploads, 'covers'), exist_ok=True) + os.makedirs(os.path.join(correct_uploads, 'images'), exist_ok=True) + os.makedirs(os.path.join(correct_uploads, 'pdfs'), exist_ok=True) + print("āœ… Created upload folder structure") + + print() + print("="*70) + print("Configuration Check") + print("="*70) + print() + + # Check config.py + config_path = os.path.join(base_dir, 'app', 'config.py') + if os.path.exists(config_path): + with open(config_path, 'r') as f: + config_content = f.read() + if "basedir, 'static', 'uploads'" in config_content: + print("āœ… config.py has correct UPLOAD_FOLDER path") + elif "basedir, 'app', 'static', 'uploads'" in config_content: + print("āŒ config.py has INCORRECT path (includes 'app')") + print(" Please update config.py:") + print(" Change: os.path.join(basedir, 'app', 'static', 'uploads')") + print(" To: os.path.join(basedir, 'static', 'uploads')") + else: + print("āš ļø Could not verify config.py UPLOAD_FOLDER") + + print() + print("="*70) + print("āœ… Cleanup and verification complete!") + print("="*70) + print() + print("Next steps:") + print("1. Restart Flask: python run.py") + print("2. Test image uploads") + print("3. Verify images display correctly") + print() + +if __name__ == "__main__": + cleanup_and_verify() diff --git a/fix_paths.py b/fix_paths.py new file mode 100644 index 0000000..10d1482 --- /dev/null +++ b/fix_paths.py @@ -0,0 +1,178 @@ +""" +Path Fix & Diagnostic Script +Fixes backslash paths in database and checks file existence +""" + +import sys +import os + +script_dir = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, script_dir) + +def find_database(): + """Find the database file in common locations.""" + possible_paths = [ + os.path.join(script_dir, 'instance', 'lego_instructions.db'), + os.path.join(script_dir, 'lego_instructions.db'), + os.path.join(script_dir, 'app', 'lego_instructions.db'), + ] + + for path in possible_paths: + if os.path.exists(path): + return path + + return None + +def main(): + # Find database + db_path = find_database() + + if not db_path: + print("=" * 70) + print("āŒ Database not found!") + print("=" * 70) + print() + print("Searched in:") + print(" - E:\\LIM\\instance\\lego_instructions.db") + print(" - E:\\LIM\\lego_instructions.db") + print(" - E:\\LIM\\app\\lego_instructions.db") + print() + return + + print(f"Found database: {db_path}") + print() + + # Set database URL + db_uri = 'sqlite:///' + db_path.replace('\\', '/') + os.environ['DATABASE_URL'] = db_uri + + from app import create_app, db + from app.models.instruction import Instruction + from app.models.set import Set + from flask import current_app + + app = create_app() + + with app.app_context(): + print("=" * 70) + print("LEGO Instructions Manager - Path Fix & Diagnostics") + print("=" * 70) + print() + + upload_folder = current_app.config['UPLOAD_FOLDER'] + print(f"Upload folder: {upload_folder}") + print() + + # Check instructions + instructions = Instruction.query.all() + print(f"Found {len(instructions)} instruction files") + print() + + fixed_paths = 0 + fixed_thumbnails = 0 + missing_files = [] + + for instruction in instructions: + # Fix file_path backslashes + if '\\' in instruction.file_path: + old_path = instruction.file_path + instruction.file_path = instruction.file_path.replace('\\', '/') + fixed_paths += 1 + print(f"Fixed path: {old_path} → {instruction.file_path}") + + # Fix thumbnail_path backslashes + if instruction.thumbnail_path and '\\' in instruction.thumbnail_path: + old_thumb = instruction.thumbnail_path + instruction.thumbnail_path = instruction.thumbnail_path.replace('\\', '/') + fixed_thumbnails += 1 + print(f"Fixed thumbnail: {old_thumb} → {instruction.thumbnail_path}") + + # Check if file exists + full_path = os.path.join(upload_folder, instruction.file_path) + if not os.path.exists(full_path): + missing_files.append({ + 'id': instruction.id, + 'file_name': instruction.file_name, + 'path': instruction.file_path, + 'full_path': full_path, + 'set_id': instruction.set_id + }) + + # Fix cover images + sets = Set.query.all() + fixed_covers = 0 + + for lego_set in sets: + if lego_set.cover_image and '\\' in lego_set.cover_image: + old_cover = lego_set.cover_image + lego_set.cover_image = lego_set.cover_image.replace('\\', '/') + fixed_covers += 1 + print(f"Fixed cover: {old_cover} → {lego_set.cover_image}") + + # Commit changes + if fixed_paths or fixed_thumbnails or fixed_covers: + db.session.commit() + print() + print("=" * 70) + print("āœ… Database Updates:") + print("=" * 70) + print(f" • Fixed {fixed_paths} instruction file paths") + print(f" • Fixed {fixed_thumbnails} thumbnail paths") + print(f" • Fixed {fixed_covers} cover image paths") + print() + else: + print() + print("āœ… No path fixes needed - all paths are correct!") + print() + + # Report missing files + if missing_files: + print("=" * 70) + print("āš ļø WARNING: Missing Files Detected") + print("=" * 70) + print() + for item in missing_files: + lego_set = Set.query.get(item['set_id']) + print(f"Missing: {item['file_name']}") + print(f" Set: {lego_set.set_number if lego_set else 'Unknown'}") + print(f" DB Path: {item['path']}") + print(f" Full Path: {item['full_path']}") + print(f" Instruction ID: {item['id']}") + print() + + print(f"Total missing: {len(missing_files)} files") + print() + print("These files may have been:") + print(" • Deleted manually from disk") + print(" • Never uploaded properly") + print(" • Moved to wrong location") + print() + print("To fix:") + print(" 1. Re-upload the files, or") + print(" 2. Delete the database records:") + print(f" DELETE FROM instructions WHERE id IN ({','.join(str(x['id']) for x in missing_files)});") + print() + else: + print("=" * 70) + print("āœ… All Files Verified") + print("=" * 70) + print(" All instruction files exist on disk!") + print() + + # Summary + print("=" * 70) + print("Summary") + print("=" * 70) + print(f" Total instructions: {len(instructions)}") + print(f" Paths fixed: {fixed_paths + fixed_thumbnails + fixed_covers}") + print(f" Files verified: {len(instructions) - len(missing_files)}") + print(f" Missing files: {len(missing_files)}") + print() + + if fixed_paths or fixed_thumbnails or fixed_covers: + print("āœ… Restart Flask to see changes!") + + print() + +if __name__ == "__main__": + main() diff --git a/fresh_git_upload.bat b/fresh_git_upload.bat new file mode 100644 index 0000000..2a6b5a5 --- /dev/null +++ b/fresh_git_upload.bat @@ -0,0 +1,232 @@ +@echo off +echo =============================================== +echo LEGO Instructions Manager - Fresh Git Upload +echo =============================================== +echo. + +REM Check if we're in the right directory +if not exist "run.py" ( + echo ERROR: run.py not found! + echo Please run this script from E:\LIM directory + pause + exit /b 1 +) + +echo Cleaning up any existing git... +if exist ".git" ( + rmdir /s /q .git + echo Old .git folder removed +) +echo. + +echo =============================================== +echo Step 1: Creating .gitignore +echo =============================================== +( +echo # Python +echo __pycache__/ +echo *.py[cod] +echo *.pyo +echo *.pyd +echo .Python +echo env/ +echo venv/ +echo ENV/ +echo build/ +echo dist/ +echo *.egg-info/ +echo. +echo # Flask +echo instance/ +echo .webassets-cache +echo. +echo # Environment +echo .env +echo .venv +echo. +echo # Database +echo *.db +echo *.sqlite +echo *.sqlite3 +echo. +echo # Uploads - user data +echo app/static/uploads/ +echo. +echo # IDE +echo .vscode/ +echo .idea/ +echo *.swp +echo *.swo +echo *~ +echo. +echo # OS +echo .DS_Store +echo Thumbs.db +echo. +echo # Logs +echo *.log +) > .gitignore +echo .gitignore created +echo. + +echo =============================================== +echo Step 2: Initializing Git repository +echo =============================================== +git init +if %ERRORLEVEL% NEQ 0 ( + echo ERROR: Git initialization failed! + echo Make sure Git is installed: https://git-scm.com/download/win + pause + exit /b 1 +) +echo Git initialized successfully +echo. + +echo =============================================== +echo Step 3: Configuring Git user +echo =============================================== +git config user.name "jessikitty" +git config user.email "jess.rogerson.29@outlook.com" +echo Git user configured +echo. + +echo =============================================== +echo Step 4: Adding all files +echo =============================================== +git add . +echo Files staged +echo. + +echo =============================================== +echo Step 5: Creating initial commit +echo =============================================== +git commit -m "Initial commit - LEGO Instructions Manager v1.5.0 + +Complete web application for managing LEGO instruction manuals. + +Features: +- User authentication and multi-user support +- Set management (official sets and MOCs) +- PDF and image instruction upload +- PDF viewer with thumbnails +- Cover image upload and display +- Extra files storage (40+ file types) +- Bulk import from Brickset API +- Admin panel with user management +- Search and filtering +- Responsive Bootstrap 5 UI + +Version: v1.5.0 +Built with Flask, SQLAlchemy, and love for LEGO!" +if %ERRORLEVEL% NEQ 0 ( + echo ERROR: Git commit failed! + pause + exit /b 1 +) +echo Commit created +echo. + +echo =============================================== +echo Step 6: Setting up main branch +echo =============================================== +git branch -M main +echo Main branch set +echo. + +echo =============================================== +echo Step 7: Adding Gitea remote +echo =============================================== +git remote add origin https://gitea.hideawaygaming.com.au/jessikitty/lego-instructions-manager.git +if %ERRORLEVEL% NEQ 0 ( + echo Note: Remote might already exist, removing and re-adding... + git remote remove origin + git remote add origin https://gitea.hideawaygaming.com.au/jessikitty/lego-instructions-manager.git +) +echo Remote added +echo. + +echo =============================================== +echo Step 8: Pushing to Gitea +echo =============================================== +echo. +echo You will be prompted for your Gitea credentials: +echo. +echo Username: jessikitty +echo Password: (your Gitea password or access token) +echo. +echo For better security, use an access token: +echo https://gitea.hideawaygaming.com.au/user/settings/applications +echo. +pause +echo. +echo Pushing to Gitea... +git push -u origin main --force +echo. + +if %ERRORLEVEL% EQU 0 ( + echo =============================================== + echo SUCCESS! Project uploaded to Gitea! + echo =============================================== + echo. + echo Your repository is now live at: + echo https://gitea.hideawaygaming.com.au/jessikitty/lego-instructions-manager + echo. + echo What was uploaded: + echo - All Python code (app/, *.py) + echo - Templates and static files + echo - Configuration files + echo - Migration scripts + echo - Documentation + echo. + echo What was excluded (by .gitignore): + echo - Database files (instance/, *.db) + echo - Environment variables (.env) + echo - Uploads folder (user data) + echo - Virtual environment (venv/) + echo - Python cache (__pycache__/) + echo. + echo =============================================== + echo Next steps: + echo =============================================== + echo 1. View your repo in browser + echo 2. Clone it on other machines + echo 3. Start collaborating! + echo. +) else ( + echo =============================================== + echo ERROR: Failed to push to Gitea! + echo =============================================== + echo. + echo Common issues and fixes: + echo. + echo 1. Authentication failed: + echo - Check your username: jessikitty + echo - Verify your password + echo - Try using an access token instead + echo. + echo 2. Network error: + echo - Check internet connection + echo - Verify Gitea server is running + echo - Try: https://gitea.hideawaygaming.com.au + echo. + echo 3. Repository issues: + echo - Make sure repository exists on Gitea + echo - Check repository permissions + echo. + echo To retry, just run this script again! + echo. +) + +echo =============================================== +echo Git Information +echo =============================================== +echo. +echo Repository: https://gitea.hideawaygaming.com.au/jessikitty/lego-instructions-manager +echo Remote: +git remote -v +echo. +echo Recent commits: +git log --oneline -5 +echo. + +pause diff --git a/install.bat b/install.bat new file mode 100644 index 0000000..cd814b9 --- /dev/null +++ b/install.bat @@ -0,0 +1,53 @@ +@echo off +echo =============================================== +echo LEGO Instructions Manager - Installation +echo =============================================== +echo. + +REM Check if virtual environment exists +if not exist "venv" ( + echo Creating virtual environment... + python -m venv venv + echo. +) + +echo Activating virtual environment... +call venv\Scripts\activate.bat +echo. + +echo Upgrading pip... +python -m pip install --upgrade pip +echo. + +echo Installing core dependencies first... +pip install Flask>=3.0.0 +pip install Flask-SQLAlchemy>=3.1.0 +pip install Flask-Login>=0.6.0 +pip install Flask-Bcrypt>=1.0.0 +pip install Flask-WTF>=1.2.0 +pip install requests>=2.31.0 +pip install python-dotenv>=1.0.0 +echo. + +echo Installing image processing (Pillow)... +pip install --upgrade Pillow +echo. + +echo Installing PDF handling... +pip install PyPDF2>=3.0.0 +pip install PyMuPDF>=1.23.0 +echo. + +echo Installing remaining dependencies... +pip install -r requirements-flexible.txt +echo. + +echo =============================================== +echo Installation Complete! +echo =============================================== +echo. +echo Next steps: +echo 1. Configure .env file with your settings +echo 2. Run: python run.py +echo. +pause diff --git a/migrate_add_cover_image.py b/migrate_add_cover_image.py new file mode 100644 index 0000000..8f39d5f --- /dev/null +++ b/migrate_add_cover_image.py @@ -0,0 +1,93 @@ +""" +Database Migration Script: Add Cover Image Upload Support +This script adds the cover_image field to the Set model for uploaded images. + +Run this AFTER updating your models and BEFORE running the application. +""" + +import sqlite3 +import os + +def migrate_database(db_path='lego_instructions.db'): + """Add cover_image field to the sets table.""" + + if not os.path.exists(db_path): + print(f"Database file '{db_path}' not found.") + print("This is normal for a new installation - no migration needed.") + return + + print("Starting database migration...") + print(f"Database: {db_path}") + + try: + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # Check if column already exists + cursor.execute("PRAGMA table_info(sets)") + columns = [column[1] for column in cursor.fetchall()] + + changes_made = False + + # Add cover_image column if it doesn't exist + if 'cover_image' not in columns: + print("Adding 'cover_image' column...") + cursor.execute(""" + ALTER TABLE sets + ADD COLUMN cover_image VARCHAR(500) + """) + changes_made = True + else: + print("Column 'cover_image' already exists - skipping") + + if changes_made: + conn.commit() + print("\nāœ… Migration completed successfully!") + print("Cover image upload support has been added to your database.") + else: + print("\nāœ… Database is already up to date - no changes needed.") + + conn.close() + + except sqlite3.Error as e: + print(f"\nāŒ Migration failed: {e}") + print("Please backup your database and try again.") + return False + + return True + +if __name__ == "__main__": + print("="*60) + print("LEGO Instructions Manager - Database Migration") + print("Adding Cover Image Upload Support") + print("="*60) + print() + + # Try to find the database file + db_paths = [ + 'lego_instructions.db', + 'instance/lego_instructions.db', + '../lego_instructions.db', + '../../lego_instructions.db', + ] + + db_found = False + for db_path in db_paths: + if os.path.exists(db_path): + print(f"Found database at: {db_path}") + if migrate_database(db_path): + db_found = True + break + + if not db_found: + print("\nNo existing database found.") + print("If this is a new installation, the database will be created") + print("with cover image support when you run the application.") + + print() + print("="*60) + print("Next steps:") + print("1. Restart your Flask application") + print("2. You can now upload cover images for sets and MOCs") + print("3. Edit existing sets to add cover images") + print("="*60) diff --git a/migrate_add_moc_support.py b/migrate_add_moc_support.py new file mode 100644 index 0000000..a6ea0c1 --- /dev/null +++ b/migrate_add_moc_support.py @@ -0,0 +1,124 @@ +""" +Database Migration Script: Add MOC Support +This script adds MOC (My Own Creation) fields to the Set model. + +Run this AFTER updating your models and BEFORE running the application. +""" + +import sqlite3 +import os + +def migrate_database(db_path='lego_instructions.db'): + """Add MOC fields to the sets table.""" + + if not os.path.exists(db_path): + print(f"Database file '{db_path}' not found.") + print("This is normal for a new installation - no migration needed.") + return + + print("Starting database migration...") + print(f"Database: {db_path}") + + try: + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # Check if columns already exist + cursor.execute("PRAGMA table_info(sets)") + columns = [column[1] for column in cursor.fetchall()] + + changes_made = False + + # Add is_moc column if it doesn't exist + if 'is_moc' not in columns: + print("Adding 'is_moc' column...") + cursor.execute(""" + ALTER TABLE sets + ADD COLUMN is_moc BOOLEAN DEFAULT 0 NOT NULL + """) + changes_made = True + else: + print("Column 'is_moc' already exists - skipping") + + # Add moc_designer column if it doesn't exist + if 'moc_designer' not in columns: + print("Adding 'moc_designer' column...") + cursor.execute(""" + ALTER TABLE sets + ADD COLUMN moc_designer VARCHAR(100) + """) + changes_made = True + else: + print("Column 'moc_designer' already exists - skipping") + + # Add moc_description column if it doesn't exist + if 'moc_description' not in columns: + print("Adding 'moc_description' column...") + cursor.execute(""" + ALTER TABLE sets + ADD COLUMN moc_description TEXT + """) + changes_made = True + else: + print("Column 'moc_description' already exists - skipping") + + if changes_made: + # Create index on is_moc for better query performance + print("Creating index on 'is_moc' column...") + try: + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_sets_is_moc + ON sets(is_moc) + """) + except sqlite3.OperationalError: + print("Index already exists - skipping") + + conn.commit() + print("\nāœ… Migration completed successfully!") + print("MOC support has been added to your database.") + else: + print("\nāœ… Database is already up to date - no changes needed.") + + conn.close() + + except sqlite3.Error as e: + print(f"\nāŒ Migration failed: {e}") + print("Please backup your database and try again.") + return False + + return True + +if __name__ == "__main__": + print("="*60) + print("LEGO Instructions Manager - Database Migration") + print("Adding MOC (My Own Creation) Support") + print("="*60) + print() + + # Try to find the database file + db_paths = [ + 'lego_instructions.db', + '../lego_instructions.db', + '../../lego_instructions.db', + ] + + db_found = False + for db_path in db_paths: + if os.path.exists(db_path): + print(f"Found database at: {db_path}") + if migrate_database(db_path): + db_found = True + break + + if not db_found: + print("\nNo existing database found.") + print("If this is a new installation, the database will be created") + print("with MOC support when you run the application.") + + print() + print("="*60) + print("Next steps:") + print("1. Restart your Flask application") + print("2. You can now add MOCs when creating new sets") + print("3. Edit existing sets to mark them as MOCs") + print("="*60) diff --git a/migrate_v1.3.0.py b/migrate_v1.3.0.py new file mode 100644 index 0000000..31b0a5e --- /dev/null +++ b/migrate_v1.3.0.py @@ -0,0 +1,156 @@ +""" +Database Migration Script v1.3.0 +Adds: +- thumbnail_path to Instructions table (for PDF thumbnails) +- is_admin to Users table (for admin panel) +""" + +import sqlite3 +import os + +def migrate_database(db_path='lego_instructions.db'): + """Add new fields for v1.3.0 features.""" + + if not os.path.exists(db_path): + print(f"Database file '{db_path}' not found.") + print("This is normal for a new installation - no migration needed.") + return + + print("="*70) + print("LEGO Instructions Manager - Database Migration v1.3.0") + print("Adding PDF Thumbnails + Admin Panel Support") + print("="*70) + print() + print(f"Database: {db_path}") + print() + + try: + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + changes_made = False + + # Check Instructions table + cursor.execute("PRAGMA table_info(instructions)") + instruction_columns = [column[1] for column in cursor.fetchall()] + + # Add thumbnail_path to instructions + if 'thumbnail_path' not in instruction_columns: + print("šŸ“„ Adding 'thumbnail_path' to instructions table...") + cursor.execute(""" + ALTER TABLE instructions + ADD COLUMN thumbnail_path VARCHAR(500) + """) + changes_made = True + print(" āœ… Added thumbnail_path column") + else: + print(" ā„¹ļø thumbnail_path already exists") + + # Check Users table + cursor.execute("PRAGMA table_info(users)") + user_columns = [column[1] for column in cursor.fetchall()] + + # Add is_admin to users + if 'is_admin' not in user_columns: + print("šŸ›”ļø Adding 'is_admin' to users table...") + cursor.execute(""" + ALTER TABLE users + ADD COLUMN is_admin BOOLEAN DEFAULT 0 NOT NULL + """) + changes_made = True + print(" āœ… Added is_admin column") + + # Create index for performance + print(" Creating index on is_admin...") + try: + cursor.execute(""" + CREATE INDEX idx_users_is_admin ON users(is_admin) + """) + print(" āœ… Created index") + except sqlite3.OperationalError: + print(" ā„¹ļø Index already exists") + else: + print(" ā„¹ļø is_admin already exists") + + if changes_made: + conn.commit() + print() + print("="*70) + print("āœ… Migration completed successfully!") + print("="*70) + print() + print("New Features Available:") + print(" šŸ“„ PDF Thumbnail Generation") + print(" šŸ›”ļø Admin Panel") + print() + else: + print() + print("="*70) + print("āœ… Database is already up to date!") + print("="*70) + print() + + conn.close() + + except sqlite3.Error as e: + print() + print("="*70) + print(f"āŒ Migration failed: {e}") + print("="*70) + print() + print("Please backup your database and try again.") + return False + + return True + +if __name__ == "__main__": + print() + + # Try to find the database file + db_paths = [ + 'lego_instructions.db', + 'instance/lego_instructions.db', + '../lego_instructions.db', + ] + + db_found = False + for db_path in db_paths: + if os.path.exists(db_path): + if migrate_database(db_path): + db_found = True + break + + if not db_found: + print("="*70) + print("No existing database found.") + print("="*70) + print() + print("If this is a new installation:") + print(" The database will be created with these features when you") + print(" run the application for the first time.") + print() + + print("="*70) + print("Next Steps:") + print("="*70) + print() + print("1. Install required package:") + print(" pip install PyMuPDF --break-system-packages") + print() + print("2. Make your first admin user:") + print(" python") + print(" >>> from app import create_app, db") + print(" >>> from app.models.user import User") + print(" >>> app = create_app()") + print(" >>> with app.app_context():") + print(" ... user = User.query.filter_by(username='YOUR_USERNAME').first()") + print(" ... user.is_admin = True") + print(" ... db.session.commit()") + print() + print("3. Restart Flask:") + print(" python run.py") + print() + print("4. Access admin panel:") + print(" http://localhost:5000/admin/") + print() + print("="*70) diff --git a/poo.py b/poo.py new file mode 100644 index 0000000..be89202 --- /dev/null +++ b/poo.py @@ -0,0 +1,13 @@ +from app import create_app, db +from app.models.user import User + +app = create_app() +with app.app_context(): + # Find your user + user = User.query.filter_by(username='jessikitty').first() + + # Make them admin + user.is_admin = True + db.session.commit() + + print(f"{user.username} is now an admin!") \ No newline at end of file diff --git a/push_to_gitea.bat b/push_to_gitea.bat new file mode 100644 index 0000000..1ca4ffe --- /dev/null +++ b/push_to_gitea.bat @@ -0,0 +1,79 @@ +@echo off +echo =============================================== +echo Push LEGO Instructions Manager to Gitea +echo =============================================== +echo. + +REM Check if we're in the right directory +if not exist "run.py" ( + echo Error: run.py not found! + echo Please run this script from E:\LIM directory + pause + exit /b 1 +) + +echo Step 1: Creating .gitignore... +echo __pycache__/ > .gitignore +echo *.pyc >> .gitignore +echo *.pyo >> .gitignore +echo instance/ >> .gitignore +echo .env >> .gitignore +echo app/static/uploads/ >> .gitignore +echo venv/ >> .gitignore +echo ENV/ >> .gitignore +echo *.db >> .gitignore +echo *.sqlite >> .gitignore +echo *.log >> .gitignore +echo .vscode/ >> .gitignore +echo .idea/ >> .gitignore +echo. + +echo Step 2: Initializing Git repository... +git init +echo. + +echo Step 3: Adding all files... +git add . +echo. + +echo Step 4: Creating initial commit... +git commit -m "Initial commit - LEGO Instructions Manager v1.5.0" +echo. + +echo Step 5: Adding Gitea remote... +git remote add origin https://gitea.hideawaygaming.com.au/jessikitty/lego-instructions-manager.git +echo. + +echo Step 6: Setting main branch... +git branch -M main +echo. + +echo Step 7: Pushing to Gitea... +echo. +echo You will be prompted for your Gitea credentials: +echo Username: jessikitty +echo Password: (your Gitea password or access token) +echo. +git push -u origin main +echo. + +if %ERRORLEVEL% EQU 0 ( + echo =============================================== + echo Success! Project pushed to Gitea! + echo =============================================== + echo. + echo View at: https://gitea.hideawaygaming.com.au/jessikitty/lego-instructions-manager + echo. +) else ( + echo =============================================== + echo Error pushing to Gitea! + echo =============================================== + echo. + echo Common fixes: + echo 1. Check your internet connection + echo 2. Verify your Gitea credentials + echo 3. Try using an access token instead of password + echo. +) + +pause diff --git a/requirements-flexible.txt b/requirements-flexible.txt new file mode 100644 index 0000000..d229bcb --- /dev/null +++ b/requirements-flexible.txt @@ -0,0 +1,40 @@ +# Core Framework +Flask>=3.0.0,<4.0.0 +Werkzeug>=3.0.0,<4.0.0 + +# Database +Flask-SQLAlchemy>=3.1.0,<4.0.0 +Flask-Migrate>=4.0.0,<5.0.0 +SQLAlchemy>=2.0.0,<3.0.0 + +# Authentication +Flask-Login>=0.6.0,<1.0.0 +Flask-Bcrypt>=1.0.0,<2.0.0 + +# Forms & Validation +Flask-WTF>=1.2.0,<2.0.0 +WTForms>=3.1.0,<4.0.0 +email-validator>=2.0.0,<3.0.0 + +# HTTP Requests (for Brickset API) +requests>=2.31.0,<3.0.0 +httpx>=0.25.0,<1.0.0 + +# File Handling +Pillow>=10.0.0 +PyPDF2>=3.0.0,<4.0.0 +PyMuPDF>=1.23.0,<2.0.0 + +# Environment Variables +python-dotenv>=1.0.0,<2.0.0 + +# Date/Time Utilities +python-dateutil>=2.8.0,<3.0.0 + +# Development & Testing (optional) +# pytest>=7.4.0 +# pytest-flask>=1.3.0 +# flask-debugtoolbar>=0.14.0 + +# Production Server (optional) +# gunicorn>=21.2.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..06cf8fc --- /dev/null +++ b/requirements.txt @@ -0,0 +1,40 @@ +# Core Framework +Flask==3.0.0 +Werkzeug==3.0.1 + +# Database +Flask-SQLAlchemy==3.1.1 +Flask-Migrate==4.0.5 +SQLAlchemy==2.0.23 + +# Authentication +Flask-Login==0.6.3 +Flask-Bcrypt==1.0.1 + +# Forms & Validation +Flask-WTF==1.2.1 +WTForms==3.1.1 +email-validator==2.1.0 + +# HTTP Requests (for Brickset API) +requests==2.31.0 +httpx==0.25.2 + +# File Handling +Pillow==10.4.0 +PyPDF2==3.0.1 +PyMuPDF==1.23.8 # For PDF thumbnail generation + +# Environment Variables +python-dotenv==1.0.0 + +# Date/Time Utilities +python-dateutil==2.8.2 + +# Development & Testing +pytest==7.4.3 +pytest-flask==1.3.0 +flask-debugtoolbar==0.14.1 + +# Production Server +gunicorn==21.2.0 diff --git a/run.py b/run.py new file mode 100644 index 0000000..484cb71 --- /dev/null +++ b/run.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +""" +LEGO Instructions Manager +Main application entry point +""" +import os +from app import create_app, db +from app.models import User, Set, Instruction + +# Create Flask application +app = create_app(os.getenv('FLASK_ENV', 'development')) + + +@app.shell_context_processor +def make_shell_context(): + """Make database models available in Flask shell.""" + return { + 'db': db, + 'User': User, + 'Set': Set, + 'Instruction': Instruction + } + + +@app.cli.command() +def init_db(): + """Initialize the database.""" + db.create_all() + print('Database initialized successfully!') + + +@app.cli.command() +def create_admin(): + """Create an admin user.""" + username = input('Enter admin username: ') + email = input('Enter admin email: ') + password = input('Enter admin password: ') + + if User.query.filter_by(username=username).first(): + print(f'User {username} already exists!') + return + + admin = User(username=username, email=email) + admin.set_password(password) + + db.session.add(admin) + db.session.commit() + + print(f'Admin user {username} created successfully!') + + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000, debug=True) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29