From 136f7d03f557bc011e9583cedd3854a89d81967d Mon Sep 17 00:00:00 2001 From: FrederikBaerentsen Date: Fri, 19 Dec 2025 22:41:28 -0500 Subject: [PATCH 01/63] feat(admin): first version of export feature. --- bricktracker/app.py | 2 + bricktracker/views/admin/export.py | 295 +++++++++++++++++++++++++++++ templates/admin.html | 1 + templates/admin/export.html | 70 +++++++ 4 files changed, 368 insertions(+) create mode 100644 bricktracker/views/admin/export.py create mode 100644 templates/admin/export.html diff --git a/bricktracker/app.py b/bricktracker/app.py index 1f28f64..e791597 100644 --- a/bricktracker/app.py +++ b/bricktracker/app.py @@ -17,6 +17,7 @@ from bricktracker.version import __version__ from bricktracker.views.add import add_page from bricktracker.views.admin.admin import admin_page from bricktracker.views.admin.database import admin_database_page +from bricktracker.views.admin.export import admin_export_page from bricktracker.views.admin.image import admin_image_page from bricktracker.views.admin.instructions import admin_instructions_page from bricktracker.views.admin.owner import admin_owner_page @@ -149,6 +150,7 @@ def setup_app(app: Flask) -> None: # Register admin routes app.register_blueprint(admin_page) app.register_blueprint(admin_database_page) + app.register_blueprint(admin_export_page) app.register_blueprint(admin_image_page) app.register_blueprint(admin_instructions_page) app.register_blueprint(admin_retired_page) diff --git a/bricktracker/views/admin/export.py b/bricktracker/views/admin/export.py new file mode 100644 index 0000000..dabb6d4 --- /dev/null +++ b/bricktracker/views/admin/export.py @@ -0,0 +1,295 @@ +import csv +import io + +from flask import ( + Blueprint, + request, +) +from flask_login import login_required +from werkzeug.wrappers.response import Response + +from ..exceptions import exception_handler +from ...part_list import BrickPartList +from ...set_list import BrickSetList + +admin_export_page = Blueprint( + 'admin_export', + __name__, + url_prefix='/admin/export' +) + + +# Export all sets to Rebrickable CSV format +@admin_export_page.route('/sets/rebrickable-csv', methods=['GET']) +@login_required +@exception_handler(__file__) +def export_sets_rebrickable() -> Response: + + set_list = BrickSetList() + all_sets = set_list.all() + + output = io.StringIO() + writer = csv.writer(output) + + writer.writerow(['Set Number', 'Quantity', 'Includes Spares', 'Inventory Ver']) + + for set_item in all_sets.records: + writer.writerow([ + set_item.fields.set, + 1, # Each set instance counts as 1 + 'True', # BrickTracker tracks spares separately + 1 # Inventory version (always 1 for now) + ]) + + # Prepare response + output.seek(0) + return Response( + output.getvalue(), + mimetype='text/csv', + headers={'Content-Disposition': 'attachment;filename=bricktracker_sets_rebrickable.csv'} + ) + + +# Export all parts to Rebrickable CSV format +@admin_export_page.route('/parts/rebrickable-csv', methods=['GET']) +@login_required +@exception_handler(__file__) +def export_parts_rebrickable() -> Response: + + owner_id = request.args.get('owner') + color_id = request.args.get('color') + theme_id = request.args.get('theme') + year = request.args.get('year') + + part_list = BrickPartList() + part_list.all_filtered(owner_id, color_id, theme_id, year) + + part_quantities = {} + for part in part_list.records: + key = (part.fields.part, part.fields.color) + if key in part_quantities: + part_quantities[key] += part.fields.quantity + else: + part_quantities[key] = part.fields.quantity + + output = io.StringIO() + writer = csv.writer(output) + + writer.writerow(['Part', 'Color', 'Quantity']) + + for (part_num, color_id), quantity in sorted(part_quantities.items()): + writer.writerow([part_num, color_id, quantity]) + + output.seek(0) + return Response( + output.getvalue(), + mimetype='text/csv', + headers={'Content-Disposition': 'attachment;filename=bricktracker_parts_rebrickable.csv'} + ) + + +# Export all parts to LEGO Pick-a-Brick CSV format +@admin_export_page.route('/parts/lego-csv', methods=['GET']) +@login_required +@exception_handler(__file__) +def export_parts_lego() -> Response: + + owner_id = request.args.get('owner') + color_id = request.args.get('color') + theme_id = request.args.get('theme') + year = request.args.get('year') + + part_list = BrickPartList() + part_list.all_filtered(owner_id, color_id, theme_id, year) + + element_quantities = {} + for part in part_list.records: + if part.fields.element: + element_id = part.fields.element + if element_id in element_quantities: + element_quantities[element_id] += part.fields.quantity + else: + element_quantities[element_id] = part.fields.quantity + + output = io.StringIO() + writer = csv.writer(output) + + writer.writerow(['elementId', 'quantity']) + + for element_id, quantity in sorted(element_quantities.items()): + writer.writerow([element_id, quantity]) + + output.seek(0) + return Response( + output.getvalue(), + mimetype='text/csv', + headers={'Content-Disposition': 'attachment;filename=bricktracker_parts_lego.csv'} + ) + + +# Export all parts to BrickLink XML format +@admin_export_page.route('/parts/bricklink-xml', methods=['GET']) +@login_required +@exception_handler(__file__) +def export_parts_bricklink() -> Response: + + owner_id = request.args.get('owner') + color_id = request.args.get('color') + theme_id = request.args.get('theme') + year = request.args.get('year') + + part_list = BrickPartList() + part_list.all_filtered(owner_id, color_id, theme_id, year) + + part_quantities = {} + for part in part_list.records: + part_num = part.fields.bricklink_part_num or part.fields.part + color_id = part.fields.bricklink_color_id or part.fields.color + + key = (part_num, color_id) + if key in part_quantities: + part_quantities[key] += part.fields.quantity + else: + part_quantities[key] = part.fields.quantity + + xml_lines = [''] + + for (part_num, color_id), quantity in sorted(part_quantities.items()): + xml_lines.append( + f'P{part_num}' + f'{color_id}{quantity}' + ) + + xml_lines.append('') + xml_content = ''.join(xml_lines) + + return Response( + xml_content, + mimetype='application/xml', + headers={'Content-Disposition': 'attachment;filename=bricktracker_parts_bricklink.xml'} + ) + + +# Export missing/damaged parts to Rebrickable CSV format +@admin_export_page.route('/problems/rebrickable-csv', methods=['GET']) +@login_required +@exception_handler(__file__) +def export_problems_rebrickable() -> Response: + + owner_id = request.args.get('owner') + color_id = request.args.get('color') + theme_id = request.args.get('theme') + year = request.args.get('year') + + part_list = BrickPartList() + part_list.problem_filtered(owner_id, color_id, theme_id, year) + + part_quantities = {} + for part in part_list.records: + qty = (part.fields.missing or 0) + (part.fields.damaged or 0) + if qty > 0: + key = (part.fields.part, part.fields.color) + if key in part_quantities: + part_quantities[key] += qty + else: + part_quantities[key] = qty + + output = io.StringIO() + writer = csv.writer(output) + + writer.writerow(['Part', 'Color', 'Quantity']) + + for (part_num, color_id), quantity in sorted(part_quantities.items()): + writer.writerow([part_num, color_id, quantity]) + + output.seek(0) + return Response( + output.getvalue(), + mimetype='text/csv', + headers={'Content-Disposition': 'attachment;filename=bricktracker_problems_rebrickable.csv'} + ) + + +# Export missing/damaged parts to LEGO Pick-a-Brick CSV format +@admin_export_page.route('/problems/lego-csv', methods=['GET']) +@login_required +@exception_handler(__file__) +def export_problems_lego() -> Response: + + owner_id = request.args.get('owner') + color_id = request.args.get('color') + theme_id = request.args.get('theme') + year = request.args.get('year') + + part_list = BrickPartList() + part_list.problem_filtered(owner_id, color_id, theme_id, year) + + element_quantities = {} + for part in part_list.records: + qty = (part.fields.missing or 0) + (part.fields.damaged or 0) + if qty > 0 and part.fields.element: + element_id = part.fields.element + if element_id in element_quantities: + element_quantities[element_id] += qty + else: + element_quantities[element_id] = qty + + output = io.StringIO() + writer = csv.writer(output) + + writer.writerow(['elementId', 'quantity']) + + for element_id, quantity in sorted(element_quantities.items()): + writer.writerow([element_id, quantity]) + + output.seek(0) + return Response( + output.getvalue(), + mimetype='text/csv', + headers={'Content-Disposition': 'attachment;filename=bricktracker_problems_lego.csv'} + ) + + +# Export missing/damaged parts to BrickLink XML format +@admin_export_page.route('/problems/bricklink-xml', methods=['GET']) +@login_required +@exception_handler(__file__) +def export_problems_bricklink() -> Response: + + owner_id = request.args.get('owner') + color_id = request.args.get('color') + theme_id = request.args.get('theme') + year = request.args.get('year') + + part_list = BrickPartList() + part_list.problem_filtered(owner_id, color_id, theme_id, year) + + part_quantities = {} + for part in part_list.records: + qty = (part.fields.missing or 0) + (part.fields.damaged or 0) + if qty > 0: + part_num = part.fields.bricklink_part_num or part.fields.part + color_id = part.fields.bricklink_color_id or part.fields.color + + key = (part_num, color_id) + if key in part_quantities: + part_quantities[key] += qty + else: + part_quantities[key] = qty + + xml_lines = [''] + + for (part_num, color_id), quantity in sorted(part_quantities.items()): + xml_lines.append( + f'P{part_num}' + f'{color_id}{quantity}' + ) + + xml_lines.append('') + xml_content = ''.join(xml_lines) + + return Response( + xml_content, + mimetype='application/xml', + headers={'Content-Disposition': 'attachment;filename=bricktracker_problems_bricklink.xml'} + ) diff --git a/templates/admin.html b/templates/admin.html index 2da5000..afd4b04 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -36,6 +36,7 @@ {% include 'admin/set/refresh.html' %} {% else %} {% include 'admin/logout.html' %} + {% include 'admin/export.html' %} {% include 'admin/instructions.html' %} {% if not config['USE_REMOTE_IMAGES'] %} {% include 'admin/image.html' %} diff --git a/templates/admin/export.html b/templates/admin/export.html new file mode 100644 index 0000000..6631f89 --- /dev/null +++ b/templates/admin/export.html @@ -0,0 +1,70 @@ +{{ accordion.header('Export', 'export', 'admin', expanded=open_export, icon='download-line') }} +
+

Export your sets, parts, or missing/damaged parts to various formats for use with Rebrickable, BrickLink, or LEGO Pick-a-Brick.

+ + +
+
+
Export Sets
+
+
+

Export all your sets to Rebrickable format for tracking your collection.

+ + Download Sets (Rebrickable CSV) + +
+
+ + +
+
+
Export All Parts
+
+
+

Export all parts from your collection in different formats.

+ +
+
+ + +
+
+
Export Missing/Damaged Parts
+
+
+

Export only missing or damaged parts to create wanted lists or shopping lists.

+ +
+
+ + +
+
Format Information
+
    +
  • Rebrickable CSV: Part,Color,Quantity format for direct import to Rebrickable
  • +
  • LEGO Pick-a-Brick CSV: Element ID and quantity for LEGO's Pick-a-Brick service
  • +
  • BrickLink XML: Wanted list format for importing to BrickLink
  • +
+
+
+{{ accordion.footer() }} From 6044841329256b284ed8993089527b3194b786ec Mon Sep 17 00:00:00 2001 From: FrederikBaerentsen Date: Fri, 19 Dec 2025 22:43:38 -0500 Subject: [PATCH 02/63] Updated changelog --- CHANGELOG.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8614de..10805d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 1.4 + +### New Features + +- **Export Functionality** + - Added comprehensive export system in admin panel for sets, parts, and problem parts + - Export accordion in `/admin/` with three main categories: + - **Export Sets**: Rebrickable CSV format for collection tracking + - **Export All Parts**: Three formats available: + - Rebrickable CSV (Part, Color, Quantity) + - LEGO Pick-a-Brick CSV (elementId, quantity) + - BrickLink XML (wanted list format) + - **Export Missing/Damaged Parts**: Same three formats as parts exports + - All exports aggregate quantities automatically (parts by part+color, LEGO by element ID) + - BrickLink exports use proper BrickLink part numbers and color IDs when available + - Filter support: All part exports accept owner, color, theme, and year query parameters + - Format information displayed in UI for user guidance + ## 1.3 - Post release fixes > Still uses docker tag `1.3` From d783b8fbc90803846c14ff0ce3c22cb279b4afac Mon Sep 17 00:00:00 2001 From: FrederikBaerentsen Date: Sat, 20 Dec 2025 17:55:37 -0500 Subject: [PATCH 03/63] feat(db): added integrity check and cleanup of database to admin page --- CHANGELOG.md | 20 ++ .../sql/schema/integrity_check_summary.sql | 24 ++ ...y_delete_minifigures_for_orphaned_sets.sql | 8 + .../integrity_delete_orphaned_parts.sql | 10 + .../schema/integrity_delete_orphaned_sets.sql | 10 + ...egrity_delete_owners_for_orphaned_sets.sql | 8 + ...tegrity_delete_parts_for_orphaned_sets.sql | 8 + .../integrity_delete_parts_missing_set.sql | 10 + ...rity_delete_statuses_for_orphaned_sets.sql | 8 + ...ntegrity_delete_tags_for_orphaned_sets.sql | 8 + .../sql/schema/integrity_orphaned_parts.sql | 17 ++ .../sql/schema/integrity_orphaned_sets.sql | 15 ++ .../schema/integrity_parts_missing_set.sql | 15 ++ bricktracker/sql/schema/optimize.sql | 39 +++ bricktracker/sql_integrity.py | 242 ++++++++++++++++++ bricktracker/views/admin/database.py | 55 ++++ templates/admin.html | 2 + templates/admin/database.html | 73 +++++- templates/admin/database/integrity_check.html | 180 +++++++++++++ 19 files changed, 746 insertions(+), 6 deletions(-) create mode 100644 bricktracker/sql/schema/integrity_check_summary.sql create mode 100644 bricktracker/sql/schema/integrity_delete_minifigures_for_orphaned_sets.sql create mode 100644 bricktracker/sql/schema/integrity_delete_orphaned_parts.sql create mode 100644 bricktracker/sql/schema/integrity_delete_orphaned_sets.sql create mode 100644 bricktracker/sql/schema/integrity_delete_owners_for_orphaned_sets.sql create mode 100644 bricktracker/sql/schema/integrity_delete_parts_for_orphaned_sets.sql create mode 100644 bricktracker/sql/schema/integrity_delete_parts_missing_set.sql create mode 100644 bricktracker/sql/schema/integrity_delete_statuses_for_orphaned_sets.sql create mode 100644 bricktracker/sql/schema/integrity_delete_tags_for_orphaned_sets.sql create mode 100644 bricktracker/sql/schema/integrity_orphaned_parts.sql create mode 100644 bricktracker/sql/schema/integrity_orphaned_sets.sql create mode 100644 bricktracker/sql/schema/integrity_parts_missing_set.sql create mode 100644 bricktracker/sql/schema/optimize.sql create mode 100644 bricktracker/sql_integrity.py create mode 100644 templates/admin/database/integrity_check.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fbf81a..9f11ee6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## Upcoming - 1.3.2 + +### New Features + +- **Database Integrity Check and Cleanup** + - Added database integrity scanner to detect orphaned records and foreign key violations + - New "Check Database Integrity" button in admin panel scans for issues + - Detects orphaned sets, parts, and parts with missing set references + - Two-step cleanup process with Bootstrap modal confirmation + - Warning prompts users to backup database before cleanup + - Automatic cleanup removes all orphaned records in one operation + - Detailed scan results show affected records with counts and descriptions +- **Database Optimization** + - Added "Optimize Database" button to re-create performance indexes + - Safe to run after database imports or restores + - Re-creates all indexes from migration #19 using `CREATE INDEX IF NOT EXISTS` + - Runs `ANALYZE` to rebuild query statistics + - Runs `PRAGMA optimize` for additional query plan optimization + - Helpful after importing backup databases that may lack performance optimizations + ## 1.3.1 ### Bug Fixes diff --git a/bricktracker/sql/schema/integrity_check_summary.sql b/bricktracker/sql/schema/integrity_check_summary.sql new file mode 100644 index 0000000..feb0bd9 --- /dev/null +++ b/bricktracker/sql/schema/integrity_check_summary.sql @@ -0,0 +1,24 @@ +-- Database integrity check summary +-- Returns count of each type of integrity issue + +SELECT 'orphaned_sets' as issue_type, COUNT(*) as count, + 'Sets in bricktracker_sets without matching rebrickable_sets record' as description +FROM bricktracker_sets bs +WHERE NOT EXISTS ( + SELECT 1 FROM rebrickable_sets rs WHERE rs."set" = bs."set" +) +UNION ALL +SELECT 'orphaned_parts' as issue_type, COUNT(*) as count, + 'Parts in bricktracker_parts without matching rebrickable_parts record' as description +FROM bricktracker_parts bp +WHERE NOT EXISTS ( + SELECT 1 FROM rebrickable_parts rp WHERE rp.part = bp.part AND rp.color_id = bp.color +) +UNION ALL +SELECT 'parts_missing_set' as issue_type, COUNT(DISTINCT bp.id) as count, + 'Parts referencing non-existent sets in bricktracker_sets' as description +FROM bricktracker_parts bp +WHERE NOT EXISTS ( + SELECT 1 FROM bricktracker_sets bs WHERE bs.id = bp.id +) +ORDER BY count DESC; diff --git a/bricktracker/sql/schema/integrity_delete_minifigures_for_orphaned_sets.sql b/bricktracker/sql/schema/integrity_delete_minifigures_for_orphaned_sets.sql new file mode 100644 index 0000000..84b11c7 --- /dev/null +++ b/bricktracker/sql/schema/integrity_delete_minifigures_for_orphaned_sets.sql @@ -0,0 +1,8 @@ +DELETE FROM bricktracker_minifigures +WHERE id IN ( + SELECT bs.id + FROM bricktracker_sets bs + WHERE NOT EXISTS ( + SELECT 1 FROM rebrickable_sets rs WHERE rs."set" = bs."set" + ) +); \ No newline at end of file diff --git a/bricktracker/sql/schema/integrity_delete_orphaned_parts.sql b/bricktracker/sql/schema/integrity_delete_orphaned_parts.sql new file mode 100644 index 0000000..89a132e --- /dev/null +++ b/bricktracker/sql/schema/integrity_delete_orphaned_parts.sql @@ -0,0 +1,10 @@ +-- Delete orphaned parts (bricktracker_parts records without parent rebrickable_parts) + +DELETE FROM bricktracker_parts +WHERE rowid IN ( + SELECT bp.rowid + FROM bricktracker_parts bp + WHERE NOT EXISTS ( + SELECT 1 FROM rebrickable_parts rp WHERE rp.part = bp.part AND rp.color_id = bp.color + ) +); diff --git a/bricktracker/sql/schema/integrity_delete_orphaned_sets.sql b/bricktracker/sql/schema/integrity_delete_orphaned_sets.sql new file mode 100644 index 0000000..802ae5b --- /dev/null +++ b/bricktracker/sql/schema/integrity_delete_orphaned_sets.sql @@ -0,0 +1,10 @@ +-- Delete orphaned sets (bricktracker_sets records without parent rebrickable_sets) + +DELETE FROM bricktracker_sets +WHERE "set" IN ( + SELECT bs."set" + FROM bricktracker_sets bs + WHERE NOT EXISTS ( + SELECT 1 FROM rebrickable_sets rs WHERE rs."set" = bs."set" + ) +); diff --git a/bricktracker/sql/schema/integrity_delete_owners_for_orphaned_sets.sql b/bricktracker/sql/schema/integrity_delete_owners_for_orphaned_sets.sql new file mode 100644 index 0000000..9e27ab9 --- /dev/null +++ b/bricktracker/sql/schema/integrity_delete_owners_for_orphaned_sets.sql @@ -0,0 +1,8 @@ +DELETE FROM bricktracker_set_owners +WHERE id IN ( + SELECT bs.id + FROM bricktracker_sets bs + WHERE NOT EXISTS ( + SELECT 1 FROM rebrickable_sets rs WHERE rs."set" = bs."set" + ) +); \ No newline at end of file diff --git a/bricktracker/sql/schema/integrity_delete_parts_for_orphaned_sets.sql b/bricktracker/sql/schema/integrity_delete_parts_for_orphaned_sets.sql new file mode 100644 index 0000000..d55cfd9 --- /dev/null +++ b/bricktracker/sql/schema/integrity_delete_parts_for_orphaned_sets.sql @@ -0,0 +1,8 @@ +DELETE FROM bricktracker_parts +WHERE id IN ( + SELECT bs.id + FROM bricktracker_sets bs + WHERE NOT EXISTS ( + SELECT 1 FROM rebrickable_sets rs WHERE rs."set" = bs."set" + ) +); \ No newline at end of file diff --git a/bricktracker/sql/schema/integrity_delete_parts_missing_set.sql b/bricktracker/sql/schema/integrity_delete_parts_missing_set.sql new file mode 100644 index 0000000..7640505 --- /dev/null +++ b/bricktracker/sql/schema/integrity_delete_parts_missing_set.sql @@ -0,0 +1,10 @@ +-- Delete parts that reference non-existent sets + +DELETE FROM bricktracker_parts +WHERE rowid IN ( + SELECT bp.rowid + FROM bricktracker_parts bp + WHERE NOT EXISTS ( + SELECT 1 FROM bricktracker_sets bs WHERE bs.id = bp.id + ) +); diff --git a/bricktracker/sql/schema/integrity_delete_statuses_for_orphaned_sets.sql b/bricktracker/sql/schema/integrity_delete_statuses_for_orphaned_sets.sql new file mode 100644 index 0000000..01e0fa6 --- /dev/null +++ b/bricktracker/sql/schema/integrity_delete_statuses_for_orphaned_sets.sql @@ -0,0 +1,8 @@ +DELETE FROM bricktracker_set_statuses +WHERE id IN ( + SELECT bs.id + FROM bricktracker_sets bs + WHERE NOT EXISTS ( + SELECT 1 FROM rebrickable_sets rs WHERE rs."set" = bs."set" + ) +); \ No newline at end of file diff --git a/bricktracker/sql/schema/integrity_delete_tags_for_orphaned_sets.sql b/bricktracker/sql/schema/integrity_delete_tags_for_orphaned_sets.sql new file mode 100644 index 0000000..004c7d8 --- /dev/null +++ b/bricktracker/sql/schema/integrity_delete_tags_for_orphaned_sets.sql @@ -0,0 +1,8 @@ +DELETE FROM bricktracker_set_tags +WHERE id IN ( + SELECT bs.id + FROM bricktracker_sets bs + WHERE NOT EXISTS ( + SELECT 1 FROM rebrickable_sets rs WHERE rs."set" = bs."set" + ) +); \ No newline at end of file diff --git a/bricktracker/sql/schema/integrity_orphaned_parts.sql b/bricktracker/sql/schema/integrity_orphaned_parts.sql new file mode 100644 index 0000000..976d877 --- /dev/null +++ b/bricktracker/sql/schema/integrity_orphaned_parts.sql @@ -0,0 +1,17 @@ +-- Find orphaned parts (bricktracker_parts records without parent rebrickable_parts) + +SELECT + bp.id, + bp.part, + bp.color, + bp.quantity, + bp.spare, + bp.missing, + bp.damaged, + bs."set" as set_number +FROM bricktracker_parts bp +LEFT JOIN bricktracker_sets bs ON bs.id = bp.id +WHERE NOT EXISTS ( + SELECT 1 FROM rebrickable_parts rp WHERE rp.part = bp.part AND rp.color_id = bp.color +) +ORDER BY bp.id, bp.part, bp.color; diff --git a/bricktracker/sql/schema/integrity_orphaned_sets.sql b/bricktracker/sql/schema/integrity_orphaned_sets.sql new file mode 100644 index 0000000..923f1cd --- /dev/null +++ b/bricktracker/sql/schema/integrity_orphaned_sets.sql @@ -0,0 +1,15 @@ +-- Find orphaned sets (bricktracker_sets records without parent rebrickable_sets) + +SELECT + bs."set", + bs.id, + bs.description, + bs.storage, + bs.purchase_date, + bs.purchase_location, + bs.purchase_price +FROM bricktracker_sets bs +WHERE NOT EXISTS ( + SELECT 1 FROM rebrickable_sets rs WHERE rs."set" = bs."set" +) +ORDER BY bs."set"; diff --git a/bricktracker/sql/schema/integrity_parts_missing_set.sql b/bricktracker/sql/schema/integrity_parts_missing_set.sql new file mode 100644 index 0000000..36e9319 --- /dev/null +++ b/bricktracker/sql/schema/integrity_parts_missing_set.sql @@ -0,0 +1,15 @@ +-- Find parts referencing non-existent sets + +SELECT + bp.id, + bp.part, + bp.color, + bp.quantity, + bp.spare, + bp.missing, + bp.damaged +FROM bricktracker_parts bp +WHERE NOT EXISTS ( + SELECT 1 FROM bricktracker_sets bs WHERE bs.id = bp.id +) +ORDER BY bp.id, bp.part, bp.color; diff --git a/bricktracker/sql/schema/optimize.sql b/bricktracker/sql/schema/optimize.sql new file mode 100644 index 0000000..9638c3c --- /dev/null +++ b/bricktracker/sql/schema/optimize.sql @@ -0,0 +1,39 @@ +-- Optimize database performance +-- Re-applies performance indexes and runs database maintenance + +CREATE INDEX IF NOT EXISTS idx_bricktracker_parts_id_missing_damaged +ON bricktracker_parts(id, missing, damaged); + +CREATE INDEX IF NOT EXISTS idx_bricktracker_parts_part_color_spare +ON bricktracker_parts(part, color, spare); + +CREATE INDEX IF NOT EXISTS idx_bricktracker_sets_set_storage +ON bricktracker_sets("set", storage); + +CREATE INDEX IF NOT EXISTS idx_rebrickable_sets_name_lower +ON rebrickable_sets(LOWER(name)); + +CREATE INDEX IF NOT EXISTS idx_rebrickable_parts_name_lower +ON rebrickable_parts(LOWER(name)); + +CREATE INDEX IF NOT EXISTS idx_bricktracker_sets_purchase_location +ON bricktracker_sets(purchase_location); + +CREATE INDEX IF NOT EXISTS idx_bricktracker_parts_quantity +ON bricktracker_parts(quantity); + +CREATE INDEX IF NOT EXISTS idx_rebrickable_sets_year +ON rebrickable_sets(year); + +CREATE INDEX IF NOT EXISTS idx_rebrickable_sets_theme_id +ON rebrickable_sets(theme_id); + +CREATE INDEX IF NOT EXISTS idx_rebrickable_sets_number_version +ON rebrickable_sets(number, version); + +CREATE INDEX IF NOT EXISTS idx_bricktracker_sets_purchase_date +ON bricktracker_sets(purchase_date); + +ANALYZE; + +PRAGMA optimize; diff --git a/bricktracker/sql_integrity.py b/bricktracker/sql_integrity.py new file mode 100644 index 0000000..a2ee4a3 --- /dev/null +++ b/bricktracker/sql_integrity.py @@ -0,0 +1,242 @@ +import logging + +from .sql import BrickSQL + +logger = logging.getLogger(__name__) + + +class BrickIntegrityIssue(object): + issue_type: str + count: int + description: str + + def __init__(self, issue_type: str, count: int, description: str, /): + self.issue_type = issue_type + self.count = count + self.description = description + + +class BrickOrphanedSet(object): + set: str + id: str + description: str | None + storage: str | None + purchase_date: float | None + purchase_location: str | None + purchase_price: float | None + + def __init__( + self, + set: str, + id: str, + description: str | None, + storage: str | None, + purchase_date: float | None, + purchase_location: str | None, + purchase_price: float | None, + / + ): + self.set = set + self.id = id + self.description = description + self.storage = storage + self.purchase_date = purchase_date + self.purchase_location = purchase_location + self.purchase_price = purchase_price + + +class BrickOrphanedPart(object): + id: str + part: str + color: int + quantity: int + spare: bool + missing: int + damaged: int + set_number: str | None + + def __init__( + self, + id: str, + part: str, + color: int, + quantity: int, + spare: bool, + missing: int, + damaged: int, + set_number: str | None, + / + ): + self.id = id + self.part = part + self.color = color + self.quantity = quantity + self.spare = spare + self.missing = missing + self.damaged = damaged + self.set_number = set_number + + +class BrickPartMissingSet(object): + id: str + part: str + color: int + quantity: int + spare: bool + missing: int + damaged: int + + def __init__( + self, + id: str, + part: str, + color: int, + quantity: int, + spare: bool, + missing: int, + damaged: int, + / + ): + self.id = id + self.part = part + self.color = color + self.quantity = quantity + self.spare = spare + self.missing = missing + self.damaged = damaged + + +class BrickIntegrityCheck(object): + def check_summary(self, /) -> list[BrickIntegrityIssue]: + sql = BrickSQL() + results = sql.fetchall('schema/integrity_check_summary') + + issues: list[BrickIntegrityIssue] = [] + for row in results: + issues.append(BrickIntegrityIssue( + row['issue_type'], + row['count'], + row['description'] + )) + + return issues + + def get_orphaned_sets(self, /) -> list[BrickOrphanedSet]: + sql = BrickSQL() + results = sql.fetchall('schema/integrity_orphaned_sets') + + sets: list[BrickOrphanedSet] = [] + for row in results: + sets.append(BrickOrphanedSet( + row['set'], + row['id'], + row['description'], + row['storage'], + row['purchase_date'], + row['purchase_location'], + row['purchase_price'] + )) + + return sets + + def get_orphaned_parts(self, /) -> list[BrickOrphanedPart]: + sql = BrickSQL() + results = sql.fetchall('schema/integrity_orphaned_parts') + + parts: list[BrickOrphanedPart] = [] + for row in results: + parts.append(BrickOrphanedPart( + row['id'], + row['part'], + row['color'], + row['quantity'], + row['spare'], + row['missing'], + row['damaged'], + row['set_number'] + )) + + return parts + + def get_parts_missing_set(self, /) -> list[BrickPartMissingSet]: + sql = BrickSQL() + results = sql.fetchall('schema/integrity_parts_missing_set') + + parts: list[BrickPartMissingSet] = [] + for row in results: + parts.append(BrickPartMissingSet( + row['id'], + row['part'], + row['color'], + row['quantity'], + row['spare'], + row['missing'], + row['damaged'] + )) + + return parts + + def cleanup_orphaned_sets(self, /) -> int: + sql = BrickSQL() + orphaned = self.get_orphaned_sets() + count = len(orphaned) + + if count > 0: + sql.executescript('schema/integrity_delete_parts_for_orphaned_sets') + sql.executescript('schema/integrity_delete_minifigures_for_orphaned_sets') + sql.executescript('schema/integrity_delete_tags_for_orphaned_sets') + sql.executescript('schema/integrity_delete_owners_for_orphaned_sets') + sql.executescript('schema/integrity_delete_statuses_for_orphaned_sets') + sql.executescript('schema/integrity_delete_orphaned_sets') + sql.commit() + logger.info(f'Deleted {count} orphaned set(s)') + + return count + + def cleanup_orphaned_parts(self, /) -> int: + sql = BrickSQL() + orphaned = self.get_orphaned_parts() + count = len(orphaned) + + if count > 0: + sql.executescript('schema/integrity_delete_orphaned_parts') + sql.commit() + logger.info(f'Deleted {count} orphaned part(s)') + + return count + + def cleanup_parts_missing_set(self, /) -> int: + sql = BrickSQL() + orphaned = self.get_parts_missing_set() + count = len(orphaned) + + if count > 0: + sql.executescript('schema/integrity_delete_parts_missing_set') + sql.commit() + logger.info(f'Deleted {count} part(s) with missing set references') + + return count + + def cleanup_all(self, /) -> dict[str, int]: + orphaned_parts = self.cleanup_orphaned_parts() + parts_missing_set = self.cleanup_parts_missing_set() + orphaned_sets = self.cleanup_orphaned_sets() + + counts = { + 'orphaned_parts': orphaned_parts, + 'parts_missing_set': parts_missing_set, + 'orphaned_sets': orphaned_sets + } + + total = sum(counts.values()) + logger.info(f'Integrity cleanup complete: {total} total records removed') + + return counts + + def optimize_database(self, /) -> None: + sql = BrickSQL() + sql.executescript('schema/optimize') + sql.commit() + + sql.connection.execute('VACUUM') + logger.info('Database optimization complete') diff --git a/bricktracker/views/admin/database.py b/bricktracker/views/admin/database.py index e2fc4bc..58c6b6f 100644 --- a/bricktracker/views/admin/database.py +++ b/bricktracker/views/admin/database.py @@ -17,6 +17,7 @@ from werkzeug.wrappers.response import Response from ..exceptions import exception_handler from ...reload import reload +from ...sql_integrity import BrickIntegrityCheck from ...sql_migration_list import BrickSQLMigrationList from ...sql import BrickSQL from ..upload import upload_helper @@ -184,3 +185,57 @@ def upgrade() -> str | Response: ), database_error=request.args.get('database_error') ) + + +@admin_database_page.route('/integrity/check', methods=['GET']) +@login_required +@exception_handler(__file__) +def integrity_check() -> str: + integrity = BrickIntegrityCheck() + issues = integrity.check_summary() + orphaned_sets = integrity.get_orphaned_sets() + orphaned_parts = integrity.get_orphaned_parts() + parts_missing_set = integrity.get_parts_missing_set() + total_issues = sum(issue.count for issue in issues) + + return render_template( + 'admin.html', + integrity_check=True, + integrity_issues=issues, + orphaned_sets=orphaned_sets, + orphaned_parts=orphaned_parts, + parts_missing_set=parts_missing_set, + total_issues=total_issues, + database_error=request.args.get('database_error') + ) + + +@admin_database_page.route('/integrity/cleanup', methods=['POST']) +@login_required +@exception_handler( + __file__, + post_redirect='admin_database.integrity_check', + error_name='database_error' +) +def integrity_cleanup() -> Response: + integrity = BrickIntegrityCheck() + counts = integrity.cleanup_all() + total = sum(counts.values()) + logger.info(f'Database integrity cleanup: removed {total} orphaned records') + + return redirect(url_for('admin.admin', cleanup_success=total)) + + +@admin_database_page.route('/optimize', methods=['POST']) +@login_required +@exception_handler( + __file__, + post_redirect='admin.admin', + error_name='database_error' +) +def optimize() -> Response: + integrity = BrickIntegrityCheck() + integrity.optimize_database() + logger.info('Database optimization complete') + + return redirect(url_for('admin.admin', optimize_success=1)) diff --git a/templates/admin.html b/templates/admin.html index 2da5000..7fec037 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -30,6 +30,8 @@ {% include 'admin/database/drop.html' %} {% elif import_database %} {% include 'admin/database/import.html' %} + {% elif integrity_check %} + {% include 'admin/database/integrity_check.html' %} {% elif upgrade_database %} {% include 'admin/database/upgrade.html' %} {% elif refresh_set %} diff --git a/templates/admin/database.html b/templates/admin/database.html index a10fbe3..2e25ec1 100644 --- a/templates/admin/database.html +++ b/templates/admin/database.html @@ -33,11 +33,72 @@ {% endif %} + +
Maintenance
+{% if request.args.get('optimize_success') %} + +{% endif %} +{% if request.args.get('cleanup_success') %} + +{% endif %} +

Maintain and optimize your database for best performance.

+
+ + Check Database Integrity + + Scan for orphaned records and foreign key violations +
+
+ + Re-create indexes and rebuild statistics (safe to run anytime) +
+ +
Danger zone
+{% if database_error %}{% endif %} + {{ accordion.footer() }} -{{ accordion.header('Database danger zone', 'database-danger', 'admin', danger=true, class='text-end') }} -{% if database_error %}{% endif %} - Import a database file - Drop the database - Delete the database file -{{ accordion.footer() }} + diff --git a/templates/admin/database/integrity_check.html b/templates/admin/database/integrity_check.html new file mode 100644 index 0000000..aabfe1f --- /dev/null +++ b/templates/admin/database/integrity_check.html @@ -0,0 +1,180 @@ +{% import 'macro/accordion.html' as accordion %} + +{{ accordion.header('Database Integrity Check', 'database-integrity', 'admin', expanded=true, icon='shield-check-line') }} + +{% if database_error %} + +{% endif %} + +
Scan Results
+ +{% if total_issues == 0 %} + + +{% else %} + + +
Summary
+ + + + + + + + + + {% for issue in integrity_issues %} + + + + + + {% endfor %} + +
Issue TypeCountDescription
{{ issue.issue_type }}{{ issue.count }}{{ issue.description }}
+ +{% if orphaned_sets|length > 0 %} +
Orphaned Sets ({{ orphaned_sets|length }})
+

These sets exist in bricktracker_sets but are missing from rebrickable_sets:

+ + + + + + + + + + + {% for set in orphaned_sets %} + + + + + + + {% endfor %} + +
Set NumberIDStoragePurchase Price
{{ set.set }}{{ set.id }}{{ set.storage or '-' }}{{ set.purchase_price or '-' }}
+{% endif %} + +{% if orphaned_parts|length > 0 %} +
Orphaned Parts ({{ orphaned_parts|length }})
+

These parts exist in bricktracker_parts but are missing from rebrickable_parts:

+ + + + + + + + + + + + {% for part in orphaned_parts[:20] %} + + + + + + + + {% endfor %} + {% if orphaned_parts|length > 20 %} + + + + {% endif %} + +
PartColorSet NumberQuantitySpare
{{ part.part }}{{ part.color }}{{ part.set_number or '-' }}{{ part.quantity }}{{ 'Yes' if part.spare else 'No' }}
... and {{ orphaned_parts|length - 20 }} more
+{% endif %} + +{% if parts_missing_set|length > 0 %} +
Parts with Missing Set References ({{ parts_missing_set|length }})
+

These parts reference sets that don't exist in bricktracker_sets:

+ + + + + + + + + + + {% for part in parts_missing_set[:20] %} + + + + + + + {% endfor %} + {% if parts_missing_set|length > 20 %} + + + + {% endif %} + +
PartColorSet IDQuantity
{{ part.part }}{{ part.color }}{{ part.id }}{{ part.quantity }}
... and {{ parts_missing_set|length - 20 }} more
+{% endif %} + +
+ Back to admin + +
+{% endif %} + +{{ accordion.footer() }} + + + From b30deef5297061f2e9c6a2e9f58d208cc7de17cf Mon Sep 17 00:00:00 2001 From: FrederikBaerentsen Date: Sun, 21 Dec 2025 18:05:09 -0500 Subject: [PATCH 04/63] fix(add): fix FK constraint errors when importing sets with metadata --- bricktracker/metadata.py | 24 +++++++++++++++++------- bricktracker/set.py | 8 ++++---- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/bricktracker/metadata.py b/bricktracker/metadata.py index 50132ec..1ae7b1a 100644 --- a/bricktracker/metadata.py +++ b/bricktracker/metadata.py @@ -182,7 +182,8 @@ class BrickMetadata(BrickRecord): /, *, json: Any | None = None, - state: Any | None = None + state: Any | None = None, + commit: bool = True ) -> Any: if state is None and json is not None: state = json.get('value', False) @@ -191,13 +192,22 @@ class BrickMetadata(BrickRecord): parameters['set_id'] = brickset.fields.id parameters['state'] = state - rows, _ = BrickSQL().execute_and_commit( - self.update_set_state_query, - parameters=parameters, - name=self.as_column(), - ) + if commit: + rows, _ = BrickSQL().execute_and_commit( + self.update_set_state_query, + parameters=parameters, + name=self.as_column(), + ) + else: + rows, _ = BrickSQL().execute( + self.update_set_state_query, + parameters=parameters, + defer=True, + name=self.as_column(), + ) - if rows != 1: + # When deferred, rows will be -1, so skip the check + if commit and rows != 1: raise DatabaseException('Could not update the {kind} state for set {set} ({id})'.format( kind=self.kind, set=brickset.fields.set, diff --git a/bricktracker/set.py b/bricktracker/set.py index cb67620..117da03 100644 --- a/bricktracker/set.py +++ b/bricktracker/set.py @@ -82,19 +82,19 @@ class BrickSet(RebrickableSet): # All operations are atomic - if anything fails, nothing is committed self.insert(commit=False) - # Save the owners + # Save the owners (deferred - will execute at final commit) owners: list[str] = list(data.get('owners', [])) for id in owners: owner = BrickSetOwnerList.get(id) - owner.update_set_state(self, state=True) + owner.update_set_state(self, state=True, commit=False) - # Save the tags + # Save the tags (deferred - will execute at final commit) tags: list[str] = list(data.get('tags', [])) for id in tags: tag = BrickSetTagList.get(id) - tag.update_set_state(self, state=True) + tag.update_set_state(self, state=True, commit=False) # Load the inventory if not BrickPartList.download(socket, self, refresh=refresh): From 2f1bba475dddfe24ead527e6198c4852fee501e3 Mon Sep 17 00:00:00 2001 From: FrederikBaerentsen Date: Sun, 21 Dec 2025 20:52:02 -0500 Subject: [PATCH 05/63] feat(admin): added options to order badges on sets and details page. --- .env.sample | 16 +++++++- CHANGELOG.md | 33 +++++++++++---- bricktracker/config.py | 2 + bricktracker/config_manager.py | 7 +++- templates/admin/configuration.html | 16 ++++++++ templates/macro/badge.html | 66 ++++++++++++++++++++++++++++++ templates/set/card.html | 30 +------------- 7 files changed, 130 insertions(+), 40 deletions(-) diff --git a/.env.sample b/.env.sample index 97bd1f8..2d065f1 100644 --- a/.env.sample +++ b/.env.sample @@ -481,6 +481,20 @@ # BK_STATISTICS_DEFAULT_EXPANDED=false # Optional: Enable dark mode by default -# When true, the application starts in dark mode. +# When true, the application starts in dark mode. # Default: false # BK_DARK_MODE=true + +# Optional: Customize badge order for Grid view (set cards on /sets/) +# Comma-separated list of badge keys in the order they should appear +# Available badges: theme, tag, year, parts, instance_count, total_minifigures, +# total_missing, total_damaged, owner, storage, purchase_date, purchase_location, +# purchase_price, instructions, rebrickable, bricklink +# Default: theme,year,parts,total_minifigures,owner +# BK_BADGE_ORDER_GRID=theme,year,parts,total_minifigures,owner,storage + +# Optional: Customize badge order for Detail view (individual set details page) +# Comma-separated list of badge keys in the order they should appear +# Use the same badge keys as BK_BADGE_ORDER_GRID +# Default: theme,tag,year,parts,instance_count,total_minifigures,total_missing,total_damaged,owner,storage,purchase_date,purchase_location,purchase_price,instructions,rebrickable,bricklink +# BK_BADGE_ORDER_DETAIL=theme,tag,year,parts,owner,storage,purchase_date,rebrickable,bricklink diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fc1e1a..da5afa2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,15 +17,29 @@ - BrickLink exports use proper BrickLink part numbers and color IDs when available - Filter support: All part exports accept owner, color, theme, and year query parameters - Format information displayed in UI for user guidance -- **Database Integrity Check and Cleanup** (from 1.3.2) +- **Badge Order Customization** + - Added customizable badge ordering for set cards and detail pages + - Separate configurations for Grid view (`/sets/` cards) and Detail view (individual set pages) + - Configure via environment variables in `.env` file: + - `BK_BADGE_ORDER_GRID`: Comma-separated badge keys for grid view (default: theme,year,parts,total_minifigures,owner) + - `BK_BADGE_ORDER_DETAIL`: Comma-separated badge keys for detail view (default: all 16 badges) + - Can also be configured via Live Settings page in admin panel under "Default Ordering & Formatting" + - Changes apply immediately without restart when edited via admin panel + - 16 available badge types: theme, tag, year, parts, instance_count, total_minifigures, total_missing, total_damaged, owner, storage, purchase_date, purchase_location, purchase_price, instructions, rebrickable, bricklink + + +## 1.3.1 + +### New Functionality + +- **Database Integrity Check and Cleanup** - Added database integrity scanner to detect orphaned records and foreign key violations - New "Check Database Integrity" button in admin panel scans for issues - Detects orphaned sets, parts, and parts with missing set references - - Two-step cleanup process with Bootstrap modal confirmation - Warning prompts users to backup database before cleanup - - Automatic cleanup removes all orphaned records in one operation + - Cleanup removes all orphaned records in one operation - Detailed scan results show affected records with counts and descriptions -- **Database Optimization** (from 1.3.2) +- **Database Optimization** - Added "Optimize Database" button to re-create performance indexes - Safe to run after database imports or restores - Re-creates all indexes from migration #19 using `CREATE INDEX IF NOT EXISTS` @@ -35,19 +49,20 @@ ### Bug Fixes -- **Fixed foreign key constraint errors during set imports** (from 1.3.1): Resolved `FOREIGN KEY constraint failed` errors when importing sets with parts and minifigures +- **Fixed foreign key constraint errors during set imports**: Resolved `FOREIGN KEY constraint failed` errors when importing sets with parts and minifigures - Fixed insertion order in `bricktracker/part.py`: Parent records (`rebrickable_parts`) now inserted before child records (`bricktracker_parts`) - Fixed insertion order in `bricktracker/minifigure.py`: Parent records (`rebrickable_minifigures`) now inserted before child records (`bricktracker_minifigures`) - Ensures foreign key references are valid when SQLite checks constraints -- **Fixed set metadata updates** (from 1.3.1): Owner, status, and tag checkboxes now properly persist changes on set details page + +- **Fixed set metadata updates**: Owner, status, and tag checkboxes now properly persist changes on set details page - Fixed `update_set_state()` method to commit database transactions (was using deferred execution without commit) - All metadata updates (owner, status, tags, storage, purchase info) now work consistently -- **Fixed nil image downloads** (from 1.3.1): Placeholder images for parts and minifigures without images now download correctly +- **Fixed nil image downloads**: Placeholder images for parts and minifigures without images now download correctly - Removed early returns that prevented nil image downloads - Nil images now properly saved to configured folders (e.g., `/app/data/parts/nil.jpg`) -- **Fixed error logging for missing files** (from 1.3.1): File not found errors now show actual configured folder paths instead of just URL paths +- **Fixed error logging for missing files**: File not found errors now show actual configured folder paths instead of just URL paths - Added detailed logging showing both file path and configured folder for easier debugging -- **Fixed minifigure filters in client-side pagination mode** (from 1.3.1): Owner and other filters now work correctly when server-side pagination is disabled +- **Fixed minifigure filters in client-side pagination mode**: Owner and other filters now work correctly when server-side pagination is disabled - Aligned filter behavior with parts page (applies filters server-side, then loads filtered data for client-side search) ## 1.3 diff --git a/bricktracker/config.py b/bricktracker/config.py index 94e681c..6968d90 100644 --- a/bricktracker/config.py +++ b/bricktracker/config.py @@ -97,4 +97,6 @@ CONFIG: Final[list[dict[str, Any]]] = [ {'n': 'STATISTICS_SHOW_CHARTS', 'd': True, 'c': bool}, {'n': 'STATISTICS_DEFAULT_EXPANDED', 'd': True, 'c': bool}, {'n': 'DARK_MODE', 'c': bool}, + {'n': 'BADGE_ORDER_GRID', 'd': ['theme', 'year', 'parts', 'total_minifigures', 'owner'], 'c': list}, + {'n': 'BADGE_ORDER_DETAIL', 'd': ['theme', 'tag', 'year', 'parts', 'instance_count', 'total_minifigures', 'total_missing', 'total_damaged', 'owner', 'storage', 'purchase_date', 'purchase_location', 'purchase_price', 'instructions', 'rebrickable', 'bricklink'], 'c': list}, ] diff --git a/bricktracker/config_manager.py b/bricktracker/config_manager.py index d6985ec..0a9663d 100644 --- a/bricktracker/config_manager.py +++ b/bricktracker/config_manager.py @@ -54,6 +54,9 @@ LIVE_CHANGEABLE_VARS: Final[List[str]] = [ 'BK_STATISTICS_SHOW_CHARTS', 'BK_STATISTICS_DEFAULT_EXPANDED', 'BK_DARK_MODE', + # Badge order preferences + 'BK_BADGE_ORDER_GRID', + 'BK_BADGE_ORDER_DETAIL', # Default ordering and formatting 'BK_INSTRUCTIONS_ALLOWED_EXTENSIONS', 'BK_MINIFIGURES_DEFAULT_ORDER', @@ -179,8 +182,8 @@ class ConfigManager: def _cast_value(self, var_name: str, value: Any) -> Any: """Cast value to appropriate type based on variable name""" - # List variables (admin sections) - Check this FIRST before boolean check - if 'sections' in var_name.lower(): + # List variables (admin sections, badge order) - Check this FIRST before boolean check + if any(keyword in var_name.lower() for keyword in ['sections', 'badge_order']): if isinstance(value, str): return [section.strip() for section in value.split(',') if section.strip()] elif isinstance(value, list): diff --git a/templates/admin/configuration.html b/templates/admin/configuration.html index 25c3f78..9119ae1 100644 --- a/templates/admin/configuration.html +++ b/templates/admin/configuration.html @@ -560,6 +560,22 @@
Default Ordering & Formatting
+
+ + +
+ +
+ + +
+
{% endif %} + {% if part_collection | length %} +

+ {% if config['RANDOM'] %}Random selection of{% else %}Latest added{% endif %} parts + {% if not config['HIDE_ALL_PARTS'] %} + All parts + {% endif %} +

+
+ {% for item in part_collection %} +
+ {% with solo=false, tiny=true, last=true, sets_using=[], minifigures_using=[] %} + {% include 'part/card.html' %} + {% endwith %} +
+ {% endfor %} +
+ {% endif %}
{% endblock %} \ No newline at end of file From 9518b0261ccdae1152660364ae85c82cbd60e1de Mon Sep 17 00:00:00 2001 From: FrederikBaerentsen Date: Sun, 21 Dec 2025 21:45:22 -0500 Subject: [PATCH 07/63] feat(frontpage): added parts row --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index da5afa2..814ecaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,12 @@ - Can also be configured via Live Settings page in admin panel under "Default Ordering & Formatting" - Changes apply immediately without restart when edited via admin panel - 16 available badge types: theme, tag, year, parts, instance_count, total_minifigures, total_missing, total_damaged, owner, storage, purchase_date, purchase_location, purchase_price, instructions, rebrickable, bricklink +- **Front Page Parts Display** + - Added latest/random parts section to the front page alongside sets and minifigures + - Shows 6 parts with quantity badges and other relevant information + - Respects `BK_RANDOM` configuration (random selection when enabled, latest when disabled) + - Respects `BK_HIDE_SPARE_PARTS` configuration + - Respects `BK_HIDE_ALL_PARTS` configuration for "All parts" button visibility ## 1.3.1 From 5418aca8f07b59c8524324693ae941fef0dc9cbf Mon Sep 17 00:00:00 2001 From: FrederikBaerentsen Date: Tue, 23 Dec 2025 23:08:30 -0500 Subject: [PATCH 08/63] fix(sets): refreshing sets should work now --- bricktracker/part.py | 15 ++++++++++- bricktracker/set.py | 19 +++++++++++-- bricktracker/sql/part/update_on_refresh.sql | 11 ++++++++ bricktracker/sql/part/upsert.sql | 30 +++++++++++++++++++++ 4 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 bricktracker/sql/part/update_on_refresh.sql create mode 100644 bricktracker/sql/part/upsert.sql diff --git a/bricktracker/part.py b/bricktracker/part.py index 704ef50..279ba45 100644 --- a/bricktracker/part.py +++ b/bricktracker/part.py @@ -23,6 +23,8 @@ class BrickPart(RebrickablePart): # Queries insert_query: str = 'part/insert' + upsert_query: str = 'part/upsert' + update_on_refresh_query: str = 'part/update_on_refresh' generic_query: str = 'part/select/generic' select_query: str = 'part/select/specific' @@ -66,7 +68,18 @@ class BrickPart(RebrickablePart): # This must happen before inserting into bricktracker_parts due to FK constraint self.insert_rebrickable() - if not refresh: + if refresh: + # Try to update existing part first (preserves checked, missing, and damaged states) + # Note: Cannot defer this because we need to check if rows were affected + rows, _ = BrickSQL().execute( + self.update_on_refresh_query, + parameters=self.sql_parameters(), + defer=False + ) + # If no rows were updated, the part doesn't exist yet, so insert it + if rows == 0: + self.insert(commit=False) + else: # Insert into bricktracker_parts database (child record) self.insert(commit=False) diff --git a/bricktracker/set.py b/bricktracker/set.py index 117da03..387d9b2 100644 --- a/bricktracker/set.py +++ b/bricktracker/set.py @@ -56,8 +56,23 @@ class BrickSet(RebrickableSet): # Grabbing the refresh flag refresh: bool = bool(data.get('refresh', False)) - # Generate an UUID for self - self.fields.id = str(uuid4()) + # Generate an UUID for self (or use existing ID if refreshing) + if refresh: + # Find the existing set by set number to get its ID + result = BrickSQL().raw_execute( + 'SELECT "id" FROM "bricktracker_sets" WHERE "set" = :set', + {'set': self.fields.set} + ).fetchone() + + if result: + # Use existing set ID + self.fields.id = result['id'] + else: + # If set doesn't exist in database, treat as new import + refresh = False + self.fields.id = str(uuid4()) + else: + self.fields.id = str(uuid4()) # Insert the rebrickable set into database FIRST # This must happen before inserting bricktracker_sets due to FK constraint diff --git a/bricktracker/sql/part/update_on_refresh.sql b/bricktracker/sql/part/update_on_refresh.sql new file mode 100644 index 0000000..739c725 --- /dev/null +++ b/bricktracker/sql/part/update_on_refresh.sql @@ -0,0 +1,11 @@ +-- Update existing part quantities during refresh while preserving tracking data +UPDATE "bricktracker_parts" +SET + "quantity" = :quantity, + "element" = :element, + "rebrickable_inventory" = :rebrickable_inventory +WHERE "id" = :id +AND "figure" IS NOT DISTINCT FROM :figure +AND "part" = :part +AND "color" = :color +AND "spare" = :spare diff --git a/bricktracker/sql/part/upsert.sql b/bricktracker/sql/part/upsert.sql new file mode 100644 index 0000000..ba7a30b --- /dev/null +++ b/bricktracker/sql/part/upsert.sql @@ -0,0 +1,30 @@ +INSERT INTO "bricktracker_parts" ( + "id", + "figure", + "part", + "color", + "spare", + "quantity", + "element", + "rebrickable_inventory", + "checked", + "missing", + "damaged" +) VALUES ( + :id, + :figure, + :part, + :color, + :spare, + :quantity, + :element, + :rebrickable_inventory, + 0, + 0, + 0 +) +ON CONFLICT("id", "figure", "part", "color", "spare") +DO UPDATE SET + "quantity" = excluded."quantity", + "element" = excluded."element", + "rebrickable_inventory" = excluded."rebrickable_inventory" From 4336ad4de3ac8c312fdbb2a9744f09ce7223ae4a Mon Sep 17 00:00:00 2001 From: FrederikBaerentsen Date: Wed, 24 Dec 2025 08:28:36 -0500 Subject: [PATCH 09/63] fix(sets): cleaup of code for set refresh --- CHANGELOG.md | 9 +++++++++ bricktracker/part.py | 1 - bricktracker/sql/part/upsert.sql | 30 ------------------------------ 3 files changed, 9 insertions(+), 31 deletions(-) delete mode 100644 bricktracker/sql/part/upsert.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 814ecaa..0694d96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## 1.4 +### Bug Fixes + +- **Fixed set refresh functionality**: Resolved issues with refreshing sets from Rebrickable + - Fixed foreign key constraint errors during refresh by reusing existing set IDs instead of generating new UUIDs + - Implemented UPDATE-then-INSERT pattern to properly update existing parts while preserving user tracking data + - Part quantities now correctly sync with Rebrickable during refresh + - User tracking data (`checked`, `missing`, `damaged`) is now preserved across refreshes + - New parts from Rebrickable are added to local inventory during refresh + ### New Features - **Export Functionality** diff --git a/bricktracker/part.py b/bricktracker/part.py index 279ba45..0275c65 100644 --- a/bricktracker/part.py +++ b/bricktracker/part.py @@ -23,7 +23,6 @@ class BrickPart(RebrickablePart): # Queries insert_query: str = 'part/insert' - upsert_query: str = 'part/upsert' update_on_refresh_query: str = 'part/update_on_refresh' generic_query: str = 'part/select/generic' select_query: str = 'part/select/specific' diff --git a/bricktracker/sql/part/upsert.sql b/bricktracker/sql/part/upsert.sql deleted file mode 100644 index ba7a30b..0000000 --- a/bricktracker/sql/part/upsert.sql +++ /dev/null @@ -1,30 +0,0 @@ -INSERT INTO "bricktracker_parts" ( - "id", - "figure", - "part", - "color", - "spare", - "quantity", - "element", - "rebrickable_inventory", - "checked", - "missing", - "damaged" -) VALUES ( - :id, - :figure, - :part, - :color, - :spare, - :quantity, - :element, - :rebrickable_inventory, - 0, - 0, - 0 -) -ON CONFLICT("id", "figure", "part", "color", "spare") -DO UPDATE SET - "quantity" = excluded."quantity", - "element" = excluded."element", - "rebrickable_inventory" = excluded."rebrickable_inventory" From f54dd3ec735bf7e4c5f1c0230dfde764620f5f5b Mon Sep 17 00:00:00 2001 From: FrederikBaerentsen Date: Thu, 25 Dec 2025 14:35:59 -0500 Subject: [PATCH 10/63] feat(filter): added not equal to filter on sets page, so it is possible to filter for not-tags, not-status, not-year etc --- bricktracker/set_list.py | 10 +- bricktracker/sql/set/list/all_filtered.sql | 32 +++++- bricktracker/sql/set/list/consolidated.sql | 24 +++++ static/scripts/grid/filter.js | 84 +++++++++++---- static/scripts/grid/filter_toggle.js | 117 +++++++++++++++++++++ static/scripts/sets.js | 72 +++++++++---- templates/base.html | 1 + templates/macro/form.html | 11 ++ templates/set/filter.html | 37 ++++--- 9 files changed, 323 insertions(+), 65 deletions(-) create mode 100644 static/scripts/grid/filter_toggle.js diff --git a/bricktracker/set_list.py b/bricktracker/set_list.py index 77cb7ce..9f23494 100644 --- a/bricktracker/set_list.py +++ b/bricktracker/set_list.py @@ -92,7 +92,15 @@ class BrickSetList(BrickRecordList[BrickSet]): # Convert theme name to theme ID for filtering theme_id_filter = None if theme_filter: - theme_id_filter = self._theme_name_to_id(theme_filter) + # Check if this is a NOT filter + if theme_filter.startswith('-'): + # Extract the actual theme value without the "-" prefix + actual_theme = theme_filter[1:] + theme_id = self._theme_name_to_id(actual_theme) + # Re-add the "-" prefix to the theme ID + theme_id_filter = f'-{theme_id}' if theme_id else None + else: + theme_id_filter = self._theme_name_to_id(theme_filter) # Check if any filters are applied has_filters = any([status_filter, theme_id_filter, owner_filter, purchase_location_filter, storage_filter, tag_filter, year_filter, duplicate_filter]) diff --git a/bricktracker/sql/set/list/all_filtered.sql b/bricktracker/sql/set/list/all_filtered.sql index 18fde15..fc566aa 100644 --- a/bricktracker/sql/set/list/all_filtered.sql +++ b/bricktracker/sql/set/list/all_filtered.sql @@ -8,20 +8,36 @@ AND (LOWER("rebrickable_sets"."name") LIKE LOWER('%{{ search_query }}%') {% endif %} {% if theme_filter %} +{% if theme_filter is string and theme_filter.startswith('-') %} +AND "rebrickable_sets"."theme_id" != {{ theme_filter[1:] }} +{% else %} AND "rebrickable_sets"."theme_id" = {{ theme_filter }} {% endif %} +{% endif %} {% if year_filter %} +{% if year_filter is string and year_filter.startswith('-') %} +AND "rebrickable_sets"."year" != {{ year_filter[1:] }} +{% else %} AND "rebrickable_sets"."year" = {{ year_filter }} {% endif %} +{% endif %} {% if storage_filter %} +{% if storage_filter.startswith('-') %} +AND ("bricktracker_sets"."storage" IS NULL OR "bricktracker_sets"."storage" != '{{ storage_filter[1:] }}') +{% else %} AND "bricktracker_sets"."storage" = '{{ storage_filter }}' {% endif %} +{% endif %} {% if purchase_location_filter %} +{% if purchase_location_filter.startswith('-') %} +AND ("bricktracker_sets"."purchase_location" IS NULL OR "bricktracker_sets"."purchase_location" != '{{ purchase_location_filter[1:] }}') +{% else %} AND "bricktracker_sets"."purchase_location" = '{{ purchase_location_filter }}' {% endif %} +{% endif %} {% if status_filter %} {% if status_filter == 'has-missing' %} @@ -52,7 +68,13 @@ AND NOT EXISTS ( {% endif %} {% if owner_filter %} -{% if owner_filter.startswith('owner-') %} +{% if owner_filter.startswith('-owner-') %} +AND NOT EXISTS ( + SELECT 1 FROM "bricktracker_set_owners" + WHERE "bricktracker_set_owners"."id" = "bricktracker_sets"."id" + AND "bricktracker_set_owners"."{{ owner_filter[1:].replace('-', '_') }}" = 1 +) +{% elif owner_filter.startswith('owner-') %} AND EXISTS ( SELECT 1 FROM "bricktracker_set_owners" WHERE "bricktracker_set_owners"."id" = "bricktracker_sets"."id" @@ -62,7 +84,13 @@ AND EXISTS ( {% endif %} {% if tag_filter %} -{% if tag_filter.startswith('tag-') %} +{% if tag_filter.startswith('-tag-') %} +AND NOT EXISTS ( + SELECT 1 FROM "bricktracker_set_tags" + WHERE "bricktracker_set_tags"."id" = "bricktracker_sets"."id" + AND "bricktracker_set_tags"."{{ tag_filter[1:].replace('-', '_') }}" = 1 +) +{% elif tag_filter.startswith('tag-') %} AND EXISTS ( SELECT 1 FROM "bricktracker_set_tags" WHERE "bricktracker_set_tags"."id" = "bricktracker_sets"."id" diff --git a/bricktracker/sql/set/list/consolidated.sql b/bricktracker/sql/set/list/consolidated.sql index e740015..902c307 100644 --- a/bricktracker/sql/set/list/consolidated.sql +++ b/bricktracker/sql/set/list/consolidated.sql @@ -91,28 +91,52 @@ AND (LOWER("rebrickable_sets"."name") LIKE LOWER('%{{ search_query }}%') {% endif %} {% if theme_filter %} +{% if theme_filter is string and theme_filter.startswith('-') %} +AND "rebrickable_sets"."theme_id" != {{ theme_filter[1:] }} +{% else %} AND "rebrickable_sets"."theme_id" = {{ theme_filter }} {% endif %} +{% endif %} {% if year_filter %} +{% if year_filter is string and year_filter.startswith('-') %} +AND "rebrickable_sets"."year" != {{ year_filter[1:] }} +{% else %} AND "rebrickable_sets"."year" = {{ year_filter }} {% endif %} +{% endif %} {% if storage_filter %} +{% if storage_filter.startswith('-') %} +AND NOT EXISTS ( + SELECT 1 FROM "bricktracker_sets" bs_filter + WHERE bs_filter."set" = "rebrickable_sets"."set" + AND bs_filter."storage" = '{{ storage_filter[1:] }}' +) +{% else %} AND EXISTS ( SELECT 1 FROM "bricktracker_sets" bs_filter WHERE bs_filter."set" = "rebrickable_sets"."set" AND bs_filter."storage" = '{{ storage_filter }}' ) {% endif %} +{% endif %} {% if purchase_location_filter %} +{% if purchase_location_filter.startswith('-') %} +AND NOT EXISTS ( + SELECT 1 FROM "bricktracker_sets" bs_filter + WHERE bs_filter."set" = "rebrickable_sets"."set" + AND bs_filter."purchase_location" = '{{ purchase_location_filter[1:] }}' +) +{% else %} AND EXISTS ( SELECT 1 FROM "bricktracker_sets" bs_filter WHERE bs_filter."set" = "rebrickable_sets"."set" AND bs_filter."purchase_location" = '{{ purchase_location_filter }}' ) {% endif %} +{% endif %} {% if status_filter %} {% if status_filter == 'has-storage' %} diff --git a/static/scripts/grid/filter.js b/static/scripts/grid/filter.js index 098e2b4..b1de62d 100644 --- a/static/scripts/grid/filter.js +++ b/static/scripts/grid/filter.js @@ -67,28 +67,33 @@ class BrickGridFilter { // Build filters for (const select of this.selects) { - if (select.value != "") { + // Get the actual filter value (includes "-" prefix if toggle is in NOT mode) + const filterValue = typeof BrickFilterToggle !== 'undefined' + ? BrickFilterToggle.getFilterValue(select) + : select.value; + + if (filterValue != "") { // Multi-attribute filter switch (select.dataset.filter) { // List contains values case "value": options.filters.push({ attribute: select.dataset.filterAttribute, - value: select.value, + value: filterValue, }) break; // List contains metadata attribute name, looking for true/false case "metadata": - if (select.value.startsWith("-")) { + if (filterValue.startsWith("-")) { options.filters.push({ - attribute: select.value.substring(1), + attribute: filterValue.substring(1), bool: true, value: "0" }) } else { options.filters.push({ - attribute: select.value, + attribute: filterValue, bool: true, value: "1" }); @@ -130,23 +135,58 @@ class BrickGridFilter { // Value check // For consolidated cards, attributes may be comma or pipe-separated (e.g., "storage1,storage2" or "storage1|storage2") - else if (attribute == null) { - // Hide if attribute is missing - current.parentElement.classList.add("d-none"); - return; - } else if (attribute.includes(',') || attribute.includes('|')) { - // Handle comma or pipe-separated values (consolidated cards) - const separator = attribute.includes('|') ? '|' : ','; - const values = attribute.split(separator).map(v => v.trim()); - if (!values.includes(filter.value)) { - current.parentElement.classList.add("d-none"); - return; - } - } else { - // Handle single values (regular cards) - if (attribute != filter.value) { - current.parentElement.classList.add("d-none"); - return; + else { + // Check if this is a NOT filter (value starts with "-") + const isNot = filter.value.startsWith('-'); + const actualValue = isNot ? filter.value.substring(1) : filter.value; + + if (attribute == null) { + // If attribute is missing + if (isNot) { + // NOT filter: missing attribute means it doesn't match, so SHOW it + // (e.g., NOT "Basement" and has no storage = show) + // Continue to next filter + } else { + // Regular filter: missing attribute means hide + current.parentElement.classList.add("d-none"); + return; + } + } else if (attribute.includes(',') || attribute.includes('|')) { + // Handle comma or pipe-separated values (consolidated cards) + const separator = attribute.includes('|') ? '|' : ','; + const values = attribute.split(separator).map(v => v.trim()); + const hasValue = values.includes(actualValue); + + if (isNot) { + // NOT filter: hide if ANY of the values match + if (hasValue) { + current.parentElement.classList.add("d-none"); + return; + } + } else { + // Regular filter: hide if NONE of the values match + if (!hasValue) { + current.parentElement.classList.add("d-none"); + return; + } + } + } else { + // Handle single values (regular cards) + const matches = (attribute == actualValue); + + if (isNot) { + // NOT filter: hide if it matches + if (matches) { + current.parentElement.classList.add("d-none"); + return; + } + } else { + // Regular filter: hide if it doesn't match + if (!matches) { + current.parentElement.classList.add("d-none"); + return; + } + } } } } diff --git a/static/scripts/grid/filter_toggle.js b/static/scripts/grid/filter_toggle.js new file mode 100644 index 0000000..864d471 --- /dev/null +++ b/static/scripts/grid/filter_toggle.js @@ -0,0 +1,117 @@ +// Filter toggle for NOT filtering +class BrickFilterToggle { + constructor() { + // Find all filter toggle buttons + this.toggles = document.querySelectorAll('.filter-toggle'); + + // Initialize each toggle + this.toggles.forEach(toggle => { + this.initializeToggle(toggle); + }); + } + + initializeToggle(toggle) { + const targetId = toggle.dataset.filterTarget; + const targetSelect = document.getElementById(targetId); + + if (!targetSelect) { + console.error(`Filter toggle: Target select #${targetId} not found`); + return; + } + + // Check if we need to initialize in NOT mode based on URL parameters + const urlParams = new URLSearchParams(window.location.search); + const filterParam = this.getFilterParamName(targetId); + const filterValue = urlParams.get(filterParam); + + // Initialize the NOT mode flag + if (filterValue && filterValue.startsWith('-')) { + targetSelect.dataset.notMode = 'true'; + this.setToggleState(toggle, 'not-equals'); + } else { + targetSelect.dataset.notMode = 'false'; + this.setToggleState(toggle, 'equals'); + } + + // Add click event listener to toggle button + toggle.addEventListener('click', () => { + this.handleToggleClick(toggle, targetSelect); + }); + + // Add change event listener to the select + targetSelect.addEventListener('change', () => { + // If select is cleared (empty value), reset toggle to equals mode + const selectValue = targetSelect.options[targetSelect.selectedIndex]?.value || ''; + if (!selectValue) { + targetSelect.dataset.notMode = 'false'; + this.setToggleState(toggle, 'equals'); + } + }); + } + + getFilterParamName(selectId) { + // Map select IDs to URL parameter names + const mapping = { + 'grid-status': 'status', + 'grid-theme': 'theme', + 'grid-owner': 'owner', + 'grid-storage': 'storage', + 'grid-purchase-location': 'purchase_location', + 'grid-tag': 'tag', + 'grid-year': 'year' + }; + return mapping[selectId] || selectId.replace('grid-', ''); + } + + handleToggleClick(toggle, targetSelect) { + const selectValue = targetSelect.options[targetSelect.selectedIndex]?.value || ''; + + // Don't toggle if no value is selected + if (!selectValue) { + return; + } + + // Toggle the NOT mode + const isNotMode = targetSelect.dataset.notMode === 'true'; + targetSelect.dataset.notMode = isNotMode ? 'false' : 'true'; + + // Update toggle button visual state + this.setToggleState(toggle, isNotMode ? 'equals' : 'not-equals'); + + // Trigger change event on the select to update the grid filter + targetSelect.dispatchEvent(new Event('change')); + } + + setToggleState(toggle, mode) { + toggle.dataset.filterMode = mode; + const icon = toggle.querySelector('i'); + + if (mode === 'not-equals') { + icon.className = 'ri-indeterminate-circle-line'; + toggle.classList.remove('btn-outline-secondary'); + toggle.classList.add('btn-outline-danger'); + toggle.title = 'NOT equals (click to toggle)'; + } else { + icon.className = 'ri-equal-line'; + toggle.classList.remove('btn-outline-danger'); + toggle.classList.add('btn-outline-secondary'); + toggle.title = 'Equals (click to toggle)'; + } + } + + // Helper method to get the actual filter value (with "-" prefix if in NOT mode) + static getFilterValue(select) { + const selectValue = select.options[select.selectedIndex]?.value || ''; + const isNotMode = select.dataset.notMode === 'true'; + + if (selectValue && isNotMode && !selectValue.startsWith('-')) { + return '-' + selectValue; + } + return selectValue; + } +} + +// Initialize when DOM is ready +document.addEventListener("DOMContentLoaded", () => { + new BrickFilterToggle(); +}); diff --git a/static/scripts/sets.js b/static/scripts/sets.js index 4e68564..7f0f651 100644 --- a/static/scripts/sets.js +++ b/static/scripts/sets.js @@ -145,12 +145,15 @@ function initializeFilterDropdowns() { // Set filter dropdown values from URL parameters const urlParams = new URLSearchParams(window.location.search); + // Helper function to strip "-" prefix from filter values + const stripNotPrefix = (value) => value && value.startsWith('-') ? value.substring(1) : value; + // Set each filter dropdown value if the parameter exists const yearParam = urlParams.get('year'); if (yearParam) { const yearDropdown = document.getElementById('grid-year'); if (yearDropdown) { - yearDropdown.value = yearParam; + yearDropdown.value = stripNotPrefix(yearParam); } } @@ -158,14 +161,15 @@ function initializeFilterDropdowns() { if (themeParam) { const themeDropdown = document.getElementById('grid-theme'); if (themeDropdown) { + const cleanTheme = stripNotPrefix(themeParam); // Try to set the theme value directly first (for theme names) - themeDropdown.value = themeParam; + themeDropdown.value = cleanTheme; // If that didn't work and the param is numeric (theme ID), // try to find the corresponding theme name by looking at cards - if (themeDropdown.value !== themeParam && /^\d+$/.test(themeParam)) { + if (themeDropdown.value !== cleanTheme && /^\d+$/.test(cleanTheme)) { // Look for a card with this theme ID and get its theme name - const cardWithTheme = document.querySelector(`[data-theme-id="${themeParam}"]`); + const cardWithTheme = document.querySelector(`[data-theme-id="${cleanTheme}"]`); if (cardWithTheme) { const themeName = cardWithTheme.getAttribute('data-theme'); if (themeName) { @@ -180,7 +184,7 @@ function initializeFilterDropdowns() { if (statusParam) { const statusDropdown = document.getElementById('grid-status'); if (statusDropdown) { - statusDropdown.value = statusParam; + statusDropdown.value = stripNotPrefix(statusParam); } } @@ -188,7 +192,7 @@ function initializeFilterDropdowns() { if (ownerParam) { const ownerDropdown = document.getElementById('grid-owner'); if (ownerDropdown) { - ownerDropdown.value = ownerParam; + ownerDropdown.value = stripNotPrefix(ownerParam); } } @@ -196,7 +200,7 @@ function initializeFilterDropdowns() { if (purchaseLocationParam) { const purchaseLocationDropdown = document.getElementById('grid-purchase-location'); if (purchaseLocationDropdown) { - purchaseLocationDropdown.value = purchaseLocationParam; + purchaseLocationDropdown.value = stripNotPrefix(purchaseLocationParam); } } @@ -204,7 +208,7 @@ function initializeFilterDropdowns() { if (storageParam) { const storageDropdown = document.getElementById('grid-storage'); if (storageDropdown) { - storageDropdown.value = storageParam; + storageDropdown.value = stripNotPrefix(storageParam); } } @@ -212,7 +216,7 @@ function initializeFilterDropdowns() { if (tagParam) { const tagDropdown = document.getElementById('grid-tag'); if (tagDropdown) { - tagDropdown.value = tagParam; + tagDropdown.value = stripNotPrefix(tagParam); } } } @@ -222,6 +226,9 @@ function initializeClientSideFilterDropdowns() { const urlParams = new URLSearchParams(window.location.search); let needsFiltering = false; + // Helper function to strip "-" prefix from filter values + const stripNotPrefix = (value) => value && value.startsWith('-') ? value.substring(1) : value; + // Check if we have any filter parameters to avoid flash of all content const hasFilterParams = urlParams.has('year') || urlParams.has('theme') || urlParams.has('storage') || urlParams.has('purchase_location'); @@ -238,7 +245,7 @@ function initializeClientSideFilterDropdowns() { if (yearParam) { const yearDropdown = document.getElementById('grid-year'); if (yearDropdown) { - yearDropdown.value = yearParam; + yearDropdown.value = stripNotPrefix(yearParam); needsFiltering = true; } } @@ -248,16 +255,17 @@ function initializeClientSideFilterDropdowns() { if (themeParam) { const themeDropdown = document.getElementById('grid-theme'); if (themeDropdown) { - if (/^\d+$/.test(themeParam)) { + const cleanTheme = stripNotPrefix(themeParam); + if (/^\d+$/.test(cleanTheme)) { // Theme parameter is an ID, need to convert to theme name by looking at cards - const themeNameFromId = findThemeNameById(themeParam); + const themeNameFromId = findThemeNameById(cleanTheme); if (themeNameFromId) { themeDropdown.value = themeNameFromId; needsFiltering = true; } } else { // Theme parameter is already a name - themeDropdown.value = themeParam.toLowerCase(); + themeDropdown.value = cleanTheme.toLowerCase(); needsFiltering = true; } } @@ -268,7 +276,7 @@ function initializeClientSideFilterDropdowns() { if (storageParam) { const storageDropdown = document.getElementById('grid-storage'); if (storageDropdown) { - storageDropdown.value = storageParam; + storageDropdown.value = stripNotPrefix(storageParam); needsFiltering = true; } } @@ -278,7 +286,7 @@ function initializeClientSideFilterDropdowns() { if (purchaseLocationParam) { const purchaseLocationDropdown = document.getElementById('grid-purchase-location'); if (purchaseLocationDropdown) { - purchaseLocationDropdown.value = purchaseLocationParam; + purchaseLocationDropdown.value = stripNotPrefix(purchaseLocationParam); needsFiltering = true; } } @@ -343,14 +351,30 @@ function setupPaginationFilterDropdowns() { function performServerFilter() { const currentUrl = new URL(window.location); - // Get all filter values - const statusFilter = document.getElementById('grid-status')?.value || ''; - const themeFilter = document.getElementById('grid-theme')?.value || ''; - const yearFilter = document.getElementById('grid-year')?.value || ''; - const ownerFilter = document.getElementById('grid-owner')?.value || ''; - const purchaseLocationFilter = document.getElementById('grid-purchase-location')?.value || ''; - const storageFilter = document.getElementById('grid-storage')?.value || ''; - const tagFilter = document.getElementById('grid-tag')?.value || ''; + // Get all filter values (using BrickFilterToggle helper to include "-" prefix if in NOT mode) + const statusSelect = document.getElementById('grid-status'); + const themeSelect = document.getElementById('grid-theme'); + const yearSelect = document.getElementById('grid-year'); + const ownerSelect = document.getElementById('grid-owner'); + const purchaseLocationSelect = document.getElementById('grid-purchase-location'); + const storageSelect = document.getElementById('grid-storage'); + const tagSelect = document.getElementById('grid-tag'); + + // Helper to safely get filter value with NOT mode support + const getFilterValue = (select) => { + if (!select) return ''; + return typeof BrickFilterToggle !== 'undefined' + ? BrickFilterToggle.getFilterValue(select) + : select.value; + }; + + const statusFilter = getFilterValue(statusSelect); + const themeFilter = getFilterValue(themeSelect); + const yearFilter = getFilterValue(yearSelect); + const ownerFilter = getFilterValue(ownerSelect); + const purchaseLocationFilter = getFilterValue(purchaseLocationSelect); + const storageFilter = getFilterValue(storageSelect); + const tagFilter = getFilterValue(tagSelect); // Update URL parameters if (statusFilter) { @@ -746,6 +770,8 @@ function initializeClearFiltersButton() { const dropdown = document.getElementById(dropdownId); if (dropdown) { dropdown.value = ''; + // Trigger change event to reset toggle button state + dropdown.dispatchEvent(new Event('change')); } }); diff --git a/templates/base.html b/templates/base.html index 968f6ca..aaf2e04 100644 --- a/templates/base.html +++ b/templates/base.html @@ -84,6 +84,7 @@ + diff --git a/templates/macro/form.html b/templates/macro/form.html index 323019b..6bd819d 100644 --- a/templates/macro/form.html +++ b/templates/macro/form.html @@ -64,3 +64,14 @@ {% endif %} {% endmacro %} + +{% macro filter_toggle(filter_id) %} + +{% endmacro %} diff --git a/templates/set/filter.html b/templates/set/filter.html index ab0cf36..198a105 100644 --- a/templates/set/filter.html +++ b/templates/set/filter.html @@ -1,3 +1,4 @@ +{% import 'macro/form.html' as form %}
@@ -8,26 +9,22 @@ autocomplete="off"> {% if not config['HIDE_TABLE_MISSING_PARTS'] %} - - + {% endif %} {% if not config['HIDE_TABLE_DAMAGED_PARTS'] %} - - + {% endif %} {% if not config['HIDE_SET_INSTRUCTIONS'] %} - - + {% endif %} {% if brickset_storages | length %} - - + {% endif %} {% for status in brickset_statuses %} - - + {% endfor %} + {{ form.filter_toggle('grid-status') }}
@@ -39,9 +36,10 @@ autocomplete="off"> {% for theme in collection.themes %} - + {% endfor %} + {{ form.filter_toggle('grid-theme') }}
{% if brickset_owners | length %} @@ -57,12 +55,13 @@ {% endfor %} + {{ form.filter_toggle('grid-owner') }} {% endif %} {% if brickset_purchase_locations | length %}
- +
Purchase location + {{ form.filter_toggle('grid-purchase-location') }}
{% endif %} @@ -80,7 +80,7 @@
{% if brickset_storages | length %}
- +
Storage + {{ form.filter_toggle('grid-storage') }}
{% endif %} @@ -104,9 +105,10 @@ autocomplete="off"> {% for tag in brickset_tags %} - + {% endfor %} + {{ form.filter_toggle('grid-tag') }} {% endif %} @@ -119,9 +121,10 @@ autocomplete="off"> {% for year in collection.years %} - + {% endfor %} + {{ form.filter_toggle('grid-year') }}
From 19e3d8afe61dc592c726a2fa27ad1a793ebe33c6 Mon Sep 17 00:00:00 2001 From: FrederikBaerentsen Date: Thu, 25 Dec 2025 15:19:47 -0500 Subject: [PATCH 11/63] fix(filter): changed equal and not equal icon to text character to avoid weird resizing --- static/scripts/grid/filter_toggle.js | 8 ++++++-- templates/macro/form.html | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/static/scripts/grid/filter_toggle.js b/static/scripts/grid/filter_toggle.js index 864d471..53c000e 100644 --- a/static/scripts/grid/filter_toggle.js +++ b/static/scripts/grid/filter_toggle.js @@ -87,12 +87,16 @@ class BrickFilterToggle { const icon = toggle.querySelector('i'); if (mode === 'not-equals') { - icon.className = 'ri-indeterminate-circle-line'; + // Use ≠ symbol (text character) + icon.className = ''; + icon.textContent = '≠'; toggle.classList.remove('btn-outline-secondary'); toggle.classList.add('btn-outline-danger'); toggle.title = 'NOT equals (click to toggle)'; } else { - icon.className = 'ri-equal-line'; + // Use = symbol (text character) instead of icon + icon.className = ''; + icon.textContent = '='; toggle.classList.remove('btn-outline-danger'); toggle.classList.add('btn-outline-secondary'); toggle.title = 'Equals (click to toggle)'; diff --git a/templates/macro/form.html b/templates/macro/form.html index 6bd819d..13577f8 100644 --- a/templates/macro/form.html +++ b/templates/macro/form.html @@ -72,6 +72,6 @@ data-filter-target="{{ filter_id }}" data-filter-mode="equals" title="Toggle between equals and not equals"> - + = {% endmacro %} From dcf9496db945fa6e953893bc5113ad4aa58a6191 Mon Sep 17 00:00:00 2001 From: FrederikBaerentsen Date: Thu, 25 Dec 2025 15:20:14 -0500 Subject: [PATCH 12/63] Updated changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0694d96..af43964 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,14 @@ - Respects `BK_RANDOM` configuration (random selection when enabled, latest when disabled) - Respects `BK_HIDE_SPARE_PARTS` configuration - Respects `BK_HIDE_ALL_PARTS` configuration for "All parts" button visibility +- **NOT Filter Toggle Buttons** + - Added toggle buttons next to all filter dropdowns to switch between "equals" and "not equals" modes + - Visual feedback: Button displays red with "not equals" icon (≠) when in NOT mode + - Works with all filter types: Status, Theme, Owner, Storage, Purchase Location, Tag, and Year + - Supports both client-side and server-side pagination modes + - Filter chains persist NOT states across page reloads via URL parameters (e.g., `?theme=-frozen&status=-has-missing`) + - Clear filters button resets all toggle states to equals mode + - Enables complex filter combinations like "Show me 2025 sets that are NOT Frozen theme AND have missing pieces" ## 1.3.1 From 57258720601d862d7f327e6b1c6dc6c225195755 Mon Sep 17 00:00:00 2001 From: FrederikBaerentsen Date: Thu, 25 Dec 2025 20:44:40 -0500 Subject: [PATCH 13/63] feat(sets): added notes field to sets. Displayed both at top of page, if not empty and in the metadata section, where it can be changed --- bricktracker/set.py | 34 +++++++++++++++++++++ bricktracker/sql/set/base/base.sql | 1 + bricktracker/sql/set/update/description.sql | 3 ++ bricktracker/views/set.py | 12 ++++++++ static/scripts/changer.js | 6 ++++ templates/macro/form.html | 24 +++++++++++++++ templates/set/card.html | 7 +++++ templates/set/management.html | 3 ++ 8 files changed, 90 insertions(+) create mode 100644 bricktracker/sql/set/update/description.sql diff --git a/bricktracker/set.py b/bricktracker/set.py index 387d9b2..d873b6e 100644 --- a/bricktracker/set.py +++ b/bricktracker/set.py @@ -30,6 +30,7 @@ class BrickSet(RebrickableSet): insert_query: str = 'set/insert' update_purchase_date_query: str = 'set/update/purchase_date' update_purchase_price_query: str = 'set/update/purchase_price' + update_description_query: str = 'set/update/description' # Delete a set def delete(self, /) -> None: @@ -370,3 +371,36 @@ class BrickSet(RebrickableSet): # Update purchase price url def url_for_purchase_price(self, /) -> str: return url_for('set.update_purchase_price', id=self.fields.id) + + # Update description + def update_description(self, json: Any | None, /) -> Any: + value = json.get('value', None) # type: ignore + + if value == '': + value = None + + self.fields.description = value + + rows, _ = BrickSQL().execute_and_commit( + self.update_description_query, + parameters=self.sql_parameters() + ) + + if rows != 1: + raise DatabaseException('Could not update the description for set {set} ({id})'.format( # noqa: E501 + set=self.fields.set, + id=self.fields.id, + )) + + # Info + logger.info('Description changed to "{value}" for set {set} ({id})'.format( # noqa: E501 + value=value, + set=self.fields.set, + id=self.fields.id, + )) + + return value + + # Update description url + def url_for_description(self, /) -> str: + return url_for('set.update_description', id=self.fields.id) diff --git a/bricktracker/sql/set/base/base.sql b/bricktracker/sql/set/base/base.sql index 5ecc2cf..1c793a5 100644 --- a/bricktracker/sql/set/base/base.sql +++ b/bricktracker/sql/set/base/base.sql @@ -4,6 +4,7 @@ SELECT "bricktracker_sets"."purchase_date", "bricktracker_sets"."purchase_location", "bricktracker_sets"."purchase_price", + "bricktracker_sets"."description", "rebrickable_sets"."set", "rebrickable_sets"."number", "rebrickable_sets"."version", diff --git a/bricktracker/sql/set/update/description.sql b/bricktracker/sql/set/update/description.sql new file mode 100644 index 0000000..0786412 --- /dev/null +++ b/bricktracker/sql/set/update/description.sql @@ -0,0 +1,3 @@ +UPDATE "bricktracker_sets" +SET "description" = :description +WHERE "bricktracker_sets"."id" IS NOT DISTINCT FROM :id diff --git a/bricktracker/views/set.py b/bricktracker/views/set.py index 0ac0a82..5855fd6 100644 --- a/bricktracker/views/set.py +++ b/bricktracker/views/set.py @@ -158,6 +158,18 @@ def update_purchase_price(*, id: str) -> Response: return jsonify({'value': value}) +# Change the value of description +@set_page.route('//description', methods=['POST']) +@login_required +@exception_handler(__file__, json=True) +def update_description(*, id: str) -> Response: + brickset = BrickSet().select_light(id) + + value = brickset.update_description(request.json) + + return jsonify({'value': value}) + + # Change the state of a owner @set_page.route('//owner/', methods=['POST']) @login_required diff --git a/static/scripts/changer.js b/static/scripts/changer.js index a32af76..5b01ef8 100644 --- a/static/scripts/changer.js +++ b/static/scripts/changer.js @@ -38,6 +38,11 @@ class BrickChanger { listener = "change"; break; + case "TEXTAREA": + this.html_type = "textarea"; + listener = "change"; + break; + default: throw Error(`Unsupported HTML tag type for BrickChanger: ${this.html_element.tagName}`); } @@ -131,6 +136,7 @@ class BrickChanger { case "text": case "select": + case "textarea": value = this.html_element.value; break; diff --git a/templates/macro/form.html b/templates/macro/form.html index 13577f8..5a5ffaa 100644 --- a/templates/macro/form.html +++ b/templates/macro/form.html @@ -65,6 +65,30 @@ {% endif %} {% endmacro %} +{% macro textarea(name, id, prefix, url, value, all=none, read_only=none, icon=none, rows=3, delete=false) %} + {% if all or read_only %} + {{ value }} + {% else %} + +
+ {% if icon %} {{ name }}{% endif %} + + {% if g.login.is_authenticated() and not delete %} + + + {% else %} + + {% endif %} +
+ {% endif %} +{% endmacro %} + {% macro filter_toggle(filter_id) %}
+ +{% if solo and g.login.is_authenticated() %} + + +{% endif %} diff --git a/templates/set/socket.html b/templates/set/socket.html index c4000a8..97bcd33 100644 --- a/templates/set/socket.html +++ b/templates/set/socket.html @@ -11,6 +11,7 @@ LOAD_SET: '{{ messages['LOAD_SET'] }}', PROGRESS: '{{ messages['PROGRESS'] }}', SET_LOADED: '{{ messages['SET_LOADED'] }}', + MINIFIGURE_LOADED: '{{ messages['MINIFIGURE_LOADED'] }}', }, {% if bulk %}true{% else %}false{% endif %}, {% if refresh %}true{% else %}false{% endif %} diff --git a/templates/statistics.html b/templates/statistics.html index 0051231..4726d7d 100644 --- a/templates/statistics.html +++ b/templates/statistics.html @@ -273,7 +273,7 @@
- Sets by Storage + Storage
@@ -286,8 +286,8 @@ Storage Location Sets - Parts - Minifigures + Parts (lots, individual parts) + Minifigures (individual minifigures) Value @@ -295,7 +295,7 @@ {% for storage in storage_statistics %} - + {{ storage.storage_name or 'Unknown' }} @@ -303,10 +303,10 @@ {{ storage.set_count }} - {{ "{:,}".format(storage.total_parts) }} + {{ "{:,}".format(storage.total_parts) }}{% if storage.lot_count or storage.individual_part_count %} ({% if storage.lot_count %}{{ storage.lot_count }} lot{{ 's' if storage.lot_count != 1 else '' }}{% endif %}{% if storage.lot_count and storage.individual_part_count %}, {% endif %}{% if storage.individual_part_count %}{{ "{:,}".format(storage.individual_part_count) }} individual{% endif %}){% endif %} - {{ storage.total_minifigures }} + {{ storage.total_minifigures }}{% if storage.individual_minifig_count %} ({{ storage.individual_minifig_count }} individual){% endif %} {% if storage.total_value %} @@ -335,7 +335,7 @@
- Sets by Purchase Location + Purchase Location
@@ -359,7 +359,7 @@ {% for location in purchase_location_statistics %} - + {{ location.location_name or 'Unknown' }} diff --git a/templates/storage/card.html b/templates/storage/card.html index cf29de3..1545f2e 100644 --- a/templates/storage/card.html +++ b/templates/storage/card.html @@ -6,10 +6,28 @@ {{ card.header(item, item.fields.name, solo=solo, icon='archive-2-line') }}
{{ badge.total_sets(sets | length, solo=solo, last=last) }} + {% if individual_minifigures | length %} + {{ badge.badge(check=individual_minifigures | length, solo=solo, last=last, color='info', icon='user-line', collapsible='Individual Minifigures:', text=individual_minifigures | length) }} + {% endif %} + {% if individual_parts | length %} + {{ badge.badge(check=individual_parts | length, solo=solo, last=last, color='success', icon='shapes-line', collapsible='Individual Parts:', text=individual_parts | length) }} + {% endif %} + {% if individual_part_lots | length %} + {{ badge.badge(check=individual_part_lots | length, solo=solo, last=last, color='warning', icon='hammer-line', collapsible='Part Lots:', text=individual_part_lots | length) }} + {% endif %}
{% if solo %}
{{ accordion.cards(sets, 'Sets', 'sets-stored', 'storage-details', 'set/card.html', expanded=true, icon='hashtag') }} + {% if individual_minifigures | length %} + {{ accordion.cards(individual_minifigures, 'Individual Minifigures', 'individual-minifigures-stored', 'storage-details', 'individual_minifigure/card.html', expanded=false, icon='user-line') }} + {% endif %} + {% if individual_parts | length %} + {{ accordion.cards(individual_parts, 'Individual Parts', 'individual-parts-stored', 'storage-details', 'individual_part/card.html', expanded=false, icon='shapes-line') }} + {% endif %} + {% if individual_part_lots | length %} + {{ accordion.cards(individual_part_lots, 'Individual Part Lots', 'individual-part-lots-stored', 'storage-details', 'individual_part/lot_card.html', expanded=false, icon='hammer-line') }} + {% endif %}
{% endif %} From 24c8f1e5dfd84f3d17744baadb49490f8d8deb2c Mon Sep 17 00:00:00 2001 From: FrederikBaerentsen Date: Sun, 18 Jan 2026 21:06:32 +0100 Subject: [PATCH 29/63] feat(core): integrate individual items features with app configuration and versioning --- bricktracker/app.py | 6 ++++++ bricktracker/config.py | 9 ++++++++- bricktracker/config_manager.py | 9 ++++++++- bricktracker/version.py | 2 +- 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/bricktracker/app.py b/bricktracker/app.py index e791597..88f30fc 100644 --- a/bricktracker/app.py +++ b/bricktracker/app.py @@ -31,10 +31,13 @@ from bricktracker.views.admin.theme import admin_theme_page from bricktracker.views.data import data_page from bricktracker.views.error import error_404 from bricktracker.views.index import index_page +from bricktracker.views.individual_minifigure import individual_minifigure_page +from bricktracker.views.individual_part import individual_part_page from bricktracker.views.instructions import instructions_page from bricktracker.views.login import login_page from bricktracker.views.minifigure import minifigure_page from bricktracker.views.part import part_page +from bricktracker.views.purchase_location import purchase_location_page from bricktracker.views.set import set_page from bricktracker.views.statistics import statistics_page from bricktracker.views.storage import storage_page @@ -138,10 +141,13 @@ def setup_app(app: Flask) -> None: app.register_blueprint(add_page) app.register_blueprint(data_page) app.register_blueprint(index_page) + app.register_blueprint(individual_minifigure_page) + app.register_blueprint(individual_part_page) app.register_blueprint(instructions_page) app.register_blueprint(login_page) app.register_blueprint(minifigure_page) app.register_blueprint(part_page) + app.register_blueprint(purchase_location_page) app.register_blueprint(set_page) app.register_blueprint(statistics_page) app.register_blueprint(storage_page) diff --git a/bricktracker/config.py b/bricktracker/config.py index 061b75b..71e925e 100644 --- a/bricktracker/config.py +++ b/bricktracker/config.py @@ -10,6 +10,8 @@ from typing import Any, Final CONFIG: Final[list[dict[str, Any]]] = [ {'n': 'AUTHENTICATION_PASSWORD', 'd': ''}, {'n': 'AUTHENTICATION_KEY', 'd': ''}, + # BrickLink minifigure links disabled - Rebrickable doesn't provide BrickLink minifigure IDs + # {'n': 'BRICKLINK_LINK_MINIFIGURE_PATTERN', 'd': 'https://www.bricklink.com/v2/catalog/catalogitem.page?M={figure}'}, # noqa: E501 {'n': 'BRICKLINK_LINK_PART_PATTERN', 'd': 'https://www.bricklink.com/v2/catalog/catalogitem.page?P={part}&C={color}'}, # noqa: E501 {'n': 'BRICKLINK_LINK_SET_PATTERN', 'd': 'https://www.bricklink.com/v2/catalog/catalogitem.page?S={set_num}'}, # noqa: E501 {'n': 'BRICKLINK_LINKS', 'c': bool}, @@ -17,6 +19,9 @@ CONFIG: Final[list[dict[str, Any]]] = [ {'n': 'DATABASE_TIMESTAMP_FORMAT', 'd': '%Y-%m-%d-%H-%M-%S'}, {'n': 'DEBUG', 'c': bool}, {'n': 'DEFAULT_TABLE_PER_PAGE', 'd': 25, 'c': int}, + {'n': 'DISABLE_INDIVIDUAL_MINIFIGURES', 'c': bool}, + {'n': 'DISABLE_INDIVIDUAL_PARTS', 'c': bool}, + {'n': 'DISABLE_QUICK_ADD_INDIVIDUAL_PARTS', 'c': bool}, {'n': 'DOMAIN_NAME', 'e': 'DOMAIN_NAME', 'd': ''}, {'n': 'FILE_DATETIME_FORMAT', 'd': '%d/%m/%Y, %H:%M:%S'}, {'n': 'HOST', 'd': '0.0.0.0'}, @@ -29,7 +34,9 @@ CONFIG: Final[list[dict[str, Any]]] = [ {'n': 'ADMIN_DEFAULT_EXPANDED_SECTIONS', 'd': ['database'], 'c': list}, {'n': 'HIDE_ALL_INSTRUCTIONS', 'c': bool}, {'n': 'HIDE_ALL_MINIFIGURES', 'c': bool}, + {'n': 'HIDE_INDIVIDUAL_MINIFIGURES', 'c': bool}, {'n': 'HIDE_ALL_PARTS', 'c': bool}, + {'n': 'HIDE_INDIVIDUAL_PARTS', 'c': bool}, {'n': 'HIDE_ALL_PROBLEMS_PARTS', 'e': 'BK_HIDE_MISSING_PARTS', 'c': bool}, {'n': 'HIDE_ALL_SETS', 'c': bool}, {'n': 'HIDE_ALL_STORAGES', 'c': bool}, @@ -47,7 +54,7 @@ CONFIG: Final[list[dict[str, Any]]] = [ {'n': 'NO_THREADED_SOCKET', 'c': bool}, {'n': 'PARTS_SERVER_SIDE_PAGINATION', 'c': bool}, {'n': 'SETS_SERVER_SIDE_PAGINATION', 'c': bool}, - {'n': 'PARTS_DEFAULT_ORDER', 'd': '"rebrickable_parts"."name" ASC, "rebrickable_parts"."color_name" ASC, "bricktracker_parts"."spare" ASC'}, # noqa: E501 + {'n': 'PARTS_DEFAULT_ORDER', 'd': '"rebrickable_parts"."name" ASC, "rebrickable_parts"."color_name" ASC, "combined"."spare" ASC'}, # noqa: E501 {'n': 'PARTS_FOLDER', 'd': 'data/parts'}, {'n': 'PARTS_PAGINATION_SIZE_DESKTOP', 'd': 10, 'c': int}, {'n': 'PARTS_PAGINATION_SIZE_MOBILE', 'd': 5, 'c': int}, diff --git a/bricktracker/config_manager.py b/bricktracker/config_manager.py index 963da3e..f3e173a 100644 --- a/bricktracker/config_manager.py +++ b/bricktracker/config_manager.py @@ -17,7 +17,9 @@ LIVE_CHANGEABLE_VARS: Final[List[str]] = [ 'BK_ADMIN_DEFAULT_EXPANDED_SECTIONS', 'BK_HIDE_ALL_INSTRUCTIONS', 'BK_HIDE_ALL_MINIFIGURES', + 'BK_HIDE_INDIVIDUAL_MINIFIGURES', 'BK_HIDE_ALL_PARTS', + 'BK_HIDE_INDIVIDUAL_PARTS', 'BK_HIDE_ALL_PROBLEMS_PARTS', 'BK_HIDE_ALL_SETS', 'BK_HIDE_ALL_STORAGES', @@ -26,6 +28,7 @@ LIVE_CHANGEABLE_VARS: Final[List[str]] = [ 'BK_HIDE_TABLE_DAMAGED_PARTS', 'BK_HIDE_TABLE_MISSING_PARTS', 'BK_HIDE_TABLE_CHECKED_PARTS', + 'BK_DISABLE_QUICK_ADD_INDIVIDUAL_PARTS', 'BK_HIDE_WISHES', 'BK_MINIFIGURES_PAGINATION_SIZE_DESKTOP', 'BK_MINIFIGURES_PAGINATION_SIZE_MOBILE', @@ -68,6 +71,8 @@ LIVE_CHANGEABLE_VARS: Final[List[str]] = [ 'BK_STORAGE_DEFAULT_ORDER', 'BK_WISHES_DEFAULT_ORDER', # URL and Pattern Variables + # BrickLink minifigure links disabled - no ID mapping available + # 'BK_BRICKLINK_LINK_MINIFIGURE_PATTERN', 'BK_BRICKLINK_LINK_PART_PATTERN', 'BK_BRICKLINK_LINK_SET_PATTERN', 'BK_REBRICKABLE_IMAGE_NIL', @@ -90,6 +95,8 @@ RESTART_REQUIRED_VARS: Final[List[str]] = [ 'BK_AUTHENTICATION_KEY', 'BK_DATABASE_PATH', 'BK_DEBUG', + 'BK_DISABLE_INDIVIDUAL_PARTS', + 'BK_DISABLE_INDIVIDUAL_MINIFIGURES', 'BK_DOMAIN_NAME', 'BK_HOST', 'BK_PORT', @@ -199,7 +206,7 @@ class ConfigManager: except (ValueError, TypeError): return 0 # Boolean variables - More specific patterns to avoid conflicts - if any(keyword in var_name.lower() for keyword in ['hide_', 'server_side_pagination', '_links', 'random', 'skip_', 'show_', 'use_', '_consolidation', '_charts', '_expanded']): + if any(keyword in var_name.lower() for keyword in ['hide_', 'disable_', 'server_side_pagination', '_links', 'random', 'skip_', 'show_', 'use_', '_consolidation', '_charts', '_expanded']): if isinstance(value, str): return value.lower() in ('true', '1', 'yes', 'on') return bool(value) diff --git a/bricktracker/version.py b/bricktracker/version.py index 17c1e83..e5c6e1d 100644 --- a/bricktracker/version.py +++ b/bricktracker/version.py @@ -1,4 +1,4 @@ from typing import Final __version__: Final[str] = '1.4.0' -__database_version__: Final[int] = 20 +__database_version__: Final[int] = 27 From dda171c027a9fd538376db2da30784ad2765f5fd Mon Sep 17 00:00:00 2001 From: FrederikBaerentsen Date: Sun, 18 Jan 2026 21:07:39 +0100 Subject: [PATCH 30/63] feat(metadata): extend metadata system to support individual minifigures and parts --- bricktracker/metadata.py | 175 ++++++++++++++++++++++++++++++++++ bricktracker/metadata_list.py | 20 ++++ bricktracker/record.py | 21 ++++ 3 files changed, 216 insertions(+) diff --git a/bricktracker/metadata.py b/bricktracker/metadata.py index 1ae7b1a..0f45fec 100644 --- a/bricktracker/metadata.py +++ b/bricktracker/metadata.py @@ -9,6 +9,8 @@ from .exceptions import DatabaseException, ErrorException, NotFoundException from .record import BrickRecord from .sql import BrickSQL if TYPE_CHECKING: + from .individual_minifigure import IndividualMinifigure + from .individual_part import IndividualPart from .set import BrickSet logger = logging.getLogger(__name__) @@ -106,6 +108,26 @@ class BrickMetadata(BrickRecord): metadata_id=self.fields.id ) + # URL to change the selected state of this metadata item for an individual part + def url_for_individual_part_state(self, part_id: str, /) -> str: + # Replace 'set' with 'individual_part' in the endpoint name + endpoint = self.set_state_endpoint.replace('set.', 'individual_part.') + return url_for( + endpoint, + id=part_id, + metadata_id=self.fields.id + ) + + # URL to change the selected state of this metadata item for an individual minifigure + def url_for_individual_minifigure_state(self, minifigure_id: str, /) -> str: + # Replace 'set' with 'individual_minifigure' in the endpoint name + endpoint = self.set_state_endpoint.replace('set.', 'individual_minifigure.') + return url_for( + endpoint, + id=minifigure_id, + metadata_id=self.fields.id + ) + # Select a specific metadata (with an id) def select_specific(self, id: str, /) -> Self: # Save the parameters to the fields @@ -270,3 +292,156 @@ class BrickMetadata(BrickRecord): )) return value + + # Update the selected state of this metadata item for an individual part + def update_individual_part_state( + self, + individual_part: 'IndividualPart', + /, + *, + json: Any | None = None, + state: Any | None = None, + commit: bool = True + ) -> Any: + if state is None and json is not None: + state = json.get('value', False) + + parameters = self.sql_parameters() + parameters['set_id'] = individual_part.fields.id # set_id parameter accepts any entity id + parameters['state'] = state + + # Use the same set query (bricktracker_set_owners/tags/statuses tables accept any entity id) + query_name = self.update_set_state_query + + if commit: + rows, _ = BrickSQL().execute_and_commit( + query_name, + parameters=parameters, + name=self.as_column(), + ) + else: + rows, _ = BrickSQL().execute( + query_name, + parameters=parameters, + defer=True, + name=self.as_column(), + ) + + # When deferred, rows will be -1, so skip the check + if commit and rows != 1: + raise DatabaseException('Could not update the {kind} state for individual part {part_id}'.format( + kind=self.kind, + part_id=individual_part.fields.id, + )) + + # Info + logger.info('{kind} "{name}" state changed to "{state}" for individual part {part_id}'.format( + kind=self.kind, + name=self.fields.name, + state=state, + part_id=individual_part.fields.id, + )) + + return state + + # Update the selected state of this metadata item for an individual minifigure + def update_individual_minifigure_state( + self, + individual_minifigure: 'IndividualMinifigure', + /, + *, + json: Any | None = None, + state: Any | None = None, + commit: bool = True + ) -> Any: + if state is None and json is not None: + state = json.get('value', False) + + parameters = self.sql_parameters() + parameters['set_id'] = individual_minifigure.fields.id # set_id parameter accepts any entity id + parameters['state'] = state + + # Use the same set query (bricktracker_set_owners/tags/statuses tables accept any entity id) + query_name = self.update_set_state_query + + if commit: + rows, _ = BrickSQL().execute_and_commit( + query_name, + parameters=parameters, + name=self.as_column(), + ) + else: + rows, _ = BrickSQL().execute( + query_name, + parameters=parameters, + defer=True, + name=self.as_column(), + ) + + # When deferred, rows will be -1, so skip the check + if commit and rows != 1: + raise DatabaseException('Could not update the {kind} state for individual minifigure {minifigure_id}'.format( + kind=self.kind, + minifigure_id=individual_minifigure.fields.id, + )) + + # Info + logger.info('{kind} "{name}" state changed to "{state}" for individual minifigure {minifigure_id}'.format( + kind=self.kind, + name=self.fields.name, + state=state, + minifigure_id=individual_minifigure.fields.id, + )) + + return state + + # Update the selected state of this metadata item for an individual part lot + def update_individual_part_lot_state( + self, + individual_part_lot: 'IndividualPartLot', + /, + *, + json: Any | None = None, + state: Any | None = None, + commit: bool = True + ) -> Any: + if state is None and json is not None: + state = json.get('value', False) + + parameters = self.sql_parameters() + parameters['set_id'] = individual_part_lot.fields.id # set_id parameter accepts any entity id + parameters['state'] = state + + # Use the same set query (bricktracker_set_owners/tags tables accept any entity id) + query_name = self.update_set_state_query + + if commit: + rows, _ = BrickSQL().execute_and_commit( + query_name, + parameters=parameters, + name=self.as_column(), + ) + else: + rows, _ = BrickSQL().execute( + query_name, + parameters=parameters, + defer=True, + name=self.as_column(), + ) + + # When deferred, rows will be -1, so skip the check + if commit and rows != 1: + raise DatabaseException('Could not update the {kind} state for individual part lot {lot_id}'.format( + kind=self.kind, + lot_id=individual_part_lot.fields.id, + )) + + # Info + logger.info('{kind} "{name}" state changed to "{state}" for individual part lot {lot_id}'.format( + kind=self.kind, + name=self.fields.name, + state=state, + lot_id=individual_part_lot.fields.id, + )) + + return state diff --git a/bricktracker/metadata_list.py b/bricktracker/metadata_list.py index aa2eb67..197fb34 100644 --- a/bricktracker/metadata_list.py +++ b/bricktracker/metadata_list.py @@ -184,3 +184,23 @@ class BrickMetadataList(BrickRecordList[T]): cls.set_value_endpoint, id=id, ) + + # URL to change the selected value of this metadata item for an individual part + @classmethod + def url_for_individual_part_value(cls, part_id: str, /) -> str: + # Replace 'set' with 'individual_part' in the endpoint name + endpoint = cls.set_value_endpoint.replace('set.', 'individual_part.') + return url_for( + endpoint, + id=part_id, + ) + + # URL to change the selected value of this metadata item for an individual minifigure + @classmethod + def url_for_individual_minifigure_value(cls, minifigure_id: str, /) -> str: + # Replace 'set' with 'individual_minifigure' in the endpoint name + endpoint = cls.set_value_endpoint.replace('set.', 'individual_minifigure.') + return url_for( + endpoint, + id=minifigure_id, + ) diff --git a/bricktracker/record.py b/bricktracker/record.py index f7cc889..571fb81 100644 --- a/bricktracker/record.py +++ b/bricktracker/record.py @@ -1,3 +1,4 @@ +from datetime import datetime from sqlite3 import Row from typing import Any, ItemsView @@ -5,6 +6,26 @@ from .fields import BrickRecordFields from .sql import BrickSQL +def format_timestamp(timestamp: float | str | None, format_key: str = 'PURCHASE_DATE_FORMAT') -> str: + if timestamp is not None: + from flask import current_app + + # Handle legacy string dates stored in database (convert to numeric timestamp) + if isinstance(timestamp, str): + try: + # Try parsing as date string first + time = datetime.strptime(timestamp, '%Y/%m/%d') + except ValueError: + # If that fails, return the string as-is (shouldn't happen but safe fallback) + return timestamp + else: + # Normal case: numeric timestamp + time = datetime.fromtimestamp(timestamp) + + return time.strftime(current_app.config.get(format_key, '%Y/%m/%d')) + return '' + + # SQLite record class BrickRecord(object): select_query: str From fa053055a3dc53ca7efc7f495fdcafcdbdc25162 Mon Sep 17 00:00:00 2001 From: FrederikBaerentsen Date: Mon, 19 Jan 2026 17:19:21 +0100 Subject: [PATCH 31/63] feat(views): update existing models to support individual items integration --- bricktracker/minifigure_list.py | 22 +++- bricktracker/parser.py | 30 ++++- bricktracker/part.py | 169 +++++++++++++++++++++---- bricktracker/part_list.py | 38 +++++- bricktracker/rebrickable_minifigure.py | 15 ++- bricktracker/rebrickable_part.py | 5 +- bricktracker/rebrickable_set.py | 12 ++ bricktracker/set_list.py | 11 ++ bricktracker/set_purchase_location.py | 9 ++ bricktracker/socket.py | 70 ++++++++++ bricktracker/statistics.py | 13 +- 11 files changed, 343 insertions(+), 51 deletions(-) diff --git a/bricktracker/minifigure_list.py b/bricktracker/minifigure_list.py index 35bafad..287b133 100644 --- a/bricktracker/minifigure_list.py +++ b/bricktracker/minifigure_list.py @@ -20,8 +20,8 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]): order: str # Queries - all_query: str = 'minifigure/list/all' - all_by_owner_query: str = 'minifigure/list/all_by_owner' + all_query: str = 'minifigure/list/all_unified' + all_by_owner_query: str = 'minifigure/list/all_by_owner_unified' damaged_part_query: str = 'minifigure/list/damaged_part' last_query: str = 'minifigure/list/last' missing_part_query: str = 'minifigure/list/missing_part' @@ -44,7 +44,7 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]): return self # Load all minifigures with problems filter - def all_filtered(self, /, owner_id: str | None = None, problems_filter: str = 'all', theme_id: str = 'all', year: str = 'all') -> Self: + def all_filtered(self, /, owner_id: str | None = None, problems_filter: str = 'all', theme_id: str = 'all', year: str = 'all', individuals_filter: str = 'all') -> Self: # Save the owner_id parameter if owner_id is not None: self.fields.owner_id = owner_id @@ -56,6 +56,8 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]): context['theme_id'] = theme_id if year and year != 'all': context['year'] = year + if individuals_filter and individuals_filter != 'all': + context['individuals_filter'] = individuals_filter # Choose query based on whether owner filtering is needed if owner_id and owner_id != 'all': @@ -77,7 +79,7 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]): return self # Load all minifigures by owner with problems filter - def all_by_owner_filtered(self, /, owner_id: str | None = None, problems_filter: str = 'all', theme_id: str = 'all', year: str = 'all') -> Self: + def all_by_owner_filtered(self, /, owner_id: str | None = None, problems_filter: str = 'all', theme_id: str = 'all', year: str = 'all', individuals_filter: str = 'all') -> Self: # Save the owner_id parameter self.fields.owner_id = owner_id @@ -88,6 +90,8 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]): context['theme_id'] = theme_id if year and year != 'all': context['year'] = year + if individuals_filter and individuals_filter != 'all': + context['individuals_filter'] = individuals_filter # Load the minifigures from the database self.list(override_query=self.all_by_owner_query, **context) @@ -101,6 +105,7 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]): problems_filter: str = 'all', theme_id: str = 'all', year: str = 'all', + individuals_filter: str = 'all', search_query: str | None = None, page: int = 1, per_page: int = 50, @@ -127,10 +132,13 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]): if year and year != 'all': filter_context['year'] = year - # Field mapping for sorting + if individuals_filter and individuals_filter != 'all': + filter_context['individuals_filter'] = individuals_filter + + # Field mapping for sorting (using column names from the unified query) field_mapping = { - 'name': '"rebrickable_minifigures"."name"', - 'parts': '"rebrickable_minifigures"."number_of_parts"', + 'name': '"name"', + 'parts': '"number_of_parts"', 'quantity': '"total_quantity"', 'missing': '"total_missing"', 'damaged': '"total_damaged"', diff --git a/bricktracker/parser.py b/bricktracker/parser.py index 5764bfe..c1444f4 100644 --- a/bricktracker/parser.py +++ b/bricktracker/parser.py @@ -17,7 +17,7 @@ def parse_set(set: str, /) -> str: if version == '': version = '1' - # Version must be a positive integer + # Version must be a valid number (but preserve leading zeros for minifigures) try: version_int = int(version) except Exception: @@ -30,4 +30,30 @@ def parse_set(set: str, /) -> str: version=version, )) - return '{number}-{version}'.format(number=number, version=version_int) + # Preserve original version string to keep leading zeros (important for minifigures like fig-000484) + return '{number}-{version}'.format(number=number, version=version) + + +# Make sense of string supposed to contain a minifigure ID +def parse_minifig(figure: str, /) -> str: + # Minifigure format is typically fig-XXXXXX + # We'll accept with or without the 'fig-' prefix + figure = figure.strip() + + if not figure.startswith('fig-'): + # Try to add the prefix if it's just numbers + if figure.isdigit(): + figure = 'fig-{figure}'.format(figure=figure.zfill(6)) + else: + raise ErrorException('Minifigure "{figure}" must start with "fig-"'.format( + figure=figure, + )) + + # Validate format: fig-XXXXXX where X can be digits or letters + parts = figure.split('-') + if len(parts) != 2 or parts[0] != 'fig': + raise ErrorException('Invalid minifigure format "{figure}". Expected format: fig-XXXXXX'.format( + figure=figure, + )) + + return figure diff --git a/bricktracker/part.py b/bricktracker/part.py index ed60714..ff9d56a 100644 --- a/bricktracker/part.py +++ b/bricktracker/part.py @@ -9,6 +9,7 @@ from .exceptions import ErrorException, NotFoundException from .rebrickable_part import RebrickablePart from .sql import BrickSQL if TYPE_CHECKING: + from .individual_minifigure import IndividualMinifigure from .minifigure import BrickMinifigure from .set import BrickSet from .socket import BrickSocket @@ -33,6 +34,7 @@ class BrickPart(RebrickablePart): *, brickset: 'BrickSet | None' = None, minifigure: 'BrickMinifigure | None' = None, + individual_minifigure: 'IndividualMinifigure | None' = None, record: Row | dict[str, Any] | None = None ): super().__init__( @@ -41,7 +43,12 @@ class BrickPart(RebrickablePart): record=record ) - if self.minifigure is not None: + self.individual_minifigure = individual_minifigure + + if self.individual_minifigure is not None: + self.identifier = self.individual_minifigure.fields.id + self.kind = 'Individual Minifigure' + elif self.minifigure is not None: self.identifier = self.minifigure.fields.figure self.kind = 'Minifigure' elif self.brickset is not None: @@ -182,6 +189,33 @@ class BrickPart(RebrickablePart): return self + # Select a specific part from an individual minifigure instance + def select_specific_individual_minifigure( + self, + individual_minifigure: 'IndividualMinifigure', + part: str, + color: int, + spare: int, + /, + ) -> Self: + # Save the parameters to the fields + self.individual_minifigure = individual_minifigure + self.fields.part = part + self.fields.color = color + self.fields.spare = spare + + if not self.select(override_query='individual_minifigure/part/select/specific'): + raise NotFoundException( + 'Part {part} with color {color} (spare: {spare}) from individual minifigure {id} was not found in the database'.format( # noqa: E501 + part=self.fields.part, + color=self.fields.color, + spare=self.fields.spare, + id=individual_minifigure.fields.id, + ), + ) + + return self + # Update checked state for part walkthrough def update_checked(self, json: Any | None, /) -> bool: # Handle both direct 'checked' key and changer.js 'value' key format @@ -202,22 +236,56 @@ class BrickPart(RebrickablePart): return checked + # Update checked state for individual minifigure part + def update_checked_individual_minifigure(self, json: Any | None, /) -> bool: + # Handle both direct 'checked' key and changer.js 'value' key format + if json: + checked = json.get('checked', json.get('value', False)) + else: + checked = False + + checked = bool(checked) + + self.fields.checked = checked + + BrickSQL().execute_and_commit( + 'individual_minifigure/part/update/checked', + parameters=self.sql_parameters() + ) + + return checked + # Compute the url for updating checked state def url_for_checked(self, /) -> str: - # Different URL for a minifigure part - if self.minifigure is not None: - figure = self.minifigure.fields.figure + # Different URL for individual minifigure part + if self.individual_minifigure is not None: + return url_for( + 'individual_minifigure.checked_part', + id=self.individual_minifigure.fields.id, + part=self.fields.part, + color=self.fields.color, + spare=self.fields.spare, + ) + # Different URL for a set minifigure part + elif self.minifigure is not None: + return url_for( + 'set.checked_part', + id=self.fields.id, + figure=self.minifigure.fields.figure, + part=self.fields.part, + color=self.fields.color, + spare=self.fields.spare, + ) + # Set part else: - figure = None - - return url_for( - 'set.checked_part', - id=self.fields.id, - figure=figure, - part=self.fields.part, - color=self.fields.color, - spare=self.fields.spare, - ) + return url_for( + 'set.checked_part', + id=self.fields.id, + figure=None, + part=self.fields.part, + color=self.fields.color, + spare=self.fields.spare, + ) # Update a problematic part def update_problem(self, problem: str, json: Any | None, /) -> int: @@ -249,20 +317,67 @@ class BrickPart(RebrickablePart): return amount + # Update a problematic part for individual minifigure + def update_problem_individual_minifigure(self, problem: str, json: Any | None, /) -> int: + amount: str | int = json.get('value', '') # type: ignore + + # We need a positive integer + try: + if amount == '': + amount = 0 + + amount = int(amount) + + if amount < 0: + amount = 0 + except Exception: + raise ErrorException('"{amount}" is not a valid integer'.format( + amount=amount + )) + + if amount < 0: + raise ErrorException('Cannot set a negative amount') + + setattr(self.fields, problem, amount) + + BrickSQL().execute_and_commit( + 'individual_minifigure/part/update/{problem}'.format(problem=problem), + parameters=self.sql_parameters() + ) + + return amount + # Compute the url for problematic part def url_for_problem(self, problem: str, /) -> str: - # Different URL for a minifigure part - if self.minifigure is not None: - figure = self.minifigure.fields.figure + # Different URL for individual minifigure part + if self.individual_minifigure is not None: + return url_for( + 'individual_minifigure.problem_part', + id=self.individual_minifigure.fields.id, + part=self.fields.part, + color=self.fields.color, + spare=self.fields.spare, + problem=problem, + ) + # Different URL for set minifigure part + elif self.minifigure is not None: + return url_for( + 'set.problem_part', + id=self.fields.id, + figure=self.minifigure.fields.figure, + part=self.fields.part, + color=self.fields.color, + spare=self.fields.spare, + problem=problem, + ) + # Set part else: - figure = None - - return url_for( - 'set.problem_part', - id=self.fields.id, - figure=figure, - part=self.fields.part, - color=self.fields.color, - spare=self.fields.spare, - problem=problem, + return url_for( + 'set.problem_part', + id=self.fields.id, + figure=None, + part=self.fields.part, + color=self.fields.color, + spare=self.fields.spare, + problem=problem, ) diff --git a/bricktracker/part_list.py b/bricktracker/part_list.py index 28f971d..32e738e 100644 --- a/bricktracker/part_list.py +++ b/bricktracker/part_list.py @@ -19,6 +19,7 @@ logger = logging.getLogger(__name__) class BrickPartList(BrickRecordList[BrickPart]): brickset: 'BrickSet | None' minifigure: 'BrickMinifigure | None' + individual_minifigure: 'IndividualMinifigure | None' order: str # Queries @@ -57,8 +58,8 @@ class BrickPartList(BrickRecordList[BrickPart]): return self - # Load all parts with filters (owner, color, theme, year) - def all_filtered(self, owner_id: str | None = None, color_id: str | None = None, theme_id: str | None = None, year: str | None = None, /) -> Self: + # Load all parts with filters (owner, color, theme, year, individuals) + def all_filtered(self, owner_id: str | None = None, color_id: str | None = None, theme_id: str | None = None, year: str | None = None, individuals_filter: str | None = None, /) -> Self: # Save the filter parameters if owner_id is not None: self.fields.owner_id = owner_id @@ -80,6 +81,8 @@ class BrickPartList(BrickRecordList[BrickPart]): context['theme_id'] = theme_id if year and year != 'all': context['year'] = year + if individuals_filter and individuals_filter == 'only': + context['individuals_filter'] = True # Load the parts from the database self.list(override_query=query, **context) @@ -93,6 +96,7 @@ class BrickPartList(BrickRecordList[BrickPart]): color_id: str | None = None, theme_id: str | None = None, year: str | None = None, + individuals_filter: str | None = None, search_query: str | None = None, page: int = 1, per_page: int = 50, @@ -113,6 +117,8 @@ class BrickPartList(BrickRecordList[BrickPart]): filter_context['theme_id'] = theme_id if year and year != 'all': filter_context['year'] = year + if individuals_filter and individuals_filter == 'only': + filter_context['individuals_filter'] = True if search_query: filter_context['search_query'] = search_query # Hide spare parts from display if configured @@ -165,6 +171,11 @@ class BrickPartList(BrickRecordList[BrickPart]): else: minifigure = None + if hasattr(self, 'individual_minifigure'): + individual_minifigure = self.individual_minifigure + else: + individual_minifigure = None + # Prepare template context for filtering context_vars = {} if hasattr(self.fields, 'owner_id') and self.fields.owner_id is not None: @@ -188,6 +199,7 @@ class BrickPartList(BrickRecordList[BrickPart]): part = BrickPart( brickset=brickset, minifigure=minifigure, + individual_minifigure=individual_minifigure, record=record, ) @@ -234,6 +246,24 @@ class BrickPartList(BrickRecordList[BrickPart]): return self + # Load parts from an individual minifigure instance + def from_individual_minifigure( + self, + individual_minifigure: 'IndividualMinifigure', + /, + ) -> Self: + from .individual_minifigure import IndividualMinifigure + + # Save the individual minifigure reference + self.individual_minifigure = individual_minifigure + + # Load the parts for this individual minifigure instance + self.list( + override_query='individual_minifigure/part/list/from_instance' + ) + + return self + # Load generic parts from a print def from_print( self, @@ -369,6 +399,10 @@ class BrickPartList(BrickRecordList[BrickPart]): if self.brickset is not None: parameters['id'] = self.brickset.fields.id + # Use the individual minifigure ID if present + if hasattr(self, 'individual_minifigure') and self.individual_minifigure is not None: + parameters['id'] = self.individual_minifigure.fields.id + # Use the minifigure number if present, if self.minifigure is not None: parameters['figure'] = self.minifigure.fields.figure diff --git a/bricktracker/rebrickable_minifigure.py b/bricktracker/rebrickable_minifigure.py index 0ef7d43..c6f11df 100644 --- a/bricktracker/rebrickable_minifigure.py +++ b/bricktracker/rebrickable_minifigure.py @@ -14,7 +14,6 @@ if TYPE_CHECKING: class RebrickableMinifigure(BrickRecord): brickset: 'BrickSet | None' - # Queries select_query: str = 'rebrickable/minifigure/select' insert_query: str = 'rebrickable/minifigure/insert' @@ -27,10 +26,8 @@ class RebrickableMinifigure(BrickRecord): ): super().__init__() - # Save the brickset self.brickset = brickset - # Ingest the record if it has one if record is not None: self.ingest(record) @@ -62,7 +59,6 @@ class RebrickableMinifigure(BrickRecord): return parameters - # Self url def url(self, /) -> str: return url_for( 'minifigure.details', @@ -89,17 +85,24 @@ class RebrickableMinifigure(BrickRecord): if current_app.config['REBRICKABLE_LINKS']: try: return current_app.config['REBRICKABLE_LINK_MINIFIGURE_PATTERN'].format( # noqa: E501 - number=self.fields.figure, + figure=self.fields.figure, ) except Exception: pass return '' + # Compute the url for the bricklink page + # Note: BrickLink uses different minifigure IDs than Rebrickable (e.g., 'adv010' vs 'fig-000359') + # Rebrickable API doesn't provide BrickLink minifigure IDs, so we can't generate valid links + def url_for_bricklink(self, /) -> str: + # BrickLink links disabled for minifigures - no ID mapping available + # Left function for later, if I find a way to implement it. + return '' + # Normalize from Rebrickable @staticmethod def from_rebrickable(data: dict[str, Any], /, **_) -> dict[str, Any]: - # Extracting number number = int(str(data['set_num'])[5:]) return { diff --git a/bricktracker/rebrickable_part.py b/bricktracker/rebrickable_part.py index 09a3761..90974f0 100644 --- a/bricktracker/rebrickable_part.py +++ b/bricktracker/rebrickable_part.py @@ -67,8 +67,11 @@ class RebrickablePart(BrickRecord): def sql_parameters(self, /) -> dict[str, Any]: parameters = super().sql_parameters() + # Individual minifigure id takes precedence + if hasattr(self, 'individual_minifigure') and self.individual_minifigure is not None: + parameters['id'] = self.individual_minifigure.fields.id # Set id - if self.brickset is not None: + elif self.brickset is not None: parameters['id'] = self.brickset.fields.id # Use the minifigure number if present, diff --git a/bricktracker/rebrickable_set.py b/bricktracker/rebrickable_set.py index f98785f..da0bd6b 100644 --- a/bricktracker/rebrickable_set.py +++ b/bricktracker/rebrickable_set.py @@ -95,6 +95,18 @@ class RebrickableSet(BrickRecord): socket.auto_progress(message='Parsing set number') set = parse_set(str(data['set'])) + # Check if this is actually a minifigure (starts with fig-) + # If so, redirect to the minifigure handler + if set.startswith('fig-'): + from .individual_minifigure import IndividualMinifigure + # Transform data: minifigure handler expects 'figure' key instead of 'set' + minifig_data = data.copy() + minifig_data['figure'] = minifig_data.pop('set') + if from_download: + return IndividualMinifigure().download(socket, minifig_data) + else: + return IndividualMinifigure().load(socket, minifig_data) + socket.auto_progress( message='Set {set}: loading from Rebrickable'.format( set=set, diff --git a/bricktracker/set_list.py b/bricktracker/set_list.py index 9f23494..a61b295 100644 --- a/bricktracker/set_list.py +++ b/bricktracker/set_list.py @@ -36,6 +36,7 @@ class BrickSetList(BrickRecordList[BrickSet]): using_minifigure_query: str = 'set/list/using_minifigure' using_part_query: str = 'set/list/using_part' using_storage_query: str = 'set/list/using_storage' + using_purchase_location_query: str = 'set/list/using_purchase_location' def __init__(self, /): super().__init__() @@ -678,6 +679,16 @@ class BrickSetList(BrickRecordList[BrickSet]): return self + # Sets using a purchase location + def using_purchase_location(self, purchase_location: BrickSetPurchaseLocation, /) -> Self: + # Save the parameters to the fields + self.fields.purchase_location = purchase_location.fields.id + + # Load the sets from the database + self.list(override_query=self.using_purchase_location_query) + + return self + # Helper to build the metadata lists def set_metadata_lists( diff --git a/bricktracker/set_purchase_location.py b/bricktracker/set_purchase_location.py index 801ccf8..f32e52b 100644 --- a/bricktracker/set_purchase_location.py +++ b/bricktracker/set_purchase_location.py @@ -1,5 +1,7 @@ from .metadata import BrickMetadata +from flask import url_for + # Lego set purchase location metadata class BrickSetPurchaseLocation(BrickMetadata): @@ -11,3 +13,10 @@ class BrickSetPurchaseLocation(BrickMetadata): select_query: str = 'set/metadata/purchase_location/select' update_field_query: str = 'set/metadata/purchase_location/update/field' update_set_value_query: str = 'set/metadata/purchase_location/update/value' + + # Self url + def url(self, /) -> str: + return url_for( + 'purchase_location.details', + id=self.fields.id, + ) diff --git a/bricktracker/socket.py b/bricktracker/socket.py index 1c13b6e..a123912 100644 --- a/bricktracker/socket.py +++ b/bricktracker/socket.py @@ -18,13 +18,22 @@ logger = logging.getLogger(__name__) MESSAGES: Final[dict[str, str]] = { 'COMPLETE': 'complete', 'CONNECT': 'connect', + 'CREATE_LOT': 'create_lot', + 'CREATE_BULK_INDIVIDUAL_PARTS': 'create_bulk_individual_parts', 'DISCONNECT': 'disconnect', 'DOWNLOAD_INSTRUCTIONS': 'download_instructions', 'DOWNLOAD_PEERON_PAGES': 'download_peeron_pages', 'FAIL': 'fail', + 'IMPORT_MINIFIGURE': 'import_minifigure', 'IMPORT_SET': 'import_set', + 'LOAD_MINIFIGURE': 'load_minifigure', + 'LOAD_PART': 'load_part', + 'LOAD_PART_COLORS': 'load_part_colors', 'LOAD_PEERON_PAGES': 'load_peeron_pages', 'LOAD_SET': 'load_set', + 'MINIFIGURE_LOADED': 'minifigure_loaded', + 'PART_COLORS_LOADED': 'part_colors_loaded', + 'PART_LOADED': 'part_loaded', 'PROGRESS': 'progress', 'SET_LOADED': 'set_loaded', } @@ -228,6 +237,67 @@ class BrickSocket(object): BrickSet().load(self, data) + @self.socket.on(MESSAGES['IMPORT_MINIFIGURE'], namespace=self.namespace) + @rebrickable_socket(self) + def import_minifigure(data: dict[str, Any], /) -> None: + logger.debug('Socket: IMPORT_MINIFIGURE={data} (from: {fr})'.format( + data=data, + fr=request.sid, # type: ignore + )) + + from .individual_minifigure import IndividualMinifigure + IndividualMinifigure().download(self, data) + + @self.socket.on(MESSAGES['LOAD_MINIFIGURE'], namespace=self.namespace) + def load_minifigure(data: dict[str, Any], /) -> None: + logger.debug('Socket: LOAD_MINIFIGURE={data} (from: {fr})'.format( + data=data, + fr=request.sid, # type: ignore + )) + + from .individual_minifigure import IndividualMinifigure + IndividualMinifigure().load(self, data) + + @self.socket.on(MESSAGES['LOAD_PART'], namespace=self.namespace) + def load_part(data: dict[str, Any], /) -> None: + logger.debug('Socket: LOAD_PART={data} (from: {fr})'.format( + data=data, + fr=request.sid, # type: ignore + )) + + from .individual_part import IndividualPart + IndividualPart().add(self, data) + + @self.socket.on(MESSAGES['LOAD_PART_COLORS'], namespace=self.namespace) + def load_part_colors(data: dict[str, Any], /) -> None: + logger.debug('Socket: LOAD_PART_COLORS={data} (from: {fr})'.format( + data=data, + fr=request.sid, # type: ignore + )) + + from .individual_part import IndividualPart + IndividualPart().load_colors(self, data) + + @self.socket.on(MESSAGES['CREATE_LOT'], namespace=self.namespace) + @rebrickable_socket(self) + def create_lot(data: dict[str, Any], /) -> None: + logger.debug('Socket: CREATE_LOT (from: {fr})'.format( + fr=request.sid, # type: ignore + )) + + from .individual_part_lot import IndividualPartLot + IndividualPartLot().create(self, data) + + @self.socket.on(MESSAGES['CREATE_BULK_INDIVIDUAL_PARTS'], namespace=self.namespace) + @rebrickable_socket(self) + def create_bulk_individual_parts(data: dict[str, Any], /) -> None: + logger.debug('Socket: CREATE_BULK_INDIVIDUAL_PARTS (from: {fr})'.format( + fr=request.sid, # type: ignore + )) + + from .individual_part import IndividualPart + IndividualPart().create_bulk(self, data) + # Update the progress auto-incrementing def auto_progress( self, diff --git a/bricktracker/statistics.py b/bricktracker/statistics.py index e446205..fe7baf7 100644 --- a/bricktracker/statistics.py +++ b/bricktracker/statistics.py @@ -53,17 +53,18 @@ class BrickStatistics: return [dict(row) for row in results] def get_financial_summary(self) -> dict[str, Any]: - """Get financial summary from overview statistics""" + """Get financial summary from overview statistics (includes all item types)""" overview = self.get_overview() return { - 'total_cost': overview.get('total_cost') or 0, - 'average_cost': overview.get('average_cost') or 0, - 'minimum_cost': overview.get('minimum_cost') or 0, - 'maximum_cost': overview.get('maximum_cost') or 0, + 'total_cost': overview.get('combined_total_cost') or 0, + 'average_cost': overview.get('combined_average_cost') or 0, + 'minimum_cost': overview.get('combined_minimum_cost') or 0, + 'maximum_cost': overview.get('combined_maximum_cost') or 0, + 'items_with_price': overview.get('total_items_with_price') or 0, 'sets_with_price': overview.get('sets_with_price') or 0, 'total_sets': overview.get('total_sets') or 0, 'percentage_with_price': round( - ((overview.get('sets_with_price') or 0) / max((overview.get('total_sets') or 0), 1)) * 100, 1 + ((overview.get('total_items_with_price') or 0) / max((overview.get('total_sets') or 0), 1)) * 100, 1 ) } From be3ac284f4e79a8922aa4ffe42424b289420d916 Mon Sep 17 00:00:00 2001 From: FrederikBaerentsen Date: Mon, 19 Jan 2026 17:23:01 +0100 Subject: [PATCH 32/63] feat(sql): update queries to support individual items and fix schema drop order --- bricktracker/sql/minifigure/base/base.sql | 6 +- .../sql/minifigure/select/generic.sql | 13 +- bricktracker/sql/part/base/base.sql | 81 ++++-- bricktracker/sql/part/colors/list.sql | 54 +++- bricktracker/sql/part/list/all.sql | 57 ++-- bricktracker/sql/part/list/all_by_owner.sql | 111 ++++++-- .../sql/part/list/from_minifigure.sql | 104 +++++-- bricktracker/sql/part/list/from_print.sql | 8 +- bricktracker/sql/part/list/last.sql | 12 +- bricktracker/sql/part/list/problem.sql | 106 ++++--- bricktracker/sql/part/list/specific.sql | 13 +- .../sql/part/list/with_different_color.sql | 8 +- bricktracker/sql/part/select/generic.sql | 39 ++- bricktracker/sql/part/select/specific.sql | 20 +- bricktracker/sql/schema/drop.sql | 53 +++- .../sql/set/metadata/owner/insert.sql | 3 +- .../set/metadata/purchase_location/base.sql | 9 +- .../sql/set/metadata/status/insert.sql | 2 + bricktracker/sql/set/metadata/tag/insert.sql | 1 + bricktracker/sql/statistics/overview.sql | 149 +++++++++- .../sql/statistics/purchase_locations.sql | 209 +++++++++++--- .../sql/statistics/purchases_by_year.sql | 263 +++++++++++++++--- bricktracker/sql/statistics/storage.sql | 160 ++++++++--- 23 files changed, 1140 insertions(+), 341 deletions(-) diff --git a/bricktracker/sql/minifigure/base/base.sql b/bricktracker/sql/minifigure/base/base.sql index 76b816e..f0844d6 100644 --- a/bricktracker/sql/minifigure/base/base.sql +++ b/bricktracker/sql/minifigure/base/base.sql @@ -17,10 +17,10 @@ SELECT {% block total_sets %} NULL AS "total_sets" -- dummy for order: total_sets {% endblock %} -FROM "bricktracker_minifigures" +FROM "rebrickable_minifigures" -INNER JOIN "rebrickable_minifigures" -ON "bricktracker_minifigures"."figure" IS NOT DISTINCT FROM "rebrickable_minifigures"."figure" +LEFT JOIN "bricktracker_minifigures" +ON "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure" {% block join %}{% endblock %} diff --git a/bricktracker/sql/minifigure/select/generic.sql b/bricktracker/sql/minifigure/select/generic.sql index 6301550..083e8a2 100644 --- a/bricktracker/sql/minifigure/select/generic.sql +++ b/bricktracker/sql/minifigure/select/generic.sql @@ -9,7 +9,7 @@ IFNULL("problem_join"."total_damaged", 0) AS "total_damaged", {% endblock %} {% block total_quantity %} -SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_quantity", +SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) + SUM(IFNULL("individual_minifigures_join"."quantity", 0)) AS "total_quantity", {% endblock %} {% block total_sets %} @@ -28,6 +28,17 @@ LEFT JOIN ( GROUP BY "bricktracker_parts"."figure" ) "problem_join" ON "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "problem_join"."figure" + +-- LEFT JOIN to include individual minifigure instances (not in sets) +LEFT JOIN ( + SELECT + "bricktracker_individual_minifigures"."figure", + SUM("bricktracker_individual_minifigures"."quantity") AS "quantity" + FROM "bricktracker_individual_minifigures" + WHERE "bricktracker_individual_minifigures"."figure" IS NOT DISTINCT FROM :figure + GROUP BY "bricktracker_individual_minifigures"."figure" +) "individual_minifigures_join" +ON "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "individual_minifigures_join"."figure" {% endblock %} {% block where %} diff --git a/bricktracker/sql/part/base/base.sql b/bricktracker/sql/part/base/base.sql index 890cf20..0fc5269 100644 --- a/bricktracker/sql/part/base/base.sql +++ b/bricktracker/sql/part/base/base.sql @@ -1,17 +1,14 @@ SELECT - "bricktracker_parts"."id", - "bricktracker_parts"."figure", - "bricktracker_parts"."part", - "bricktracker_parts"."color", - "bricktracker_parts"."spare", - "bricktracker_parts"."quantity", - "bricktracker_parts"."element", - --"bricktracker_parts"."rebrickable_inventory", - "bricktracker_parts"."missing", - "bricktracker_parts"."damaged", - "bricktracker_parts"."checked", - --"rebrickable_parts"."part", - --"rebrickable_parts"."color_id", + "combined"."id", + "combined"."figure", + "combined"."part", + "combined"."color", + "combined"."spare", + "combined"."quantity", + "combined"."element", + "combined"."missing", + "combined"."damaged", + "combined"."checked", "rebrickable_parts"."color_name", "rebrickable_parts"."color_rgb", "rebrickable_parts"."color_transparent", @@ -19,7 +16,6 @@ SELECT "rebrickable_parts"."bricklink_color_name", "rebrickable_parts"."bricklink_part_num", "rebrickable_parts"."name", - --"rebrickable_parts"."category", "rebrickable_parts"."image", "rebrickable_parts"."image_id", "rebrickable_parts"."url", @@ -42,11 +38,62 @@ SELECT {% block total_minifigures %} NULL AS "total_minifigures" -- dummy for order: total_minifigures {% endblock %} -FROM "bricktracker_parts" +FROM ( + -- Parts from set-based minifigures + SELECT + "bricktracker_parts"."id", + "bricktracker_parts"."figure", + "bricktracker_parts"."part", + "bricktracker_parts"."color", + "bricktracker_parts"."spare", + "bricktracker_parts"."quantity", + "bricktracker_parts"."element", + "bricktracker_parts"."missing", + "bricktracker_parts"."damaged", + "bricktracker_parts"."checked", + 'set' AS "source_type" + FROM "bricktracker_parts" + + UNION ALL + + -- Parts from individual minifigures + SELECT + "bricktracker_individual_minifigure_parts"."id", + "bricktracker_individual_minifigures"."figure", + "bricktracker_individual_minifigure_parts"."part", + "bricktracker_individual_minifigure_parts"."color", + "bricktracker_individual_minifigure_parts"."spare", + "bricktracker_individual_minifigure_parts"."quantity", + "bricktracker_individual_minifigure_parts"."element", + "bricktracker_individual_minifigure_parts"."missing", + "bricktracker_individual_minifigure_parts"."damaged", + "bricktracker_individual_minifigure_parts"."checked", + 'individual_minifigure' AS "source_type" + FROM "bricktracker_individual_minifigure_parts" + INNER JOIN "bricktracker_individual_minifigures" + ON "bricktracker_individual_minifigure_parts"."id" = "bricktracker_individual_minifigures"."id" + + UNION ALL + + -- Individual/standalone parts (not from any set or minifigure) + SELECT + "bricktracker_individual_parts"."id", + NULL AS "figure", + "bricktracker_individual_parts"."part", + "bricktracker_individual_parts"."color", + 0 AS "spare", + "bricktracker_individual_parts"."quantity", + NULL AS "element", + "bricktracker_individual_parts"."missing", + "bricktracker_individual_parts"."damaged", + "bricktracker_individual_parts"."checked", + 'individual_part' AS "source_type" + FROM "bricktracker_individual_parts" +) AS "combined" INNER JOIN "rebrickable_parts" -ON "bricktracker_parts"."part" IS NOT DISTINCT FROM "rebrickable_parts"."part" -AND "bricktracker_parts"."color" IS NOT DISTINCT FROM "rebrickable_parts"."color_id" +ON "combined"."part" IS NOT DISTINCT FROM "rebrickable_parts"."part" +AND "combined"."color" IS NOT DISTINCT FROM "rebrickable_parts"."color_id" {% block join %}{% endblock %} diff --git a/bricktracker/sql/part/colors/list.sql b/bricktracker/sql/part/colors/list.sql index a055ec0..17f7296 100644 --- a/bricktracker/sql/part/colors/list.sql +++ b/bricktracker/sql/part/colors/list.sql @@ -1,16 +1,40 @@ SELECT DISTINCT - "rebrickable_parts"."color_id" AS "color_id", - "rebrickable_parts"."color_name" AS "color_name", - "rebrickable_parts"."color_rgb" AS "color_rgb" -FROM "rebrickable_parts" -INNER JOIN "bricktracker_parts" -ON "bricktracker_parts"."part" IS NOT DISTINCT FROM "rebrickable_parts"."part" -AND "bricktracker_parts"."color" IS NOT DISTINCT FROM "rebrickable_parts"."color_id" -{% if owner_id and owner_id != 'all' %} -INNER JOIN "bricktracker_sets" -ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id" -INNER JOIN "bricktracker_set_owners" -ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id" -WHERE "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 -{% endif %} -ORDER BY "rebrickable_parts"."color_name" ASC \ No newline at end of file + "color_id", + "color_name", + "color_rgb" +FROM ( + -- Colors from set-based parts + SELECT DISTINCT + "rebrickable_parts"."color_id" AS "color_id", + "rebrickable_parts"."color_name" AS "color_name", + "rebrickable_parts"."color_rgb" AS "color_rgb" + FROM "rebrickable_parts" + INNER JOIN "bricktracker_parts" + ON "bricktracker_parts"."part" IS NOT DISTINCT FROM "rebrickable_parts"."part" + AND "bricktracker_parts"."color" IS NOT DISTINCT FROM "rebrickable_parts"."color_id" + {% if owner_id and owner_id != 'all' %} + INNER JOIN "bricktracker_sets" + ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id" + INNER JOIN "bricktracker_set_owners" + ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id" + WHERE "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 + {% endif %} + + UNION + + -- Colors from individual parts + SELECT DISTINCT + "rebrickable_parts"."color_id" AS "color_id", + "rebrickable_parts"."color_name" AS "color_name", + "rebrickable_parts"."color_rgb" AS "color_rgb" + FROM "rebrickable_parts" + INNER JOIN "bricktracker_individual_parts" + ON "bricktracker_individual_parts"."part" IS NOT DISTINCT FROM "rebrickable_parts"."part" + AND "bricktracker_individual_parts"."color" IS NOT DISTINCT FROM "rebrickable_parts"."color_id" + {% if owner_id and owner_id != 'all' %} + INNER JOIN "bricktracker_set_owners" + ON "bricktracker_individual_parts"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id" + WHERE "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 + {% endif %} +) +ORDER BY "color_name" ASC \ No newline at end of file diff --git a/bricktracker/sql/part/list/all.sql b/bricktracker/sql/part/list/all.sql index 154ba29..78e5568 100644 --- a/bricktracker/sql/part/list/all.sql +++ b/bricktracker/sql/part/list/all.sql @@ -1,55 +1,60 @@ {% extends 'part/base/base.sql' %} {% block total_missing %} -SUM("bricktracker_parts"."missing") AS "total_missing", +SUM("combined"."missing") AS "total_missing", {% endblock %} {% block total_damaged %} -SUM("bricktracker_parts"."damaged") AS "total_damaged", +SUM("combined"."damaged") AS "total_damaged", {% endblock %} {% block total_quantity %} -SUM("bricktracker_parts"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)) AS "total_quantity", +SUM("combined"."quantity" * IFNULL("minifigure_quantities"."quantity", 1)) AS "total_quantity", {% endblock %} {% block total_sets %} -IFNULL(COUNT(DISTINCT "bricktracker_parts"."id"), 0) AS "total_sets", +IFNULL(COUNT(DISTINCT CASE WHEN "combined"."source_type" = 'set' THEN "combined"."id" ELSE NULL END), 0) AS "total_sets", {% endblock %} {% block total_minifigures %} -SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_minifigures" +SUM(IFNULL("minifigure_quantities"."quantity", 0)) AS "total_minifigures" {% endblock %} {% block join %} -LEFT JOIN "bricktracker_minifigures" -ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_minifigures"."id" -AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure" +-- Join to get minifigure quantities from both set-based and individual minifigures +LEFT JOIN ( + SELECT + "bricktracker_minifigures"."id", + "bricktracker_minifigures"."figure", + "bricktracker_minifigures"."quantity" + FROM "bricktracker_minifigures" -{% if theme_id or year %} -INNER JOIN "bricktracker_sets" AS "filter_sets" -ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "filter_sets"."id" -INNER JOIN "rebrickable_sets" AS "filter_rs" -ON "filter_sets"."set" IS NOT DISTINCT FROM "filter_rs"."set" -{% endif %} + UNION ALL + + SELECT + "bricktracker_individual_minifigures"."id", + "bricktracker_individual_minifigures"."figure", + "bricktracker_individual_minifigures"."quantity" + FROM "bricktracker_individual_minifigures" +) AS "minifigure_quantities" +ON "combined"."id" IS NOT DISTINCT FROM "minifigure_quantities"."id" +AND "combined"."figure" IS NOT DISTINCT FROM "minifigure_quantities"."figure" {% endblock %} {% block where %} {% set conditions = [] %} {% if color_id and color_id != 'all' %} - {% set _ = conditions.append('"bricktracker_parts"."color" = ' ~ color_id) %} -{% endif %} -{% if theme_id and theme_id != 'all' %} - {% set _ = conditions.append('"filter_rs"."theme_id" = ' ~ theme_id) %} -{% endif %} -{% if year and year != 'all' %} - {% set _ = conditions.append('"filter_rs"."year" = ' ~ year) %} + {% set _ = conditions.append('"combined"."color" = ' ~ color_id) %} {% endif %} {% if search_query %} - {% set search_condition = '(LOWER("rebrickable_parts"."name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("rebrickable_parts"."color_name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("bricktracker_parts"."part") LIKE LOWER(\'%' ~ search_query ~ '%\'))' %} + {% set search_condition = '(LOWER("rebrickable_parts"."name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("rebrickable_parts"."color_name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("combined"."part") LIKE LOWER(\'%' ~ search_query ~ '%\'))' %} {% set _ = conditions.append(search_condition) %} {% endif %} {% if skip_spare_parts %} - {% set _ = conditions.append('"bricktracker_parts"."spare" = 0') %} + {% set _ = conditions.append('"combined"."spare" = 0') %} +{% endif %} +{% if individuals_filter %} + {% set _ = conditions.append('"combined"."source_type" = \'individual_part\'') %} {% endif %} {% if conditions %} WHERE {{ conditions | join(' AND ') }} @@ -58,7 +63,7 @@ WHERE {{ conditions | join(' AND ') }} {% block group %} GROUP BY - "bricktracker_parts"."part", - "bricktracker_parts"."color", - "bricktracker_parts"."spare" + "combined"."part", + "combined"."color", + "combined"."spare" {% endblock %} diff --git a/bricktracker/sql/part/list/all_by_owner.sql b/bricktracker/sql/part/list/all_by_owner.sql index 338f6e0..f6b4a0b 100644 --- a/bricktracker/sql/part/list/all_by_owner.sql +++ b/bricktracker/sql/part/list/all_by_owner.sql @@ -2,73 +2,136 @@ {% block total_missing %} {% if owner_id and owner_id != 'all' %} -SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."missing" ELSE 0 END) AS "total_missing", +SUM(CASE + WHEN "combined"."source_type" = 'set' AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."missing" + WHEN "combined"."source_type" = 'individual_minifigure' AND "individual_minifigure_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."missing" + WHEN "combined"."source_type" = 'individual_part' AND ("individual_part_owners"."owner_{{ owner_id }}" = 1 OR "individual_part_lot_owners"."owner_{{ owner_id }}" = 1) THEN "combined"."missing" + ELSE 0 +END) AS "total_missing", {% else %} -SUM("bricktracker_parts"."missing") AS "total_missing", +SUM("combined"."missing") AS "total_missing", {% endif %} {% endblock %} {% block total_damaged %} {% if owner_id and owner_id != 'all' %} -SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."damaged" ELSE 0 END) AS "total_damaged", +SUM(CASE + WHEN "combined"."source_type" = 'set' AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."damaged" + WHEN "combined"."source_type" = 'individual_minifigure' AND "individual_minifigure_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."damaged" + WHEN "combined"."source_type" = 'individual_part' AND ("individual_part_owners"."owner_{{ owner_id }}" = 1 OR "individual_part_lot_owners"."owner_{{ owner_id }}" = 1) THEN "combined"."damaged" + ELSE 0 +END) AS "total_damaged", {% else %} -SUM("bricktracker_parts"."damaged") AS "total_damaged", +SUM("combined"."damaged") AS "total_damaged", {% endif %} {% endblock %} {% block total_quantity %} {% if owner_id and owner_id != 'all' %} -SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1) ELSE 0 END) AS "total_quantity", +SUM(CASE + WHEN "combined"."source_type" = 'set' AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1) + WHEN "combined"."source_type" = 'individual_minifigure' AND "individual_minifigure_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."quantity" + WHEN "combined"."source_type" = 'individual_part' AND ("individual_part_owners"."owner_{{ owner_id }}" = 1 OR "individual_part_lot_owners"."owner_{{ owner_id }}" = 1) THEN "combined"."quantity" + ELSE 0 +END) AS "total_quantity", {% else %} -SUM("bricktracker_parts"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)) AS "total_quantity", +SUM(CASE + WHEN "combined"."source_type" = 'set' THEN "combined"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1) + ELSE "combined"."quantity" +END) AS "total_quantity", {% endif %} {% endblock %} {% block total_sets %} {% if owner_id and owner_id != 'all' %} -COUNT(DISTINCT CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."id" ELSE NULL END) AS "total_sets", +COUNT(DISTINCT CASE WHEN "combined"."source_type" = 'set' AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."id" ELSE NULL END) AS "total_sets", {% else %} -COUNT(DISTINCT "bricktracker_parts"."id") AS "total_sets", +COUNT(DISTINCT CASE WHEN "combined"."source_type" = 'set' THEN "combined"."id" ELSE NULL END) AS "total_sets", {% endif %} {% endblock %} {% block total_minifigures %} {% if owner_id and owner_id != 'all' %} -SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN IFNULL("bricktracker_minifigures"."quantity", 0) ELSE 0 END) AS "total_minifigures" +SUM(CASE + WHEN "combined"."source_type" = 'set' AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN IFNULL("bricktracker_minifigures"."quantity", 0) + WHEN "combined"."source_type" = 'individual_minifigure' AND "individual_minifigure_owners"."owner_{{ owner_id }}" = 1 THEN 1 + ELSE 0 +END) AS "total_minifigures" {% else %} -SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_minifigures" +SUM(CASE + WHEN "combined"."source_type" = 'set' THEN IFNULL("bricktracker_minifigures"."quantity", 0) + WHEN "combined"."source_type" = 'individual_minifigure' THEN 1 + ELSE 0 +END) AS "total_minifigures" {% endif %} {% endblock %} {% block join %} --- Join with sets to get owner information -INNER JOIN "bricktracker_sets" -ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id" +-- Left join with sets (for set-based parts) +LEFT JOIN "bricktracker_sets" +ON "combined"."source_type" = 'set' +AND "combined"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id" -- Left join with set owners (using dynamic columns) LEFT JOIN "bricktracker_set_owners" -ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id" +ON "combined"."source_type" = 'set' +AND "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id" --- Left join with minifigures +-- Left join with set-based minifigures LEFT JOIN "bricktracker_minifigures" -ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_minifigures"."id" -AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure" +ON "combined"."source_type" = 'set' +AND "combined"."id" IS NOT DISTINCT FROM "bricktracker_minifigures"."id" +AND "combined"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure" + +-- Left join with individual minifigures (for individual minifigure parts) +LEFT JOIN "bricktracker_individual_minifigures" +ON "combined"."source_type" = 'individual_minifigure' +AND "combined"."id" IS NOT DISTINCT FROM "bricktracker_individual_minifigures"."id" + +-- Left join with set owners for individual minifigures (using dynamic columns) - reuse set_owners table +LEFT JOIN "bricktracker_set_owners" AS "individual_minifigure_owners" +ON "combined"."source_type" = 'individual_minifigure' +AND "bricktracker_individual_minifigures"."id" IS NOT DISTINCT FROM "individual_minifigure_owners"."id" + +-- Left join with individual parts (for standalone parts and lot parts) +LEFT JOIN "bricktracker_individual_parts" +ON "combined"."source_type" = 'individual_part' +AND "combined"."id" IS NOT DISTINCT FROM "bricktracker_individual_parts"."id" + +-- Left join with set owners for individual parts (using dynamic columns) - for standalone parts +LEFT JOIN "bricktracker_set_owners" AS "individual_part_owners" +ON "combined"."source_type" = 'individual_part' +AND "bricktracker_individual_parts"."id" IS NOT DISTINCT FROM "individual_part_owners"."id" + +-- Left join with individual part lots (for parts belonging to a lot) +LEFT JOIN "bricktracker_individual_part_lots" +ON "combined"."source_type" = 'individual_part' +AND "bricktracker_individual_parts"."lot_id" IS NOT DISTINCT FROM "bricktracker_individual_part_lots"."id" + +-- Left join with set owners for individual part lots (using dynamic columns) +LEFT JOIN "bricktracker_set_owners" AS "individual_part_lot_owners" +ON "combined"."source_type" = 'individual_part' +AND "bricktracker_individual_part_lots"."id" IS NOT DISTINCT FROM "individual_part_lot_owners"."id" {% endblock %} {% block where %} {% set conditions = [] %} {% if owner_id and owner_id != 'all' %} - {% set _ = conditions.append('"bricktracker_set_owners"."owner_' ~ owner_id ~ '" = 1') %} + {% set owner_condition = '(("combined"."source_type" = \'set\' AND "bricktracker_set_owners"."owner_' ~ owner_id ~ '" = 1) OR ("combined"."source_type" = \'individual_minifigure\' AND "individual_minifigure_owners"."owner_' ~ owner_id ~ '" = 1) OR ("combined"."source_type" = \'individual_part\' AND ("individual_part_owners"."owner_' ~ owner_id ~ '" = 1 OR "individual_part_lot_owners"."owner_' ~ owner_id ~ '" = 1)))' %} + {% set _ = conditions.append(owner_condition) %} {% endif %} {% if color_id and color_id != 'all' %} - {% set _ = conditions.append('"bricktracker_parts"."color" = ' ~ color_id) %} + {% set _ = conditions.append('"combined"."color" = ' ~ color_id) %} {% endif %} {% if search_query %} - {% set search_condition = '(LOWER("rebrickable_parts"."name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("rebrickable_parts"."color_name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("bricktracker_parts"."part") LIKE LOWER(\'%' ~ search_query ~ '%\'))' %} + {% set search_condition = '(LOWER("rebrickable_parts"."name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("rebrickable_parts"."color_name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("combined"."part") LIKE LOWER(\'%' ~ search_query ~ '%\'))' %} {% set _ = conditions.append(search_condition) %} {% endif %} {% if skip_spare_parts %} - {% set _ = conditions.append('"bricktracker_parts"."spare" = 0') %} + {% set _ = conditions.append('"combined"."spare" = 0') %} +{% endif %} +{% if individuals_filter %} + {% set _ = conditions.append('"combined"."source_type" = \'individual_part\'') %} {% endif %} {% if conditions %} WHERE {{ conditions | join(' AND ') }} @@ -77,7 +140,7 @@ WHERE {{ conditions | join(' AND ') }} {% block group %} GROUP BY - "bricktracker_parts"."part", - "bricktracker_parts"."color", - "bricktracker_parts"."spare" + "combined"."part", + "combined"."color", + "combined"."spare" {% endblock %} \ No newline at end of file diff --git a/bricktracker/sql/part/list/from_minifigure.sql b/bricktracker/sql/part/list/from_minifigure.sql index a2b456f..af7949d 100644 --- a/bricktracker/sql/part/list/from_minifigure.sql +++ b/bricktracker/sql/part/list/from_minifigure.sql @@ -1,26 +1,88 @@ +-- Query parts from both set-based and individual minifigures +SELECT + "parts_combined"."id", + "parts_combined"."figure", + "parts_combined"."part", + "parts_combined"."color", + "parts_combined"."spare", + SUM("parts_combined"."quantity") AS "quantity", + "parts_combined"."element", + SUM("parts_combined"."missing") AS "total_missing", + SUM("parts_combined"."damaged") AS "total_damaged", + MAX("parts_combined"."checked") AS "checked", + "rebrickable_parts"."color_name", + "rebrickable_parts"."color_rgb", + "rebrickable_parts"."color_transparent", + "rebrickable_parts"."bricklink_color_id", + "rebrickable_parts"."bricklink_color_name", + "rebrickable_parts"."bricklink_part_num", + "rebrickable_parts"."name", + "rebrickable_parts"."image", + "rebrickable_parts"."image_id", + "rebrickable_parts"."url", + "rebrickable_parts"."print", + NULL AS "total_quantity", + NULL AS "total_spare", + NULL AS "total_sets", + NULL AS "total_minifigures" +FROM ( + -- Set-based minifigure parts + SELECT + "bricktracker_parts"."id", + "bricktracker_parts"."figure", + "bricktracker_parts"."part", + "bricktracker_parts"."color", + "bricktracker_parts"."spare", + "bricktracker_parts"."quantity", + "bricktracker_parts"."element", + "bricktracker_parts"."missing", + "bricktracker_parts"."damaged", + "bricktracker_parts"."checked" + FROM "bricktracker_parts" + WHERE "bricktracker_parts"."figure" IS NOT DISTINCT FROM :figure -{% extends 'part/base/base.sql' %} + UNION ALL -{% block total_missing %} -SUM("bricktracker_parts"."missing") AS "total_missing", -{% endblock %} + -- Individual minifigure parts + SELECT + "bricktracker_individual_minifigure_parts"."id", + "bricktracker_individual_minifigures"."figure", + "bricktracker_individual_minifigure_parts"."part", + "bricktracker_individual_minifigure_parts"."color", + "bricktracker_individual_minifigure_parts"."spare", + "bricktracker_individual_minifigure_parts"."quantity", + "bricktracker_individual_minifigure_parts"."element", + "bricktracker_individual_minifigure_parts"."missing", + "bricktracker_individual_minifigure_parts"."damaged", + "bricktracker_individual_minifigure_parts"."checked" + FROM "bricktracker_individual_minifigure_parts" + INNER JOIN "bricktracker_individual_minifigures" + ON "bricktracker_individual_minifigure_parts"."id" = "bricktracker_individual_minifigures"."id" + WHERE "bricktracker_individual_minifigures"."figure" IS NOT DISTINCT FROM :figure +) AS "parts_combined" -{% block total_damaged %} -SUM("bricktracker_parts"."damaged") AS "total_damaged", -{% endblock %} +INNER JOIN "rebrickable_parts" +ON "parts_combined"."part" = "rebrickable_parts"."part" +AND "parts_combined"."color" = "rebrickable_parts"."color_id" -{% block where %} -{% set conditions = [] %} -{% set _ = conditions.append('"bricktracker_parts"."figure" IS NOT DISTINCT FROM :figure') %} -{% if skip_spare_parts %} - {% set _ = conditions.append('"bricktracker_parts"."spare" = 0') %} -{% endif %} -WHERE {{ conditions | join(' AND ') }} -{% endblock %} - -{% block group %} GROUP BY - "bricktracker_parts"."part", - "bricktracker_parts"."color", - "bricktracker_parts"."spare" -{% endblock %} + "parts_combined"."part", + "parts_combined"."color", + "parts_combined"."spare", + "parts_combined"."element", + "rebrickable_parts"."color_name", + "rebrickable_parts"."color_rgb", + "rebrickable_parts"."color_transparent", + "rebrickable_parts"."bricklink_color_id", + "rebrickable_parts"."bricklink_color_name", + "rebrickable_parts"."bricklink_part_num", + "rebrickable_parts"."name", + "rebrickable_parts"."image", + "rebrickable_parts"."image_id", + "rebrickable_parts"."url", + "rebrickable_parts"."print" + +{% if order %} +-- Replace combined/bricktracker_parts references with parts_combined for this query +ORDER BY {{ order | replace('"combined"', '"parts_combined"') | replace('"bricktracker_parts"', '"parts_combined"') }} +{% endif %} diff --git a/bricktracker/sql/part/list/from_print.sql b/bricktracker/sql/part/list/from_print.sql index fe1198c..bd283a1 100644 --- a/bricktracker/sql/part/list/from_print.sql +++ b/bricktracker/sql/part/list/from_print.sql @@ -7,12 +7,12 @@ {% block where %} WHERE "rebrickable_parts"."print" IS NOT DISTINCT FROM :print -AND "bricktracker_parts"."color" IS NOT DISTINCT FROM :color -AND "bricktracker_parts"."part" IS DISTINCT FROM :part +AND "combined"."color" IS NOT DISTINCT FROM :color +AND "combined"."part" IS DISTINCT FROM :part {% endblock %} {% block group %} GROUP BY - "bricktracker_parts"."part", - "bricktracker_parts"."color" + "combined"."part", + "combined"."color" {% endblock %} diff --git a/bricktracker/sql/part/list/last.sql b/bricktracker/sql/part/list/last.sql index 0106c22..4166e61 100644 --- a/bricktracker/sql/part/list/last.sql +++ b/bricktracker/sql/part/list/last.sql @@ -1,19 +1,19 @@ {% extends 'part/base/base.sql' %} {% block total_missing %} -"bricktracker_parts"."missing" AS "total_missing", +"combined"."missing" AS "total_missing", {% endblock %} {% block total_damaged %} -"bricktracker_parts"."damaged" AS "total_damaged", +"combined"."damaged" AS "total_damaged", {% endblock %} {% block total_quantity %} -"bricktracker_parts"."quantity" AS "total_quantity", +"combined"."quantity" AS "total_quantity", {% endblock %} {% block total_spare %} -"bricktracker_parts"."spare" AS "total_spare", +"combined"."spare" AS "total_spare", {% endblock %} {% block total_sets %} @@ -21,13 +21,13 @@ {% endblock %} {% block total_minifigures %} -CASE WHEN "bricktracker_parts"."figure" IS NOT NULL THEN 1 ELSE 0 END AS "total_minifigures" +CASE WHEN "combined"."figure" IS NOT NULL THEN 1 ELSE 0 END AS "total_minifigures" {% endblock %} {% block where %} {% set conditions = [] %} {% if skip_spare_parts %} - {% set _ = conditions.append('"bricktracker_parts"."spare" = 0') %} + {% set _ = conditions.append('"combined"."spare" = 0') %} {% endif %} {% if conditions %} WHERE {{ conditions | join(' AND ') }} diff --git a/bricktracker/sql/part/list/problem.sql b/bricktracker/sql/part/list/problem.sql index 4d36df3..5a51ae6 100644 --- a/bricktracker/sql/part/list/problem.sql +++ b/bricktracker/sql/part/list/problem.sql @@ -2,104 +2,118 @@ {% block total_missing %} {% if owner_id and owner_id != 'all' %} -SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."missing" ELSE 0 END) AS "total_missing", +SUM(CASE + WHEN "combined"."source_type" = 'set' AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."missing" + WHEN "combined"."source_type" = 'individual' AND "ind_minifig_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."missing" + ELSE 0 +END) AS "total_missing", {% else %} -SUM("bricktracker_parts"."missing") AS "total_missing", +SUM("combined"."missing") AS "total_missing", {% endif %} {% endblock %} {% block total_damaged %} {% if owner_id and owner_id != 'all' %} -SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."damaged" ELSE 0 END) AS "total_damaged", +SUM(CASE + WHEN "combined"."source_type" = 'set' AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."damaged" + WHEN "combined"."source_type" = 'individual' AND "ind_minifig_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."damaged" + ELSE 0 +END) AS "total_damaged", {% else %} -SUM("bricktracker_parts"."damaged") AS "total_damaged", +SUM("combined"."damaged") AS "total_damaged", {% endif %} {% endblock %} {% block total_quantity %} {% if owner_id and owner_id != 'all' %} -SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1) ELSE 0 END) AS "total_quantity", +SUM(CASE + WHEN "combined"."source_type" = 'set' AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1) + WHEN "combined"."source_type" = 'individual' AND "ind_minifig_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."quantity" * IFNULL("bricktracker_individual_minifigures"."quantity", 1) + ELSE 0 +END) AS "total_quantity", {% else %} -SUM("bricktracker_parts"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)) AS "total_quantity", +SUM(CASE + WHEN "combined"."source_type" = 'set' THEN "combined"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1) + WHEN "combined"."source_type" = 'individual' THEN "combined"."quantity" * IFNULL("bricktracker_individual_minifigures"."quantity", 1) + ELSE "combined"."quantity" +END) AS "total_quantity", {% endif %} {% endblock %} {% block total_sets %} {% if owner_id and owner_id != 'all' %} -COUNT(DISTINCT CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."id" ELSE NULL END) AS "total_sets", +COUNT(DISTINCT CASE WHEN "combined"."source_type" = 'set' AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."id" ELSE NULL END) AS "total_sets", {% else %} -COUNT(DISTINCT "bricktracker_parts"."id") AS "total_sets", +COUNT(DISTINCT CASE WHEN "combined"."source_type" = 'set' THEN "combined"."id" ELSE NULL END) AS "total_sets", {% endif %} {% endblock %} {% block total_minifigures %} {% if owner_id and owner_id != 'all' %} -SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN IFNULL("bricktracker_minifigures"."quantity", 0) ELSE 0 END) AS "total_minifigures" +SUM(CASE + WHEN "combined"."source_type" = 'set' AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN IFNULL("bricktracker_minifigures"."quantity", 0) + WHEN "combined"."source_type" = 'individual' AND "ind_minifig_owners"."owner_{{ owner_id }}" = 1 THEN IFNULL("bricktracker_individual_minifigures"."quantity", 0) + ELSE 0 +END) AS "total_minifigures" {% else %} -SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_minifigures" +SUM(CASE + WHEN "combined"."source_type" = 'set' THEN IFNULL("bricktracker_minifigures"."quantity", 0) + WHEN "combined"."source_type" = 'individual' THEN IFNULL("bricktracker_individual_minifigures"."quantity", 0) + ELSE 0 +END) AS "total_minifigures" {% endif %} {% endblock %} {% block join %} --- Join with sets to get owner information -INNER JOIN "bricktracker_sets" -ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id" - --- Join with rebrickable sets for theme/year filtering -INNER JOIN "rebrickable_sets" -ON "bricktracker_sets"."set" IS NOT DISTINCT FROM "rebrickable_sets"."set" +-- Left join with sets for set-based parts +LEFT JOIN "bricktracker_sets" +ON "combined"."source_type" = 'set' +AND "combined"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id" -- Left join with set owners (using dynamic columns) LEFT JOIN "bricktracker_set_owners" ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id" --- Left join with set tags (for tag filtering) -{% if tag_id and tag_id != 'all' %} -LEFT JOIN "bricktracker_set_tags" -ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_tags"."id" -{% endif %} - --- Left join with minifigures +-- Left join with set-based minifigures LEFT JOIN "bricktracker_minifigures" -ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_minifigures"."id" -AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure" +ON "combined"."source_type" = 'set' +AND "combined"."id" IS NOT DISTINCT FROM "bricktracker_minifigures"."id" +AND "combined"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure" + +-- Left join with individual minifigures +LEFT JOIN "bricktracker_individual_minifigures" +ON "combined"."source_type" = 'individual' +AND "combined"."id" IS NOT DISTINCT FROM "bricktracker_individual_minifigures"."id" + +-- Left join with individual minifigure owners (using consolidated metadata table) +LEFT JOIN "bricktracker_set_owners" AS "ind_minifig_owners" +ON "bricktracker_individual_minifigures"."id" IS NOT DISTINCT FROM "ind_minifig_owners"."id" {% endblock %} {% block where %} {% set conditions = [] %} -- Always filter for problematic parts -{% set _ = conditions.append('("bricktracker_parts"."missing" > 0 OR "bricktracker_parts"."damaged" > 0)') %} +{% set _ = conditions.append('("combined"."missing" > 0 OR "combined"."damaged" > 0)') %} {% if owner_id and owner_id != 'all' %} - {% set _ = conditions.append('"bricktracker_set_owners"."owner_' ~ owner_id ~ '" = 1') %} + {% set owner_condition = '(("combined"."source_type" = \'set\' AND "bricktracker_set_owners"."owner_' ~ owner_id ~ '" = 1) OR ("combined"."source_type" = \'individual\' AND "ind_minifig_owners"."owner_' ~ owner_id ~ '" = 1))' %} + {% set _ = conditions.append(owner_condition) %} {% endif %} {% if color_id and color_id != 'all' %} - {% set _ = conditions.append('"bricktracker_parts"."color" = ' ~ color_id) %} -{% endif %} -{% if theme_id and theme_id != 'all' %} - {% set _ = conditions.append('"rebrickable_sets"."theme_id" = ' ~ theme_id) %} -{% endif %} -{% if year and year != 'all' %} - {% set _ = conditions.append('"rebrickable_sets"."year" = ' ~ year) %} -{% endif %} -{% if storage_id and storage_id != 'all' %} - {% set _ = conditions.append('"bricktracker_sets"."storage" = \'' ~ storage_id ~ '\'') %} -{% endif %} -{% if tag_id and tag_id != 'all' %} - {% set _ = conditions.append('"bricktracker_set_tags"."tag_' ~ tag_id ~ '" = 1') %} + {% set _ = conditions.append('"combined"."color" = ' ~ color_id) %} {% endif %} {% if search_query %} - {% set search_condition = '(LOWER("rebrickable_parts"."name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("rebrickable_parts"."color_name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("bricktracker_parts"."part") LIKE LOWER(\'%' ~ search_query ~ '%\'))' %} + {% set search_condition = '(LOWER("rebrickable_parts"."name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("rebrickable_parts"."color_name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("combined"."part") LIKE LOWER(\'%' ~ search_query ~ '%\'))' %} {% set _ = conditions.append(search_condition) %} {% endif %} {% if skip_spare_parts %} - {% set _ = conditions.append('"bricktracker_parts"."spare" = 0') %} + {% set _ = conditions.append('"combined"."spare" = 0') %} {% endif %} WHERE {{ conditions | join(' AND ') }} {% endblock %} {% block group %} GROUP BY - "bricktracker_parts"."part", - "bricktracker_parts"."color", - "bricktracker_parts"."spare" + "combined"."part", + "combined"."color", + "combined"."spare" {% endblock %} diff --git a/bricktracker/sql/part/list/specific.sql b/bricktracker/sql/part/list/specific.sql index 996c127..262973d 100644 --- a/bricktracker/sql/part/list/specific.sql +++ b/bricktracker/sql/part/list/specific.sql @@ -2,19 +2,14 @@ {% extends 'part/base/base.sql' %} {% block total_missing %} -IFNULL("bricktracker_parts"."missing", 0) AS "total_missing", +IFNULL("combined"."missing", 0) AS "total_missing", {% endblock %} {% block total_damaged %} -IFNULL("bricktracker_parts"."damaged", 0) AS "total_damaged", +IFNULL("combined"."damaged", 0) AS "total_damaged", {% endblock %} {% block where %} -{% set conditions = [] %} -{% set _ = conditions.append('"bricktracker_parts"."id" IS NOT DISTINCT FROM :id') %} -{% set _ = conditions.append('"bricktracker_parts"."figure" IS NOT DISTINCT FROM :figure') %} -{% if skip_spare_parts %} - {% set _ = conditions.append('"bricktracker_parts"."spare" = 0') %} -{% endif %} -WHERE {{ conditions | join(' AND ') }} +WHERE "combined"."id" IS NOT DISTINCT FROM :id +AND "combined"."figure" IS NOT DISTINCT FROM :figure {% endblock %} diff --git a/bricktracker/sql/part/list/with_different_color.sql b/bricktracker/sql/part/list/with_different_color.sql index d75501d..66d9f21 100644 --- a/bricktracker/sql/part/list/with_different_color.sql +++ b/bricktracker/sql/part/list/with_different_color.sql @@ -6,12 +6,12 @@ {% block total_damaged %}{% endblock %} {% block where %} -WHERE "bricktracker_parts"."color" IS DISTINCT FROM :color -AND "bricktracker_parts"."part" IS NOT DISTINCT FROM :part +WHERE "combined"."color" IS DISTINCT FROM :color +AND "combined"."part" IS NOT DISTINCT FROM :part {% endblock %} {% block group %} GROUP BY - "bricktracker_parts"."part", - "bricktracker_parts"."color" + "combined"."part", + "combined"."color" {% endblock %} diff --git a/bricktracker/sql/part/select/generic.sql b/bricktracker/sql/part/select/generic.sql index 43a26da..9767e4c 100644 --- a/bricktracker/sql/part/select/generic.sql +++ b/bricktracker/sql/part/select/generic.sql @@ -1,34 +1,51 @@ {% extends 'part/base/base.sql' %} {% block total_missing %} -SUM("bricktracker_parts"."missing") AS "total_missing", +SUM("combined"."missing") AS "total_missing", {% endblock %} {% block total_damaged %} -SUM("bricktracker_parts"."damaged") AS "total_damaged", +SUM("combined"."damaged") AS "total_damaged", {% endblock %} {% block total_quantity %} -SUM((NOT "bricktracker_parts"."spare") * "bricktracker_parts"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)) AS "total_quantity", +SUM((NOT "combined"."spare") * "combined"."quantity" * IFNULL("minifigure_quantities"."quantity", 1)) AS "total_quantity", {% endblock %} {% block total_spare %} -SUM("bricktracker_parts"."spare" * "bricktracker_parts"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)) AS "total_spare", +SUM("combined"."spare" * "combined"."quantity" * IFNULL("minifigure_quantities"."quantity", 1)) AS "total_spare", {% endblock %} {% block join %} -LEFT JOIN "bricktracker_minifigures" -ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_minifigures"."id" -AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure" +-- Join to get minifigure quantities from both set-based and individual minifigures +LEFT JOIN ( + -- Set-based minifigure quantities + SELECT + "bricktracker_minifigures"."id", + "bricktracker_minifigures"."figure", + "bricktracker_minifigures"."quantity" + FROM "bricktracker_minifigures" + + UNION ALL + + -- Individual minifigure quantities + SELECT + "bricktracker_individual_minifigures"."id", + "bricktracker_individual_minifigures"."figure", + "bricktracker_individual_minifigures"."quantity" + FROM "bricktracker_individual_minifigures" +) AS "minifigure_quantities" +ON "combined"."id" IS NOT DISTINCT FROM "minifigure_quantities"."id" +AND "combined"."figure" IS NOT DISTINCT FROM "minifigure_quantities"."figure" {% endblock %} {% block where %} -WHERE "bricktracker_parts"."part" IS NOT DISTINCT FROM :part -AND "bricktracker_parts"."color" IS NOT DISTINCT FROM :color +WHERE "combined"."part" IS NOT DISTINCT FROM :part +AND "combined"."color" IS NOT DISTINCT FROM :color {% endblock %} {% block group %} GROUP BY - "bricktracker_parts"."part", - "bricktracker_parts"."color" + "combined"."part", + "combined"."color" {% endblock %} diff --git a/bricktracker/sql/part/select/specific.sql b/bricktracker/sql/part/select/specific.sql index c74a535..6b643bb 100644 --- a/bricktracker/sql/part/select/specific.sql +++ b/bricktracker/sql/part/select/specific.sql @@ -1,18 +1,18 @@ {% extends 'part/base/base.sql' %} {% block where %} -WHERE "bricktracker_parts"."id" IS NOT DISTINCT FROM :id -AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM :figure -AND "bricktracker_parts"."part" IS NOT DISTINCT FROM :part -AND "bricktracker_parts"."color" IS NOT DISTINCT FROM :color -AND "bricktracker_parts"."spare" IS NOT DISTINCT FROM :spare +WHERE "combined"."id" IS NOT DISTINCT FROM :id +AND "combined"."figure" IS NOT DISTINCT FROM :figure +AND "combined"."part" IS NOT DISTINCT FROM :part +AND "combined"."color" IS NOT DISTINCT FROM :color +AND "combined"."spare" IS NOT DISTINCT FROM :spare {% endblock %} {% block group %} GROUP BY - "bricktracker_parts"."id", - "bricktracker_parts"."figure", - "bricktracker_parts"."part", - "bricktracker_parts"."color", - "bricktracker_parts"."spare" + "combined"."id", + "combined"."figure", + "combined"."part", + "combined"."color", + "combined"."spare" {% endblock %} diff --git a/bricktracker/sql/schema/drop.sql b/bricktracker/sql/schema/drop.sql index 8c4cedb..aa0d4b4 100644 --- a/bricktracker/sql/schema/drop.sql +++ b/bricktracker/sql/schema/drop.sql @@ -1,31 +1,70 @@ BEGIN transaction; -DROP TABLE IF EXISTS "bricktracker_metadata_owners"; -DROP TABLE IF EXISTS "bricktracker_metadata_statuses"; -DROP TABLE IF EXISTS "bricktracker_metadata_tags"; +-- Disable foreign key checks during drop to avoid constraint errors +PRAGMA foreign_keys = OFF; + +-- Drop child tables first (those with foreign key references) + +-- Individual minifigure parts (references individual_minifigures) +DROP TABLE IF EXISTS "bricktracker_individual_minifigure_parts"; + +-- Individual parts (references individual_part_lots) +-- Drop before lots since lot_id is a foreign key +DROP TABLE IF EXISTS "bricktracker_individual_parts"; + +-- Individual minifigures (references rebrickable_minifigures, metadata tables) +DROP TABLE IF EXISTS "bricktracker_individual_minifigures"; + +-- Individual part lots (references metadata tables) +DROP TABLE IF EXISTS "bricktracker_individual_part_lots"; + +-- Set-based parts and minifigures (references sets) DROP TABLE IF EXISTS "bricktracker_minifigures"; DROP TABLE IF EXISTS "bricktracker_parts"; -DROP TABLE IF EXISTS "bricktracker_sets"; + +-- Set metadata junction tables (reference sets and metadata) DROP TABLE IF EXISTS "bricktracker_set_checkboxes"; DROP TABLE IF EXISTS "bricktracker_set_owners"; DROP TABLE IF EXISTS "bricktracker_set_statuses"; DROP TABLE IF EXISTS "bricktracker_set_storages"; DROP TABLE IF EXISTS "bricktracker_set_tags"; + +-- Wish metadata junction tables +DROP TABLE IF EXISTS "bricktracker_wish_owners"; + +-- Main sets and wishes tables +DROP TABLE IF EXISTS "bricktracker_sets"; DROP TABLE IF EXISTS "bricktracker_wishes"; + +-- Metadata definition tables +DROP TABLE IF EXISTS "bricktracker_metadata_owners"; +DROP TABLE IF EXISTS "bricktracker_metadata_statuses"; +DROP TABLE IF EXISTS "bricktracker_metadata_tags"; +DROP TABLE IF EXISTS "bricktracker_metadata_storages"; +DROP TABLE IF EXISTS "bricktracker_metadata_purchase_locations"; + +-- Rebrickable reference tables +DROP TABLE IF EXISTS "rebrickable_colors"; +DROP TABLE IF EXISTS "rebrickable_minifigures"; +DROP TABLE IF EXISTS "rebrickable_parts"; +DROP TABLE IF EXISTS "rebrickable_sets"; +DROP TABLE IF EXISTS "rebrickable_sets_new"; + +-- Legacy/migration tables DROP TABLE IF EXISTS "inventory"; DROP TABLE IF EXISTS "inventory_old"; DROP TABLE IF EXISTS "minifigures"; DROP TABLE IF EXISTS "minifigures_old"; DROP TABLE IF EXISTS "missing"; DROP TABLE IF EXISTS "missing_old"; -DROP TABLE IF EXISTS "rebrickable_minifigures"; -DROP TABLE IF EXISTS "rebrickable_parts"; -DROP TABLE IF EXISTS "rebrickable_sets"; DROP TABLE IF EXISTS "sets"; DROP TABLE IF EXISTS "sets_old"; DROP TABLE IF EXISTS "wishlist"; DROP TABLE IF EXISTS "wishlist_old"; +-- Re-enable foreign key checks +PRAGMA foreign_keys = ON; + COMMIT; PRAGMA user_version = 0; \ No newline at end of file diff --git a/bricktracker/sql/set/metadata/owner/insert.sql b/bricktracker/sql/set/metadata/owner/insert.sql index 6b2de77..3d024d1 100644 --- a/bricktracker/sql/set/metadata/owner/insert.sql +++ b/bricktracker/sql/set/metadata/owner/insert.sql @@ -1,9 +1,10 @@ BEGIN TRANSACTION; +-- Add owner column to set_owners table (used by all entities: sets, individual parts, individual minifigures, individual part lots) ALTER TABLE "bricktracker_set_owners" ADD COLUMN "owner_{{ id }}" BOOLEAN NOT NULL DEFAULT 0; --- Also inject into wishes +-- Also inject into wishes (wishes use their own table) ALTER TABLE "bricktracker_wish_owners" ADD COLUMN "owner_{{ id }}" BOOLEAN NOT NULL DEFAULT 0; diff --git a/bricktracker/sql/set/metadata/purchase_location/base.sql b/bricktracker/sql/set/metadata/purchase_location/base.sql index c0a0ce0..5c9a433 100644 --- a/bricktracker/sql/set/metadata/purchase_location/base.sql +++ b/bricktracker/sql/set/metadata/purchase_location/base.sql @@ -1,10 +1,17 @@ SELECT "bricktracker_metadata_purchase_locations"."id", - "bricktracker_metadata_purchase_locations"."name" + "bricktracker_metadata_purchase_locations"."name", + {% block total_sets %} + NULL as "total_sets" -- dummy for order: total_sets + {% endblock %} FROM "bricktracker_metadata_purchase_locations" +{% block join %}{% endblock %} + {% block where %}{% endblock %} +{% block group %}{% endblock %} + {% if order %} ORDER BY {{ order }} {% endif %} diff --git a/bricktracker/sql/set/metadata/status/insert.sql b/bricktracker/sql/set/metadata/status/insert.sql index 2704d72..c527d5e 100644 --- a/bricktracker/sql/set/metadata/status/insert.sql +++ b/bricktracker/sql/set/metadata/status/insert.sql @@ -1,5 +1,7 @@ BEGIN TRANSACTION; +-- Add status column to set_statuses table (used by all entities: sets, individual parts, individual minifigures) +-- Note: Individual part lots don't have statuses ALTER TABLE "bricktracker_set_statuses" ADD COLUMN "status_{{ id }}" BOOLEAN NOT NULL DEFAULT 0; diff --git a/bricktracker/sql/set/metadata/tag/insert.sql b/bricktracker/sql/set/metadata/tag/insert.sql index 7a62866..3e0fa98 100644 --- a/bricktracker/sql/set/metadata/tag/insert.sql +++ b/bricktracker/sql/set/metadata/tag/insert.sql @@ -1,5 +1,6 @@ BEGIN TRANSACTION; +-- Add tag column to set_tags table (used by all entities: sets, individual parts, individual minifigures, individual part lots) ALTER TABLE "bricktracker_set_tags" ADD COLUMN "tag_{{ id }}" BOOLEAN NOT NULL DEFAULT 0; diff --git a/bricktracker/sql/statistics/overview.sql b/bricktracker/sql/statistics/overview.sql index 4c19520..0b805d3 100644 --- a/bricktracker/sql/statistics/overview.sql +++ b/bricktracker/sql/statistics/overview.sql @@ -1,7 +1,5 @@ --- Statistics Overview Query (Optimized with CTEs) --- Provides comprehensive statistics for BrickTracker dashboard --- Performance improved by consolidating subqueries into CTEs --- Expected impact: 60-80% performance improvement for dashboard loading +-- Statistics Overview Query +-- Provides statistics for BrickTracker dashboard WITH -- Set statistics aggregation @@ -21,31 +19,147 @@ set_stats AS ( FROM "bricktracker_sets" ), --- Part statistics aggregation -part_stats AS ( +-- Part statistics aggregation (set-based parts) +set_part_stats AS ( SELECT COUNT(*) AS total_part_instances, - SUM("quantity") AS total_parts_count, + COALESCE(SUM("quantity"), 0) AS total_parts_count, COUNT(DISTINCT "part") AS unique_parts, - SUM("missing") AS total_missing_parts, - SUM("damaged") AS total_damaged_parts + COALESCE(SUM("missing"), 0) AS total_missing_parts, + COALESCE(SUM("damaged"), 0) AS total_damaged_parts FROM "bricktracker_parts" ), --- Minifigure statistics aggregation -minifig_stats AS ( +-- Individual part statistics aggregation +individual_part_stats AS ( + SELECT + COUNT(*) AS total_individual_parts, + COALESCE(SUM("quantity"), 0) AS total_individual_parts_count, + COUNT(DISTINCT "part") AS unique_individual_parts, + COALESCE(SUM("missing"), 0) AS total_missing_individual_parts, + COALESCE(SUM("damaged"), 0) AS total_damaged_individual_parts, + COUNT(CASE WHEN "purchase_price" IS NOT NULL AND "lot_id" IS NULL THEN 1 END) AS individual_parts_with_price, + COALESCE(ROUND(SUM(CASE WHEN "lot_id" IS NULL THEN "purchase_price" END), 2), 0) AS individual_parts_total_cost + FROM "bricktracker_individual_parts" +), + +-- Combined part statistics +part_stats AS ( + SELECT + set_part_stats.total_part_instances + COALESCE(individual_part_stats.total_individual_parts, 0) AS total_part_instances, + set_part_stats.total_parts_count + COALESCE(individual_part_stats.total_individual_parts_count, 0) AS total_parts_count, + (SELECT COUNT(DISTINCT "part") FROM ( + SELECT "part" FROM "bricktracker_parts" + UNION + SELECT "part" FROM "bricktracker_individual_parts" + )) AS unique_parts, + set_part_stats.total_missing_parts + COALESCE(individual_part_stats.total_missing_individual_parts, 0) AS total_missing_parts, + set_part_stats.total_damaged_parts + COALESCE(individual_part_stats.total_damaged_individual_parts, 0) AS total_damaged_parts + FROM set_part_stats, individual_part_stats +), + +-- Minifigure statistics aggregation (set-based minifigures) +set_minifig_stats AS ( SELECT COUNT(*) AS total_minifigure_instances, - SUM("quantity") AS total_minifigures_count, + COALESCE(SUM("quantity"), 0) AS total_minifigures_count, COUNT(DISTINCT "figure") AS unique_minifigures FROM "bricktracker_minifigures" ), +-- Individual minifigure statistics aggregation +individual_minifig_stats AS ( + SELECT + COUNT(*) AS total_individual_minifigures, + COALESCE(SUM("quantity"), 0) AS total_individual_minifigures_count, + COUNT(DISTINCT "figure") AS unique_individual_minifigures, + COUNT(CASE WHEN "purchase_price" IS NOT NULL THEN 1 END) AS individual_minifigs_with_price, + COALESCE(ROUND(SUM("purchase_price"), 2), 0) AS individual_minifigs_total_cost + FROM "bricktracker_individual_minifigures" +), + +-- Combined minifigure statistics +minifig_stats AS ( + SELECT + set_minifig_stats.total_minifigure_instances + COALESCE(individual_minifig_stats.total_individual_minifigures, 0) AS total_minifigure_instances, + set_minifig_stats.total_minifigures_count + COALESCE(individual_minifig_stats.total_individual_minifigures_count, 0) AS total_minifigures_count, + (SELECT COUNT(DISTINCT "figure") FROM ( + SELECT "figure" FROM "bricktracker_minifigures" + UNION + SELECT "figure" FROM "bricktracker_individual_minifigures" + )) AS unique_minifigures + FROM set_minifig_stats, individual_minifig_stats +), + +-- Part lot statistics aggregation +part_lot_stats AS ( + SELECT + COUNT(*) AS total_part_lots, + COUNT(CASE WHEN "purchase_price" IS NOT NULL THEN 1 END) AS part_lots_with_price, + ROUND(SUM("purchase_price"), 2) AS part_lots_total_cost + FROM "bricktracker_individual_part_lots" +), + -- Rebrickable sets count (for sets we actually own) rebrickable_stats AS ( SELECT COUNT(*) AS unique_rebrickable_sets FROM "rebrickable_sets" WHERE "set" IN (SELECT DISTINCT "set" FROM "bricktracker_sets") +), + +-- Combined financial statistics +financial_stats AS ( + SELECT + -- Items with price + set_stats.sets_with_price + + COALESCE(individual_part_stats.individual_parts_with_price, 0) + + COALESCE(individual_minifig_stats.individual_minifigs_with_price, 0) + + COALESCE(part_lot_stats.part_lots_with_price, 0) AS total_items_with_price, + + -- Total cost across all item types + ROUND(COALESCE(set_stats.total_cost, 0) + + COALESCE(individual_part_stats.individual_parts_total_cost, 0) + + COALESCE(individual_minifig_stats.individual_minifigs_total_cost, 0) + + COALESCE(part_lot_stats.part_lots_total_cost, 0), 2) AS combined_total_cost, + + -- Average cost across all items with price + CASE + WHEN (set_stats.sets_with_price + + COALESCE(individual_part_stats.individual_parts_with_price, 0) + + COALESCE(individual_minifig_stats.individual_minifigs_with_price, 0) + + COALESCE(part_lot_stats.part_lots_with_price, 0)) > 0 + THEN ROUND((COALESCE(set_stats.total_cost, 0) + + COALESCE(individual_part_stats.individual_parts_total_cost, 0) + + COALESCE(individual_minifig_stats.individual_minifigs_total_cost, 0) + + COALESCE(part_lot_stats.part_lots_total_cost, 0)) / + (set_stats.sets_with_price + + COALESCE(individual_part_stats.individual_parts_with_price, 0) + + COALESCE(individual_minifig_stats.individual_minifigs_with_price, 0) + + COALESCE(part_lot_stats.part_lots_with_price, 0)), 2) + ELSE 0 + END AS combined_average_cost, + + -- Min/Max price across all item types + (SELECT MIN(price) FROM ( + SELECT "purchase_price" AS price FROM "bricktracker_sets" WHERE "purchase_price" IS NOT NULL + UNION ALL + SELECT "purchase_price" FROM "bricktracker_individual_parts" WHERE "purchase_price" IS NOT NULL AND "lot_id" IS NULL + UNION ALL + SELECT "purchase_price" FROM "bricktracker_individual_minifigures" WHERE "purchase_price" IS NOT NULL + UNION ALL + SELECT "purchase_price" FROM "bricktracker_individual_part_lots" WHERE "purchase_price" IS NOT NULL + )) AS combined_minimum_cost, + + (SELECT MAX(price) FROM ( + SELECT "purchase_price" AS price FROM "bricktracker_sets" WHERE "purchase_price" IS NOT NULL + UNION ALL + SELECT "purchase_price" FROM "bricktracker_individual_parts" WHERE "purchase_price" IS NOT NULL AND "lot_id" IS NULL + UNION ALL + SELECT "purchase_price" FROM "bricktracker_individual_minifigures" WHERE "purchase_price" IS NOT NULL + UNION ALL + SELECT "purchase_price" FROM "bricktracker_individual_part_lots" WHERE "purchase_price" IS NOT NULL + )) AS combined_maximum_cost + FROM set_stats, individual_part_stats, individual_minifig_stats, part_lot_stats ) -- Final select combining all statistics @@ -67,17 +181,24 @@ SELECT minifig_stats.total_minifigures_count, minifig_stats.unique_minifigures, - -- Financial statistics + -- Financial statistics (set-only for backwards compatibility) set_stats.sets_with_price, set_stats.total_cost, set_stats.average_cost, set_stats.minimum_cost, set_stats.maximum_cost, + -- Combined financial statistics (all item types) + financial_stats.total_items_with_price, + financial_stats.combined_total_cost, + financial_stats.combined_average_cost, + financial_stats.combined_minimum_cost, + financial_stats.combined_maximum_cost, + -- Storage and location statistics set_stats.storage_locations_used, set_stats.purchase_locations_used, set_stats.sets_with_storage, set_stats.sets_with_purchase_location -FROM set_stats, part_stats, minifig_stats, rebrickable_stats \ No newline at end of file +FROM set_stats, part_stats, minifig_stats, rebrickable_stats, financial_stats \ No newline at end of file diff --git a/bricktracker/sql/statistics/purchase_locations.sql b/bricktracker/sql/statistics/purchase_locations.sql index 81db813..58f7b97 100644 --- a/bricktracker/sql/statistics/purchase_locations.sql +++ b/bricktracker/sql/statistics/purchase_locations.sql @@ -1,45 +1,174 @@ -- Purchase Location Statistics -- Shows statistics grouped by purchase location +-- Includes sets, individual parts, individual minifigures, and part lots +WITH + +-- Set statistics by purchase location +set_purchase_stats AS ( + SELECT + "bricktracker_sets"."purchase_location" AS "location_id", + COUNT("bricktracker_sets"."id") AS "set_count", + COUNT(DISTINCT "bricktracker_sets"."set") AS "unique_set_count", + SUM("rebrickable_sets"."number_of_parts") AS "total_parts", + COUNT(CASE WHEN "bricktracker_sets"."purchase_price" IS NOT NULL THEN 1 END) AS "sets_with_price", + ROUND(SUM("bricktracker_sets"."purchase_price"), 2) AS "total_spent", + MIN("bricktracker_sets"."purchase_date") AS "first_purchase", + MAX("bricktracker_sets"."purchase_date") AS "latest_purchase", + COALESCE(SUM("problem_stats"."missing_parts"), 0) AS "missing_parts", + COALESCE(SUM("problem_stats"."damaged_parts"), 0) AS "damaged_parts", + COALESCE(SUM("minifigure_stats"."minifigure_count"), 0) AS "total_minifigures" + FROM "bricktracker_sets" + INNER JOIN "rebrickable_sets" ON "bricktracker_sets"."set" = "rebrickable_sets"."set" + LEFT JOIN ( + SELECT + "bricktracker_parts"."id", + SUM("bricktracker_parts"."missing") AS "missing_parts", + SUM("bricktracker_parts"."damaged") AS "damaged_parts" + FROM "bricktracker_parts" + GROUP BY "bricktracker_parts"."id" + ) "problem_stats" ON "bricktracker_sets"."id" = "problem_stats"."id" + LEFT JOIN ( + SELECT + "bricktracker_minifigures"."id", + SUM("bricktracker_minifigures"."quantity") AS "minifigure_count" + FROM "bricktracker_minifigures" + GROUP BY "bricktracker_minifigures"."id" + ) "minifigure_stats" ON "bricktracker_sets"."id" = "minifigure_stats"."id" + WHERE "bricktracker_sets"."purchase_location" IS NOT NULL + GROUP BY "bricktracker_sets"."purchase_location" +), + +-- Individual part statistics by purchase location +individual_part_purchase_stats AS ( + SELECT + "purchase_location" AS "location_id", + COUNT(*) AS "individual_part_count", + SUM("quantity") AS "individual_part_quantity", + SUM("missing") AS "individual_missing_parts", + SUM("damaged") AS "individual_damaged_parts", + COUNT(CASE WHEN "purchase_price" IS NOT NULL THEN 1 END) AS "individual_parts_with_price", + ROUND(SUM("purchase_price"), 2) AS "individual_total_spent", + MIN("purchase_date") AS "individual_first_purchase", + MAX("purchase_date") AS "individual_latest_purchase" + FROM "bricktracker_individual_parts" + WHERE "purchase_location" IS NOT NULL AND "lot_id" IS NULL + GROUP BY "purchase_location" +), + +-- Individual minifigure statistics by purchase location +individual_minifig_purchase_stats AS ( + SELECT + "purchase_location" AS "location_id", + COUNT(*) AS "individual_minifig_count", + SUM("quantity") AS "individual_minifig_quantity", + COUNT(CASE WHEN "purchase_price" IS NOT NULL THEN 1 END) AS "individual_minifigs_with_price", + ROUND(SUM("purchase_price"), 2) AS "individual_minifig_total_spent", + MIN("purchase_date") AS "individual_minifig_first_purchase", + MAX("purchase_date") AS "individual_minifig_latest_purchase" + FROM "bricktracker_individual_minifigures" + WHERE "purchase_location" IS NOT NULL + GROUP BY "purchase_location" +), + +-- Part lot statistics by purchase location +part_lot_purchase_stats AS ( + SELECT + "purchase_location" AS "location_id", + COUNT(*) AS "lot_count", + COUNT(CASE WHEN "purchase_price" IS NOT NULL THEN 1 END) AS "lots_with_price", + ROUND(SUM("purchase_price"), 2) AS "lot_total_spent", + MIN("purchase_date") AS "lot_first_purchase", + MAX("purchase_date") AS "lot_latest_purchase" + FROM "bricktracker_individual_part_lots" + WHERE "purchase_location" IS NOT NULL + GROUP BY "purchase_location" +), + +-- Min/Max price calculations (across all item types) +price_stats AS ( + SELECT + "purchase_location" AS "location_id", + MIN("purchase_price") AS "min_price", + MAX("purchase_price") AS "max_price" + FROM ( + SELECT "purchase_location", "purchase_price" FROM "bricktracker_sets" WHERE "purchase_price" IS NOT NULL + UNION ALL + SELECT "purchase_location", "purchase_price" FROM "bricktracker_individual_parts" WHERE "purchase_price" IS NOT NULL AND "lot_id" IS NULL + UNION ALL + SELECT "purchase_location", "purchase_price" FROM "bricktracker_individual_minifigures" WHERE "purchase_price" IS NOT NULL + UNION ALL + SELECT "purchase_location", "purchase_price" FROM "bricktracker_individual_part_lots" WHERE "purchase_price" IS NOT NULL + ) + WHERE "purchase_location" IS NOT NULL + GROUP BY "purchase_location" +) + +-- Combine all statistics SELECT - "bricktracker_sets"."purchase_location" AS "location_id", + COALESCE(sps.location_id, ipps.location_id, imps.location_id, plps.location_id) AS "location_id", "bricktracker_metadata_purchase_locations"."name" AS "location_name", - COUNT("bricktracker_sets"."id") AS "set_count", - COUNT(DISTINCT "bricktracker_sets"."set") AS "unique_set_count", - SUM("rebrickable_sets"."number_of_parts") AS "total_parts", - ROUND(AVG("rebrickable_sets"."number_of_parts"), 0) AS "avg_parts_per_set", - -- Financial statistics per purchase location - COUNT(CASE WHEN "bricktracker_sets"."purchase_price" IS NOT NULL THEN 1 END) AS "sets_with_price", - ROUND(SUM("bricktracker_sets"."purchase_price"), 2) AS "total_spent", - ROUND(AVG("bricktracker_sets"."purchase_price"), 2) AS "avg_price", - ROUND(MIN("bricktracker_sets"."purchase_price"), 2) AS "min_price", - ROUND(MAX("bricktracker_sets"."purchase_price"), 2) AS "max_price", - -- Date range statistics - MIN("bricktracker_sets"."purchase_date") AS "first_purchase", - MAX("bricktracker_sets"."purchase_date") AS "latest_purchase", - -- Problem statistics per purchase location - COALESCE(SUM("problem_stats"."missing_parts"), 0) AS "missing_parts", - COALESCE(SUM("problem_stats"."damaged_parts"), 0) AS "damaged_parts", - -- Minifigure statistics per purchase location - COALESCE(SUM("minifigure_stats"."minifigure_count"), 0) AS "total_minifigures" -FROM "bricktracker_sets" -INNER JOIN "rebrickable_sets" ON "bricktracker_sets"."set" = "rebrickable_sets"."set" -LEFT JOIN "bricktracker_metadata_purchase_locations" ON "bricktracker_sets"."purchase_location" = "bricktracker_metadata_purchase_locations"."id" -LEFT JOIN ( - SELECT - "bricktracker_parts"."id", - SUM("bricktracker_parts"."missing") AS "missing_parts", - SUM("bricktracker_parts"."damaged") AS "damaged_parts" - FROM "bricktracker_parts" - GROUP BY "bricktracker_parts"."id" -) "problem_stats" ON "bricktracker_sets"."id" = "problem_stats"."id" -LEFT JOIN ( - SELECT - "bricktracker_minifigures"."id", - SUM("bricktracker_minifigures"."quantity") AS "minifigure_count" - FROM "bricktracker_minifigures" - GROUP BY "bricktracker_minifigures"."id" -) "minifigure_stats" ON "bricktracker_sets"."id" = "minifigure_stats"."id" -WHERE "bricktracker_sets"."purchase_location" IS NOT NULL -GROUP BY "bricktracker_sets"."purchase_location", "bricktracker_metadata_purchase_locations"."name" -ORDER BY "set_count" DESC, "location_name" ASC \ No newline at end of file + + -- Set counts + COALESCE(sps.set_count, 0) AS "set_count", + COALESCE(sps.unique_set_count, 0) AS "unique_set_count", + + -- Individual item counts + COALESCE(ipps.individual_part_count, 0) AS "individual_part_count", + COALESCE(imps.individual_minifig_count, 0) AS "individual_minifig_count", + COALESCE(plps.lot_count, 0) AS "lot_count", + + -- Part counts + COALESCE(sps.total_parts, 0) + COALESCE(ipps.individual_part_quantity, 0) AS "total_parts", + CASE + WHEN COALESCE(sps.set_count, 0) > 0 + THEN ROUND(CAST(COALESCE(sps.total_parts, 0) AS FLOAT) / sps.set_count, 0) + ELSE 0 + END AS "avg_parts_per_set", + + -- Financial statistics + COALESCE(sps.sets_with_price, 0) + COALESCE(ipps.individual_parts_with_price, 0) + + COALESCE(imps.individual_minifigs_with_price, 0) + COALESCE(plps.lots_with_price, 0) AS "items_with_price", + ROUND(COALESCE(sps.total_spent, 0) + COALESCE(ipps.individual_total_spent, 0) + + COALESCE(imps.individual_minifig_total_spent, 0) + COALESCE(plps.lot_total_spent, 0), 2) AS "total_spent", + CASE + WHEN (COALESCE(sps.sets_with_price, 0) + COALESCE(ipps.individual_parts_with_price, 0) + + COALESCE(imps.individual_minifigs_with_price, 0) + COALESCE(plps.lots_with_price, 0)) > 0 + THEN ROUND((COALESCE(sps.total_spent, 0) + COALESCE(ipps.individual_total_spent, 0) + + COALESCE(imps.individual_minifig_total_spent, 0) + COALESCE(plps.lot_total_spent, 0)) / + (COALESCE(sps.sets_with_price, 0) + COALESCE(ipps.individual_parts_with_price, 0) + + COALESCE(imps.individual_minifigs_with_price, 0) + COALESCE(plps.lots_with_price, 0)), 2) + ELSE 0 + END AS "avg_price", + ROUND(COALESCE(ps.min_price, 0), 2) AS "min_price", + ROUND(COALESCE(ps.max_price, 0), 2) AS "max_price", + + -- Date range statistics (earliest and latest purchases across all types) + (SELECT MIN(d) FROM ( + SELECT sps.first_purchase AS d + UNION ALL SELECT ipps.individual_first_purchase + UNION ALL SELECT imps.individual_minifig_first_purchase + UNION ALL SELECT plps.lot_first_purchase + ) WHERE d IS NOT NULL) AS "first_purchase", + (SELECT MAX(d) FROM ( + SELECT sps.latest_purchase AS d + UNION ALL SELECT ipps.individual_latest_purchase + UNION ALL SELECT imps.individual_minifig_latest_purchase + UNION ALL SELECT plps.lot_latest_purchase + ) WHERE d IS NOT NULL) AS "latest_purchase", + + -- Problem statistics + COALESCE(sps.missing_parts, 0) + COALESCE(ipps.individual_missing_parts, 0) AS "missing_parts", + COALESCE(sps.damaged_parts, 0) + COALESCE(ipps.individual_damaged_parts, 0) AS "damaged_parts", + + -- Minifigure counts + COALESCE(sps.total_minifigures, 0) + COALESCE(imps.individual_minifig_quantity, 0) AS "total_minifigures" + +FROM set_purchase_stats sps +FULL OUTER JOIN individual_part_purchase_stats ipps ON sps.location_id = ipps.location_id +FULL OUTER JOIN individual_minifig_purchase_stats imps ON COALESCE(sps.location_id, ipps.location_id) = imps.location_id +FULL OUTER JOIN part_lot_purchase_stats plps ON COALESCE(sps.location_id, ipps.location_id, imps.location_id) = plps.location_id +LEFT JOIN price_stats ps ON COALESCE(sps.location_id, ipps.location_id, imps.location_id, plps.location_id) = ps.location_id +LEFT JOIN "bricktracker_metadata_purchase_locations" ON COALESCE(sps.location_id, ipps.location_id, imps.location_id, plps.location_id) = "bricktracker_metadata_purchase_locations"."id" + +ORDER BY "set_count" DESC, "location_name" ASC diff --git a/bricktracker/sql/statistics/purchases_by_year.sql b/bricktracker/sql/statistics/purchases_by_year.sql index d89eeba..0b8d906 100644 --- a/bricktracker/sql/statistics/purchases_by_year.sql +++ b/bricktracker/sql/statistics/purchases_by_year.sql @@ -1,49 +1,220 @@ -- Purchases by Year Statistics --- Shows statistics grouped by purchase year (when you bought the sets) +-- Shows statistics grouped by purchase year (when you bought items) +-- Includes sets, individual parts, individual minifigures, and part lots +WITH + +-- Set purchases by year +set_purchases AS ( + SELECT + strftime('%Y', datetime("bricktracker_sets"."purchase_date", 'unixepoch')) AS "purchase_year", + COUNT("bricktracker_sets"."id") AS "total_sets", + COUNT(DISTINCT "bricktracker_sets"."set") AS "unique_sets", + SUM("rebrickable_sets"."number_of_parts") AS "total_parts", + ROUND(AVG("rebrickable_sets"."number_of_parts"), 0) AS "avg_parts_per_set", + COUNT(CASE WHEN "bricktracker_sets"."purchase_price" IS NOT NULL THEN 1 END) AS "sets_with_price", + ROUND(SUM("bricktracker_sets"."purchase_price"), 2) AS "sets_total_spent", + MIN("rebrickable_sets"."year") AS "oldest_set_year", + MAX("rebrickable_sets"."year") AS "newest_set_year", + ROUND(AVG("rebrickable_sets"."year"), 0) AS "avg_set_release_year", + COALESCE(SUM("problem_stats"."missing_parts"), 0) AS "set_missing_parts", + COALESCE(SUM("problem_stats"."damaged_parts"), 0) AS "set_damaged_parts", + COALESCE(SUM("minifigure_stats"."minifigure_count"), 0) AS "set_minifigures", + COALESCE(SUM("minifigure_stats"."unique_minifigures"), 0) AS "set_unique_minifigures", + COUNT(DISTINCT "rebrickable_sets"."theme_id") AS "unique_themes", + COUNT(DISTINCT "bricktracker_sets"."purchase_location") AS "set_unique_purchase_locations", + COUNT(DISTINCT strftime('%m', datetime("bricktracker_sets"."purchase_date", 'unixepoch'))) AS "months_with_purchases" + FROM "bricktracker_sets" + INNER JOIN "rebrickable_sets" ON "bricktracker_sets"."set" = "rebrickable_sets"."set" + LEFT JOIN ( + SELECT + "bricktracker_parts"."id", + SUM("bricktracker_parts"."missing") AS "missing_parts", + SUM("bricktracker_parts"."damaged") AS "damaged_parts" + FROM "bricktracker_parts" + GROUP BY "bricktracker_parts"."id" + ) "problem_stats" ON "bricktracker_sets"."id" = "problem_stats"."id" + LEFT JOIN ( + SELECT + "bricktracker_minifigures"."id", + SUM("bricktracker_minifigures"."quantity") AS "minifigure_count", + COUNT(DISTINCT "bricktracker_minifigures"."figure") AS "unique_minifigures" + FROM "bricktracker_minifigures" + GROUP BY "bricktracker_minifigures"."id" + ) "minifigure_stats" ON "bricktracker_sets"."id" = "minifigure_stats"."id" + WHERE "bricktracker_sets"."purchase_date" IS NOT NULL + GROUP BY strftime('%Y', datetime("bricktracker_sets"."purchase_date", 'unixepoch')) +), + +-- Individual part purchases by year +individual_part_purchases AS ( + SELECT + strftime('%Y', datetime("bricktracker_individual_parts"."purchase_date", 'unixepoch')) AS "purchase_year", + COUNT(*) AS "individual_part_count", + SUM("bricktracker_individual_parts"."quantity") AS "individual_part_quantity", + COUNT(DISTINCT "bricktracker_individual_parts"."part" || '-' || "bricktracker_individual_parts"."color") AS "unique_individual_parts", + SUM("bricktracker_individual_parts"."missing") AS "individual_missing_parts", + SUM("bricktracker_individual_parts"."damaged") AS "individual_damaged_parts", + COUNT(CASE WHEN "bricktracker_individual_parts"."purchase_price" IS NOT NULL AND "bricktracker_individual_parts"."lot_id" IS NULL THEN 1 END) AS "individual_parts_with_price", + ROUND(SUM(CASE WHEN "bricktracker_individual_parts"."lot_id" IS NULL THEN "bricktracker_individual_parts"."purchase_price" END), 2) AS "individual_parts_total_spent", + COUNT(DISTINCT "bricktracker_individual_parts"."purchase_location") AS "individual_part_unique_purchase_locations" + FROM "bricktracker_individual_parts" + WHERE "bricktracker_individual_parts"."purchase_date" IS NOT NULL + GROUP BY strftime('%Y', datetime("bricktracker_individual_parts"."purchase_date", 'unixepoch')) +), + +-- Individual minifigure purchases by year +individual_minifig_purchases AS ( + SELECT + strftime('%Y', datetime("bricktracker_individual_minifigures"."purchase_date", 'unixepoch')) AS "purchase_year", + COUNT(*) AS "individual_minifig_count", + SUM("bricktracker_individual_minifigures"."quantity") AS "individual_minifig_quantity", + COUNT(DISTINCT "bricktracker_individual_minifigures"."figure") AS "unique_individual_minifigures", + COUNT(CASE WHEN "bricktracker_individual_minifigures"."purchase_price" IS NOT NULL THEN 1 END) AS "individual_minifigs_with_price", + ROUND(SUM("bricktracker_individual_minifigures"."purchase_price"), 2) AS "individual_minifigs_total_spent", + COUNT(DISTINCT "bricktracker_individual_minifigures"."purchase_location") AS "individual_minifig_unique_purchase_locations" + FROM "bricktracker_individual_minifigures" + WHERE "bricktracker_individual_minifigures"."purchase_date" IS NOT NULL + GROUP BY strftime('%Y', datetime("bricktracker_individual_minifigures"."purchase_date", 'unixepoch')) +), + +-- Part lot purchases by year +part_lot_purchases AS ( + SELECT + strftime('%Y', datetime("bricktracker_individual_part_lots"."purchase_date", 'unixepoch')) AS "purchase_year", + COUNT(*) AS "lot_count", + COUNT(CASE WHEN "bricktracker_individual_part_lots"."purchase_price" IS NOT NULL THEN 1 END) AS "lots_with_price", + ROUND(SUM("bricktracker_individual_part_lots"."purchase_price"), 2) AS "lots_total_spent", + COUNT(DISTINCT "bricktracker_individual_part_lots"."purchase_location") AS "lot_unique_purchase_locations" + FROM "bricktracker_individual_part_lots" + WHERE "bricktracker_individual_part_lots"."purchase_date" IS NOT NULL + GROUP BY strftime('%Y', datetime("bricktracker_individual_part_lots"."purchase_date", 'unixepoch')) +), + +-- All purchase years (union of all types) +all_years AS ( + SELECT DISTINCT "purchase_year" FROM set_purchases WHERE "purchase_year" IS NOT NULL + UNION + SELECT DISTINCT "purchase_year" FROM individual_part_purchases WHERE "purchase_year" IS NOT NULL + UNION + SELECT DISTINCT "purchase_year" FROM individual_minifig_purchases WHERE "purchase_year" IS NOT NULL + UNION + SELECT DISTINCT "purchase_year" FROM part_lot_purchases WHERE "purchase_year" IS NOT NULL +) + +-- Combine all statistics SELECT - strftime('%Y', datetime("bricktracker_sets"."purchase_date", 'unixepoch')) AS "purchase_year", - COUNT("bricktracker_sets"."id") AS "total_sets", - COUNT(DISTINCT "bricktracker_sets"."set") AS "unique_sets", - SUM("rebrickable_sets"."number_of_parts") AS "total_parts", - ROUND(AVG("rebrickable_sets"."number_of_parts"), 0) AS "avg_parts_per_set", - -- Financial statistics per purchase year - COUNT(CASE WHEN "bricktracker_sets"."purchase_price" IS NOT NULL THEN 1 END) AS "sets_with_price", - ROUND(SUM("bricktracker_sets"."purchase_price"), 2) AS "total_spent", - ROUND(AVG("bricktracker_sets"."purchase_price"), 2) AS "avg_price_per_set", - ROUND(MIN("bricktracker_sets"."purchase_price"), 2) AS "min_price", - ROUND(MAX("bricktracker_sets"."purchase_price"), 2) AS "max_price", - -- Release year statistics for sets purchased in this year - MIN("rebrickable_sets"."year") AS "oldest_set_year", - MAX("rebrickable_sets"."year") AS "newest_set_year", - ROUND(AVG("rebrickable_sets"."year"), 0) AS "avg_set_release_year", - -- Problem statistics per purchase year - COALESCE(SUM("problem_stats"."missing_parts"), 0) AS "missing_parts", - COALESCE(SUM("problem_stats"."damaged_parts"), 0) AS "damaged_parts", - -- Minifigure statistics per purchase year - COALESCE(SUM("minifigure_stats"."minifigure_count"), 0) AS "total_minifigures", - -- Diversity statistics per purchase year - COUNT(DISTINCT "rebrickable_sets"."theme_id") AS "unique_themes", - COUNT(DISTINCT "bricktracker_sets"."purchase_location") AS "unique_purchase_locations", - -- Monthly statistics within the year - COUNT(DISTINCT strftime('%m', datetime("bricktracker_sets"."purchase_date", 'unixepoch'))) AS "months_with_purchases" -FROM "bricktracker_sets" -INNER JOIN "rebrickable_sets" ON "bricktracker_sets"."set" = "rebrickable_sets"."set" -LEFT JOIN ( - SELECT - "bricktracker_parts"."id", - SUM("bricktracker_parts"."missing") AS "missing_parts", - SUM("bricktracker_parts"."damaged") AS "damaged_parts" - FROM "bricktracker_parts" - GROUP BY "bricktracker_parts"."id" -) "problem_stats" ON "bricktracker_sets"."id" = "problem_stats"."id" -LEFT JOIN ( - SELECT - "bricktracker_minifigures"."id", - SUM("bricktracker_minifigures"."quantity") AS "minifigure_count" - FROM "bricktracker_minifigures" - GROUP BY "bricktracker_minifigures"."id" -) "minifigure_stats" ON "bricktracker_sets"."id" = "minifigure_stats"."id" -WHERE "bricktracker_sets"."purchase_date" IS NOT NULL -GROUP BY strftime('%Y', datetime("bricktracker_sets"."purchase_date", 'unixepoch')) -ORDER BY "purchase_year" DESC \ No newline at end of file + ay.purchase_year, + + -- Set counts + COALESCE(sp.total_sets, 0) AS "total_sets", + COALESCE(sp.unique_sets, 0) AS "unique_sets", + + -- Individual item counts + COALESCE(ipp.individual_part_count, 0) AS "individual_part_count", + COALESCE(imp.individual_minifig_count, 0) AS "individual_minifig_count", + COALESCE(plp.lot_count, 0) AS "lot_count", + + -- Part counts (unique and total) + COALESCE(ipp.unique_individual_parts, 0) AS "unique_parts", + COALESCE(sp.total_parts, 0) + COALESCE(ipp.individual_part_quantity, 0) AS "total_parts", + COALESCE(sp.avg_parts_per_set, 0) AS "avg_parts_per_set", + + -- Minifigure counts (unique and total) + COALESCE(sp.set_unique_minifigures, 0) + COALESCE(imp.unique_individual_minifigures, 0) AS "unique_minifigures", + COALESCE(sp.set_minifigures, 0) + COALESCE(imp.individual_minifig_quantity, 0) AS "total_minifigures", + + -- Financial statistics (combined) + COALESCE(sp.sets_with_price, 0) + COALESCE(ipp.individual_parts_with_price, 0) + + COALESCE(imp.individual_minifigs_with_price, 0) + COALESCE(plp.lots_with_price, 0) AS "items_with_price", + ROUND(COALESCE(sp.sets_total_spent, 0) + COALESCE(ipp.individual_parts_total_spent, 0) + + COALESCE(imp.individual_minifigs_total_spent, 0) + COALESCE(plp.lots_total_spent, 0), 2) AS "total_spent", + + -- Average, min, max price across all item types for this year + CASE + WHEN (COALESCE(sp.sets_with_price, 0) + COALESCE(ipp.individual_parts_with_price, 0) + + COALESCE(imp.individual_minifigs_with_price, 0) + COALESCE(plp.lots_with_price, 0)) > 0 + THEN ROUND((COALESCE(sp.sets_total_spent, 0) + COALESCE(ipp.individual_parts_total_spent, 0) + + COALESCE(imp.individual_minifigs_total_spent, 0) + COALESCE(plp.lots_total_spent, 0)) / + (COALESCE(sp.sets_with_price, 0) + COALESCE(ipp.individual_parts_with_price, 0) + + COALESCE(imp.individual_minifigs_with_price, 0) + COALESCE(plp.lots_with_price, 0)), 2) + ELSE 0 + END AS "avg_price_per_item", + + (SELECT MIN(price) FROM ( + SELECT "purchase_price" AS price FROM "bricktracker_sets" + WHERE "purchase_price" IS NOT NULL + AND strftime('%Y', datetime("purchase_date", 'unixepoch')) = ay.purchase_year + UNION ALL + SELECT "purchase_price" FROM "bricktracker_individual_parts" + WHERE "purchase_price" IS NOT NULL AND "lot_id" IS NULL + AND strftime('%Y', datetime("purchase_date", 'unixepoch')) = ay.purchase_year + UNION ALL + SELECT "purchase_price" FROM "bricktracker_individual_minifigures" + WHERE "purchase_price" IS NOT NULL + AND strftime('%Y', datetime("purchase_date", 'unixepoch')) = ay.purchase_year + UNION ALL + SELECT "purchase_price" FROM "bricktracker_individual_part_lots" + WHERE "purchase_price" IS NOT NULL + AND strftime('%Y', datetime("purchase_date", 'unixepoch')) = ay.purchase_year + )) AS "min_price", + + (SELECT MAX(price) FROM ( + SELECT "purchase_price" AS price FROM "bricktracker_sets" + WHERE "purchase_price" IS NOT NULL + AND strftime('%Y', datetime("purchase_date", 'unixepoch')) = ay.purchase_year + UNION ALL + SELECT "purchase_price" FROM "bricktracker_individual_parts" + WHERE "purchase_price" IS NOT NULL AND "lot_id" IS NULL + AND strftime('%Y', datetime("purchase_date", 'unixepoch')) = ay.purchase_year + UNION ALL + SELECT "purchase_price" FROM "bricktracker_individual_minifigures" + WHERE "purchase_price" IS NOT NULL + AND strftime('%Y', datetime("purchase_date", 'unixepoch')) = ay.purchase_year + UNION ALL + SELECT "purchase_price" FROM "bricktracker_individual_part_lots" + WHERE "purchase_price" IS NOT NULL + AND strftime('%Y', datetime("purchase_date", 'unixepoch')) = ay.purchase_year + )) AS "max_price", + + -- Set-specific statistics (for backward compatibility, may be NULL if no sets purchased this year) + sp.oldest_set_year, + sp.newest_set_year, + sp.avg_set_release_year, + + -- Backward compatibility: avg_price_per_set uses combined average (duplicate calculation) + CASE + WHEN (COALESCE(sp.sets_with_price, 0) + COALESCE(ipp.individual_parts_with_price, 0) + + COALESCE(imp.individual_minifigs_with_price, 0) + COALESCE(plp.lots_with_price, 0)) > 0 + THEN ROUND((COALESCE(sp.sets_total_spent, 0) + COALESCE(ipp.individual_parts_total_spent, 0) + + COALESCE(imp.individual_minifigs_total_spent, 0) + COALESCE(plp.lots_total_spent, 0)) / + (COALESCE(sp.sets_with_price, 0) + COALESCE(ipp.individual_parts_with_price, 0) + + COALESCE(imp.individual_minifigs_with_price, 0) + COALESCE(plp.lots_with_price, 0)), 2) + ELSE 0 + END AS "avg_price_per_set", + + -- Problem statistics + COALESCE(sp.set_missing_parts, 0) + COALESCE(ipp.individual_missing_parts, 0) AS "missing_parts", + COALESCE(sp.set_damaged_parts, 0) + COALESCE(ipp.individual_damaged_parts, 0) AS "damaged_parts", + + -- Diversity statistics + COALESCE(sp.unique_themes, 0) AS "unique_themes", + (SELECT COUNT(DISTINCT location) FROM ( + SELECT COALESCE(sp.set_unique_purchase_locations, 0) AS location + UNION + SELECT COALESCE(ipp.individual_part_unique_purchase_locations, 0) + UNION + SELECT COALESCE(imp.individual_minifig_unique_purchase_locations, 0) + UNION + SELECT COALESCE(plp.lot_unique_purchase_locations, 0) + )) AS "unique_purchase_locations", + + COALESCE(sp.months_with_purchases, 0) AS "months_with_purchases" + +FROM all_years ay +LEFT JOIN set_purchases sp ON ay.purchase_year = sp.purchase_year +LEFT JOIN individual_part_purchases ipp ON ay.purchase_year = ipp.purchase_year +LEFT JOIN individual_minifig_purchases imp ON ay.purchase_year = imp.purchase_year +LEFT JOIN part_lot_purchases plp ON ay.purchase_year = plp.purchase_year + +ORDER BY ay.purchase_year DESC \ No newline at end of file diff --git a/bricktracker/sql/statistics/storage.sql b/bricktracker/sql/statistics/storage.sql index 2d78849..1f06d6c 100644 --- a/bricktracker/sql/statistics/storage.sql +++ b/bricktracker/sql/statistics/storage.sql @@ -1,40 +1,130 @@ -- Storage Location Statistics -- Shows statistics grouped by storage location +-- Includes sets, individual parts, individual minifigures, and part lots +WITH + +-- Set statistics by storage +set_storage_stats AS ( + SELECT + "bricktracker_sets"."storage" AS "storage_id", + COUNT("bricktracker_sets"."id") AS "set_count", + COUNT(DISTINCT "bricktracker_sets"."set") AS "unique_set_count", + SUM("rebrickable_sets"."number_of_parts") AS "total_parts", + COUNT(CASE WHEN "bricktracker_sets"."purchase_price" IS NOT NULL THEN 1 END) AS "sets_with_price", + ROUND(SUM("bricktracker_sets"."purchase_price"), 2) AS "total_value", + COALESCE(SUM("problem_stats"."missing_parts"), 0) AS "missing_parts", + COALESCE(SUM("problem_stats"."damaged_parts"), 0) AS "damaged_parts", + COALESCE(SUM("minifigure_stats"."minifigure_count"), 0) AS "total_minifigures" + FROM "bricktracker_sets" + INNER JOIN "rebrickable_sets" ON "bricktracker_sets"."set" = "rebrickable_sets"."set" + LEFT JOIN ( + SELECT + "bricktracker_parts"."id", + SUM("bricktracker_parts"."missing") AS "missing_parts", + SUM("bricktracker_parts"."damaged") AS "damaged_parts" + FROM "bricktracker_parts" + GROUP BY "bricktracker_parts"."id" + ) "problem_stats" ON "bricktracker_sets"."id" = "problem_stats"."id" + LEFT JOIN ( + SELECT + "bricktracker_minifigures"."id", + SUM("bricktracker_minifigures"."quantity") AS "minifigure_count" + FROM "bricktracker_minifigures" + GROUP BY "bricktracker_minifigures"."id" + ) "minifigure_stats" ON "bricktracker_sets"."id" = "minifigure_stats"."id" + WHERE "bricktracker_sets"."storage" IS NOT NULL + GROUP BY "bricktracker_sets"."storage" +), + +-- Individual part statistics by storage +individual_part_storage_stats AS ( + SELECT + "storage" AS "storage_id", + COUNT(*) AS "individual_part_count", + SUM("quantity") AS "individual_part_quantity", + SUM("missing") AS "individual_missing_parts", + SUM("damaged") AS "individual_damaged_parts", + COUNT(CASE WHEN "purchase_price" IS NOT NULL THEN 1 END) AS "individual_parts_with_price", + ROUND(SUM("purchase_price"), 2) AS "individual_total_value" + FROM "bricktracker_individual_parts" + WHERE "storage" IS NOT NULL AND "lot_id" IS NULL + GROUP BY "storage" +), + +-- Individual minifigure statistics by storage +individual_minifig_storage_stats AS ( + SELECT + "storage" AS "storage_id", + COUNT(*) AS "individual_minifig_count", + SUM("quantity") AS "individual_minifig_quantity", + COUNT(CASE WHEN "purchase_price" IS NOT NULL THEN 1 END) AS "individual_minifigs_with_price", + ROUND(SUM("purchase_price"), 2) AS "individual_minifig_total_value" + FROM "bricktracker_individual_minifigures" + WHERE "storage" IS NOT NULL + GROUP BY "storage" +), + +-- Part lot statistics by storage +part_lot_storage_stats AS ( + SELECT + "storage" AS "storage_id", + COUNT(*) AS "lot_count", + COUNT(CASE WHEN "purchase_price" IS NOT NULL THEN 1 END) AS "lots_with_price", + ROUND(SUM("purchase_price"), 2) AS "lot_total_value" + FROM "bricktracker_individual_part_lots" + WHERE "storage" IS NOT NULL + GROUP BY "storage" +) + +-- Combine all statistics SELECT - "bricktracker_sets"."storage" AS "storage_id", + COALESCE(sss.storage_id, ipss.storage_id, imss.storage_id, plss.storage_id) AS "storage_id", "bricktracker_metadata_storages"."name" AS "storage_name", - COUNT("bricktracker_sets"."id") AS "set_count", - COUNT(DISTINCT "bricktracker_sets"."set") AS "unique_set_count", - SUM("rebrickable_sets"."number_of_parts") AS "total_parts", - ROUND(AVG("rebrickable_sets"."number_of_parts"), 0) AS "avg_parts_per_set", - -- Financial statistics per storage - COUNT(CASE WHEN "bricktracker_sets"."purchase_price" IS NOT NULL THEN 1 END) AS "sets_with_price", - ROUND(SUM("bricktracker_sets"."purchase_price"), 2) AS "total_value", - ROUND(AVG("bricktracker_sets"."purchase_price"), 2) AS "avg_price", - -- Problem statistics per storage - COALESCE(SUM("problem_stats"."missing_parts"), 0) AS "missing_parts", - COALESCE(SUM("problem_stats"."damaged_parts"), 0) AS "damaged_parts", - -- Minifigure statistics per storage - COALESCE(SUM("minifigure_stats"."minifigure_count"), 0) AS "total_minifigures" -FROM "bricktracker_sets" -INNER JOIN "rebrickable_sets" ON "bricktracker_sets"."set" = "rebrickable_sets"."set" -LEFT JOIN "bricktracker_metadata_storages" ON "bricktracker_sets"."storage" = "bricktracker_metadata_storages"."id" -LEFT JOIN ( - SELECT - "bricktracker_parts"."id", - SUM("bricktracker_parts"."missing") AS "missing_parts", - SUM("bricktracker_parts"."damaged") AS "damaged_parts" - FROM "bricktracker_parts" - GROUP BY "bricktracker_parts"."id" -) "problem_stats" ON "bricktracker_sets"."id" = "problem_stats"."id" -LEFT JOIN ( - SELECT - "bricktracker_minifigures"."id", - SUM("bricktracker_minifigures"."quantity") AS "minifigure_count" - FROM "bricktracker_minifigures" - GROUP BY "bricktracker_minifigures"."id" -) "minifigure_stats" ON "bricktracker_sets"."id" = "minifigure_stats"."id" -WHERE "bricktracker_sets"."storage" IS NOT NULL -GROUP BY "bricktracker_sets"."storage", "bricktracker_metadata_storages"."name" -ORDER BY "set_count" DESC, "storage_name" ASC \ No newline at end of file + + -- Set counts + COALESCE(sss.set_count, 0) AS "set_count", + COALESCE(sss.unique_set_count, 0) AS "unique_set_count", + + -- Individual item counts + COALESCE(ipss.individual_part_count, 0) AS "individual_part_count", + COALESCE(imss.individual_minifig_count, 0) AS "individual_minifig_count", + COALESCE(plss.lot_count, 0) AS "lot_count", + + -- Part counts + COALESCE(sss.total_parts, 0) + COALESCE(ipss.individual_part_quantity, 0) AS "total_parts", + CASE + WHEN COALESCE(sss.set_count, 0) > 0 + THEN ROUND(CAST(COALESCE(sss.total_parts, 0) AS FLOAT) / sss.set_count, 0) + ELSE 0 + END AS "avg_parts_per_set", + + -- Financial statistics + COALESCE(sss.sets_with_price, 0) + COALESCE(ipss.individual_parts_with_price, 0) + + COALESCE(imss.individual_minifigs_with_price, 0) + COALESCE(plss.lots_with_price, 0) AS "items_with_price", + ROUND(COALESCE(sss.total_value, 0) + COALESCE(ipss.individual_total_value, 0) + + COALESCE(imss.individual_minifig_total_value, 0) + COALESCE(plss.lot_total_value, 0), 2) AS "total_value", + CASE + WHEN (COALESCE(sss.sets_with_price, 0) + COALESCE(ipss.individual_parts_with_price, 0) + + COALESCE(imss.individual_minifigs_with_price, 0) + COALESCE(plss.lots_with_price, 0)) > 0 + THEN ROUND((COALESCE(sss.total_value, 0) + COALESCE(ipss.individual_total_value, 0) + + COALESCE(imss.individual_minifig_total_value, 0) + COALESCE(plss.lot_total_value, 0)) / + (COALESCE(sss.sets_with_price, 0) + COALESCE(ipss.individual_parts_with_price, 0) + + COALESCE(imss.individual_minifigs_with_price, 0) + COALESCE(plss.lots_with_price, 0)), 2) + ELSE 0 + END AS "avg_price", + + -- Problem statistics + COALESCE(sss.missing_parts, 0) + COALESCE(ipss.individual_missing_parts, 0) AS "missing_parts", + COALESCE(sss.damaged_parts, 0) + COALESCE(ipss.individual_damaged_parts, 0) AS "damaged_parts", + + -- Minifigure counts + COALESCE(sss.total_minifigures, 0) + COALESCE(imss.individual_minifig_quantity, 0) AS "total_minifigures" + +FROM set_storage_stats sss +FULL OUTER JOIN individual_part_storage_stats ipss ON sss.storage_id = ipss.storage_id +FULL OUTER JOIN individual_minifig_storage_stats imss ON COALESCE(sss.storage_id, ipss.storage_id) = imss.storage_id +FULL OUTER JOIN part_lot_storage_stats plss ON COALESCE(sss.storage_id, ipss.storage_id, imss.storage_id) = plss.storage_id +LEFT JOIN "bricktracker_metadata_storages" ON COALESCE(sss.storage_id, ipss.storage_id, imss.storage_id, plss.storage_id) = "bricktracker_metadata_storages"."id" + +ORDER BY "set_count" DESC, "storage_name" ASC From 77be333bb209ed9d985560e69a2a292354195280 Mon Sep 17 00:00:00 2001 From: FrederikBaerentsen Date: Mon, 19 Jan 2026 17:24:13 +0100 Subject: [PATCH 33/63] feat(views): integrate individual items into existing views --- bricktracker/views/add.py | 22 +++++++++++++++++++++- bricktracker/views/minifigure.py | 7 ++++++- bricktracker/views/part.py | 9 ++++++++- bricktracker/views/storage.py | 6 ++++++ 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/bricktracker/views/add.py b/bricktracker/views/add.py index db4671e..369787c 100644 --- a/bricktracker/views/add.py +++ b/bricktracker/views/add.py @@ -1,4 +1,4 @@ -from flask import Blueprint, current_app, render_template +from flask import Blueprint, current_app, render_template, abort from flask_login import login_required from ..configuration_list import BrickConfigurationList @@ -40,3 +40,23 @@ def bulk() -> str: bulk=True, **set_metadata_lists() ) + + +# Add individual parts +@add_page.route('/parts', methods=['GET']) +@login_required +@exception_handler(__file__) +def parts() -> str: + # Block route if individual parts feature is disabled + if current_app.config.get('DISABLE_INDIVIDUAL_PARTS', False): + abort(404) + + BrickConfigurationList.error_unless_is_set('REBRICKABLE_API_KEY') + + return render_template( + 'add_parts.html', + path=current_app.config['SOCKET_PATH'], + namespace=current_app.config['SOCKET_NAMESPACE'], + messages=MESSAGES, + **set_metadata_lists() + ) diff --git a/bricktracker/views/minifigure.py b/bricktracker/views/minifigure.py index 7fbb833..f700c67 100644 --- a/bricktracker/views/minifigure.py +++ b/bricktracker/views/minifigure.py @@ -1,6 +1,7 @@ from flask import Blueprint, current_app, render_template, request from .exceptions import exception_handler +from ..individual_minifigure_list import IndividualMinifigureList from ..minifigure import BrickMinifigure from ..minifigure_list import BrickMinifigureList from ..pagination_helper import get_pagination_config, build_pagination_context, get_request_params @@ -19,6 +20,7 @@ def list() -> str: problems_filter = request.args.get('problems', 'all') theme_id = request.args.get('theme', 'all') year = request.args.get('year', 'all') + individuals_filter = request.args.get('individuals', 'all') search_query, sort_field, sort_order, page = get_request_params() # Get pagination configuration @@ -32,6 +34,7 @@ def list() -> str: problems_filter=problems_filter, theme_id=theme_id, year=year, + individuals_filter=individuals_filter, search_query=search_query, page=page, per_page=per_page, @@ -42,7 +45,7 @@ def list() -> str: pagination_context = build_pagination_context(page, per_page, total_count, is_mobile) else: # ORIGINAL MODE - Single page with all data for client-side search - minifigures = BrickMinifigureList().all_filtered(owner_id=owner_id, problems_filter=problems_filter, theme_id=theme_id, year=year) + minifigures = BrickMinifigureList().all_filtered(owner_id=owner_id, problems_filter=problems_filter, theme_id=theme_id, year=year, individuals_filter=individuals_filter) pagination_context = None @@ -79,6 +82,7 @@ def list() -> str: 'selected_theme': theme_id, 'years': years, 'selected_year': year, + 'selected_individuals': individuals_filter, 'search_query': search_query, 'use_pagination': use_pagination, 'current_sort': sort_field, @@ -101,5 +105,6 @@ def details(*, figure: str) -> str: using=BrickSetList().using_minifigure(figure), missing=BrickSetList().missing_minifigure(figure), damaged=BrickSetList().damaged_minifigure(figure), + individual_instances=IndividualMinifigureList().instances_by_figure(figure), **set_metadata_lists(as_class=True) ) diff --git a/bricktracker/views/part.py b/bricktracker/views/part.py index 7450f48..771934e 100644 --- a/bricktracker/views/part.py +++ b/bricktracker/views/part.py @@ -1,6 +1,8 @@ from flask import Blueprint, render_template, request from .exceptions import exception_handler +from ..individual_part_list import IndividualPartList +from ..individual_part_lot_list import IndividualPartLotList from ..minifigure_list import BrickMinifigureList from ..pagination_helper import get_pagination_config, build_pagination_context, get_request_params from ..part import BrickPart @@ -21,6 +23,7 @@ def list() -> str: color_id = request.args.get('color', 'all') theme_id = request.args.get('theme', 'all') year = request.args.get('year', 'all') + individuals_filter = request.args.get('individuals', 'all') search_query, sort_field, sort_order, page = get_request_params() # Get pagination configuration @@ -34,6 +37,7 @@ def list() -> str: color_id=color_id, theme_id=theme_id, year=year, + individuals_filter=individuals_filter, search_query=search_query, page=page, per_page=per_page, @@ -44,7 +48,7 @@ def list() -> str: pagination_context = build_pagination_context(page, per_page, total_count, is_mobile) else: # ORIGINAL MODE - Single page with all data for client-side search - parts = BrickPartList().all_filtered(owner_id, color_id, theme_id, year) + parts = BrickPartList().all_filtered(owner_id, color_id, theme_id, year, individuals_filter) pagination_context = None # Get list of owners for filter dropdown @@ -83,6 +87,7 @@ def list() -> str: 'selected_theme': theme_id, 'years': years, 'selected_year': year, + 'selected_individuals': individuals_filter, 'search_query': search_query, 'use_pagination': use_pagination, 'current_sort': sort_field, @@ -225,5 +230,7 @@ def details(*, part: str, color: int) -> str: ), different_color=BrickPartList().with_different_color(brickpart), similar_prints=BrickPartList().from_print(brickpart), + individual_parts=IndividualPartList().by_part_and_color(part, color), + individual_lots=IndividualPartLotList().by_part_and_color(part, color), **set_metadata_lists(as_class=True) ) diff --git a/bricktracker/views/storage.py b/bricktracker/views/storage.py index 7d5ba3f..ed4f4a0 100644 --- a/bricktracker/views/storage.py +++ b/bricktracker/views/storage.py @@ -4,6 +4,9 @@ from .exceptions import exception_handler from ..set_list import BrickSetList, set_metadata_lists from ..set_storage import BrickSetStorage from ..set_storage_list import BrickSetStorageList +from ..individual_minifigure_list import IndividualMinifigureList +from ..individual_part_list import IndividualPartList +from ..individual_part_lot_list import IndividualPartLotList storage_page = Blueprint('storage', __name__, url_prefix='/storages') @@ -28,5 +31,8 @@ def details(*, id: str) -> str: 'storage.html', item=storage, sets=BrickSetList().using_storage(storage), + individual_minifigures=IndividualMinifigureList().using_storage(storage), + individual_parts=IndividualPartList().using_storage(storage), + individual_part_lots=IndividualPartLotList().using_storage(storage), **set_metadata_lists(as_class=True) ) From 05d98b38476875ebc3a99bd696f647109e90e38c Mon Sep 17 00:00:00 2001 From: FrederikBaerentsen Date: Mon, 19 Jan 2026 17:24:57 +0100 Subject: [PATCH 34/63] feat(frontend): add socket support and styling for individual items --- static/scripts/socket/minifigure.js | 258 ++++++++++++++++++++++++++++ static/scripts/socket/set.js | 57 ++++++ static/styles.css | 49 ++++++ 3 files changed, 364 insertions(+) create mode 100644 static/scripts/socket/minifigure.js diff --git a/static/scripts/socket/minifigure.js b/static/scripts/socket/minifigure.js new file mode 100644 index 0000000..34a987a --- /dev/null +++ b/static/scripts/socket/minifigure.js @@ -0,0 +1,258 @@ +// Minifigure Socket class +class BrickMinifigureSocket extends BrickSocket { + constructor(id, path, namespace, messages) { + super(id, path, namespace, messages, false); + + // Listeners + this.add_listener = undefined; + this.input_listener = undefined; + this.confirm_listener = undefined; + + // Form elements (built based on the initial id) + this.html_button = document.getElementById(id); + this.html_input = document.getElementById(`${id}-set`); + this.html_no_confim = document.getElementById(`${id}-no-confirm`); + this.html_owners = document.getElementById(`${id}-owners`); + this.html_purchase_location = document.getElementById(`${id}-purchase-location`); + this.html_storage = document.getElementById(`${id}-storage`); + this.html_tags = document.getElementById(`${id}-tags`); + + // Card elements + this.html_card = document.getElementById(`${id}-card`); + this.html_card_set = document.getElementById(`${id}-card-set`); + this.html_card_name = document.getElementById(`${id}-card-name`); + this.html_card_image_container = document.getElementById(`${id}-card-image-container`); + this.html_card_image = document.getElementById(`${id}-card-image`); + this.html_card_footer = document.getElementById(`${id}-card-footer`); + this.html_card_confirm = document.getElementById(`${id}-card-confirm`); + this.html_card_dismiss = document.getElementById(`${id}-card-dismiss`); + + if (this.html_button) { + this.add_listener = this.html_button.addEventListener("click", ((bricksocket) => (e) => { + bricksocket.execute(); + })(this)); + + this.input_listener = this.html_input.addEventListener("keyup", ((bricksocket) => (e) => { + if (e.key === 'Enter') { + bricksocket.execute(); + } + })(this)) + } + + if (this.html_card_dismiss && this.html_card) { + this.html_card_dismiss.addEventListener("click", ((card) => (e) => { + card.classList.add("d-none"); + })(this.html_card)); + } + + // Setup the socket + this.setup(); + } + + // Clear form + clear() { + super.clear(); + + if (this.html_card) { + this.html_card.classList.add("d-none"); + } + + if (this.html_card_footer) { + this.html_card_footer.classList.add("d-none"); + + if (this.html_card_confirm) { + this.html_card_footer.classList.add("d-none"); + } + } + } + + // Execute the action + execute() { + if (!this.disabled && this.socket !== undefined && this.socket.connected) { + this.toggle(false); + + if (this.html_no_confim && this.html_no_confim.checked) { + this.import_minifigure(true); + } else { + this.load_minifigure(); + } + } + } + + // Import a minifigure + import_minifigure(no_confirm, figure) { + if (this.html_input) { + if (no_confirm) { + this.clear(); + } else { + this.clear_status(); + } + + // Grab the owners + const owners = []; + if (this.html_owners) { + this.html_owners.querySelectorAll('input').forEach(input => { + if (input.checked) { + owners.push(input.value); + } + }); + } + + // Grab the purchase location + let purchase_location = null; + if (this.html_purchase_location) { + purchase_location = this.html_purchase_location.value; + } + + // Grab the storage + let storage = null; + if (this.html_storage) { + storage = this.html_storage.value; + } + + // Grab the tags + const tags = []; + if (this.html_tags) { + this.html_tags.querySelectorAll('input').forEach(input => { + if (input.checked) { + tags.push(input.value); + } + }); + } + + this.spinner(true); + + if (this.html_progress_bar) { + this.html_progress_bar.scrollIntoView(); + } + + this.socket.emit(this.messages.IMPORT_MINIFIGURE, { + figure: (figure !== undefined) ? figure : this.html_input.value, + owners: owners, + purchase_location: purchase_location, + storage: storage, + tags: tags, + quantity: 1 + }); + } else { + this.fail("Could not find the input field for the minifigure number"); + } + } + + // Load a minifigure + load_minifigure() { + if (this.html_input) { + // Reset the progress + this.clear() + this.spinner(true); + + this.socket.emit(this.messages.LOAD_MINIFIGURE, { + figure: this.html_input.value + }); + } else { + this.fail("Could not find the input field for the minifigure number"); + } + } + + // Minifigure is loaded + minifigure_loaded(data) { + if (this.html_card) { + this.html_card.classList.remove("d-none"); + + if (this.html_card_set) { + this.html_card_set.textContent = data["figure"]; + } + + if (this.html_card_name) { + this.html_card_name.textContent = data["name"]; + } + + if (this.html_card_image_container) { + this.html_card_image_container.setAttribute("style", `background-image: url(${data["image"]})`); + } + + if (this.html_card_image) { + this.html_card_image.setAttribute("src", data["image"]); + this.html_card_image.setAttribute("alt", data["figure"]); + } + + if (this.html_card_footer) { + this.html_card_footer.classList.add("d-none"); + + if (!data.download) { + this.html_card_footer.classList.remove("d-none"); + + if (this.html_card_confirm) { + if (this.confirm_listener !== undefined) { + this.html_card_confirm.removeEventListener("click", this.confirm_listener); + } + + this.confirm_listener = ((bricksocket, figure) => (e) => { + if (!bricksocket.disabled) { + bricksocket.toggle(false); + bricksocket.import_minifigure(false, figure); + } + })(this, data["figure"]); + + this.html_card_confirm.addEventListener("click", this.confirm_listener); + + this.html_card_confirm.scrollIntoView(); + } + } + } + } + } + + // Setup the actual socket + setup() { + super.setup(); + + if (this.socket !== undefined) { + // Minifigure loaded + this.socket.on(this.messages.MINIFIGURE_LOADED, ((bricksocket) => (data) => { + bricksocket.minifigure_loaded(data); + })(this)); + } + } + + // Toggle clicking on the button, or sending events + toggle(enabled) { + super.toggle(enabled); + + if (this.html_button) { + this.html_button.disabled = !enabled; + } + + if (this.html_input) { + this.html_input.disabled = !enabled; + } + + if (this.html_no_confim) { + this.html_no_confim.disabled = !enabled; + } + + if (this.html_owners) { + this.html_owners.querySelectorAll('input').forEach(input => input.disabled = !enabled); + } + + if (this.html_purchase_location) { + this.html_purchase_location.disabled = !enabled; + } + + if (this.html_storage) { + this.html_storage.disabled = !enabled; + } + + if (this.html_tags) { + this.html_tags.querySelectorAll('input').forEach(input => input.disabled = !enabled); + } + + if (this.html_card_confirm) { + this.html_card_confirm.disabled = !enabled; + } + + if (this.html_card_dismiss) { + this.html_card_dismiss.disabled = !enabled; + } + } +} diff --git a/static/scripts/socket/set.js b/static/scripts/socket/set.js index aea2993..992ea12 100644 --- a/static/scripts/socket/set.js +++ b/static/scripts/socket/set.js @@ -271,6 +271,56 @@ class BrickSetSocket extends BrickSocket { } } + // Minifigure is loaded (when bulk adding minifigures through set socket) + minifigure_loaded(data) { + if (this.html_card) { + this.html_card.classList.remove("d-none"); + + if (this.html_card_set) { + this.html_card_set.textContent = data["figure"]; + } + + if (this.html_card_name) { + this.html_card_name.textContent = data["name"]; + } + + if (this.html_card_image_container) { + this.html_card_image_container.setAttribute("style", `background-image: url(${data["image"]})`); + } + + if (this.html_card_image) { + this.html_card_image.setAttribute("src", data["image"]); + this.html_card_image.setAttribute("alt", data["figure"]); + } + + if (this.html_card_footer) { + this.html_card_footer.classList.add("d-none"); + + if (!data.download) { + this.html_card_footer.classList.remove("d-none"); + + if (this.html_card_confirm) { + if (this.confirm_listener !== undefined) { + this.html_card_confirm.removeEventListener("click", this.confirm_listener); + } + + this.confirm_listener = ((bricksocket, figure) => (e) => { + if (!bricksocket.disabled) { + bricksocket.toggle(false); + // For minifigures, we use import_set with the figure number + bricksocket.import_set(false, figure); + } + })(this, data["figure"]); + + this.html_card_confirm.addEventListener("click", this.confirm_listener); + + this.html_card_confirm.scrollIntoView(); + } + } + } + } + } + // Setup the actual socket setup() { super.setup(); @@ -280,6 +330,13 @@ class BrickSetSocket extends BrickSocket { this.socket.on(this.messages.SET_LOADED, ((bricksocket) => (data) => { bricksocket.set_loaded(data); })(this)); + + // Minifigure loaded (for bulk add with mixed sets/minifigures) + if (this.messages.MINIFIGURE_LOADED) { + this.socket.on(this.messages.MINIFIGURE_LOADED, ((bricksocket) => (data) => { + bricksocket.minifigure_loaded(data); + })(this)); + } } } diff --git a/static/styles.css b/static/styles.css index 857c3c6..2671992 100644 --- a/static/styles.css +++ b/static/styles.css @@ -272,3 +272,52 @@ [data-bs-theme="dark"] .table tbody th { color: var(--bs-body-color); } + +/* Navbar split button dropdown styling */ +.navbar-nav .dropdown { + display: flex; + align-items: center; + position: relative; +} + +.navbar-nav .dropdown .dropdown-toggle-split { + padding-left: 0.375rem; + padding-right: 0.375rem; + margin-left: -1px; +} + +.navbar-nav .dropdown .dropdown-toggle-split::after { + margin-left: 0; +} + +/* Navbar dropdown positioning */ +.navbar-nav .dropdown-menu { + position: absolute; + top: 100%; + left: 0; + z-index: 1000; + margin-top: 0.125rem; +} + +/* Navbar dropdown styling to match navbar theme */ +.navbar-dark .navbar-nav .dropdown-menu { + background-color: #212529; + border-color: rgba(255, 255, 255, 0.15); +} + +.navbar-dark .navbar-nav .dropdown-menu .dropdown-item { + color: rgba(255, 255, 255, 0.55); +} + +.navbar-dark .navbar-nav .dropdown-menu .dropdown-item:hover, +.navbar-dark .navbar-nav .dropdown-menu .dropdown-item:focus { + color: rgba(255, 255, 255, 0.75); + background-color: rgba(255, 255, 255, 0.1); +} + +/* Navbar dropdown hover support for desktop */ +@media (min-width: 992px) { + .navbar-nav .dropdown:hover > .dropdown-menu { + display: block; + } +} From c944575fbd3275f0ae767f8721e24530830d7ab4 Mon Sep 17 00:00:00 2001 From: FrederikBaerentsen Date: Mon, 19 Jan 2026 18:03:37 +0100 Subject: [PATCH 35/63] Release 1.4: Massive update with new individual parts and minifigure management. Documentation updated. Commits for the feature: 05d98b3, 77be333, be3ac28, fa05305, dda171c, 24c8f1e, caaef97, e46e1d5, 93ef88b, b9ae977, 202e924, c947d29, 58ff39f, 6ba28ea --- CHANGELOG.md | 96 +++++++++++++++++++++++++++++++++++++++++++++++++--- docs/env.md | 5 +++ 2 files changed, 97 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f93a7aa..2de69bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,12 @@ ### Bug Fixes +- **Fixed purchase location templates**: Created missing template files for purchase location pages - **Fixed set refresh functionality**: Resolved issues with refreshing sets from Rebrickable - Fixed foreign key constraint errors during refresh by reusing existing set IDs instead of generating new UUIDs - Implemented UPDATE-then-INSERT pattern to properly update existing parts while preserving user tracking data - Part quantities now correctly sync with Rebrickable during refresh - - User tracking data (`checked`, `missing`, `damaged`) is now preserved across refreshes + - User tracking data (`checked`, `missing`, `damaged`) is preserved across refreshes - New parts from Rebrickable are added to local inventory during refresh - Orphaned parts (parts no longer in Rebrickable's inventory) are now properly removed during refresh - Refresh now works correctly for both set parts and minifigure parts @@ -24,13 +25,49 @@ - New `BK_SHOW_NOTES_GRID` setting (default: `false`) - controls whether notes appear on grid view cards - New `BK_SHOW_NOTES_DETAIL` setting (default: `true`) - controls whether notes appear on set detail pages - Notes display as an info alert box below badges when enabled - - Both settings can be toggled in Admin → Live Settings panel without container restart + - Both settings can be toggled in Admin -> Live Settings panel without container restart - Fixed consolidated SQL query to include description field for proper notes display in server-side pagination ### New Features +- **Individual Minifigures Tracking** + - Track loose/individual minifigures outside of sets + - Part-level tracking for individual minifigures with problem states (missing/damaged/checked) + - Complete metadata support (owners, tags, statuses, storage, purchase info) + - Purchase tracking with date, location, and price + - Quick navigation from set minifigures to individual instances + - Filter and search capabilities + - Feature flags: `BK_HIDE_INDIVIDUAL_MINIFIGURES` (hide UI), `BK_DISABLE_INDIVIDUAL_MINIFIGURES` (block writes) + +- **Individual Parts Tracking** + - Track loose parts outside of sets and minifigures + - Quick-add functionality from set parts tables + - Complete metadata support (owners, tags, storage, purchase info) + - Problem tracking (missing/damaged/checked states) + - Purchase tracking with date, location, and price + - Bulk part addition interface + - Feature flags: `BK_HIDE_INDIVIDUAL_PARTS` (hide UI), `BK_DISABLE_INDIVIDUAL_PARTS` (block writes) + +- **Part Lots System** + - Organize individual parts into logical lots/collections + - Lot-level metadata (name, description, created date) + - Shared metadata across lot (storage, purchase info) + - View all parts in a lot with filtering + +- **Purchase Location Management** + - Centralized purchase location tracking for sets, individual minifigures, parts, and lots + - New purchase location management page (`/purchase-locations/`) + - Track which items were purchased from each location + - Integrated with existing storage and owner metadata systems + +- **Rebrickable Color Database** + - Caches color information from Rebrickable API + - Provides BrickLink color ID mapping + - Reduces repeated API calls for color data + - Supports export functionality with correct color IDs + - **Export Functionality** - - Added comprehensive export system in admin panel for sets, parts, and problem parts + - Added export system in admin panel for sets, parts, and problem parts - Export accordion in `/admin/` with three main categories: - **Export Sets**: Rebrickable CSV format for collection tracking - **Export All Parts**: Three formats available: @@ -40,7 +77,6 @@ - **Export Missing/Damaged Parts**: Same three formats as parts exports - All exports aggregate quantities automatically (parts by part+color, LEGO by element ID) - BrickLink exports use proper BrickLink part numbers and color IDs when available - - Filter support: All part exports accept owner, color, theme, and year query parameters - Format information displayed in UI for user guidance - **Badge Order Customization** - Added customizable badge ordering for set cards and detail pages @@ -80,6 +116,58 @@ - Shows real-time progress with current set being processed - Failed sets remain in input field for easy retry +### Database Improvements + +- **Standardized ON DELETE Behavior**: Unified foreign key deletion handling across all metadata tables + - All metadata foreign keys now use RESTRICT (prevent deletion if referenced) + - Prevents accidental deletion of storage locations or purchase locations that are in use +- **Performance Indexes Added**: New composite indexes for common query patterns + - `idx_individual_parts_lot_id_part_color` - Optimizes listing parts within a lot + - `idx_individual_parts_missing_damaged` - Optimizes finding parts with problems + - `idx_individual_minifigure_parts_checked` - Optimizes finding unchecked parts in minifigures +- **Consolidated Metadata Tables**: Migration 0027 removes foreign key constraints from metadata junction tables + - `bricktracker_set_owners`, `bricktracker_set_tags`, `bricktracker_set_statuses` now accept any entity type + - Enables reusing metadata tables for sets, individual minifigures, individual parts, and lots +- **Fixed Schema Drop Script**: Resolved foreign key constraint errors during database reset + - Added proper table drop ordering (children before parents) + - Implemented `PRAGMA foreign_keys OFF/ON` wrapping + - Includes all new tables from migrations 0021-0027 + + +### Configuration & Environment Variables + +- **New Configuration Options**: + - `BK_HIDE_INDIVIDUAL_MINIFIGURES` - Hide individual minifigures UI elements in navigation + - `BK_DISABLE_INDIVIDUAL_MINIFIGURES` - Block write operations for individual minifigures (view-only mode) + - `BK_HIDE_INDIVIDUAL_PARTS` - Hide individual parts UI elements in navigation + - `BK_DISABLE_INDIVIDUAL_PARTS` - Block write operations for individual parts (view-only mode) + - `BK_BADGE_ORDER_GRID` - Customize badge order on set cards in grid view (comma-separated list) + - `BK_BADGE_ORDER_DETAIL` - Customize badge order on set detail pages (comma-separated list) + - `BK_SHOW_NOTES_GRID` - Show notes on set cards in grid view (default: false) + - `BK_SHOW_NOTES_DETAIL` - Show notes on set detail pages (default: true) + - All new settings support live configuration updates via Admin panel + +### Technical Improvements + +- **Route Protection Decorators**: New decorator pattern for feature flag enforcement + - `@require_individual_minifigures_write` - Blocks writes when feature is disabled + - `@require_individual_parts_write` - Blocks writes when feature is disabled + - Allows viewing existing data while preventing new additions +- **SQL Query Organization**: New query directory structure for individual features + - `bricktracker/sql/individual_minifigure/` - All individual minifigure queries + - `bricktracker/sql/individual_part/` - All individual part queries + - `bricktracker/sql/individual_part_lot/` - All part lot queries + - `bricktracker/sql/rebrickable_colors/` - Color reference queries + - `bricktracker/sql/rebrickable_parts/` - Part reference queries +- **Database Migrations**: 7 new migrations (0021-0027) + - 0021: Individual minifigures and parts tables + - 0022: Individual part lots system with proper foreign keys + - 0023: Performance indexes for individual features + - 0024: Rebrickable colors cache table + - 0025: Additional composite indexes for query optimization + - 0026: Standardized ON DELETE behavior across metadata tables + - 0027: Consolidated metadata tables (remove FK constraints) + ## 1.3.1 diff --git a/docs/env.md b/docs/env.md index dfbd09a..768a88e 100644 --- a/docs/env.md +++ b/docs/env.md @@ -29,11 +29,16 @@ | `BK_HIDE_ALL_PROBLEMS_PARTS` | Hide problems parts menu entry | `false` | No | | `BK_HIDE_TABLE_MISSING_PARTS` | Hide Missing column in tables | `false` | No | | `BK_HIDE_TABLE_DAMAGED_PARTS` | Hide Damaged column in tables | `false` | No | +| `BK_HIDE_TABLE_CHECKED_PARTS` | Hide Checked column in tables | `false` | No | | `BK_HIDE_WISHES` | Hide wishlist menu entry | `false` | No | | `BK_HIDE_ALL_STORAGES` | Hide storages menu entry | `false` | No | +| `BK_HIDE_INDIVIDUAL_MINIFIGURES` | Hide individual minifigures UI | `false` | No | +| `BK_HIDE_INDIVIDUAL_PARTS` | Hide individual parts UI | `false` | No | | `BK_SHOW_GRID_SORT` | Show sort options by default | `false` | No | | `BK_SHOW_GRID_FILTERS` | Show filter options by default | `false` | No | | `BK_INDEPENDENT_ACCORDIONS` | Make accordions independent | `false` | No | +| `BK_DISABLE_INDIVIDUAL_MINIFIGURES` | Block write operations for individual minifigures | `false` | No | +| `BK_DISABLE_INDIVIDUAL_PARTS` | Block write operations for individual parts | `false` | No | ## Sort Order Configuration | Variable | Purpose | Default | Required | From 4f1997305f9c2b461d068b0f440e0484db58781b Mon Sep 17 00:00:00 2001 From: FrederikBaerentsen Date: Mon, 19 Jan 2026 20:33:18 +0100 Subject: [PATCH 36/63] feat(minifigures): lock edit if BK_DISABLE_INDIVIDUAL_MINIFIGURES=true --- bricktracker/views/individual_minifigure.py | 3 ++ templates/individual_minifigure/details.html | 12 ++++--- .../individual_minifigure/management.html | 31 ++++++++++++++----- 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/bricktracker/views/individual_minifigure.py b/bricktracker/views/individual_minifigure.py index ca4508e..105b662 100644 --- a/bricktracker/views/individual_minifigure.py +++ b/bricktracker/views/individual_minifigure.py @@ -60,9 +60,12 @@ def list() -> str: def details(*, id: str) -> str: item = IndividualMinifigure().select_by_id(id) + writes_disabled = current_app.config.get('DISABLE_INDIVIDUAL_MINIFIGURES', False) + return render_template( 'individual_minifigure/details.html', item=item, + writes_disabled=writes_disabled, **set_metadata_lists(as_class=True) ) diff --git a/templates/individual_minifigure/details.html b/templates/individual_minifigure/details.html index ab9cd42..861120e 100644 --- a/templates/individual_minifigure/details.html +++ b/templates/individual_minifigure/details.html @@ -27,11 +27,15 @@
{{ accordion.header('Quantity', 'accordion-quantity-' ~ item.fields.id, 'individual-minifigure-details-' ~ item.fields.id, icon='functions') }} - {{ form.input('Quantity', item.fields.id, 'quantity', item.url_for_quantity(), item.fields.quantity, icon='functions') }} + {% if writes_disabled %} +
Quantity: {{ item.fields.quantity }}
+ {% else %} + {{ form.input('Quantity', item.fields.id, 'quantity', item.url_for_quantity(), item.fields.quantity, icon='functions') }} + {% endif %} {{ accordion.footer() }} - {{ accordion.table(item.generic_parts(), 'Parts', 'accordion-parts-' ~ item.fields.id, 'individual-minifigure-details-' ~ item.fields.id, 'part/table.html', icon='shapes-line', alt=item.fields.figure, hamburger_menu=g.login.is_authenticated()) }} - {% include 'individual_minifigure/management.html' %} - {% if g.login.is_authenticated() %} + {{ accordion.table(item.generic_parts(), 'Parts', 'accordion-parts-' ~ item.fields.id, 'individual-minifigure-details-' ~ item.fields.id, 'part/table.html', icon='shapes-line', alt=item.fields.figure, read_only=writes_disabled) }} + {% set management_read_only = writes_disabled %}{% include 'individual_minifigure/management.html' %} + {% if g.login.is_authenticated() and not writes_disabled %} {{ accordion.header('Danger zone', 'accordion-danger-zone-' ~ item.fields.id, 'individual-minifigure-details-' ~ item.fields.id, danger=true, class='text-end') }} Delete this individual minifigure instance {{ accordion.footer() }} diff --git a/templates/individual_minifigure/management.html b/templates/individual_minifigure/management.html index 0a35f8e..ad31f82 100644 --- a/templates/individual_minifigure/management.html +++ b/templates/individual_minifigure/management.html @@ -7,7 +7,7 @@
    {% if brickset_owners | length %} {% for owner in brickset_owners %} -
  • {{ form.checkbox(owner.fields.name, item.fields.id, owner.as_dataset(), owner.url_for_individual_minifigure_state(item.fields.id), item.fields[owner.as_column()]) }}
  • +
  • {{ form.checkbox(owner.fields.name, item.fields.id, owner.as_dataset(), owner.url_for_individual_minifigure_state(item.fields.id), item.fields[owner.as_column()], delete=management_read_only) }}
  • {% endfor %} {% else %}
  • No owner found.
  • @@ -21,14 +21,31 @@
    - {{ form.input('Date', item.fields.id, 'purchase_date', url_for('individual_minifigure.update_purchase_date', id=item.fields.id), item.fields.purchase_date or '', date=true, icon='calendar-line') }} + {% if management_read_only %} +
    + Date + + +
    + {% else %} + {{ form.input('Date', item.fields.id, 'purchase_date', url_for('individual_minifigure.update_purchase_date', id=item.fields.id), item.fields.purchase_date or '', date=true, icon='calendar-line') }} + {% endif %}
    - {{ form.input('Price', item.fields.id, 'purchase_price', url_for('individual_minifigure.update_purchase_price', id=item.fields.id), item.fields.purchase_price or '', suffix=config['PURCHASE_CURRENCY'], icon='wallet-3-line') }} + {% if management_read_only %} +
    + Price + + {{ config['PURCHASE_CURRENCY'] }} + +
    + {% else %} + {{ form.input('Price', item.fields.id, 'purchase_price', url_for('individual_minifigure.update_purchase_price', id=item.fields.id), item.fields.purchase_price or '', suffix=config['PURCHASE_CURRENCY'], icon='wallet-3-line') }} + {% endif %}
    {% if brickset_purchase_locations | length %} - {{ form.select('Location', item.fields.id, brickset_purchase_locations.as_prefix(), brickset_purchase_locations.url_for_individual_minifigure_value(item.fields.id), item.fields.purchase_location, brickset_purchase_locations, icon='building-line') }} + {{ form.select('Location', item.fields.id, brickset_purchase_locations.as_prefix(), brickset_purchase_locations.url_for_individual_minifigure_value(item.fields.id), item.fields.purchase_location, brickset_purchase_locations, icon='building-line', delete=management_read_only) }} {% else %} No purchase location found. {% endif %} @@ -38,11 +55,11 @@ Manage the minifigure purchase locations {{ accordion.footer() }} {{ accordion.header('Notes', 'accordion-notes-' ~ item.fields.id, 'accordion-management-' ~ item.fields.id, icon='sticky-note-line') }} - {{ form.textarea('Notes', item.fields.id, 'description', url_for('individual_minifigure.update_description', id=item.fields.id), item.fields.description, rows=4) }} + {{ form.textarea('Notes', item.fields.id, 'description', url_for('individual_minifigure.update_description', id=item.fields.id), item.fields.description, rows=4, delete=management_read_only) }} {{ accordion.footer() }} {{ accordion.header('Storage', 'accordion-storage-' ~ item.fields.id, 'accordion-management-' ~ item.fields.id, icon='archive-2-line') }} {% if brickset_storages | length %} - {{ form.select('Storage', item.fields.id, brickset_storages.as_prefix(), brickset_storages.url_for_individual_minifigure_value(item.fields.id), item.fields.storage, brickset_storages, icon='building-line') }} + {{ form.select('Storage', item.fields.id, brickset_storages.as_prefix(), brickset_storages.url_for_individual_minifigure_value(item.fields.id), item.fields.storage, brickset_storages, icon='building-line', delete=management_read_only) }} {% else %}

    No storage found.

    {% endif %} @@ -53,7 +70,7 @@
      {% if brickset_tags | length %} {% for tag in brickset_tags %} -
    • {{ form.checkbox(tag.fields.name, item.fields.id, tag.as_dataset(), tag.url_for_individual_minifigure_state(item.fields.id), item.fields[tag.as_column()]) }}
    • +
    • {{ form.checkbox(tag.fields.name, item.fields.id, tag.as_dataset(), tag.url_for_individual_minifigure_state(item.fields.id), item.fields[tag.as_column()], delete=management_read_only) }}
    • {% endfor %} {% else %}
    • No tag found.
    • From fbf330705f52501e8147d995750b41e06963d79b Mon Sep 17 00:00:00 2001 From: FrederikBaerentsen Date: Tue, 20 Jan 2026 07:38:06 +0100 Subject: [PATCH 37/63] feat(individual): implement read-only mode for individual minifigures and parts --- CHANGELOG.md | 8 ++- bricktracker/views/individual_part.py | 9 +++ bricktracker/views/minifigure.py | 3 + bricktracker/views/part.py | 5 +- templates/add.html | 6 +- templates/base.html | 2 +- templates/individual_minifigure/card.html | 12 +++- templates/individual_part/card.html | 10 +++- templates/individual_part/details.html | 30 ++++++---- templates/individual_part/lot_card.html | 8 +-- templates/individual_part/management.html | 73 ++++++++++++++++++----- 11 files changed, 125 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2de69bd..078fe30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,7 +37,9 @@ - Purchase tracking with date, location, and price - Quick navigation from set minifigures to individual instances - Filter and search capabilities - - Feature flags: `BK_HIDE_INDIVIDUAL_MINIFIGURES` (hide UI), `BK_DISABLE_INDIVIDUAL_MINIFIGURES` (block writes) + - Feature flags: + - `BK_HIDE_INDIVIDUAL_MINIFIGURES`: Hides individual minifigures UI elements (navbar menu item, links from minifigure detail pages) + - `BK_DISABLE_INDIVIDUAL_MINIFIGURES`: Enables read-only mode - all individual minifigure pages remain accessible but with all editing fields disabled (quantity, parts table, metadata inputs), delete buttons hidden, and write operations blocked. - **Individual Parts Tracking** - Track loose parts outside of sets and minifigures @@ -46,7 +48,9 @@ - Problem tracking (missing/damaged/checked states) - Purchase tracking with date, location, and price - Bulk part addition interface - - Feature flags: `BK_HIDE_INDIVIDUAL_PARTS` (hide UI), `BK_DISABLE_INDIVIDUAL_PARTS` (block writes) + - Feature flags: + - `BK_HIDE_INDIVIDUAL_PARTS`: Hides individual parts UI elements (navbar menu item, "Add Parts" button, links from part detail pages) + - `BK_DISABLE_INDIVIDUAL_PARTS`: Enables read-only mode - all individual parts and lot pages remain accessible but with all editing fields disabled (quantity, missing/damaged, parts table, metadata inputs), delete buttons hidden, "Add Parts" menu item removed, and write operations blocked. The /add/ page also hides the "Adding individual parts?" section. - **Part Lots System** - Organize individual parts into logical lots/collections diff --git a/bricktracker/views/individual_part.py b/bricktracker/views/individual_part.py index 43a9811..e0a7faf 100644 --- a/bricktracker/views/individual_part.py +++ b/bricktracker/views/individual_part.py @@ -48,9 +48,12 @@ def require_individual_parts_write(f): def list() -> str: parts = IndividualPartList().all() + writes_disabled = current_app.config.get('DISABLE_INDIVIDUAL_PARTS', False) + return render_template( 'individual_parts.html', parts=parts, + writes_disabled=writes_disabled, **set_metadata_lists(as_class=True) ) @@ -181,10 +184,13 @@ def details(*, id: str) -> str: error=e )) + writes_disabled = current_app.config.get('DISABLE_INDIVIDUAL_PARTS', False) + return render_template( 'individual_part/details.html', item=item, lot=lot, + writes_disabled=writes_disabled, **set_metadata_lists(as_class=True) ) @@ -481,10 +487,13 @@ def lot_details(*, lot_id: str) -> str: """Display details for an individual part lot (behaves like a set)""" lot = IndividualPartLot().select_by_id(lot_id) + writes_disabled = current_app.config.get('DISABLE_INDIVIDUAL_PARTS', False) + return render_template( 'individual_part/lot_details.html', item=lot, # Pass as 'item' like sets do solo=True, + writes_disabled=writes_disabled, **set_metadata_lists(as_class=True) ) diff --git a/bricktracker/views/minifigure.py b/bricktracker/views/minifigure.py index f700c67..167dc5b 100644 --- a/bricktracker/views/minifigure.py +++ b/bricktracker/views/minifigure.py @@ -99,6 +99,8 @@ def list() -> str: @minifigure_page.route('/
      /details') @exception_handler(__file__) def details(*, figure: str) -> str: + writes_disabled = current_app.config.get('DISABLE_INDIVIDUAL_MINIFIGURES', False) + return render_template( 'minifigure.html', item=BrickMinifigure().select_generic(figure), @@ -106,5 +108,6 @@ def details(*, figure: str) -> str: missing=BrickSetList().missing_minifigure(figure), damaged=BrickSetList().damaged_minifigure(figure), individual_instances=IndividualMinifigureList().instances_by_figure(figure), + writes_disabled=writes_disabled, **set_metadata_lists(as_class=True) ) diff --git a/bricktracker/views/part.py b/bricktracker/views/part.py index 771934e..7d8c1cf 100644 --- a/bricktracker/views/part.py +++ b/bricktracker/views/part.py @@ -1,4 +1,4 @@ -from flask import Blueprint, render_template, request +from flask import Blueprint, current_app, render_template, request from .exceptions import exception_handler from ..individual_part_list import IndividualPartList @@ -201,6 +201,8 @@ def problem() -> str: def details(*, part: str, color: int) -> str: brickpart = BrickPart().select_generic(part, color) + writes_disabled = current_app.config.get('DISABLE_INDIVIDUAL_PARTS', False) + return render_template( 'part.html', item=brickpart, @@ -232,5 +234,6 @@ def details(*, part: str, color: int) -> str: similar_prints=BrickPartList().from_print(brickpart), individual_parts=IndividualPartList().by_part_and_color(part, color), individual_lots=IndividualPartLotList().by_part_and_color(part, color), + writes_disabled=writes_disabled, **set_metadata_lists(as_class=True) ) diff --git a/templates/add.html b/templates/add.html index 0c35113..9eb0499 100644 --- a/templates/add.html +++ b/templates/add.html @@ -6,17 +6,17 @@ {% block main %}
      - {% if not bulk and (not config['HIDE_ADD_BULK_SET'] or not config['HIDE_INDIVIDUAL_PARTS']) %} + {% if not bulk and (not config['HIDE_ADD_BULK_SET'] or (not config['HIDE_INDIVIDUAL_PARTS'] and not config['DISABLE_INDIVIDUAL_PARTS'])) %}
      {% if not config['HIDE_ADD_BULK_SET'] %} -
      +
      {% endif %} - {% if not config['HIDE_INDIVIDUAL_PARTS'] %} + {% if not config['HIDE_INDIVIDUAL_PARTS'] and not config['DISABLE_INDIVIDUAL_PARTS'] %}
      {% if g.login.is_authenticated() %} {% endif %}
      diff --git a/templates/individual_part/details.html b/templates/individual_part/details.html index 4479a26..a7f77b9 100644 --- a/templates/individual_part/details.html +++ b/templates/individual_part/details.html @@ -31,17 +31,25 @@
      {% endif %}
      -
      -
      - {{ form.input('Quantity', item.fields.id, 'quantity', item.url_for_quantity(), item.fields.quantity, icon='functions') }} + {% if writes_disabled %} +
      + Quantity: {{ item.fields.quantity }} | + Missing: {{ item.fields.missing or 0 }} | + Damaged: {{ item.fields.damaged or 0 }}
      -
      - {{ form.input('Missing', item.fields.id, 'missing', item.url_for_problem('missing'), item.fields.missing, icon='question-line') }} + {% else %} +
      +
      + {{ form.input('Quantity', item.fields.id, 'quantity', item.url_for_quantity(), item.fields.quantity, icon='functions') }} +
      +
      + {{ form.input('Missing', item.fields.id, 'missing', item.url_for_problem('missing'), item.fields.missing, icon='question-line') }} +
      +
      + {{ form.input('Damaged', item.fields.id, 'damaged', item.url_for_problem('damaged'), item.fields.damaged, icon='error-warning-line') }} +
      -
      - {{ form.input('Damaged', item.fields.id, 'damaged', item.url_for_problem('damaged'), item.fields.damaged, icon='error-warning-line') }} -
      -
      + {% endif %}
      {% if lot %} @@ -61,9 +69,9 @@ {{ accordion.footer() }} {% else %} {# Only show management accordion if NOT part of a lot #} - {% include 'individual_part/management.html' %} + {% set management_read_only = writes_disabled %}{% include 'individual_part/management.html' %} {% endif %} - {% if g.login.is_authenticated() %} + {% if g.login.is_authenticated() and not writes_disabled %} {{ accordion.header('Danger zone', 'accordion-danger-zone-' ~ item.fields.id, 'individual-part-details-' ~ item.fields.id, danger=true, class='text-end') }} Delete this individual part instance {{ accordion.footer() }} diff --git a/templates/individual_part/lot_card.html b/templates/individual_part/lot_card.html index e800f64..08f353d 100644 --- a/templates/individual_part/lot_card.html +++ b/templates/individual_part/lot_card.html @@ -51,9 +51,9 @@ {% if solo %}
      - {{ accordion.table(item.parts(), 'Parts', 'parts-inventory', 'lot-details', 'part/lot_table.html', icon='shapes-line', hamburger_menu=g.login.is_authenticated())}} - {% include 'individual_part/management.html' %} - {% if g.login.is_authenticated() %} + {{ accordion.table(item.parts(), 'Parts', 'parts-inventory', 'lot-details', 'part/lot_table.html', icon='shapes-line', hamburger_menu=g.login.is_authenticated(), read_only=writes_disabled)}} + {% set management_read_only = writes_disabled %}{% include 'individual_part/management.html' %} + {% if g.login.is_authenticated() and not writes_disabled %} {{ accordion.header('Danger zone', 'danger-zone', 'lot-details', danger=true, class='text-end') }} Delete entire lot and all parts @@ -66,7 +66,7 @@
      -{% if solo and g.login.is_authenticated() %} +{% if solo and g.login.is_authenticated() and not writes_disabled %}