Compare commits

..

107 Commits

Author SHA1 Message Date
FrederikBaerentsen 8d00569798 Massive update for individual parts and part-lots 2025-11-06 08:24:42 +01:00
FrederikBaerentsen 21d104280c feat(minifigures): individual minifigures can now be disabled using env var 2025-10-10 16:45:04 +02:00
FrederikBaerentsen 5946f86dfa feat(storages): added not in storage section 2025-10-10 14:52:08 +02:00
FrederikBaerentsen 0155144881 Updated gitignore 2025-10-10 14:46:57 +02:00
FrederikBaerentsen 1353153394 feat(minifigures): fixed owner filter on parts page 2025-10-10 11:29:36 +02:00
FrederikBaerentsen 0f45192f8e fix(index): fixed startpage error 2025-10-10 11:25:56 +02:00
FrederikBaerentsen b02f851865 feat(minifigures): fixed badge style and amount. Added env var for desc length and fixed wrap around style 2025-10-10 11:18:41 +02:00
FrederikBaerentsen bddfbb5235 feat(problems): all problems now show on /parts/problems, even from individual figures 2025-10-10 10:57:56 +02:00
FrederikBaerentsen dc34916331 fix(minifigures): metadata now works and saves correctly 2025-10-10 10:54:07 +02:00
FrederikBaerentsen a8d36bc5f1 fix(minifigures): fixed metadata format and individual minifigures layout 2025-10-10 08:29:12 +02:00
FrederikBaerentsen bd32ca5b8f feat(minifigures): added individual sort on /minifigure page 2025-10-09 17:12:04 +02:00
FrederikBaerentsen 2ed60e3fe3 fix(minifigure): fixed double click issue, nil image and bulk add. 2025-10-09 16:28:22 +02:00
FrederikBaerentsen 0ec1d37c36 feat(minifigures): initial upload. 2025-10-09 15:57:55 +02:00
FrederikBaerentsen 8053f5d30c feat(sets): show bricklink if enabled 2025-10-03 10:16:56 +02:00
FrederikBaerentsen 7eb199d289 fix(env): changed default minifigures folder from minifigs to minifigures (#92) 2025-10-03 09:50:41 +02:00
FrederikBaerentsen 6364da676b fix(admin): added log into to respect debug var 2025-10-03 09:22:45 +02:00
FrederikBaerentsen a3d08d8cf6 feat(sets): added filter on sets page to show duplicate sets. default is shown. can be hidden using env var. works with consolidated sets too. 2025-10-03 09:13:15 +02:00
FrederikBaerentsen 4b653ac270 feat(admin): added live configuration management, where user can enable/disable and change configurations without editing .env file. Some changes will need an application restart 2025-10-03 00:15:21 +02:00
FrederikBaerentsen a70a1660f0 fix(admin): open the right drawer on database upgrade 2025-10-02 23:52:13 +02:00
FrederikBaerentsen 0db749fce0 doc(changelog): updated changelog. 2025-10-02 14:58:23 +02:00
FrederikBaerentsen 256108bbdb feat(sql): WAL and index optimization 2025-10-02 14:53:58 +02:00
FrederikBaerentsen 145d9d5dcb feat(admin): database is expanded by default 2025-10-02 14:35:37 +02:00
FrederikBaerentsen b9d42c2866 feat(admin): new env var. for which sections should be open by default in the admin page. 2025-10-02 14:27:32 +02:00
FrederikBaerentsen d1988d015e fix(sets): year-filter now correctly show all years not just current page. 2025-10-02 14:02:51 +02:00
FrederikBaerentsen 8e458b01d1 Merge pull request 'feature/statistics' (#107) from feature/statistics into release/1.3
Reviewed-on: FrederikBaerentsen/BrickTracker#107
2025-10-02 13:36:31 +02:00
FrederikBaerentsen 989e0d57d0 Fixed date formatting on consolidated sets 2025-10-01 21:17:44 +02:00
FrederikBaerentsen 1097255dca Fixed consolidated price on card 2025-10-01 21:11:14 +02:00
FrederikBaerentsen 7ffbc41f0a Updated changelog 2025-10-01 21:02:58 +02:00
FrederikBaerentsen 11f9e5782f Added charts, env var for charts, fixed formatting and table columns 2025-10-01 20:52:29 +02:00
FrederikBaerentsen 5f43e979f9 feat(statistics): Initial upload 2025-10-01 19:43:25 +02:00
FrederikBaerentsen 4375f018a4 Merge pull request 'feature/consolidation' (#106) from feature/consolidation into release/1.3
Reviewed-on: FrederikBaerentsen/BrickTracker#106
2025-10-01 19:28:49 +02:00
FrederikBaerentsen 87472039be Changed border color 2025-10-01 19:22:57 +02:00
FrederikBaerentsen c1089c349f Fixed total minifigures for consolidated sets 2025-09-28 08:59:10 +02:00
FrederikBaerentsen 3f6af51a43 Changed the look of consolidated cards when multiple statuses are used. 2025-09-28 08:42:33 +02:00
FrederikBaerentsen bc3cc176ef Fixed purchase information on consolidated cards 2025-09-27 23:43:27 +02:00
FrederikBaerentsen 4a1a265fa8 Updated changelog 2025-09-27 23:32:45 +02:00
FrederikBaerentsen 7c95583345 Changed the "Multiple Copies Available" view and fixed border formatting. 2025-09-27 23:30:13 +02:00
FrederikBaerentsen 65f23c1f12 Fixed nested box formatting. 2025-09-27 23:06:53 +02:00
FrederikBaerentsen aa6c969a6b Fixed consolidating sets. 2025-09-27 23:06:06 +02:00
FrederikBaerentsen 0bff20215c Merge pull request 'feature/checkbox' (#105) from feature/checkbox into release/1.3
Reviewed-on: FrederikBaerentsen/BrickTracker#105
2025-09-27 16:26:04 +02:00
FrederikBaerentsen d0147b8061 Incremented version to 1.3.0 2025-09-27 16:17:05 +02:00
FrederikBaerentsen ca0de215ab Fixed damaged parts drawer showing on minifigures when no parts are damaged. 2025-09-26 12:46:31 +02:00
FrederikBaerentsen 05b259e494 Removed checkboxes from minifigures details page 2025-09-26 12:28:49 +02:00
FrederikBaerentsen f03fd82be1 Feat(checkbox): Initial upload 2025-09-26 11:47:15 +02:00
FrederikBaerentsen a769e5464b Merge pull request 'feature/peeron' (#104) from feature/peeron into release/1.3
Reviewed-on: FrederikBaerentsen/BrickTracker#104
2025-09-26 11:40:01 +02:00
FrederikBaerentsen 40871a1c10 Changed download string 2025-09-26 11:37:49 +02:00
FrederikBaerentsen caac283905 Updated peeron download logic with proper socket. 2025-09-26 11:31:22 +02:00
FrederikBaerentsen 4bc0ef5cc4 Peeron thumbnails cache, as peeron uses http and cant live link to https 2025-09-25 22:09:36 +02:00
FrederikBaerentsen ec4f44a3ab Removed unused import 2025-09-25 21:46:58 +02:00
FrederikBaerentsen 0a29543939 Cleanup of peeron download 2025-09-25 21:42:15 +02:00
FrederikBaerentsen 74fe14f09b Added rotation, moved select all, added link after download 2025-09-25 20:47:41 +02:00
FrederikBaerentsen 787624c432 Added env variables and fixed socket for peeron 2025-09-24 21:59:10 +02:00
FrederikBaerentsen eddf4311d3 Feat(peeron): Initial upload 2025-09-24 21:59:10 +02:00
FrederikBaerentsen 90c0c20d75 Merge pull request 'feature/pagination' (#101) from feature/pagination into release/1.3
Reviewed-on: FrederikBaerentsen/BrickTracker#101
2025-09-24 21:49:05 +02:00
FrederikBaerentsen d2d388b142 Merge branch 'release/1.3' into feature/pagination 2025-09-24 21:47:54 +02:00
FrederikBaerentsen acf06e1955 Updated change log 2025-09-24 21:36:40 +02:00
FrederikBaerentsen c465e9814c Fixed duplicate color in parts dropdown 2025-09-24 21:24:51 +02:00
FrederikBaerentsen 046493294f Moved sort/filter buttons 2025-09-24 20:44:50 +02:00
FrederikBaerentsen 1096fbdef6 Fixed sorting icon on sets page 2025-09-24 20:40:46 +02:00
FrederikBaerentsen fc405e0832 Consolidated parts.js, problems.js and minifigures.js 2025-09-24 20:18:30 +02:00
FrederikBaerentsen cce96af09b Consolidate duplicate collapsible state management 2025-09-24 19:53:01 +02:00
FrederikBaerentsen f953a44593 Disabled table sort using headers, if server-side pagination is enabled. 2025-09-24 19:08:34 +02:00
FrederikBaerentsen e87cb90e20 Updated gitignore 2025-09-23 18:07:42 +02:00
FrederikBaerentsen f3fada9dd8 Updated gitignore 2025-09-23 17:58:15 +02:00
FrederikBaerentsen 4eae6b19dc Updated gitignore 2025-09-23 17:55:26 +02:00
FrederikBaerentsen 064b79bf9e Merge remote-tracking branch 'origin/master' into feature/pagination 2025-09-23 17:24:58 +02:00
FrederikBaerentsen 7c1cb66f67 Merge pull request 'hotfix/pagination-bug' (#99) from hotfix/pagination-bug into master
Reviewed-on: FrederikBaerentsen/BrickTracker#99
2025-09-23 17:16:30 +02:00
FrederikBaerentsen 5641b3e740 Merge branch 'master' into hotfix/pagination-bug 2025-09-23 17:12:37 +02:00
FrederikBaerentsen 9317a1baae Removed code for another feature 2025-09-23 17:10:13 +02:00
FrederikBaerentsen 6f6d90aa60 fix(pagination): Fixed socket gevent (#95) 2025-09-23 17:06:18 +02:00
FrederikBaerentsen 83a45795c3 Merge pull request 'fix(pagination): Fix #95. Switch from eventlet to gevent' (#98) from hotfix/pagination-bug into master
Reviewed-on: FrederikBaerentsen/BrickTracker#98
2025-09-23 16:51:27 +02:00
FrederikBaerentsen 572c52dada fix(pagination): Added requirements.txt 2025-09-23 16:46:43 +02:00
FrederikBaerentsen 909655c10a fix(pagination): Fix #95. Switch from eventlet to gevent 2025-09-23 16:42:03 +02:00
FrederikBaerentsen d1b79de411 Updated .env.sample with new variables 2025-09-23 16:41:38 +02:00
FrederikBaerentsen 1e767537b9 fix(pagination): Fix #95. Switch from eventlet to gevent 2025-09-23 16:36:22 +02:00
FrederikBaerentsen 8ee0d144be Updated gitignore 2025-09-23 15:16:51 +02:00
FrederikBaerentsen f7963b4723 Removed datatable-search field from minifigures page 2025-09-22 10:08:41 +02:00
FrederikBaerentsen 52b6c94483 Fixed problems pagination 2025-09-22 10:01:16 +02:00
FrederikBaerentsen b5236fae51 Added filter/search/pagination to 'Problems' 2025-09-22 09:36:25 +02:00
FrederikBaerentsen 9d0a48ee2a Fixed gitignore 2025-09-21 19:03:08 +02:00
FrederikBaerentsen 5677d731e4 Updated gitignore 2025-09-21 18:56:56 +02:00
FrederikBaerentsen fcdcd12184 Updated .env sample file 2025-09-21 18:21:29 +02:00
FrederikBaerentsen e1891e8bd6 Added more pagination options 2025-09-21 18:18:26 +02:00
FrederikBaerentsen af53b29818 Removed print log spam 2025-09-21 17:32:11 +02:00
FrederikBaerentsen 8a0a7837dc Fixed filtering on /sets page. 2025-09-21 17:26:57 +02:00
FrederikBaerentsen 4b3aef577a Fixed sorting and filtering on /sets. 2025-09-21 15:58:32 +02:00
FrederikBaerentsen 9a32a3f193 Merge remote-tracking branch 'origin/master' into feature/pagination 2025-09-17 18:32:53 +02:00
FrederikBaerentsen c71667cd41 Fix: #80, default images not downloading (also present in feature/pagination) 2025-09-17 18:07:28 +02:00
FrederikBaerentsen 421d635dd3 Moved import and added ignore to BeautifulSoup type annotation issues 2025-09-17 17:03:24 +02:00
FrederikBaerentsen 6bc406b70d Fixed broken wishlist page 2025-09-17 16:49:53 +02:00
FrederikBaerentsen 5fa145a9d7 Fixed pagination button size 2025-09-17 16:34:29 +02:00
FrederikBaerentsen 3bfd1c17dd Sets, Parts and Minifigures have pagination now 2025-09-17 07:06:34 +02:00
FrederikBaerentsen 46dada312a Added page size option 2025-09-16 18:26:21 +02:00
FrederikBaerentsen c876e1e3a4 Added pagination to /parts page. 2025-09-16 15:30:54 +02:00
FrederikBaerentsen 787a376553 Fixed sorting issue on minifigs, updated change log and version. 2025-09-16 10:44:47 +02:00
FrederikBaerentsen 7f4be9da36 Updated gitignore 2025-09-16 10:43:52 +02:00
FrederikBaerentsen d6f69bca9d Merge pull request 'Added migration to get new bricklink data fields, fixed bricklink links, added set refresh based on missing bricklink data' (#88) from feature/bricklink-data into master
Reviewed-on: FrederikBaerentsen/BrickTracker#88

Fixes #87 and #55
2025-09-16 10:34:44 +02:00
FrederikBaerentsen 3adeef086b Added migration to get new bricklink data fields, fixed bricklink links, added set refresh based on missing bricklink data 2025-09-16 09:43:01 +02:00
FrederikBaerentsen 40b63fff6a Merge pull request 'Added filter/sort/search to /minifigures and /parts' (#86) from feature/sort-search-filter into master
Reviewed-on: FrederikBaerentsen/BrickTracker#86
2025-09-16 09:37:33 +02:00
FrederikBaerentsen 1cac17a420 Added filter/sort/search to /minifigures and /parts 2025-09-15 20:29:26 +02:00
FrederikBaerentsen 7bfbbbf298 Merge pull request 'Fixed the rebrickable scraping to deal with changes' (#81) from hiddenside/BrickTracker:fix-instructions-download into master
Reviewed-on: FrederikBaerentsen/BrickTracker#81
2025-08-08 19:47:14 +02:00
hiddenside 79f348178c Tweaks to get the progress bar working as expected. 2025-08-02 12:01:01 -07:00
jl 07be7b6004 Fixed the rebrickable scraping to deal with changes
Created a common naming schema for the instructions when downloaded
	setnumber-set-name-rebrickable-name
so set 3816-1 Glove World would end up
	3816-1-Glove-World-BI-3004-32-3816-V-29-39
If there is ever a duplicate name it appends _1+++
2025-08-02 12:01:01 -07:00
FrederikBaerentsen cb24cfc014 Merge pull request 'Fix legibility of "Damaged" and "Missing" fields for tiny screen by reducing horizontal padding' (#74) from gregoo/BrickTracker:master into master
Reviewed-on: FrederikBaerentsen/BrickTracker#74
2025-08-02 13:20:37 +02:00
FrederikBaerentsen 418bd5cd9d Merge pull request 'Fixed broken URLs in quickstart.md and setup.md' (#75) from KingColton1/BrickTracker:master into master
Reviewed-on: FrederikBaerentsen/BrickTracker#75
2025-08-02 13:20:17 +02:00
kingcolton 9953e3921a Fixed broken URLs in quickstart.md and setup.md 2025-04-08 19:10:59 -04:00
gregoo 2d0fa7bf89 Fix legibility of "Damaged" and "Missing" fields for tiny screen by reducing horizontal padding 2025-03-31 16:08:53 +02:00
234 changed files with 16563 additions and 557 deletions
+145 -13
View File
@@ -28,9 +28,15 @@
# BK_AUTHENTICATION_KEY=change-this-to-something-random # BK_AUTHENTICATION_KEY=change-this-to-something-random
# Optional: Pattern of the link to Bricklink for a part. Will be passed to Python .format() # Optional: Pattern of the link to Bricklink for a part. Will be passed to Python .format()
# Default: https://www.bricklink.com/v2/catalog/catalogitem.page?P={part} # Supports {part} and {color} parameters. BrickLink part numbers and color IDs are used when available.
# Default: https://www.bricklink.com/v2/catalog/catalogitem.page?P={part}&C={color}
# BK_BRICKLINK_LINK_PART_PATTERN= # BK_BRICKLINK_LINK_PART_PATTERN=
# Optional: Pattern of the link to Bricklink for a set. Will be passed to Python .format()
# Supports {set_num} parameter. Set numbers in format like '10255-1' are used.
# Default: https://www.bricklink.com/v2/catalog/catalogitem.page?S={set_num}
# BK_BRICKLINK_LINK_SET_PATTERN=
# Optional: Display Bricklink links wherever applicable # Optional: Display Bricklink links wherever applicable
# Default: false # Default: false
# BK_BRICKLINK_LINKS=true # BK_BRICKLINK_LINKS=true
@@ -55,6 +61,10 @@
# Default: 25 # Default: 25
# BK_DEFAULT_TABLE_PER_PAGE=50 # BK_DEFAULT_TABLE_PER_PAGE=50
# Optional: Maximum length for description text in badges before truncating with ellipsis
# Default: 15
# BK_DESCRIPTION_BADGE_MAX_LENGTH=15
# Optional: if set up, will add a CORS allow origin restriction to the socket. # Optional: if set up, will add a CORS allow origin restriction to the socket.
# Default: # Default:
# Legacy name: DOMAIN_NAME # Legacy name: DOMAIN_NAME
@@ -96,6 +106,14 @@
# Default: false # Default: false
# BK_HIDE_ADMIN=true # BK_HIDE_ADMIN=true
# Optional: Admin sections to expand by default (comma-separated list)
# Valid sections: authentication, instructions, image, theme, retired, metadata, owner, purchase_location, status, storage, tag, database
# Default: database (maintains original behavior with database section expanded)
# Examples:
# BK_ADMIN_DEFAULT_EXPANDED_SECTIONS=database,theme
# BK_ADMIN_DEFAULT_EXPANDED_SECTIONS=instructions,metadata
# BK_ADMIN_DEFAULT_EXPANDED_SECTIONS= (all sections collapsed)
# Optional: Hide the 'Instructions' entry from the menu. Does not disable the route. # Optional: Hide the 'Instructions' entry from the menu. Does not disable the route.
# Default: false # Default: false
# BK_HIDE_ALL_INSTRUCTIONS=true # BK_HIDE_ALL_INSTRUCTIONS=true
@@ -104,6 +122,20 @@
# Default: false # Default: false
# BK_HIDE_ALL_MINIFIGURES=true # BK_HIDE_ALL_MINIFIGURES=true
# Optional: Disable the individual/loose minifigures system. This hides all individual
# minifigure UI elements and prevents adding new individual minifigures. The routes remain
# accessible so existing individual minifigures can still be viewed. Users who only track
# set-based minifigures can use this to simplify the interface. Does not disable the route.
# Default: false
# BK_DISABLE_INDIVIDUAL_MINIFIGURES=false
# Optional: Disable the individual/loose parts system. This hides all individual part UI
# elements and prevents adding new individual parts (parts not associated with any set).
# The routes remain accessible so existing individual parts can still be viewed. Users who
# only track set-based parts can use this to simplify the interface. Does not disable the route.
# Default: false
# BK_DISABLE_INDIVIDUAL_PARTS=false
# Optional: Hide the 'Parts' entry from the menu. Does not disable the route. # Optional: Hide the 'Parts' entry from the menu. Does not disable the route.
# Default: false # Default: false
# BK_HIDE_ALL_PARTS=true # BK_HIDE_ALL_PARTS=true
@@ -121,6 +153,10 @@
# Default: false # Default: false
# BK_HIDE_ALL_STORAGES=true # BK_HIDE_ALL_STORAGES=true
# Optional: Hide the 'Statistics' entry from the menu. Does not disable the route.
# Default: false
# BK_HIDE_STATISTICS=true
# Optional: Hide the 'Instructions' entry in a Set card # Optional: Hide the 'Instructions' entry in a Set card
# Default: false # Default: false
# BK_HIDE_SET_INSTRUCTIONS=true # BK_HIDE_SET_INSTRUCTIONS=true
@@ -133,17 +169,24 @@
# Default: false # Default: false
# BK_HIDE_TABLE_MISSING_PARTS=true # BK_HIDE_TABLE_MISSING_PARTS=true
# Optional: Hide the 'Checked' column from the parts table.
# Default: false
# BK_HIDE_TABLE_CHECKED_PARTS=true
# Optional: Hide the 'Wishlist' entry from the menu. Does not disable the route. # Optional: Hide the 'Wishlist' entry from the menu. Does not disable the route.
# Default: false # Default: false
# BK_HIDE_WISHES=true # BK_HIDE_WISHES=true
# Optional: Change the default order of minifigures. By default ordered by insertion order. # Optional: Change the default order of minifigures. By default ordered by insertion order.
# Note: Minifigures are queried from a combined view that merges both set-based and individual minifigures.
# Therefore, column references should use the "combined" table alias.
# Useful column names for this option are: # Useful column names for this option are:
# - "rebrickable_minifigures"."figure": minifigure ID (fig-xxxxx) # - "combined"."figure": minifigure ID (fig-xxxxx)
# - "rebrickable_minifigures"."number": minifigure ID as an integer (xxxxx) # - "combined"."number": minifigure ID as an integer (xxxxx)
# - "rebrickable_minifigures"."name": minifigure name # - "combined"."name": minifigure name
# Default: "rebrickable_minifigures"."name" ASC # - "combined"."rowid": insertion order (for both set and individual minifigures)
# BK_MINIFIGURES_DEFAULT_ORDER="rebrickable_minifigures"."name" ASC # Default: "combined"."name" ASC
# BK_MINIFIGURES_DEFAULT_ORDER="combined"."name" ASC
# Optional: Folder where to store the minifigures images, relative to the '/app/static/' folder # Optional: Folder where to store the minifigures images, relative to the '/app/static/' folder
# Default: minifigs # Default: minifigs
@@ -156,19 +199,62 @@
# BK_NO_THREADED_SOCKET=true # BK_NO_THREADED_SOCKET=true
# Optional: Change the default order of parts. By default ordered by insertion order. # Optional: Change the default order of parts. By default ordered by insertion order.
# Note: Parts are queried from a combined view that merges both set-based and individual minifigure parts.
# Some columns use the "combined" table alias for fields from the merged view.
# Useful column names for this option are: # Useful column names for this option are:
# - "bricktracker_parts"."part": part number # - "combined"."part": part number
# - "bricktracker_parts"."spare": part is a spare part # - "combined"."spare": part is a spare part (use "combined" not "bricktracker_parts")
# - "rebrickable_parts"."name": part name # - "rebrickable_parts"."name": part name
# - "rebrickable_parts"."color_name": part color name # - "rebrickable_parts"."color_name": part color name
# - "total_missing": number of missing parts # - "total_missing": number of missing parts
# Default: "rebrickable_parts"."name" ASC, "rebrickable_parts"."color_name" ASC, "bricktracker_parts"."spare" ASC # Default: "rebrickable_parts"."name" ASC, "rebrickable_parts"."color_name" ASC, "combined"."spare" ASC
# BK_PARTS_DEFAULT_ORDER="total_missing" DESC, "rebrickable_parts"."name"."name" ASC # BK_PARTS_DEFAULT_ORDER="total_missing" DESC, "rebrickable_parts"."name" ASC
# Optional: Folder where to store the parts images, relative to the '/app/static/' folder # Optional: Folder where to store the parts images, relative to the '/app/static/' folder
# Default: parts # Default: parts
# BK_PARTS_FOLDER=parts # BK_PARTS_FOLDER=parts
# Optional: Enable server-side pagination for individual pages (recommended for large collections)
# When enabled, pages use server-side pagination with configurable page sizes
# When disabled, pages load all data at once with instant client-side search
# Default: false for all
# BK_SETS_SERVER_SIDE_PAGINATION=true
# BK_PARTS_SERVER_SIDE_PAGINATION=true
# BK_MINIFIGURES_SERVER_SIDE_PAGINATION=true
# BK_PROBLEMS_SERVER_SIDE_PAGINATION=true
# Optional: Number of parts to show per page on desktop devices (when server-side pagination is enabled)
# Default: 10
# BK_PARTS_PAGINATION_SIZE_DESKTOP=10
# Optional: Number of parts to show per page on mobile devices (when server-side pagination is enabled)
# Default: 5
# BK_PARTS_PAGINATION_SIZE_MOBILE=5
# Optional: Number of sets to show per page on desktop devices (when server-side pagination is enabled)
# Should be divisible by 4 for grid layout. Default: 12
# BK_SETS_PAGINATION_SIZE_DESKTOP=12
# Optional: Number of sets to show per page on mobile devices (when server-side pagination is enabled)
# Default: 4
# BK_SETS_PAGINATION_SIZE_MOBILE=4
# Optional: Number of minifigures to show per page on desktop devices (when server-side pagination is enabled)
# Default: 10
# BK_MINIFIGURES_PAGINATION_SIZE_DESKTOP=10
# Optional: Number of minifigures to show per page on mobile devices (when server-side pagination is enabled)
# Default: 5
# BK_MINIFIGURES_PAGINATION_SIZE_MOBILE=5
# Optional: Number of problems to show per page on desktop devices (when server-side pagination is enabled)
# Default: 10
# BK_PROBLEMS_PAGINATION_SIZE_DESKTOP=10
# Optional: Number of problems to show per page on mobile devices (when server-side pagination is enabled)
# Default: 5
# BK_PROBLEMS_PAGINATION_SIZE_MOBILE=5
# Optional: Port the server will listen on. # Optional: Port the server will listen on.
# Default: 3333 # Default: 3333
# BK_PORT=3333 # BK_PORT=3333
@@ -220,9 +306,36 @@
# Default: https://rebrickable.com/instructions/{path} # Default: https://rebrickable.com/instructions/{path}
# BK_REBRICKABLE_LINK_INSTRUCTIONS_PATTERN= # BK_REBRICKABLE_LINK_INSTRUCTIONS_PATTERN=
# Optional: User-Agent to use when querying Rebrickable outside of the Rebrick python library # Optional: User-Agent to use when querying Rebrickable and Peeron outside of the Rebrick python library
# Default: 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' # Default: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36
# BK_REBRICKABLE_USER_AGENT= # BK_USER_AGENT=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36
# Legacy: User-Agent for Rebrickable (use BK_USER_AGENT instead)
# BK_REBRICKABLE_USER_AGENT=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36
# Optional: Delay in milliseconds between Peeron page downloads to avoid being potentially blocked
# Default: 1000
# BK_PEERON_DOWNLOAD_DELAY=1000
# Optional: Minimum image size (width/height) for valid Peeron instruction pages
# Images smaller than this are considered error placeholders and will be rejected
# Default: 100
# BK_PEERON_MIN_IMAGE_SIZE=100
# Optional: Pattern for Peeron instruction page URLs. Will be passed to Python .format()
# Supports {set_number} and {version_number} parameters
# Default: http://peeron.com/scans/{set_number}-{version_number}
# BK_PEERON_INSTRUCTION_PATTERN=
# Optional: Pattern for Peeron thumbnail URLs. Will be passed to Python .format()
# Supports {set_number} and {version_number} parameters
# Default: http://belay.peeron.com/thumbs/{set_number}-{version_number}/
# BK_PEERON_THUMBNAIL_PATTERN=
# Optional: Pattern for Peeron scan URLs. Will be passed to Python .format()
# Supports {set_number} and {version_number} parameters
# Default: http://belay.peeron.com/scans/{set_number}-{version_number}/
# BK_PEERON_SCAN_PATTERN=
# Optional: Display Rebrickable links wherever applicable # Optional: Display Rebrickable links wherever applicable
# Default: false # Default: false
@@ -259,6 +372,12 @@
# Default: sets # Default: sets
# BK_SETS_FOLDER=sets # BK_SETS_FOLDER=sets
# Optional: Enable set consolidation/grouping on the main sets page
# When enabled, multiple copies of the same set are grouped together showing instance count
# When disabled, each set copy is displayed individually (original behavior)
# Default: false
# BK_SETS_CONSOLIDATION=true
# Optional: Make the grid filters displayed by default, rather than collapsed # Optional: Make the grid filters displayed by default, rather than collapsed
# Default: false # Default: false
# BK_SHOW_GRID_FILTERS=true # BK_SHOW_GRID_FILTERS=true
@@ -267,6 +386,10 @@
# Default: false # Default: false
# BK_SHOW_GRID_SORT=true # BK_SHOW_GRID_SORT=true
# Optional: Show duplicate filter button on sets page
# Default: true
# BK_SHOW_SETS_DUPLICATE_FILTER=true
# Optional: Skip saving or displaying spare parts # Optional: Skip saving or displaying spare parts
# Default: false # Default: false
# BK_SKIP_SPARE_PARTS=true # BK_SKIP_SPARE_PARTS=true
@@ -312,3 +435,12 @@
# - "bricktracker_wishes"."number_of_parts": set number of parts # - "bricktracker_wishes"."number_of_parts": set number of parts
# Default: "bricktracker_wishes"."rowid" DESC # Default: "bricktracker_wishes"."rowid" DESC
# BK_WISHES_DEFAULT_ORDER="bricktracker_wishes"."set" DESC # BK_WISHES_DEFAULT_ORDER="bricktracker_wishes"."set" DESC
# Optional: Show collection growth charts on the statistics page
# Default: true
# BK_STATISTICS_SHOW_CHARTS=false
# Optional: Default state of statistics page sections (expanded or collapsed)
# When true, all sections start expanded. When false, all sections start collapsed.
# Default: true
# BK_STATISTICS_DEFAULT_EXPANDED=false
+13
View File
@@ -21,6 +21,19 @@ static/sets/
# Temporary # Temporary
*.csv *.csv
/local/ /local/
run_local.sh
settings.local.json
# Apple idiocy # Apple idiocy
.DS_Store .DS_Store
# Documentation
docusaurus/
vitepress/
# Local data
offline/
TODO.md
run-local.sh
test-server.sh
data/
+130
View File
@@ -1,5 +1,135 @@
# Changelog # Changelog
## Unreleased
### 1.3
- Add individual pagination control system per entity type
- `BK_SETS_SERVER_SIDE_PAGINATION`: Enable/disable pagination for sets
- `BK_PARTS_SERVER_SIDE_PAGINATION`: Enable/disable pagination for parts
- `BK_MINIFIGURES_SERVER_SIDE_PAGINATION`: Enable/disable pagination for minifigures
- Device-specific pagination sizes (desktop/mobile) for each entity type
- Supports search, filtering, and sorting in both server-side and client-side modes
- Consolidated duplicate code across parts.js, problems.js, and minifigures.js
- Created shared functions in collapsible-state.js for common operations
- Fixed dynamic sort icons across all pages
- Sort icons now properly toggle between ascending/descending states
- Improved DataTable integration
- Disabled column header sorting when server-side pagination is enabled
- Prevents conflicting sort mechanisms between DataTable and server-side sorting
- Enhanced color dropdown functionality
- Automatic merging of duplicate color entries with same color_id
- Keeps entries with valid RGB data, removes entries with None/empty RGB
- Preserves selection state during dropdown consolidation
- Consistent search behavior (instant for client-side, Enter key for server-side)
- Mobile-friendly pagination navigation
- Add Peeron instructions integration
- Full image caching system with automatic thumbnail generation
- Optimized HTTP calls by downloading full images once and generating thumbnails locally
- Automatic cache cleanup after PDF generation to save disk space
- Add parts checking/inventory system
- New "Checked" column in parts tables for tracking inventory progress
- Checkboxes to mark parts as verified during set walkthrough
- `BK_HIDE_TABLE_CHECKED_PARTS`: Environment variable to hide checked column
- Add set consolidation/grouping functionality
- Automatic grouping of duplicate sets on main sets page
- Shows instance count with stack icon badge (e.g., "3 copies")
- Expandable drawer interface to view all set copies individually
- Full set cards for each instance with all badges, statuses, and functionality
- `BK_SETS_CONSOLIDATION`: Environment variable to enable/disable consolidation (default: false)
- Backwards compatible - when disabled, behaves exactly like original individual view
- Improved theme filtering: handles duplicate theme names correctly
- Fixed set number sorting: proper numeric sorting in both ascending and descending order
- Mixed status indicators for consolidated sets: three-state checkboxes (unchecked/partial/checked) with count badges
- Template logic handles three states: none (0/2), all (2/2), partial (1/2) with visual indicators
- Purple overlay styling for partial states, disabled checkboxes for read-only consolidated status display
- Individual sets maintain full interactive checkbox functionality
- Add comprehensive statistics system (#91)
- New Statistics page with collection analytics
- Financial overview: total cost, average price, price range, investment tracking
- Collection metrics: total sets, unique sets, parts count, minifigures count
- Theme distribution statistics with clickable drill-down to filtered sets
- Storage location statistics showing sets per location with value calculations
- Purchase location analytics with spending patterns and date ranges
- Problem tracking: missing and damaged parts statistics
- Clickable numbers throughout statistics that filter to relevant sets
- `BK_HIDE_STATISTICS`: Environment variable to hide statistics menu item
- Year-based analytics: Sets by release year and purchases by year
- Sets by Release Year: Shows collection distribution across LEGO release years
- Purchases by Year: Tracks spending patterns and acquisition timeline
- Year summary with peak collection/spending years and timeline insights
- Enhanced statistics interface and functionality
- Collapsible sections: All statistics sections have clickable headers to expand/collapse
- Collection growth charts: Line charts showing sets, parts, and minifigures over time
- Configuration options: `BK_STATISTICS_SHOW_CHARTS` and `BK_STATISTICS_DEFAULT_EXPANDED` environment variables
- Add configurable admin page section expansion
- `BK_ADMIN_DEFAULT_EXPANDED_SECTIONS`: Environment variable to specify which sections expand by default
- Accepts comma-separated list of section names (e.g., "database,theme,instructions")
- Valid sections: authentication, instructions, image, theme, retired, metadata, owner, purchase_location, status, storage, tag, database
- URL parameters take priority over configuration (e.g., `?open_database=1`)
- Database section expanded by default to maintain original behavior
- Smart metadata handling: sub-section expansion automatically expands parent metadata section
- Add duplicate sets filter functionality
- New filter button on Sets page to show only duplicate/consolidated sets
- `BK_SHOW_SETS_DUPLICATE_FILTER`: Environment variable to show/hide the filter button (default: true)
- Works with both server-side and client-side pagination modes
- Consolidated mode: Shows sets that have multiple instances
- Non-consolidated mode: Shows sets that appear multiple times in collection
- Add BrickLink links for sets
- BrickLink badge links now appear on set cards and set details pages alongside Rebrickable links
- `BK_BRICKLINK_LINK_SET_PATTERN`: New environment variable for BrickLink set URL pattern (default: https://www.bricklink.com/v2/catalog/catalogitem.page?S={set_num})
- Controlled by existing `BK_BRICKLINK_LINKS` environment variable
- Add live environment variable configuration management system
- Configuration Management interface in admin panel with live preview and badge system
- Live settings: Can be changed without application restart (menu visibility, table display, pagination, features)
- Static settings: Require restart but can be edited and saved to .env file (authentication, server, database, API keys)
- Advanced badge system showing value status: True/False for booleans, Set/Default/Unset for other values, Changed indicator
- Live API endpoints: `/admin/api/config/update` for immediate changes, `/admin/api/config/update-static` for .env updates
- Form pre-population with current values and automatic page reload after successful live updates
- **BREAKING CHANGE**: Default minifigures folder path changed from `minifigs` to `minifigures`
- Impact: Users who relied on the default `BK_MINIFIGURES_FOLDER` value (without explicitly setting it) will need to either:
1. Set `BK_MINIFIGURES_FOLDER=minifigs` in their environment to maintain existing behavior, or
2. Rename their existing `minifigs` folder to `minifigures`
- No impact: Users who already have `BK_MINIFIGURES_FOLDER` explicitly configured
- Improved consistency across documentation and Docker configurations
- Add performance optimization
- SQLite WAL Mode:
- Increased cache size to 10,000 pages (~40MB) for faster query execution
- Set temp_store to memory for accelerated temporary operations
- Enabled foreign key constraints and optimized synchronous mode
- Added ANALYZE for improved query planning and statistics
- Database Indexes (Migration 0019):
- High-impact composite index for problem parts aggregation (`idx_bricktracker_parts_id_missing_damaged`)
- Parts lookup optimization (`idx_bricktracker_parts_part_color_spare`)
- Set storage filtering (`idx_bricktracker_sets_set_storage`)
- Search optimization with case-insensitive indexes (`idx_rebrickable_sets_name_lower`, `idx_rebrickable_parts_name_lower`)
- Year and theme filtering optimization (`idx_rebrickable_sets_year`, `idx_rebrickable_sets_theme_id`)
- Additional indexes for purchase dates, quantities, sorting, and minifigures aggregation
- Statistics Query Optimization:
- Replaced separate subqueries with efficient CTEs (Common Table Expressions)
- Consolidated aggregations for set, part, minifigure, and financial statistics
### 1.2.4
> **Warning**
> To use the new BrickLink color parameter in URLs, update your `.env` file:
> `BK_BRICKLINK_LINK_PART_PATTERN=https://www.bricklink.com/v2/catalog/catalogitem.page?P={part}&C={color}`
- Add BrickLink color and part number support for accurate BrickLink URLs
- Database migrations to store BrickLink color ID, color name, and part number
- Updated Rebrickable API integration to extract BrickLink data from external_ids
- Enhanced BrickLink URL generation with proper part number fallback
- Extended admin set refresh to detect and track missing BrickLink data
## 1.2.3
Added search/filter/sort options to `parts` and `minifigures`.
## 1.2.2
Fix legibility of "Damaged" and "Missing" fields for tiny screen by reducing horizontal padding
Fixed instructions download from Rebrickable
## 1.2.2: ## 1.2.2:
This release fixes a bug where orphaned parts in the `inventory` table are blocking the database upgrade. This release fixes a bug where orphaned parts in the `inventory` table are blocking the database upgrade.
+3
View File
@@ -5,6 +5,9 @@ WORKDIR /app
# Bricktracker # Bricktracker
COPY . . COPY . .
# Fix line endings and set executable permissions for entrypoint script
RUN sed -i 's/\r$//' entrypoint.sh && chmod +x entrypoint.sh
# Python library requirements # Python library requirements
RUN pip --no-cache-dir install -r requirements.txt RUN pip --no-cache-dir install -r requirements.txt
+2 -2
View File
@@ -1,6 +1,6 @@
# This need to be first # This need to be first
import eventlet import gevent.monkey
eventlet.monkey_patch() gevent.monkey.patch_all()
import logging # noqa: E402 import logging # noqa: E402
+43 -1
View File
@@ -10,6 +10,7 @@ from bricktracker.configuration_list import BrickConfigurationList
from bricktracker.login import LoginManager from bricktracker.login import LoginManager
from bricktracker.navbar import Navbar from bricktracker.navbar import Navbar
from bricktracker.sql import close from bricktracker.sql import close
from bricktracker.template_filters import replace_query_filter
from bricktracker.version import __version__ from bricktracker.version import __version__
from bricktracker.views.add import add_page from bricktracker.views.add import add_page
from bricktracker.views.admin.admin import admin_page from bricktracker.views.admin.admin import admin_page
@@ -28,17 +29,51 @@ from bricktracker.views.error import error_404
from bricktracker.views.index import index_page from bricktracker.views.index import index_page
from bricktracker.views.instructions import instructions_page from bricktracker.views.instructions import instructions_page
from bricktracker.views.login import login_page from bricktracker.views.login import login_page
from bricktracker.views.individual_minifigure import individual_minifigure_page
from bricktracker.views.individual_part import individual_part_page
from bricktracker.views.minifigure import minifigure_page from bricktracker.views.minifigure import minifigure_page
from bricktracker.views.part import part_page from bricktracker.views.part import part_page
from bricktracker.views.set import set_page from bricktracker.views.set import set_page
from bricktracker.views.statistics import statistics_page
from bricktracker.views.storage import storage_page from bricktracker.views.storage import storage_page
from bricktracker.views.wish import wish_page from bricktracker.views.wish import wish_page
logger = logging.getLogger(__name__)
def _validate_config(app: Flask) -> None:
"""
Validate application configuration and log warnings for potential issues.
"""
# Check if both individual features are disabled
if app.config.get('DISABLE_INDIVIDUAL_PARTS') and app.config.get('DISABLE_INDIVIDUAL_MINIFIGURES'):
logger.warning(
'Both DISABLE_INDIVIDUAL_PARTS and DISABLE_INDIVIDUAL_MINIFIGURES are enabled. '
'Users will not be able to track standalone parts or minifigures.'
)
# Check if Rebrickable API key is missing
if not app.config.get('REBRICKABLE_API_KEY'):
logger.warning(
'REBRICKABLE_API_KEY is not set. You will not be able to fetch data from Rebrickable API. '
'Please set this in your .env file or environment variables.'
)
# Check authentication configuration
if not app.config.get('AUTHENTICATION_PASSWORD') and not app.config.get('AUTHENTICATION_KEY'):
logger.info(
'No authentication configured (AUTHENTICATION_PASSWORD or AUTHENTICATION_KEY). '
'Admin features will be accessible without login.'
)
def setup_app(app: Flask) -> None: def setup_app(app: Flask) -> None:
# Load the configuration # Load the configuration
BrickConfigurationList(app) BrickConfigurationList(app)
# Validate configuration
_validate_config(app)
# Set the logging level # Set the logging level
if app.config['DEBUG']: if app.config['DEBUG']:
logging.basicConfig( logging.basicConfig(
@@ -59,7 +94,8 @@ def setup_app(app: Flask) -> None:
# Setup the login manager # Setup the login manager
LoginManager(app) LoginManager(app)
# I don't know :-) # Configure proxy header handling for reverse proxy deployments (nginx, Apache, etc.)
# This ensures proper client IP detection and HTTPS scheme recognition
app.wsgi_app = ProxyFix( app.wsgi_app = ProxyFix(
app.wsgi_app, app.wsgi_app,
x_for=1, x_for=1,
@@ -77,9 +113,12 @@ def setup_app(app: Flask) -> None:
app.register_blueprint(index_page) app.register_blueprint(index_page)
app.register_blueprint(instructions_page) app.register_blueprint(instructions_page)
app.register_blueprint(login_page) app.register_blueprint(login_page)
app.register_blueprint(individual_minifigure_page)
app.register_blueprint(individual_part_page)
app.register_blueprint(minifigure_page) app.register_blueprint(minifigure_page)
app.register_blueprint(part_page) app.register_blueprint(part_page)
app.register_blueprint(set_page) app.register_blueprint(set_page)
app.register_blueprint(statistics_page)
app.register_blueprint(storage_page) app.register_blueprint(storage_page)
app.register_blueprint(wish_page) app.register_blueprint(wish_page)
@@ -121,6 +160,9 @@ def setup_app(app: Flask) -> None:
# Version # Version
g.version = __version__ g.version = __version__
# Register custom Jinja2 filters
app.jinja_env.filters['replace_query'] = replace_query_filter
# Make sure all connections are closed at the end # Make sure all connections are closed at the end
@app.teardown_request @app.teardown_request
def teardown_request(_: BaseException | None) -> None: def teardown_request(_: BaseException | None) -> None:
+33 -4
View File
@@ -10,12 +10,16 @@ from typing import Any, Final
CONFIG: Final[list[dict[str, Any]]] = [ CONFIG: Final[list[dict[str, Any]]] = [
{'n': 'AUTHENTICATION_PASSWORD', 'd': ''}, {'n': 'AUTHENTICATION_PASSWORD', 'd': ''},
{'n': 'AUTHENTICATION_KEY', 'd': ''}, {'n': 'AUTHENTICATION_KEY', 'd': ''},
{'n': 'BRICKLINK_LINK_PART_PATTERN', 'd': 'https://www.bricklink.com/v2/catalog/catalogitem.page?P={part}'}, # 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}, {'n': 'BRICKLINK_LINKS', 'c': bool},
{'n': 'DATABASE_PATH', 'd': './app.db'}, {'n': 'DATABASE_PATH', 'd': './app.db'},
{'n': 'DATABASE_TIMESTAMP_FORMAT', 'd': '%Y-%m-%d-%H-%M-%S'}, {'n': 'DATABASE_TIMESTAMP_FORMAT', 'd': '%Y-%m-%d-%H-%M-%S'},
{'n': 'DEBUG', 'c': bool}, {'n': 'DEBUG', 'c': bool},
{'n': 'DEFAULT_TABLE_PER_PAGE', 'd': 25, 'c': int}, {'n': 'DEFAULT_TABLE_PER_PAGE', 'd': 25, 'c': int},
{'n': 'DESCRIPTION_BADGE_MAX_LENGTH', 'd': 15, 'c': int},
{'n': 'DISABLE_INDIVIDUAL_MINIFIGURES', 'c': bool},
{'n': 'DISABLE_INDIVIDUAL_PARTS', 'c': bool},
{'n': 'DOMAIN_NAME', 'e': 'DOMAIN_NAME', 'd': ''}, {'n': 'DOMAIN_NAME', 'e': 'DOMAIN_NAME', 'd': ''},
{'n': 'FILE_DATETIME_FORMAT', 'd': '%d/%m/%Y, %H:%M:%S'}, {'n': 'FILE_DATETIME_FORMAT', 'd': '%d/%m/%Y, %H:%M:%S'},
{'n': 'HOST', 'd': '0.0.0.0'}, {'n': 'HOST', 'd': '0.0.0.0'},
@@ -25,21 +29,36 @@ CONFIG: Final[list[dict[str, Any]]] = [
{'n': 'HIDE_ADD_SET', 'c': bool}, {'n': 'HIDE_ADD_SET', 'c': bool},
{'n': 'HIDE_ADD_BULK_SET', 'c': bool}, {'n': 'HIDE_ADD_BULK_SET', 'c': bool},
{'n': 'HIDE_ADMIN', 'c': bool}, {'n': 'HIDE_ADMIN', 'c': bool},
{'n': 'ADMIN_DEFAULT_EXPANDED_SECTIONS', 'd': ['database'], 'c': list},
{'n': 'HIDE_ALL_INSTRUCTIONS', 'c': bool}, {'n': 'HIDE_ALL_INSTRUCTIONS', 'c': bool},
{'n': 'HIDE_ALL_MINIFIGURES', 'c': bool}, {'n': 'HIDE_ALL_MINIFIGURES', 'c': bool},
{'n': 'HIDE_ALL_PARTS', 'c': bool}, {'n': 'HIDE_ALL_PARTS', 'c': bool},
{'n': 'HIDE_ALL_PROBLEMS_PARTS', 'e': 'BK_HIDE_MISSING_PARTS', 'c': bool}, {'n': 'HIDE_ALL_PROBLEMS_PARTS', 'e': 'BK_HIDE_MISSING_PARTS', 'c': bool},
{'n': 'HIDE_ALL_SETS', 'c': bool}, {'n': 'HIDE_ALL_SETS', 'c': bool},
{'n': 'HIDE_ALL_STORAGES', 'c': bool}, {'n': 'HIDE_ALL_STORAGES', 'c': bool},
{'n': 'HIDE_STATISTICS', 'c': bool},
{'n': 'HIDE_SET_INSTRUCTIONS', 'c': bool}, {'n': 'HIDE_SET_INSTRUCTIONS', 'c': bool},
{'n': 'HIDE_TABLE_DAMAGED_PARTS', 'c': bool}, {'n': 'HIDE_TABLE_DAMAGED_PARTS', 'c': bool},
{'n': 'HIDE_TABLE_MISSING_PARTS', 'c': bool}, {'n': 'HIDE_TABLE_MISSING_PARTS', 'c': bool},
{'n': 'HIDE_TABLE_CHECKED_PARTS', 'c': bool},
{'n': 'HIDE_WISHES', 'c': bool}, {'n': 'HIDE_WISHES', 'c': bool},
{'n': 'MINIFIGURES_DEFAULT_ORDER', 'd': '"rebrickable_minifigures"."name" ASC'}, # noqa: E501 {'n': 'MINIFIGURES_DEFAULT_ORDER', 'd': '"combined"."name" ASC'}, # noqa: E501
{'n': 'MINIFIGURES_FOLDER', 'd': 'minifigs', 's': True}, {'n': 'MINIFIGURES_FOLDER', 'd': 'minifigures', 's': True},
{'n': 'MINIFIGURES_PAGINATION_SIZE_DESKTOP', 'd': 10, 'c': int},
{'n': 'MINIFIGURES_PAGINATION_SIZE_MOBILE', 'd': 5, 'c': int},
{'n': 'MINIFIGURES_SERVER_SIDE_PAGINATION', 'c': bool},
{'n': 'NO_THREADED_SOCKET', 'c': bool}, {'n': 'NO_THREADED_SOCKET', 'c': bool},
{'n': 'PARTS_DEFAULT_ORDER', 'd': '"rebrickable_parts"."name" ASC, "rebrickable_parts"."color_name" ASC, "bricktracker_parts"."spare" ASC'}, # noqa: E501 {'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, "combined"."spare" ASC'}, # noqa: E501
{'n': 'PARTS_FOLDER', 'd': 'parts', 's': True}, {'n': 'PARTS_FOLDER', 'd': 'parts', 's': True},
{'n': 'PARTS_PAGINATION_SIZE_DESKTOP', 'd': 10, 'c': int},
{'n': 'PARTS_PAGINATION_SIZE_MOBILE', 'd': 5, 'c': int},
{'n': 'PROBLEMS_PAGINATION_SIZE_DESKTOP', 'd': 10, 'c': int},
{'n': 'PROBLEMS_PAGINATION_SIZE_MOBILE', 'd': 10, 'c': int},
{'n': 'PROBLEMS_SERVER_SIDE_PAGINATION', 'c': bool},
{'n': 'SETS_PAGINATION_SIZE_DESKTOP', 'd': 12, 'c': int},
{'n': 'SETS_PAGINATION_SIZE_MOBILE', 'd': 4, 'c': int},
{'n': 'PORT', 'd': 3333, 'c': int}, {'n': 'PORT', 'd': 3333, 'c': int},
{'n': 'PURCHASE_DATE_FORMAT', 'd': '%d/%m/%Y'}, {'n': 'PURCHASE_DATE_FORMAT', 'd': '%d/%m/%Y'},
{'n': 'PURCHASE_CURRENCY', 'd': ''}, {'n': 'PURCHASE_CURRENCY', 'd': ''},
@@ -52,14 +71,22 @@ CONFIG: Final[list[dict[str, Any]]] = [
{'n': 'REBRICKABLE_LINK_PART_PATTERN', 'd': 'https://rebrickable.com/parts/{part}/_/{color}'}, # noqa: E501 {'n': 'REBRICKABLE_LINK_PART_PATTERN', 'd': 'https://rebrickable.com/parts/{part}/_/{color}'}, # noqa: E501
{'n': 'REBRICKABLE_LINK_INSTRUCTIONS_PATTERN', 'd': 'https://rebrickable.com/instructions/{path}'}, # noqa: E501 {'n': 'REBRICKABLE_LINK_INSTRUCTIONS_PATTERN', 'd': 'https://rebrickable.com/instructions/{path}'}, # noqa: E501
{'n': 'REBRICKABLE_USER_AGENT', 'd': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'}, # noqa: E501 {'n': 'REBRICKABLE_USER_AGENT', 'd': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'}, # noqa: E501
{'n': 'USER_AGENT', 'd': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'}, # noqa: E501
{'n': 'PEERON_DOWNLOAD_DELAY', 'd': 1000, 'c': int},
{'n': 'PEERON_INSTRUCTION_PATTERN', 'd': 'http://peeron.com/scans/{set_number}-{version_number}'},
{'n': 'PEERON_MIN_IMAGE_SIZE', 'd': 100, 'c': int},
{'n': 'PEERON_SCAN_PATTERN', 'd': 'http://belay.peeron.com/scans/{set_number}-{version_number}/'},
{'n': 'PEERON_THUMBNAIL_PATTERN', 'd': 'http://belay.peeron.com/thumbs/{set_number}-{version_number}/'},
{'n': 'REBRICKABLE_LINKS', 'e': 'LINKS', 'c': bool}, {'n': 'REBRICKABLE_LINKS', 'e': 'LINKS', 'c': bool},
{'n': 'REBRICKABLE_PAGE_SIZE', 'd': 100, 'c': int}, {'n': 'REBRICKABLE_PAGE_SIZE', 'd': 100, 'c': int},
{'n': 'RETIRED_SETS_FILE_URL', 'd': 'https://docs.google.com/spreadsheets/d/1rlYfEXtNKxUOZt2Mfv0H17DvK7bj6Pe0CuYwq6ay8WA/gviz/tq?tqx=out:csv&sheet=Sorted%20by%20Retirement%20Date'}, # noqa: E501 {'n': 'RETIRED_SETS_FILE_URL', 'd': 'https://docs.google.com/spreadsheets/d/1rlYfEXtNKxUOZt2Mfv0H17DvK7bj6Pe0CuYwq6ay8WA/gviz/tq?tqx=out:csv&sheet=Sorted%20by%20Retirement%20Date'}, # noqa: E501
{'n': 'RETIRED_SETS_PATH', 'd': './retired_sets.csv'}, {'n': 'RETIRED_SETS_PATH', 'd': './retired_sets.csv'},
{'n': 'SETS_DEFAULT_ORDER', 'd': '"rebrickable_sets"."number" DESC, "rebrickable_sets"."version" ASC'}, # noqa: E501 {'n': 'SETS_DEFAULT_ORDER', 'd': '"rebrickable_sets"."number" DESC, "rebrickable_sets"."version" ASC'}, # noqa: E501
{'n': 'SETS_FOLDER', 'd': 'sets', 's': True}, {'n': 'SETS_FOLDER', 'd': 'sets', 's': True},
{'n': 'SETS_CONSOLIDATION', 'd': False, 'c': bool},
{'n': 'SHOW_GRID_FILTERS', 'c': bool}, {'n': 'SHOW_GRID_FILTERS', 'c': bool},
{'n': 'SHOW_GRID_SORT', 'c': bool}, {'n': 'SHOW_GRID_SORT', 'c': bool},
{'n': 'SHOW_SETS_DUPLICATE_FILTER', 'd': True, 'c': bool},
{'n': 'SKIP_SPARE_PARTS', 'c': bool}, {'n': 'SKIP_SPARE_PARTS', 'c': bool},
{'n': 'SOCKET_NAMESPACE', 'd': 'bricksocket'}, {'n': 'SOCKET_NAMESPACE', 'd': 'bricksocket'},
{'n': 'SOCKET_PATH', 'd': '/bricksocket/'}, {'n': 'SOCKET_PATH', 'd': '/bricksocket/'},
@@ -69,4 +96,6 @@ CONFIG: Final[list[dict[str, Any]]] = [
{'n': 'TIMEZONE', 'd': 'Etc/UTC'}, {'n': 'TIMEZONE', 'd': 'Etc/UTC'},
{'n': 'USE_REMOTE_IMAGES', 'c': bool}, {'n': 'USE_REMOTE_IMAGES', 'c': bool},
{'n': 'WISHES_DEFAULT_ORDER', 'd': '"bricktracker_wishes"."rowid" DESC'}, {'n': 'WISHES_DEFAULT_ORDER', 'd': '"bricktracker_wishes"."rowid" DESC'},
{'n': 'STATISTICS_SHOW_CHARTS', 'd': True, 'c': bool},
{'n': 'STATISTICS_DEFAULT_EXPANDED', 'd': True, 'c': bool},
] ]
+315
View File
@@ -0,0 +1,315 @@
import os
import logging
from typing import Any, Dict, Final, List, Optional
from pathlib import Path
from flask import current_app
logger = logging.getLogger(__name__)
# Environment variables that can be changed live without restart
LIVE_CHANGEABLE_VARS: Final[List[str]] = [
'BK_BRICKLINK_LINKS',
'BK_DEFAULT_TABLE_PER_PAGE',
'BK_DESCRIPTION_BADGE_MAX_LENGTH',
'BK_INDEPENDENT_ACCORDIONS',
'BK_HIDE_ADD_SET',
'BK_HIDE_ADD_BULK_SET',
'BK_HIDE_ADMIN',
'BK_ADMIN_DEFAULT_EXPANDED_SECTIONS',
'BK_HIDE_ALL_INSTRUCTIONS',
'BK_HIDE_ALL_MINIFIGURES',
'BK_HIDE_ALL_PARTS',
'BK_HIDE_ALL_PROBLEMS_PARTS',
'BK_HIDE_ALL_SETS',
'BK_HIDE_ALL_STORAGES',
'BK_HIDE_STATISTICS',
'BK_HIDE_SET_INSTRUCTIONS',
'BK_HIDE_TABLE_DAMAGED_PARTS',
'BK_HIDE_TABLE_MISSING_PARTS',
'BK_HIDE_TABLE_CHECKED_PARTS',
'BK_HIDE_WISHES',
'BK_MINIFIGURES_PAGINATION_SIZE_DESKTOP',
'BK_MINIFIGURES_PAGINATION_SIZE_MOBILE',
'BK_MINIFIGURES_SERVER_SIDE_PAGINATION',
'BK_PARTS_PAGINATION_SIZE_DESKTOP',
'BK_PARTS_PAGINATION_SIZE_MOBILE',
'BK_PARTS_SERVER_SIDE_PAGINATION',
'BK_SETS_SERVER_SIDE_PAGINATION',
'BK_PROBLEMS_PAGINATION_SIZE_DESKTOP',
'BK_PROBLEMS_PAGINATION_SIZE_MOBILE',
'BK_PROBLEMS_SERVER_SIDE_PAGINATION',
'BK_SETS_PAGINATION_SIZE_DESKTOP',
'BK_SETS_PAGINATION_SIZE_MOBILE',
'BK_SETS_CONSOLIDATION',
'BK_RANDOM',
'BK_REBRICKABLE_LINKS',
'BK_SHOW_GRID_FILTERS',
'BK_SHOW_GRID_SORT',
'BK_SHOW_SETS_DUPLICATE_FILTER',
'BK_SKIP_SPARE_PARTS',
'BK_USE_REMOTE_IMAGES',
'BK_PEERON_DOWNLOAD_DELAY',
'BK_PEERON_MIN_IMAGE_SIZE',
'BK_REBRICKABLE_PAGE_SIZE',
'BK_STATISTICS_SHOW_CHARTS',
'BK_STATISTICS_DEFAULT_EXPANDED',
# Default ordering and formatting
'BK_INSTRUCTIONS_ALLOWED_EXTENSIONS',
'BK_MINIFIGURES_DEFAULT_ORDER',
'BK_PARTS_DEFAULT_ORDER',
'BK_SETS_DEFAULT_ORDER',
'BK_PURCHASE_LOCATION_DEFAULT_ORDER',
'BK_STORAGE_DEFAULT_ORDER',
'BK_WISHES_DEFAULT_ORDER',
# URL and Pattern Variables
'BK_BRICKLINK_LINK_PART_PATTERN',
'BK_BRICKLINK_LINK_SET_PATTERN',
'BK_REBRICKABLE_IMAGE_NIL',
'BK_REBRICKABLE_IMAGE_NIL_MINIFIGURE',
'BK_REBRICKABLE_LINK_MINIFIGURE_PATTERN',
'BK_REBRICKABLE_LINK_PART_PATTERN',
'BK_REBRICKABLE_LINK_INSTRUCTIONS_PATTERN',
'BK_PEERON_INSTRUCTION_PATTERN',
'BK_PEERON_SCAN_PATTERN',
'BK_PEERON_THUMBNAIL_PATTERN',
'BK_RETIRED_SETS_FILE_URL',
'BK_RETIRED_SETS_PATH',
'BK_THEMES_FILE_URL',
'BK_THEMES_PATH'
]
# Environment variables that require restart
RESTART_REQUIRED_VARS: Final[List[str]] = [
'BK_AUTHENTICATION_PASSWORD',
'BK_AUTHENTICATION_KEY',
'BK_DATABASE_PATH',
'BK_DEBUG',
'BK_DISABLE_INDIVIDUAL_MINIFIGURES',
'BK_DISABLE_INDIVIDUAL_PARTS',
'BK_DOMAIN_NAME',
'BK_HOST',
'BK_PORT',
'BK_SOCKET_NAMESPACE',
'BK_SOCKET_PATH',
'BK_NO_THREADED_SOCKET',
'BK_TIMEZONE',
'BK_REBRICKABLE_API_KEY',
'BK_INSTRUCTIONS_FOLDER',
'BK_PARTS_FOLDER',
'BK_SETS_FOLDER',
'BK_MINIFIGURES_FOLDER',
'BK_DATABASE_TIMESTAMP_FORMAT',
'BK_FILE_DATETIME_FORMAT',
'BK_PURCHASE_DATE_FORMAT',
'BK_PURCHASE_CURRENCY',
'BK_REBRICKABLE_USER_AGENT',
'BK_USER_AGENT'
]
class ConfigManager:
"""Manages live configuration updates for BrickTracker"""
def __init__(self):
self.env_file_path = Path('.env')
def get_current_config(self) -> Dict[str, Any]:
"""Get current configuration values for live-changeable variables"""
config = {}
for var in LIVE_CHANGEABLE_VARS:
# Get internal config name
internal_name = var.replace('BK_', '')
# Get current value from Flask config
if internal_name in current_app.config:
config[var] = current_app.config[internal_name]
else:
# Fallback to environment variable
config[var] = os.environ.get(var, '')
return config
def get_restart_required_config(self) -> Dict[str, Any]:
"""Get current configuration values for restart-required variables"""
config = {}
for var in RESTART_REQUIRED_VARS:
# Get internal config name
internal_name = var.replace('BK_', '')
# Get current value from Flask config
if internal_name in current_app.config:
config[var] = current_app.config[internal_name]
else:
# Fallback to environment variable
config[var] = os.environ.get(var, '')
return config
def update_config(self, updates: Dict[str, Any]) -> Dict[str, str]:
"""Update configuration values. Returns dict with status for each update"""
results = {}
for var_name, new_value in updates.items():
if var_name not in LIVE_CHANGEABLE_VARS:
results[var_name] = f"Error: {var_name} requires restart to change"
continue
try:
# Update environment variable
os.environ[var_name] = str(new_value)
# Update Flask config
internal_name = var_name.replace('BK_', '')
cast_value = self._cast_value(var_name, new_value)
current_app.config[internal_name] = cast_value
# Update .env file
self._update_env_file(var_name, new_value)
results[var_name] = "Updated successfully"
if current_app.debug:
logger.info(f"Config updated: {var_name}={new_value}")
except Exception as e:
results[var_name] = f"Error: {str(e)}"
logger.error(f"Failed to update {var_name}: {e}")
return results
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():
if isinstance(value, str):
return [section.strip() for section in value.split(',') if section.strip()]
elif isinstance(value, list):
return value
else:
return []
# Integer variables (pagination sizes, delays, etc.) - Check BEFORE boolean check
if any(keyword in var_name.lower() for keyword in ['_size', '_page', 'delay', 'min_', 'per_page', 'page_size', '_length']):
try:
return int(value)
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 isinstance(value, str):
return value.lower() in ('true', '1', 'yes', 'on')
return bool(value)
# String variables (default)
return str(value)
def _format_env_value(self, value: Any) -> str:
"""Format value for .env file storage"""
if isinstance(value, bool):
return 'true' if value else 'false'
elif isinstance(value, (int, float)):
return str(value)
elif isinstance(value, list):
return ','.join(str(item) for item in value)
elif value is None:
return ''
else:
return str(value)
def _update_env_file(self, var_name: str, value: Any) -> None:
"""Update the .env file with new value"""
if not self.env_file_path.exists():
self.env_file_path.touch()
# Read current .env content
lines = []
if self.env_file_path.exists():
with open(self.env_file_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
# Format value for .env file
env_value = self._format_env_value(value)
# Find and update the line, or add new line
updated = False
# First pass: Look for existing active variable
for i, line in enumerate(lines):
if line.strip().startswith(f"{var_name}="):
lines[i] = f"{var_name}={env_value}\n"
updated = True
break
# Second pass: If not found, look for commented-out variable
if not updated:
for i, line in enumerate(lines):
stripped = line.strip()
# Check for commented-out variable: # BK_VAR= or #BK_VAR=
if stripped.startswith('#') and var_name in stripped:
# Extract the part after #, handling optional space
comment_content = stripped[1:].strip()
if comment_content.startswith(f"{var_name}=") or comment_content.startswith(f"{var_name} ="):
# Uncomment and set new value, preserving any leading whitespace from original line
leading_whitespace = line[:len(line) - len(line.lstrip())]
lines[i] = f"{leading_whitespace}{var_name}={env_value}\n"
updated = True
logger.info(f"Uncommented and updated {var_name} in .env file")
break
# Third pass: If still not found, append to end
if not updated:
lines.append(f"{var_name}={env_value}\n")
logger.info(f"Added new {var_name} to end of .env file")
# Write back to file
with open(self.env_file_path, 'w', encoding='utf-8') as f:
f.writelines(lines)
def validate_config(self) -> Dict[str, Any]:
"""Validate current configuration"""
issues = []
warnings = []
# Check if critical variables are set
if not os.environ.get('BK_REBRICKABLE_API_KEY'):
warnings.append("BK_REBRICKABLE_API_KEY not set - some features may not work")
# Check for conflicting settings
if (os.environ.get('BK_PARTS_SERVER_SIDE_PAGINATION', '').lower() == 'false' and
int(os.environ.get('BK_PARTS_PAGINATION_SIZE_DESKTOP', '10')) > 100):
warnings.append("Large pagination size with client-side pagination may cause performance issues")
# Check pagination sizes are reasonable
for var in ['BK_SETS_PAGINATION_SIZE_DESKTOP', 'BK_PARTS_PAGINATION_SIZE_DESKTOP', 'BK_MINIFIGURES_PAGINATION_SIZE_DESKTOP']:
try:
size = int(os.environ.get(var, '10'))
if size < 1:
issues.append(f"{var} must be at least 1")
elif size > 1000:
warnings.append(f"{var} is very large ({size}) - may cause performance issues")
except ValueError:
issues.append(f"{var} must be a valid integer")
return {
'issues': issues,
'warnings': warnings,
'status': 'valid' if not issues else 'has_issues'
}
def get_variable_help(self, var_name: str) -> str:
"""Get help text for a configuration variable"""
help_text = {
'BK_BRICKLINK_LINKS': 'Show BrickLink links throughout the application',
'BK_DEFAULT_TABLE_PER_PAGE': 'Default number of items per page in tables',
'BK_INDEPENDENT_ACCORDIONS': 'Make accordion sections independent (can open multiple)',
'BK_HIDE_ADD_SET': 'Hide the "Add Set" menu entry',
'BK_HIDE_ADD_BULK_SET': 'Hide the "Add Bulk Set" menu entry',
'BK_HIDE_ADMIN': 'Hide the "Admin" menu entry',
'BK_ADMIN_DEFAULT_EXPANDED_SECTIONS': 'Admin sections to expand by default (comma-separated)',
'BK_HIDE_ALL_INSTRUCTIONS': 'Hide the "Instructions" menu entry',
'BK_HIDE_ALL_MINIFIGURES': 'Hide the "Minifigures" menu entry',
'BK_HIDE_ALL_PARTS': 'Hide the "Parts" menu entry',
'BK_HIDE_ALL_PROBLEMS_PARTS': 'Hide the "Problems" menu entry',
'BK_HIDE_ALL_SETS': 'Hide the "Sets" menu entry',
'BK_HIDE_ALL_STORAGES': 'Hide the "Storages" menu entry',
'BK_HIDE_STATISTICS': 'Hide the "Statistics" menu entry',
'BK_HIDE_SET_INSTRUCTIONS': 'Hide instructions section in set details',
'BK_HIDE_TABLE_DAMAGED_PARTS': 'Hide the "Damaged" column in parts tables',
'BK_HIDE_TABLE_MISSING_PARTS': 'Hide the "Missing" column in parts tables',
'BK_HIDE_TABLE_CHECKED_PARTS': 'Hide the "Checked" column in parts tables',
'BK_HIDE_WISHES': 'Hide the "Wishes" menu entry',
'BK_SETS_CONSOLIDATION': 'Enable set consolidation/grouping functionality',
'BK_SHOW_GRID_FILTERS': 'Show filter options on grids by default',
'BK_SHOW_GRID_SORT': 'Show sort options on grids by default',
'BK_SKIP_SPARE_PARTS': 'Skip spare parts when importing sets',
'BK_USE_REMOTE_IMAGES': 'Use remote images from Rebrickable CDN instead of local',
'BK_STATISTICS_SHOW_CHARTS': 'Show collection growth charts on statistics page',
'BK_STATISTICS_DEFAULT_EXPANDED': 'Expand all statistics sections by default'
}
return help_text.get(var_name, 'No help available for this variable')
+500
View File
@@ -0,0 +1,500 @@
import logging
import traceback
from typing import Any, Self, TYPE_CHECKING
from uuid import uuid4
from flask import current_app, url_for
from .exceptions import NotFoundException, DatabaseException, ErrorException
from .parser import parse_minifig
from .rebrickable import Rebrickable
from .rebrickable_minifigure import RebrickableMinifigure
from .set_owner_list import BrickSetOwnerList
from .set_purchase_location_list import BrickSetPurchaseLocationList
from .set_storage_list import BrickSetStorageList
from .set_tag_list import BrickSetTagList
from .sql import BrickSQL
if TYPE_CHECKING:
from .socket import BrickSocket
logger = logging.getLogger(__name__)
# Individual minifigure (not associated with a set)
class IndividualMinifigure(RebrickableMinifigure):
# Queries
select_query: str = 'individual_minifigure/select/by_id'
light_query: str = 'individual_minifigure/select/light'
insert_query: str = 'individual_minifigure/insert'
# Delete a individual minifigure
def delete(self, /) -> None:
BrickSQL().executescript(
'individual_minifigure/delete/individual_minifigure',
id=self.fields.id
)
# Import a individual minifigure into the database
def download(self, socket: 'BrickSocket', data: dict[str, Any], /) -> bool:
# Load the minifigure
if not self.load(socket, data, from_download=True):
return False
try:
# Insert into the database
socket.auto_progress(
message='Minifigure {figure}: inserting into database'.format(
figure=self.fields.figure
),
increment_total=True,
)
# Generate an UUID for self
self.fields.id = str(uuid4())
# Save the storage
storage = BrickSetStorageList.get(
data.get('storage', ''),
allow_none=True
)
self.fields.storage = storage.fields.id if storage else None
# Save the purchase location
purchase_location = BrickSetPurchaseLocationList.get(
data.get('purchase_location', ''),
allow_none=True
)
self.fields.purchase_location = purchase_location.fields.id if purchase_location else None
# Save quantity and description
self.fields.quantity = int(data.get('quantity', 1))
self.fields.description = data.get('description', '')
# IMPORTANT: Insert rebrickable minifigure FIRST
# bricktracker_individual_minifigures has FK to rebrickable_minifigures
self.insert_rebrickable_loose()
# Now insert into bricktracker_individual_minifigures
# Use no_defer=True to ensure the insert happens before we insert parts
# (parts have a foreign key constraint on this id)
self.insert(commit=False, no_defer=True)
# Save the owners
owners: list[str] = list(data.get('owners', []))
for id in owners:
owner = BrickSetOwnerList.get(id)
owner.update_individual_minifigure_state(self, state=True)
# Save the tags
tags: list[str] = list(data.get('tags', []))
for id in tags:
tag = BrickSetTagList.get(id)
tag.update_individual_minifigure_state(self, state=True)
# Load the parts (elements) for this minifigure
if not self.download_parts(socket):
return False
# Commit the transaction to the database
socket.auto_progress(
message='Minifigure {figure}: writing to the database'.format(
figure=self.fields.figure
),
increment_total=True,
)
BrickSQL().commit()
# Info
logger.info('Minifigure {figure}: imported (id: {id})'.format(
figure=self.fields.figure,
id=self.fields.id,
))
# Complete
socket.complete(
message='Minifigure {figure}: imported (<a href="{url}">Go to the minifigure</a>)'.format(
figure=self.fields.figure,
url=self.url()
),
download=True
)
except Exception as e:
socket.fail(
message='Error while importing minifigure {figure}: {error}'.format(
figure=self.fields.figure,
error=e,
)
)
logger.debug(traceback.format_exc())
return False
return True
# Download parts (elements) for this individual minifigure
def download_parts(self, socket: 'BrickSocket', /) -> bool:
"""Download minifigure parts using get_minifig_elements()"""
try:
# Check if we have cached parts data from load()
if hasattr(self, '_cached_parts_response'):
response = self._cached_parts_response
logger.debug('Using cached parts data from load()')
else:
# Need to fetch parts data
socket.auto_progress(
message='Minifigure {figure}: loading parts from Rebrickable'.format(
figure=self.fields.figure
),
increment_total=True,
)
logger.debug('rebrick.lego.get_minifig_elements("{figure}")'.format(
figure=self.fields.figure,
))
# Load parts data from Rebrickable API
import json
from rebrick import lego
parameters = {
'api_key': current_app.config['REBRICKABLE_API_KEY'],
'page_size': current_app.config['REBRICKABLE_PAGE_SIZE'],
}
response = json.loads(lego.get_minifig_elements(
self.fields.figure,
**parameters
).read())
socket.auto_progress(
message='Minifigure {figure}: saving parts to database'.format(
figure=self.fields.figure
),
)
# Insert each part into individual_minifigure_parts table
from .rebrickable_part import RebrickablePart
if 'results' in response:
logger.debug(f'Processing {len(response["results"])} parts for minifigure {self.fields.figure}')
for idx, result in enumerate(response['results']):
part_num = result['part']['part_num']
color_id = result['color']['id']
logger.debug(
f'Part {idx+1}/{len(response["results"])}: {part_num} '
f'(color: {color_id}, quantity: {result["quantity"]})'
)
# Insert rebrickable part data first
part_data = RebrickablePart.from_rebrickable(result)
logger.debug(f'Rebrickable part data keys: {list(part_data.keys())}')
# Insert into rebrickable_parts if not exists
BrickSQL().execute(
'rebrickable/part/insert',
parameters=part_data,
commit=False,
)
# Download part image if not using remote images
if not current_app.config['USE_REMOTE_IMAGES']:
# Create a RebrickablePart instance for image download
from .set import BrickSet
try:
part_instance = RebrickablePart(record=part_data)
from .rebrickable_image import RebrickableImage
RebrickableImage(
BrickSet(), # Dummy set
minifigure=self,
part=part_instance,
).download()
except Exception as e:
logger.warning(
f'Could not download image for part {part_num}: {e}'
)
# Insert into bricktracker_individual_minifigure_parts
individual_part_params = {
'id': self.fields.id,
'part': part_num,
'color': color_id,
'spare': result.get('is_spare', False),
'quantity': result['quantity'],
'element': result.get('element_id'),
'rebrickable_inventory': result['id'],
}
logger.debug(f'Individual part params: {individual_part_params}')
BrickSQL().execute(
'individual_minifigure/part/insert',
parameters=individual_part_params,
commit=False,
)
logger.debug(f'Successfully inserted all {len(response["results"])} parts')
else:
logger.warning(f'No results in parts response for minifigure {self.fields.figure}')
# Clean up cached data
if hasattr(self, '_cached_parts_response'):
delattr(self, '_cached_parts_response')
return True
except Exception as e:
socket.fail(
message='Error loading parts for minifigure {figure}: {error}'.format(
figure=self.fields.figure,
error=e,
)
)
logger.debug(traceback.format_exc())
return False
# Insert the individual minifigure from Rebrickable
def insert_rebrickable_loose(self, /) -> None:
"""Insert rebrickable minifigure data (without set association)"""
# Insert the Rebrickable minifigure to the database
# Note: We override the parent's insert_rebrickable since we don't have a brickset
from .rebrickable_image import RebrickableImage
# Explicitly build parameters for rebrickable_minifigures insert
params = {
'figure': self.fields.figure,
'number': self.fields.number,
'name': self.fields.name,
'image': self.fields.image,
'number_of_parts': self.fields.number_of_parts,
}
BrickSQL().execute(
RebrickableMinifigure.insert_query,
parameters=params,
commit=False,
)
# Download image locally if not using remote images
if not current_app.config['USE_REMOTE_IMAGES']:
# Create a dummy BrickSet for RebrickableImage
# RebrickableImage checks minifigure first before set, so this works
from .set import BrickSet
try:
RebrickableImage(
BrickSet(), # Dummy set - not used since minifigure takes priority
minifigure=self,
).download()
logger.debug(f'Downloaded image for individual minifigure {self.fields.figure}')
except Exception as e:
logger.warning(
f'Could not download image for individual minifigure {self.fields.figure}: {e}'
)
# Load the minifigure from Rebrickable
def load(
self,
socket: 'BrickSocket',
data: dict[str, Any],
/,
*,
from_download=False,
) -> bool:
# Reset the progress
socket.progress_count = 0
socket.progress_total = 2
try:
# Check if individual minifigures are disabled
from flask import current_app
if current_app.config.get('DISABLE_INDIVIDUAL_MINIFIGURES', False):
raise ErrorException(
'Individual minifigures system is disabled. '
'Only set-based minifigures can be added.'
)
socket.auto_progress(message='Parsing minifigure number')
figure = parse_minifig(str(data['figure']))
socket.auto_progress(
message='Minifigure {figure}: loading from Rebrickable'.format(
figure=figure,
),
)
logger.debug('rebrick.lego.get_minifig_elements("{figure}")'.format(
figure=figure,
))
# Load from Rebrickable using get_minifig_elements
# This gives us both minifigure info and parts in one call
import json
from rebrick import lego
parameters = {
'api_key': current_app.config['REBRICKABLE_API_KEY'],
'page_size': current_app.config['REBRICKABLE_PAGE_SIZE'],
}
response = json.loads(lego.get_minifig_elements(
figure,
**parameters
).read())
# Extract minifigure info from the first part's metadata
if 'results' in response and len(response['results']) > 0:
first_part = response['results'][0]
# Build minifigure data from the response
self.fields.figure = first_part['set_num']
self.fields.number_of_parts = response['count']
# We need to fetch the proper name and image from get_minifig()
# This is a small additional call but gives us the proper minifigure data
try:
# get_minifig() only needs api_key, not page_size
minifig_params = {
'api_key': current_app.config['REBRICKABLE_API_KEY']
}
minifig_response = json.loads(lego.get_minifig(
figure,
**minifig_params
).read())
self.fields.name = minifig_response.get('name', f"Minifigure {figure}")
# Use the minifig image from get_minifig() - this is the assembled minifig
self.fields.image = minifig_response.get('set_img_url')
# Extract number from figure (e.g., fig-005997 -> 5997)
try:
self.fields.number = int(figure.split('-')[1])
except:
self.fields.number = 0
except Exception as e:
logger.warning(f'Could not fetch minifigure name: {e}')
self.fields.name = f"Minifigure {figure}"
# Try to extract number anyway
try:
self.fields.number = int(figure.split('-')[1])
except:
self.fields.number = 0
# Fallback: try to extract image from first part with element_id
self.fields.image = None
for result in response['results']:
if result.get('element_id') and result['part'].get('part_img_url'):
self.fields.image = result['part']['part_img_url']
break
# Store the parts data for later use in download
self._cached_parts_response = response
else:
raise NotFoundException(f'Minifigure {figure} has no parts in Rebrickable')
socket.emit('MINIFIGURE_LOADED', self.short(
from_download=from_download
))
if not from_download:
socket.complete(
message='Minifigure {figure}: loaded from Rebrickable'.format(
figure=self.fields.figure
)
)
return True
except Exception as e:
# Check if this is the "disabled" error - if so, show cleaner message
error_msg = str(e)
if 'Individual minifigures system is disabled' in error_msg:
socket.fail(message=error_msg)
else:
socket.fail(
message='Could not load the minifigure from Rebrickable: {error}. Data: {data}'.format(
error=error_msg,
data=data,
)
)
if not isinstance(e, (NotFoundException, ErrorException)):
logger.debug(traceback.format_exc())
return False
# Return a short form of the minifigure
def short(self, /, *, from_download: bool = False) -> dict[str, Any]:
return {
'download': from_download,
'image': self.url_for_image(),
'name': self.fields.name,
'figure': self.fields.figure,
}
# Select a individual minifigure by ID
def select_by_id(self, id: str, /) -> Self:
# Save the ID parameter
self.fields.id = id
# Import status list here to get metadata columns
from .set_status_list import BrickSetStatusList
# Pass metadata columns to the query with correct table names for individual minifigures
context = {
'owners': ', ' + BrickSetOwnerList.as_columns(table='bricktracker_individual_minifigure_owners') if BrickSetOwnerList.list() else '',
'statuses': ', ' + BrickSetStatusList.as_columns(table='bricktracker_individual_minifigure_statuses', all=True) if BrickSetStatusList.list(all=True) else '',
'tags': ', ' + BrickSetTagList.as_columns(table='bricktracker_individual_minifigure_tags') if BrickSetTagList.list() else '',
}
if not self.select(**context):
raise NotFoundException(
'Individual minifigure with ID {id} was not found in the database'.format(
id=id,
),
)
return self
# URL to this individual minifigure instance
def url(self, /) -> str:
return url_for('individual_minifigure.details', id=self.fields.id)
# String representation for debugging
def __repr__(self, /) -> str:
"""String representation for debugging"""
figure = getattr(self.fields, 'figure', 'unknown')
name = getattr(self.fields, 'name', 'Unknown')
qty = getattr(self.fields, 'quantity', 0)
return f'<IndividualMinifigure {figure} "{name}" qty:{qty}>'
# URL for updating quantity
def url_for_quantity(self, /) -> str:
return url_for('individual_minifigure.update_quantity', id=self.fields.id)
# URL for updating description
def url_for_description(self, /) -> str:
return url_for('individual_minifigure.update_description', id=self.fields.id)
# Parts
def generic_parts(self, /):
from .part_list import BrickPartList
return BrickPartList().from_individual_minifigure(self)
# Override from_rebrickable to handle minifigure data
@staticmethod
def from_rebrickable(data: dict[str, Any], /, **_) -> dict[str, Any]:
# Extracting number
number = int(str(data['set_num'])[5:])
return {
'figure': str(data['set_num']),
'number': int(number),
'name': str(data['set_name']),
'image': data.get('set_img_url'),
'number_of_parts': int(data.get('num_parts', 0)),
}
@@ -0,0 +1,77 @@
import logging
from typing import Self
from .individual_minifigure import IndividualMinifigure
from .record_list import BrickRecordList
logger = logging.getLogger(__name__)
# Individual minifigures list
class IndividualMinifigureList(BrickRecordList[IndividualMinifigure]):
# Queries
instances_by_figure_query: str = 'individual_minifigure/select/instances_by_figure'
using_storage_query: str = 'individual_minifigure/list/using_storage'
without_storage_query: str = 'individual_minifigure/list/without_storage'
def __init__(self, /):
super().__init__()
# Load all individual instances of a specific minifigure figure
def instances_by_figure(self, figure: str, /) -> Self:
# Save the figure parameter
self.fields.figure = figure
# Import metadata lists to get columns
from .set_owner_list import BrickSetOwnerList
from .set_status_list import BrickSetStatusList
from .set_tag_list import BrickSetTagList
# Prepare context with metadata columns
context = {
'owners': BrickSetOwnerList.as_columns(table='bricktracker_individual_minifigure_owners') if BrickSetOwnerList.list() else 'NULL AS "no_owners"',
'statuses': BrickSetStatusList.as_columns(table='bricktracker_individual_minifigure_statuses', all=True) if BrickSetStatusList.list(all=True) else 'NULL AS "no_statuses"',
'tags': BrickSetTagList.as_columns(table='bricktracker_individual_minifigure_tags') if BrickSetTagList.list() else 'NULL AS "no_tags"',
}
# Load the instances from the database
self.list(override_query=self.instances_by_figure_query, **context)
return self
# Load all individual minifigures using a specific storage
def using_storage(self, storage: 'BrickSetStorage', /) -> Self:
# Save the storage parameter
self.fields.storage = storage.fields.id
# Load the minifigures from the database
self.list(override_query=self.using_storage_query)
return self
# Load all individual minifigures without storage
def without_storage(self, /) -> Self:
# Load minifigures with no storage
self.list(override_query=self.without_storage_query)
return self
# Base individual minifigure list
def list(
self,
/,
*,
override_query: str | None = None,
order: str | None = None,
limit: int | None = None,
**context,
) -> None:
# Load the individual minifigures from the database
for record in super().select(
override_query=override_query,
order=order,
limit=limit,
**context
):
individual_minifigure = IndividualMinifigure(record=record)
self.records.append(individual_minifigure)
+700
View File
@@ -0,0 +1,700 @@
import logging
import os
import traceback
from typing import Any, Self, TYPE_CHECKING
from urllib.parse import urlparse
from uuid import uuid4
from flask import current_app, url_for
import requests
from shutil import copyfileobj
from .exceptions import NotFoundException, DatabaseException, ErrorException
from .record import BrickRecord
from .set_owner_list import BrickSetOwnerList
from .set_purchase_location_list import BrickSetPurchaseLocationList
from .set_storage_list import BrickSetStorageList
from .set_tag_list import BrickSetTagList
from .sql import BrickSQL
if TYPE_CHECKING:
from .socket import BrickSocket
logger = logging.getLogger(__name__)
# Individual part (standalone, not associated with a set or minifigure)
class IndividualPart(BrickRecord):
# Queries
select_query: str = 'individual_part/select/by_id'
insert_query: str = 'individual_part/insert'
update_query: str = 'individual_part/update'
def __init__(
self,
/,
*,
record: Any | None = None
):
super().__init__()
# Ingest the record if it has one
if record is not None:
self.ingest(record)
# Select a specific individual part by UUID
def select_by_id(self, id: str, /) -> Self:
self.fields.id = id
if not self.select(override_query=self.select_query):
raise NotFoundException(
'Individual part with id "{id}" not found'.format(id=id)
)
return self
# Delete an individual part
def delete(self, /) -> None:
sql = BrickSQL()
sql.executescript(
'individual_part/delete',
id=self.fields.id
)
sql.commit()
# Generate HTML ID for form elements
def html_id(self, prefix: str | None = None, /) -> str:
"""Generate HTML ID for form elements"""
components: list[str] = ['individual-part']
if prefix is not None:
components.append(prefix)
components.append(self.fields.part)
components.append(str(self.fields.color))
components.append(self.fields.id)
return '-'.join(components)
# URL for quantity update
def url_for_quantity(self, /) -> str:
"""URL for updating quantity"""
return url_for('individual_part.update_quantity', id=self.fields.id)
# URL for description update
def url_for_description(self, /) -> str:
"""URL for updating description"""
return url_for('individual_part.update_description', id=self.fields.id)
# URL for problem (missing/damaged) update
def url_for_problem(self, problem_type: str, /) -> str:
"""URL for updating problem counts (missing/damaged)"""
if problem_type == 'missing':
return url_for('individual_part.update_missing', id=self.fields.id)
elif problem_type == 'damaged':
return url_for('individual_part.update_damaged', id=self.fields.id)
else:
raise ValueError(f'Invalid problem type: {problem_type}')
# URL for checked status update
def url_for_checked(self, /) -> str:
"""URL for updating checked status"""
return url_for('individual_part.update_checked', id=self.fields.id)
# URL for this part's detail page
def url(self, /) -> str:
"""URL for this part's detail page"""
return url_for('individual_part.details', id=self.fields.id)
# String representation for debugging
def __repr__(self, /) -> str:
"""String representation for debugging"""
part_id = getattr(self.fields, 'part', 'unknown')
color_id = getattr(self.fields, 'color', 'unknown')
qty = getattr(self.fields, 'quantity', 0)
return f'<IndividualPart {part_id} color:{color_id} qty:{qty}>'
# Get or fetch color information from rebrickable_colors table
@staticmethod
def get_or_fetch_color(color_id: int, /) -> dict[str, Any] | None:
"""
Get color information from cache table, or fetch from API if not cached.
Returns dict with: name, rgb, is_trans, bricklink_color_id, bricklink_color_name
"""
sql = BrickSQL()
# Check if color exists in cache
check_query = """
SELECT "color_id", "name", "rgb", "is_trans",
"bricklink_color_id", "bricklink_color_name"
FROM "rebrickable_colors"
WHERE "color_id" = :color_id
"""
sql.cursor.execute(check_query, {'color_id': color_id})
result = sql.cursor.fetchone()
if result:
# Color found in cache
return {
'color_id': result[0],
'name': result[1],
'rgb': result[2],
'is_trans': result[3],
'bricklink_color_id': result[4],
'bricklink_color_name': result[5]
}
# Color not in cache, fetch from API
try:
import rebrick
import json
rebrick.init(current_app.config['REBRICKABLE_API_KEY'])
color_response = rebrick.lego.get_color(color_id)
color_data = json.loads(color_response.read())
# Extract BrickLink color info
bricklink_color_id = None
bricklink_color_name = None
if 'external_ids' in color_data and 'BrickLink' in color_data['external_ids']:
bricklink_info = color_data['external_ids']['BrickLink']
if 'ext_ids' in bricklink_info and bricklink_info['ext_ids']:
bricklink_color_id = bricklink_info['ext_ids'][0]
if 'ext_descrs' in bricklink_info and bricklink_info['ext_descrs']:
bricklink_color_name = bricklink_info['ext_descrs'][0][0] if bricklink_info['ext_descrs'][0] else None
# Store in cache
insert_query = """
INSERT OR REPLACE INTO "rebrickable_colors" (
"color_id", "name", "rgb", "is_trans",
"bricklink_color_id", "bricklink_color_name"
) VALUES (
:color_id, :name, :rgb, :is_trans,
:bricklink_color_id, :bricklink_color_name
)
"""
sql.cursor.execute(insert_query, {
'color_id': color_data['id'],
'name': color_data['name'],
'rgb': color_data.get('rgb'),
'is_trans': color_data.get('is_trans', False),
'bricklink_color_id': bricklink_color_id,
'bricklink_color_name': bricklink_color_name
})
sql.connection.commit()
logger.info(f'Cached color {color_id} ({color_data["name"]}) with BrickLink ID {bricklink_color_id}')
return {
'color_id': color_data['id'],
'name': color_data['name'],
'rgb': color_data.get('rgb'),
'is_trans': color_data.get('is_trans', False),
'bricklink_color_id': bricklink_color_id,
'bricklink_color_name': bricklink_color_name
}
except Exception as e:
logger.warning(f'Could not fetch color {color_id} from API: {e}')
return None
# Download image for this part
def download_image(self, image_url: str, /) -> None:
if not image_url:
return
# Create image_id from URL
image_id, _ = os.path.splitext(os.path.basename(urlparse(image_url).path))
if not image_id:
return
# Build path
parts_folder = current_app.config['PARTS_FOLDER']
extension = 'jpg' # Everything is saved as jpg
path = os.path.join(
current_app.static_folder, # type: ignore
parts_folder,
f'{image_id}.{extension}'
)
# Avoid downloading if file exists
if os.path.exists(path):
return
# Create directory if it doesn't exist
os.makedirs(os.path.dirname(path), exist_ok=True)
# Download the image
try:
response = requests.get(image_url, stream=True)
if response.ok:
with open(path, 'wb') as f:
copyfileobj(response.raw, f)
logger.info(f'Downloaded image for part {self.fields.part} color {self.fields.color} to {path}')
except Exception as e:
logger.warning(f'Could not download image for part {self.fields.part} color {self.fields.color}: {e}')
# Load available colors for a part
def load_colors(self, socket: 'BrickSocket', data: dict[str, Any], /) -> bool:
# Check if individual parts are disabled
if current_app.config.get('DISABLE_INDIVIDUAL_PARTS', False):
socket.fail(message='Individual parts system is disabled.')
return False
try:
# Extract part number
part_num = str(data.get('part', '')).strip()
if not part_num:
raise ErrorException('Part number is required')
# Fetch available colors from Rebrickable
import rebrick
import json
rebrick.init(current_app.config['REBRICKABLE_API_KEY'])
# Setup progress tracking
socket.progress_count = 0
socket.progress_total = 2 # Fetch part info + fetch colors
try:
# Get part information for the name
socket.auto_progress(message='Fetching part information')
part_response = rebrick.lego.get_part(part_num)
part_data = json.loads(part_response.read())
part_name = part_data.get('name', part_num)
# Get all available colors for this part
socket.auto_progress(message='Fetching available colors')
colors_response = rebrick.lego.get_part_colors(part_num)
colors_data = json.loads(colors_response.read())
# Extract the results
colors = colors_data.get('results', [])
if not colors:
raise ErrorException(f'No colors found for part {part_num}')
# Download images locally if USE_REMOTE_IMAGES is False
if not current_app.config.get('USE_REMOTE_IMAGES', False):
# Add image downloads to progress
socket.progress_total += len(colors)
for color in colors:
image_url = color.get('part_img_url', '')
if image_url:
socket.auto_progress(message=f'Downloading image for {color.get("color_name", "color")}')
try:
self.download_image(image_url)
except Exception as e:
logger.warning(f'Could not download image for part {part_num} color {color.get("color_id")}: {e}')
# Emit the part colors loaded event
logger.info(f'Emitting {len(colors)} colors for part {part_num} ({part_name})')
socket.emit(
'PART_COLORS_LOADED',
{
'part': part_num,
'part_name': part_name,
'colors': colors,
'count': len(colors)
}
)
logger.info(f'Successfully loaded {len(colors)} colors for part {part_num}')
return True
except Exception as e:
error_msg = str(e)
# Provide helpful error message for printed/decorated parts
if '404' in error_msg or 'Not Found' in error_msg:
# Check if this might be a printed part (has letters/pattern code)
base_part = ''.join(c for c in part_num if c.isdigit())
if base_part and base_part != part_num:
raise ErrorException(
f'Part {part_num} not found in Rebrickable. This appears to be a printed/decorated part. '
f'Try searching for the base part number: {base_part}'
)
else:
raise ErrorException(
f'Part {part_num} not found in Rebrickable. '
f'Please verify the part number is correct.'
)
else:
raise ErrorException(
f'Could not fetch colors for part {part_num}: {error_msg}'
)
except Exception as e:
error_msg = str(e)
socket.fail(message=f'Could not load part colors: {error_msg}')
if not isinstance(e, (NotFoundException, ErrorException)):
logger.debug(traceback.format_exc())
return False
# Add a new individual part
def add(self, socket: 'BrickSocket', data: dict[str, Any], /) -> bool:
# Check if individual parts are disabled
if current_app.config.get('DISABLE_INDIVIDUAL_PARTS', False):
socket.fail(message='Individual parts system is disabled.')
return False
try:
# Reset progress
socket.progress_count = 0
socket.progress_total = 3
socket.auto_progress(message='Validating part and color')
# Extract data
part_num = str(data.get('part', '')).strip()
color_id = int(data.get('color', 0))
quantity = int(data.get('quantity', 1))
if not part_num:
raise ErrorException('Part number is required')
if color_id <= 0:
raise ErrorException('Valid color ID is required')
if quantity <= 0:
raise ErrorException('Quantity must be greater than 0')
# Check if color info was pre-loaded (from load_colors)
color_data = data.get('color_info', None)
part_name = data.get('part_name', None)
# Validate part+color exists in rebrickable_parts
# If not, fetch from Rebrickable or use pre-loaded data and insert
sql = BrickSQL()
check_query = """
SELECT COUNT(*) FROM "rebrickable_parts"
WHERE "part" = :part AND "color_id" = :color_id
"""
sql.cursor.execute(check_query, {'part': part_num, 'color_id': color_id})
exists = sql.cursor.fetchone()[0] > 0
# Store image URL for downloading later
image_url = None
if not exists:
# Fetch full color information (with BrickLink mapping)
socket.auto_progress(message='Fetching color information')
full_color_info = IndividualPart.get_or_fetch_color(color_id)
# If we have pre-loaded color data, use it; otherwise fetch from Rebrickable
if color_data and part_name:
# Use pre-loaded data from get_part_colors() response
socket.auto_progress(message='Using cached part info')
image_url = color_data.get('part_img_url', '')
# Insert into rebrickable_parts using the pre-loaded data
insert_part_query = """
INSERT OR IGNORE INTO "rebrickable_parts" (
"part", "color_id", "color_name", "color_rgb", "color_transparent",
"bricklink_color_id", "bricklink_color_name",
"name", "image", "url"
) VALUES (
:part, :color_id, :color_name, :color_rgb, :color_transparent,
:bricklink_color_id, :bricklink_color_name,
:name, :image, :url
)
"""
sql.cursor.execute(insert_part_query, {
'part': part_num,
'color_id': color_id,
'color_name': color_data.get('color_name', ''),
'color_rgb': full_color_info.get('rgb') if full_color_info else None,
'color_transparent': full_color_info.get('is_trans') if full_color_info else None,
'bricklink_color_id': full_color_info.get('bricklink_color_id') if full_color_info else None,
'bricklink_color_name': full_color_info.get('bricklink_color_name') if full_color_info else None,
'name': part_name,
'image': image_url,
'url': f'https://rebrickable.com/parts/{part_num}/'
})
else:
# Fetch from Rebrickable (fallback for old workflow)
socket.auto_progress(message='Fetching part info from Rebrickable')
import rebrick
import json
# Initialize rebrick with API key
rebrick.init(current_app.config['REBRICKABLE_API_KEY'])
try:
# Get part information
part_info = json.loads(rebrick.lego.get_part(part_num).read())
# Get color information (this also caches it in rebrickable_colors)
# full_color_info already fetched above, but get again to be sure
if not full_color_info:
full_color_info = IndividualPart.get_or_fetch_color(color_id)
# Get part+color specific info (for the image)
part_color_info = json.loads(rebrick.lego.get_part_color(part_num, color_id).read())
# Get image URL
image_url = part_color_info.get('part_img_url', part_info.get('part_img_url', ''))
# Insert into rebrickable_parts with BrickLink color info
insert_part_query = """
INSERT OR IGNORE INTO "rebrickable_parts" (
"part", "color_id", "color_name", "color_rgb", "color_transparent",
"bricklink_color_id", "bricklink_color_name",
"name", "image", "url"
) VALUES (
:part, :color_id, :color_name, :color_rgb, :color_transparent,
:bricklink_color_id, :bricklink_color_name,
:name, :image, :url
)
"""
sql.cursor.execute(insert_part_query, {
'part': part_info['part_num'],
'color_id': full_color_info['color_id'] if full_color_info else color_id,
'color_name': full_color_info['name'] if full_color_info else '',
'color_rgb': full_color_info['rgb'] if full_color_info else None,
'color_transparent': full_color_info['is_trans'] if full_color_info else None,
'bricklink_color_id': full_color_info.get('bricklink_color_id') if full_color_info else None,
'bricklink_color_name': full_color_info.get('bricklink_color_name') if full_color_info else None,
'name': part_info['name'],
'image': image_url,
'url': part_info['part_url']
})
except Exception as e:
error_msg = str(e)
# Provide helpful error message for printed/decorated parts
if '404' in error_msg or 'Not Found' in error_msg:
base_part = ''.join(c for c in part_num if c.isdigit())
if base_part and base_part != part_num:
raise ErrorException(
f'Part {part_num} with color {color_id} not found in Rebrickable. '
f'This appears to be a printed/decorated part. '
f'Try using the base part number: {base_part}'
)
else:
raise ErrorException(
f'Part {part_num} with color {color_id} not found in Rebrickable. '
f'Please verify the part number is correct.'
)
else:
raise ErrorException(
f'Part {part_num} with color {color_id} not found in Rebrickable: {error_msg}'
)
else:
# Part already exists in rebrickable_parts, get the image URL
sql.cursor.execute(
'SELECT "image" FROM "rebrickable_parts" WHERE "part" = :part AND "color_id" = :color_id',
{'part': part_num, 'color_id': color_id}
)
result = sql.cursor.fetchone()
if result and result[0]:
image_url = result[0]
# Generate UUID and insert individual part
socket.auto_progress(message='Adding part to collection')
part_id = str(uuid4())
# Get storage and purchase location
storage = BrickSetStorageList.get(
data.get('storage', ''),
allow_none=True
)
purchase_location = BrickSetPurchaseLocationList.get(
data.get('purchase_location', ''),
allow_none=True
)
# Set fields
self.fields.id = part_id
self.fields.part = part_num
self.fields.color = color_id
self.fields.quantity = quantity
self.fields.missing = 0
self.fields.damaged = 0
self.fields.checked = 0
self.fields.description = data.get('description', '')
self.fields.storage = storage.fields.id if storage else None
self.fields.purchase_location = purchase_location.fields.id if purchase_location else None
self.fields.purchase_date = data.get('purchase_date', None)
self.fields.purchase_price = data.get('purchase_price', None)
# Insert into database
self.insert(commit=False, no_defer=True)
# Save owners
owners: list[str] = list(data.get('owners', []))
for owner_id in owners:
owner = BrickSetOwnerList.get(owner_id)
owner.update_individual_part_state(self, state=True)
# Save tags
tags: list[str] = list(data.get('tags', []))
for tag_id in tags:
tag = BrickSetTagList.get(tag_id)
tag.update_individual_part_state(self, state=True)
# Commit
sql.connection.commit()
# Download image if we have a URL
if image_url:
try:
self.download_image(image_url)
except Exception as e:
# Don't fail the whole operation if image download fails
logger.warning(f'Could not download image for part {part_num} color {color_id}: {e}')
# Get color name for success message
color_name = 'Unknown'
if color_data and color_data.get('color_name'):
color_name = color_data.get('color_name')
elif full_color_info and full_color_info.get('name'):
color_name = full_color_info.get('name')
# Generate link to part details page
part_url = url_for('part.details', part=part_num, color=color_id)
socket.complete(
message=f'Successfully added part {part_num} in {color_name} (<a href="{part_url}">View details</a>)'
)
return True
except Exception as e:
error_msg = str(e)
if 'Individual parts system is disabled' in error_msg:
socket.fail(message=error_msg)
else:
socket.fail(
message=f'Could not add individual part: {error_msg}'
)
if not isinstance(e, (NotFoundException, ErrorException)):
logger.debug(traceback.format_exc())
return False
# Update a field
def update_field(self, field: str, value: Any, /) -> Self:
setattr(self.fields, field, value)
# Use a specific update query for each field
sql = BrickSQL()
update_query = f"""
UPDATE "bricktracker_individual_parts"
SET "{field}" = :value
WHERE "id" = :id
"""
sql.cursor.execute(update_query, {
'id': self.fields.id,
'value': value
})
sql.commit()
return self
# Update problem count (missing/damaged)
def update_problem(self, problem: str, data: dict[str, Any], /) -> int:
# Handle both 'value' key and 'amount' key
amount: str | int = data.get('value', data.get('amount', '')) # type: ignore
# We need a positive integer
try:
if amount == '':
amount = 0
amount = int(amount)
if amount < 0:
amount = 0
except Exception:
raise ErrorException(f'"{amount}" is not a valid integer')
if amount < 0:
raise ErrorException('Cannot set a negative amount')
setattr(self.fields, problem, amount)
BrickSQL().execute_and_commit(
f'individual_part/update/{problem}',
parameters={
'id': self.fields.id,
problem: amount
}
)
return amount
# Update checked status
def update_checked(self, data: dict[str, Any], /) -> bool:
# Handle both direct 'checked' key and changer.js 'value' key format
if data:
checked = data.get('checked', data.get('value', False))
else:
checked = False
checked = bool(checked)
self.fields.checked = 1 if checked else 0
BrickSQL().execute_and_commit(
'individual_part/update/checked',
parameters={
'id': self.fields.id,
'checked': self.fields.checked
}
)
return checked
# URL methods
def url(self, /) -> str:
return url_for('individual_part.details', id=self.fields.id)
def url_for_quantity(self, /) -> str:
return url_for('individual_part.update_quantity', id=self.fields.id)
def url_for_description(self, /) -> str:
return url_for('individual_part.update_description', id=self.fields.id)
def url_for_problem(self, problem: str, /) -> str:
if problem == 'missing':
return url_for('individual_part.update_missing', id=self.fields.id)
elif problem == 'damaged':
return url_for('individual_part.update_damaged', id=self.fields.id)
return ''
def url_for_checked(self, /) -> str:
return url_for('individual_part.update_checked', id=self.fields.id)
def url_for_delete(self, /) -> str:
return url_for('individual_part.delete_part', id=self.fields.id)
def url_for_image(self, /) -> str:
# Check if we should use remote images
if current_app.config.get('USE_REMOTE_IMAGES', False):
# Return remote URL directly
if hasattr(self.fields, 'image') and self.fields.image:
return self.fields.image
else:
return current_app.config.get('REBRICKABLE_IMAGE_NIL', '')
else:
# Use local images
from .rebrickable_image import RebrickableImage
if hasattr(self.fields, 'image') and self.fields.image:
# Extract image_id from URL
image_id, _ = os.path.splitext(os.path.basename(urlparse(self.fields.image).path))
if image_id:
# Return local static URL using RebrickableImage helper
return RebrickableImage.static_url(image_id, 'PARTS_FOLDER')
# Fallback to nil image
return RebrickableImage.static_url(RebrickableImage.nil_name(), 'PARTS_FOLDER')
+93
View File
@@ -0,0 +1,93 @@
import logging
from typing import Self, TYPE_CHECKING
from .record_list import BrickRecordList
from .individual_part import IndividualPart
if TYPE_CHECKING:
from .set_storage import BrickSetStorage
logger = logging.getLogger(__name__)
# List of individual parts
class IndividualPartList(BrickRecordList):
# Queries
list_query: str = 'individual_part/list/all'
by_part_query: str = 'individual_part/list/by_part'
by_color_query: str = 'individual_part/list/by_color'
by_part_and_color_query: str = 'individual_part/list/by_part_and_color'
by_storage_query: str = 'individual_part/list/by_storage'
using_storage_query: str = 'individual_part/list/using_storage'
without_storage_query: str = 'individual_part/list/without_storage'
problem_query: str = 'individual_part/list/problem'
# Get all individual parts
def all(self, /) -> Self:
self.list(override_query=self.list_query)
return self
# Get individual parts by part number
def by_part(self, part: str, /) -> Self:
self.fields.part = part
self.list(override_query=self.by_part_query)
return self
# Get individual parts by color
def by_color(self, color_id: int, /) -> Self:
self.fields.color = color_id
self.list(override_query=self.by_color_query)
return self
# Get individual parts by part number and color
def by_part_and_color(self, part: str, color_id: int, /) -> Self:
self.fields.part = part
self.fields.color = color_id
self.list(override_query=self.by_part_and_color_query)
return self
# Get individual parts by storage location
def by_storage(self, storage: 'BrickSetStorage', /) -> Self:
self.fields.storage = storage.fields.id
self.list(override_query=self.by_storage_query)
return self
# Get individual parts using a specific storage location
def using_storage(self, storage: 'BrickSetStorage', /) -> Self:
self.fields.storage = storage.fields.id
self.list(override_query=self.using_storage_query)
return self
# Get individual parts without storage
def without_storage(self, /) -> Self:
self.list(override_query=self.without_storage_query)
return self
# Get individual parts with problems (missing or damaged)
def with_problems(self, /) -> Self:
self.list(override_query=self.problem_query)
return self
# Base individual part list
def list(
self,
/,
*,
override_query: str | None = None,
order: str | None = None,
limit: int | None = None,
**context,
) -> None:
# Load the individual parts from the database
for record in super().select(
override_query=override_query,
order=order,
limit=limit,
**context
):
individual_part = IndividualPart(record=record)
self.records.append(individual_part)
# Set the record class
def set_record_class(self, /) -> None:
self.record_class = IndividualPart
+340
View File
@@ -0,0 +1,340 @@
import logging
import traceback
from typing import Any, Self, TYPE_CHECKING
from uuid import uuid4
from flask import url_for
from .exceptions import NotFoundException, DatabaseException, ErrorException
from .individual_part import IndividualPart
from .record import BrickRecord, format_timestamp
from .set_owner_list import BrickSetOwnerList
from .set_purchase_location_list import BrickSetPurchaseLocationList
from .set_storage_list import BrickSetStorageList
from .set_tag_list import BrickSetTagList
from .sql import BrickSQL
if TYPE_CHECKING:
from .socket import BrickSocket
logger = logging.getLogger(__name__)
# Individual part lot (collection/batch of individual parts added together)
class IndividualPartLot(BrickRecord):
# Queries
select_query: str = 'individual_part_lot/select/by_id'
insert_query: str = 'individual_part_lot/insert'
def __init__(
self,
/,
*,
record: Any | None = None
):
super().__init__()
# Ingest the record if it has one
if record is not None:
self.ingest(record)
# Select a specific lot by UUID
def select_by_id(self, id: str, /) -> Self:
self.fields.id = id
if not self.select(override_query=self.select_query):
raise NotFoundException(
'Individual part lot with id "{id}" not found'.format(id=id)
)
return self
# Delete a lot and all its parts
def delete(self, /) -> None:
BrickSQL().executescript(
'individual_part_lot/delete',
id=self.fields.id
)
# Get the URL for this lot
def url(self, /) -> str:
return url_for('individual_part.lot_details', lot_id=self.fields.id)
# String representation for debugging
def __repr__(self, /) -> str:
"""String representation for debugging"""
name = getattr(self.fields, 'name', 'Unnamed') or 'Unnamed'
lot_id = getattr(self.fields, 'id', 'unknown')
# Try to get part_count if available (from optimized query)
part_count = getattr(self.fields, 'part_count', '?')
return f'<IndividualPartLot "{name}" ({part_count} parts) id:{lot_id[:8]}...>'
# Format created date
def created_date_formatted(self, /) -> str:
"""Format the created date for display"""
return format_timestamp(self.fields.created_date)
# Format purchase date
def purchase_date_formatted(self, /) -> str:
"""Format the purchase date for display"""
return format_timestamp(self.fields.purchase_date)
# Get all parts in this lot
def parts(self, /) -> list['IndividualPart']:
"""Get all individual parts that belong to this lot"""
sql = BrickSQL()
parts_data = sql.fetchall('individual_part_lot/list/parts', lot_id=self.fields.id)
# Convert to list of IndividualPart objects using ingest()
return [IndividualPart(record=record) for record in parts_data]
# Create a new lot with parts from cart
def create(self, socket: 'BrickSocket', data: dict[str, Any], /) -> bool:
"""
Create a new individual part lot with multiple parts.
Expected data format:
{
'cart': [
{
'part': '3001',
'part_name': 'Brick 2 x 4',
'color_id': 1,
'color_name': 'White',
'quantity': 10,
'color_info': {...}
},
...
],
'name': 'Optional lot name',
'description': 'Optional lot description',
'storage': 'storage_id',
'purchase_location': 'purchase_location_id',
'purchase_date': timestamp,
'purchase_price': 0.0,
'owners': ['owner_id1', ...],
'tags': ['tag_id1', ...]
}
"""
try:
# Validate cart data
cart = data.get('cart', [])
if not cart or not isinstance(cart, list):
raise ErrorException('Cart is empty or invalid')
socket.auto_progress(
message=f'Creating lot with {len(cart)} parts',
increment_total=True
)
# Generate UUID for the lot
lot_id = str(uuid4())
self.fields.id = lot_id
# Set lot metadata
self.fields.name = data.get('name', None)
self.fields.description = data.get('description', None)
self.fields.created_date = datetime.now().timestamp()
# Get storage
storage = BrickSetStorageList.get(
data.get('storage', ''),
allow_none=True
)
self.fields.storage = storage.fields.id if storage else None
# Get purchase location
purchase_location = BrickSetPurchaseLocationList.get(
data.get('purchase_location', ''),
allow_none=True
)
self.fields.purchase_location = purchase_location.fields.id if purchase_location else None
# Set purchase info
self.fields.purchase_date = data.get('purchase_date', None)
self.fields.purchase_price = data.get('purchase_price', None)
# Insert the lot record
socket.auto_progress(
message='Inserting lot into database',
increment_total=True
)
self.insert(commit=False)
# Save owners
owners: list[str] = list(data.get('owners', []))
for owner_id in owners:
owner = BrickSetOwnerList.get(owner_id)
# Insert into junction table
sql = BrickSQL()
sql.cursor.execute(
'INSERT INTO "bricktracker_individual_part_lot_owners" ("id") VALUES (:id)',
{'id': lot_id}
)
# Save tags
tags: list[str] = list(data.get('tags', []))
for tag_id in tags:
tag = BrickSetTagList.get(tag_id)
# Insert into junction table
sql = BrickSQL()
sql.cursor.execute(
'INSERT INTO "bricktracker_individual_part_lot_tags" ("id") VALUES (:id)',
{'id': lot_id}
)
# Add all parts from cart
socket.auto_progress(
message=f'Adding {len(cart)} parts to lot',
increment_total=True
)
for idx, cart_item in enumerate(cart):
part_num = cart_item.get('part')
color_id = cart_item.get('color_id')
quantity = cart_item.get('quantity', 1)
color_info = cart_item.get('color_info', {})
socket.auto_progress(
message=f'Adding part {idx + 1}/{len(cart)}: {part_num} in {cart_item.get("color_name", "unknown color")}',
increment_total=True
)
# Create individual part with lot_id
part_uuid = str(uuid4())
# Use the add method but with lot_id
# We need to insert the part with the lot_id
sql = BrickSQL()
# First ensure the part exists in rebrickable_parts
IndividualPart.get_or_fetch_color(color_id)
# Insert the part with lot_id (NO individual metadata - inherited from lot)
insert_query = """
INSERT INTO "bricktracker_individual_parts" (
"id",
"part",
"color",
"quantity",
"missing",
"damaged",
"checked",
"description",
"storage",
"purchase_location",
"purchase_date",
"purchase_price",
"lot_id"
) VALUES (
:id,
:part,
:color,
:quantity,
0,
0,
0,
NULL,
NULL,
NULL,
NULL,
NULL,
:lot_id
)
"""
sql.cursor.execute(insert_query, {
'id': part_uuid,
'part': part_num,
'color': color_id,
'quantity': quantity,
'lot_id': lot_id
})
# Ensure part data is in rebrickable_parts
try:
# Check if part exists
check_query = """
SELECT COUNT(*) FROM "rebrickable_parts"
WHERE "part" = :part AND "color_id" = :color_id
"""
sql.cursor.execute(check_query, {'part': part_num, 'color_id': color_id})
exists = sql.cursor.fetchone()[0] > 0
if not exists:
# Insert part data
part_name = cart_item.get('part_name', '')
color_name = cart_item.get('color_name', '')
insert_part_query = """
INSERT OR IGNORE INTO "rebrickable_parts" (
"part",
"name",
"color_id",
"color_name",
"color_rgb",
"color_transparent",
"image",
"url",
"bricklink_color_id",
"bricklink_color_name"
) VALUES (
:part,
:name,
:color_id,
:color_name,
:color_rgb,
:color_transparent,
:image,
:url,
:bricklink_color_id,
:bricklink_color_name
)
"""
sql.cursor.execute(insert_part_query, {
'part': part_num,
'name': part_name,
'color_id': color_id,
'color_name': color_name,
'color_rgb': color_info.get('rgb', ''),
'color_transparent': color_info.get('is_trans', False),
'image': color_info.get('part_img_url', ''),
'url': f'https://rebrickable.com/parts/{part_num}/',
'bricklink_color_id': color_info.get('bricklink_color_id', None),
'bricklink_color_name': color_info.get('bricklink_color_name', None)
})
except Exception as e:
logger.warning(f'Could not ensure part data for {part_num}/{color_id}: {e}')
# Commit all changes
socket.auto_progress(
message='Committing changes to database',
increment_total=True
)
sql.commit()
socket.auto_progress(
message=f'Lot created successfully with {len(cart)} parts',
increment_total=True
)
# Complete with success message and lot URL
lot_url = self.url()
socket.complete(
message=f'Successfully created lot with {len(cart)} parts. <a href="{lot_url}">View lot</a>',
data={
'lot_id': lot_id,
'lot_url': lot_url
}
)
return True
except ErrorException as e:
socket.fail(message=str(e))
logger.error(f'Error creating lot: {e}')
return False
except Exception as e:
socket.fail(message=f'Unexpected error creating lot: {str(e)}')
logger.error(f'Unexpected error creating lot: {e}')
logger.error(traceback.format_exc())
return False
+45
View File
@@ -0,0 +1,45 @@
import logging
from typing import Self, TYPE_CHECKING
from .record_list import BrickRecordList
from .individual_part_lot import IndividualPartLot
if TYPE_CHECKING:
from .set_storage import BrickSetStorage
logger = logging.getLogger(__name__)
# List of individual part lots
class IndividualPartLotList(BrickRecordList):
# Queries
list_query: str = 'individual_part_lot/list/all'
# Get all individual part lots
def all(self, /) -> Self:
self.list(override_query=self.list_query)
return self
# Base individual part lot list
def list(
self,
/,
*,
override_query: str | None = None,
order: str | None = None,
limit: int | None = None,
**context,
) -> None:
# Load the individual part lots from the database
for record in super().select(
override_query=override_query,
order=order,
limit=limit,
**context
):
lot = IndividualPartLot(record=record)
self.records.append(lot)
# Set the record class
def set_record_class(self, /) -> None:
self.record_class = IndividualPartLot
+95 -97
View File
@@ -1,6 +1,7 @@
from datetime import datetime, timezone from datetime import datetime, timezone
import logging import logging
import os import os
from urllib.parse import urljoin
from shutil import copyfileobj from shutil import copyfileobj
import traceback import traceback
from typing import Tuple, TYPE_CHECKING from typing import Tuple, TYPE_CHECKING
@@ -11,6 +12,8 @@ import humanize
import requests import requests
from werkzeug.datastructures import FileStorage from werkzeug.datastructures import FileStorage
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
import re
import cloudscraper
from .exceptions import ErrorException, DownloadException from .exceptions import ErrorException, DownloadException
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -89,91 +92,74 @@ class BrickInstructions(object):
# Download an instruction file # Download an instruction file
def download(self, path: str, /) -> None: def download(self, path: str, /) -> None:
"""
Streams the PDF in chunks and uses self.socket.update_total
+ self.socket.progress_count to drive a determinate bar.
"""
try: try:
# Just to make sure that the progress is initiated
self.socket.progress(
message='Downloading {file}'.format(
file=self.filename,
)
)
target = self.path(filename=secure_filename(self.filename)) target = self.path(filename=secure_filename(self.filename))
# Skipping rather than failing here # Skip if we already have it
if os.path.isfile(target): if os.path.isfile(target):
self.socket.complete( pdf_url = self.url()
message='File {file} already exists, skipped'.format( return self.socket.complete(
file=self.filename, message=f'File {self.filename} already exists, skipped - <a href="{pdf_url}" target="_blank" class="btn btn-sm btn-primary ms-2"><i class="ri-external-link-line"></i> Open PDF</a>'
)
) )
else: # Fetch PDF via cloudscraper (to bypass Cloudflare)
url = current_app.config['REBRICKABLE_LINK_INSTRUCTIONS_PATTERN'].format( # noqa: E501 scraper = cloudscraper.create_scraper()
path=path scraper.headers.update({
) "User-Agent": current_app.config['REBRICKABLE_USER_AGENT']
trimmed_url = current_app.config['REBRICKABLE_LINK_INSTRUCTIONS_PATTERN'].format( # noqa: E501 })
path=path.partition('/')[0] resp = scraper.get(path, stream=True)
) if not resp.ok:
raise DownloadException(f"Failed to download: HTTP {resp.status_code}")
# Request the file # Tell the socket how many bytes in total
self.socket.progress( total = int(resp.headers.get("Content-Length", 0))
message='Requesting {url}'.format( self.socket.update_total(total)
url=trimmed_url,
)
)
response = requests.get(url, stream=True) # Reset the counter and kick off at 0%
if response.ok: self.socket.progress_count = 0
self.socket.progress(message=f"Starting download {self.filename}")
# Store the content header as size # Write out in 8 KiB chunks and update the counter
try: with open(target, "wb") as f:
self.size = int( for chunk in resp.iter_content(chunk_size=8192):
response.headers.get('Content-length', 0) if not chunk:
) continue
except Exception: f.write(chunk)
self.size = 0
# Downloading the file # Bump the internal counter and emit
self.socket.progress_count += len(chunk)
self.socket.progress( self.socket.progress(
message='Downloading {url} ({size})'.format( message=(
url=trimmed_url, f"Downloading {self.filename} "
size=self.human_size(), f"({humanize.naturalsize(self.socket.progress_count)}/"
f"{humanize.naturalsize(self.socket.progress_total)})"
) )
) )
with open(target, 'wb') as f: # Done!
copyfileobj(response.raw, f) logger.info(f"Downloaded {self.filename}")
else: pdf_url = self.url()
raise DownloadException('failed to download: {code}'.format( # noqa: E501 self.socket.complete(
code=response.status_code message=f'File {self.filename} downloaded ({self.human_size()}) - <a href="{pdf_url}" target="_blank" class="btn btn-sm btn-primary ms-2"><i class="ri-external-link-line"></i> Open PDF</a>'
))
# Info
logger.info('The instruction file {file} has been downloaded'.format( # noqa: E501
file=self.filename
))
# Complete
self.socket.complete(
message='File {file} downloaded ({size})'.format( # noqa: E501
file=self.filename,
size=self.human_size()
)
)
except Exception as e:
self.socket.fail(
message='Error while downloading instruction {file}: {error}'.format( # noqa: E501
file=self.filename,
error=e,
)
) )
except Exception as e:
logger.debug(traceback.format_exc()) logger.debug(traceback.format_exc())
self.socket.fail(
message=f"Error downloading {self.filename}: {e}"
)
# Display the size in a human format # Display the size in a human format
def human_size(self) -> str: def human_size(self) -> str:
return humanize.naturalsize(self.size) try:
size = self.size
except AttributeError:
size = os.path.getsize(self.path())
return humanize.naturalsize(size)
# Display the time in a human format # Display the time in a human format
def human_time(self) -> str: def human_time(self) -> str:
@@ -250,40 +236,52 @@ class BrickInstructions(object):
# Find the instructions for a set # Find the instructions for a set
@staticmethod @staticmethod
def find_instructions(set: str, /) -> list[Tuple[str, str]]: def find_instructions(set: str, /) -> list[Tuple[str, str]]:
response = requests.get( """
current_app.config['REBRICKABLE_LINK_INSTRUCTIONS_PATTERN'].format( Scrape Rebrickables HTML and return a list of
path=set, (filename_slug, download_url). Duplicate slugs get _1, _2, …
), """
headers={ page_url = f"https://rebrickable.com/instructions/{set}/"
'User-Agent': current_app.config['REBRICKABLE_USER_AGENT'] logger.debug(f"[find_instructions] fetching HTML from {page_url!r}")
}
)
if not response.ok: # Solve Cloudflares challenge
raise ErrorException('Failed to load the Rebrickable instructions page. Status code: {code}'.format( # noqa: E501 scraper = cloudscraper.create_scraper()
code=response.status_code scraper.headers.update({'User-Agent': current_app.config['REBRICKABLE_USER_AGENT']})
)) resp = scraper.get(page_url)
if not resp.ok:
raise ErrorException(f'Failed to load instructions page for {set}. HTTP {resp.status_code}')
# Parse the HTML content soup = BeautifulSoup(resp.content, 'html.parser')
soup = BeautifulSoup(response.content, 'html.parser') link_re = re.compile(r'^/instructions/\d+/.+/download/')
# Collect all <img> tags with "LEGO Building Instructions" in the raw: list[tuple[str, str]] = []
# alt attribute for a in soup.find_all('a', href=link_re):
found_tags: list[Tuple[str, str]] = [] img = a.find('img', alt=True) # type: ignore
for a_tag in soup.find_all('a', href=True): if not img or set not in img['alt']: # type: ignore
img_tag = a_tag.find('img', alt=True) continue
if img_tag and "LEGO Building Instructions" in img_tag['alt']:
found_tags.append(
(
img_tag['alt'].removeprefix('LEGO Building Instructions for '), # noqa: E501
a_tag['href']
)
) # Save alt and href
# Raise an error if nothing found # Turn the alt text into a slug
if not len(found_tags): alt_text = img['alt'].removeprefix('LEGO Building Instructions for ') # type: ignore
raise ErrorException('No instruction found for set {set}'.format( slug = re.sub(r'[^A-Za-z0-9]+', '-', alt_text).strip('-')
set=set
))
return found_tags # Build the absolute download URL
download_url = urljoin('https://rebrickable.com', a['href']) # type: ignore
raw.append((slug, download_url))
if not raw:
raise ErrorException(f'No download links found on instructions page for {set}')
# Disambiguate duplicate slugs by appending _1, _2, …
from collections import Counter, defaultdict
counts = Counter(name for name, _ in raw)
seen: dict[str, int] = defaultdict(int)
unique: list[tuple[str, str]] = []
for name, url in raw:
idx = seen[name]
if counts[name] > 1 and idx > 0:
final_name = f"{name}_{idx}"
else:
final_name = name
seen[name] += 1
unique.append((final_name, url))
return unique
+153 -30
View File
@@ -9,6 +9,8 @@ from .exceptions import DatabaseException, ErrorException, NotFoundException
from .record import BrickRecord from .record import BrickRecord
from .sql import BrickSQL from .sql import BrickSQL
if TYPE_CHECKING: if TYPE_CHECKING:
from .individual_minifigure import IndividualMinifigure
from .individual_part import IndividualPart
from .set import BrickSet from .set import BrickSet
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -18,16 +20,24 @@ logger = logging.getLogger(__name__)
class BrickMetadata(BrickRecord): class BrickMetadata(BrickRecord):
kind: str kind: str
# Set state endpoint # Endpoints (optional, not all metadata types use all of these)
set_state_endpoint: str set_state_endpoint: str = ''
individual_minifigure_state_endpoint: str = ''
individual_minifigure_value_endpoint: str = ''
individual_part_state_endpoint: str = ''
individual_part_value_endpoint: str = ''
# Queries # Queries
delete_query: str delete_query: str
insert_query: str insert_query: str
select_query: str select_query: str
update_field_query: str update_field_query: str
update_set_state_query: str update_set_state_query: str = ''
update_set_value_query: str update_set_value_query: str = ''
update_individual_minifigure_state_query: str = ''
update_individual_minifigure_value_query: str = ''
update_individual_part_state_query: str = ''
update_individual_part_value_query: str = ''
def __init__( def __init__(
self, self,
@@ -106,6 +116,36 @@ class BrickMetadata(BrickRecord):
metadata_id=self.fields.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, id: str, /) -> str:
return url_for(
self.individual_minifigure_state_endpoint,
id=id,
metadata_id=self.fields.id
)
# URL to change the value for an individual minifigure
def url_for_individual_minifigure_value(self, id: str, /) -> str:
return url_for(
self.individual_minifigure_value_endpoint,
id=id
)
# URL to change the selected state of this metadata item for an individual part
def url_for_individual_part_state(self, id: str, /) -> str:
return url_for(
self.individual_part_state_endpoint,
id=id,
metadata_id=self.fields.id
)
# URL to change the value for an individual part
def url_for_individual_part_value(self, id: str, /) -> str:
return url_for(
self.individual_part_value_endpoint,
id=id
)
# Select a specific metadata (with an id) # Select a specific metadata (with an id)
def select_specific(self, id: str, /) -> Self: def select_specific(self, id: str, /) -> Self:
# Save the parameters to the fields # Save the parameters to the fields
@@ -175,6 +215,40 @@ class BrickMetadata(BrickRecord):
return value return value
# Generic method to update state for any entity type
def _update_entity_state(
self,
entity_type: str,
entity_id: str,
entity_name: str,
query: str,
/,
*,
json: Any | None = None,
state: Any | None = None
) -> Any:
"""Generic state update logic for sets, minifigures, and parts"""
if state is None and json is not None:
state = json.get('value', False)
parameters = self.sql_parameters()
parameters['id'] = entity_id
parameters['state'] = state
rows, _ = BrickSQL().execute_and_commit(
query,
parameters=parameters,
name=self.as_column(),
)
if rows != 1:
raise DatabaseException(f'Could not update the {self.kind} "{self.fields.name}" state for {entity_type} {entity_name} ({entity_id})')
# Info
logger.info(f'{self.kind.capitalize()} "{self.fields.name}" state changed to "{state}" for {entity_type} {entity_name} ({entity_id})')
return state
# Update the selected state of this metadata item for a set # Update the selected state of this metadata item for a set
def update_set_state( def update_set_state(
self, self,
@@ -184,37 +258,86 @@ class BrickMetadata(BrickRecord):
json: Any | None = None, json: Any | None = None,
state: Any | None = None state: Any | None = None
) -> Any: ) -> Any:
if state is None and json is not None: return self._update_entity_state(
state = json.get('value', False) 'set',
brickset.fields.id,
parameters = self.sql_parameters() brickset.fields.set,
parameters['set_id'] = brickset.fields.id
parameters['state'] = state
rows, _ = BrickSQL().execute_and_commit(
self.update_set_state_query, self.update_set_state_query,
parameters=parameters, json=json,
name=self.as_column(), state=state
) )
if rows != 1: # Check if this metadata has a specific individual minifigure
raise DatabaseException('Could not update the {kind} "{name}" state for set {set} ({id})'.format( # noqa: E501 def has_individual_minifigure(
kind=self.kind, self,
name=self.fields.name, individual_minifigure: 'IndividualMinifigure',
set=brickset.fields.set, /,
id=brickset.fields.id, ) -> bool:
)) """Check if this owner/tag/status is assigned to a individual minifigure"""
# Determine the table name based on metadata type
table_name = f'bricktracker_individual_minifigure_{self.kind}s'
column_name = f'{self.kind}_{self.fields.id}'
# Info # Query to check if the relationship exists using raw SQL
logger.info('{kind} "{name}" state changed to "{state}" for set {set} ({id})'.format( # noqa: E501 sql = BrickSQL()
kind=self.kind, query = f'SELECT COUNT(*) as count FROM "{table_name}" WHERE "id" = ? AND "{column_name}" = 1'
name=self.fields.name, result = sql.cursor.execute(query, (individual_minifigure.fields.id,)).fetchone()
state=state,
set=brickset.fields.set,
id=brickset.fields.id,
))
return state return result and result['count'] > 0
# Update the selected state of this metadata item for a individual minifigure
def update_individual_minifigure_state(
self,
individual_minifigure: 'IndividualMinifigure',
/,
*,
json: Any | None = None,
state: Any | None = None
) -> Any:
return self._update_entity_state(
'individual minifigure',
individual_minifigure.fields.id,
individual_minifigure.fields.figure,
self.update_individual_minifigure_state_query,
json=json,
state=state
)
# Check if this metadata has a specific individual part
def has_individual_part(
self,
individual_part: 'IndividualPart',
/,
) -> bool:
"""Check if this owner/tag/status is assigned to an individual part"""
# Determine the table name based on metadata type
table_name = f'bricktracker_individual_part_{self.kind}s'
column_name = f'{self.kind}_{self.fields.id}'
# Query to check if the relationship exists using raw SQL
sql = BrickSQL()
query = f'SELECT COUNT(*) as count FROM "{table_name}" WHERE "id" = ? AND "{column_name}" = 1'
result = sql.cursor.execute(query, (individual_part.fields.id,)).fetchone()
return result and result['count'] > 0
# 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
) -> Any:
return self._update_entity_state(
'individual part',
individual_part.fields.id,
f'{individual_part.fields.part} color {individual_part.fields.color}',
self.update_individual_part_state_query,
json=json,
state=state
)
# Update the selected value of this metadata item for a set # Update the selected value of this metadata item for a set
def update_set_value( def update_set_value(
+27 -5
View File
@@ -39,9 +39,10 @@ class BrickMetadataList(BrickRecordList[T]):
# Queries # Queries
select_query: str select_query: str
# Set endpoints # List-specific endpoints (for operations on the list itself)
set_state_endpoint: str set_state_endpoint: str = ''
set_value_endpoint: str set_value_endpoint: str = ''
individual_minifigure_value_endpoint: str = ''
def __init__( def __init__(
self, self,
@@ -99,18 +100,31 @@ class BrickMetadataList(BrickRecordList[T]):
# Return the items as columns for a select # Return the items as columns for a select
@classmethod @classmethod
def as_columns(cls, /, **kwargs) -> str: def as_columns(cls, /, table: str | None = None, **kwargs) -> str:
new = cls.new() new = cls.new()
# Use provided table name or default to class table
table_name = table if table is not None else cls.table
return ', '.join([ return ', '.join([
'"{table}"."{column}"'.format( '"{table}"."{column}"'.format(
table=cls.table, table=table_name,
column=record.as_column(), column=record.as_column(),
) )
for record for record
in new.filter(**kwargs) in new.filter(**kwargs)
]) ])
# Return the items as a dictionary mapping column names to UUIDs
@classmethod
def as_column_mapping(cls, /, **kwargs) -> dict:
new = cls.new()
return {
record.as_column(): record.fields.id
for record in new.filter(**kwargs)
}
# Grab a specific status # Grab a specific status
@classmethod @classmethod
def get(cls, id: str | None, /, *, allow_none: bool = False) -> T: def get(cls, id: str | None, /, *, allow_none: bool = False) -> T:
@@ -174,3 +188,11 @@ class BrickMetadataList(BrickRecordList[T]):
cls.set_value_endpoint, cls.set_value_endpoint,
id=id, id=id,
) )
# URL to change the selected value of this metadata item for an individual minifigure
@classmethod
def url_for_individual_minifigure_value(cls, id: str, /) -> str:
return url_for(
cls.individual_minifigure_value_endpoint,
id=id,
)
+68 -1
View File
@@ -21,6 +21,7 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
# Queries # Queries
all_query: str = 'minifigure/list/all' all_query: str = 'minifigure/list/all'
all_by_owner_query: str = 'minifigure/list/all_by_owner'
damaged_part_query: str = 'minifigure/list/damaged_part' damaged_part_query: str = 'minifigure/list/damaged_part'
last_query: str = 'minifigure/list/last' last_query: str = 'minifigure/list/last'
missing_part_query: str = 'minifigure/list/missing_part' missing_part_query: str = 'minifigure/list/missing_part'
@@ -42,6 +43,59 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
return self return self
# Load all minifigures by owner
def all_by_owner(self, owner_id: str | None = None, /) -> Self:
# Save the owner_id parameter
self.fields.owner_id = owner_id
# Load the minifigures from the database
self.list(override_query=self.all_by_owner_query)
return self
# Load minifigures with pagination support
def all_filtered_paginated(
self,
owner_id: str | None = None,
search_query: str | None = None,
page: int = 1,
per_page: int = 50,
sort_field: str | None = None,
sort_order: str = 'asc'
) -> tuple[Self, int]:
# Prepare filter context
filter_context = {}
if owner_id and owner_id != 'all':
filter_context['owner_id'] = owner_id
list_query = self.all_by_owner_query
else:
list_query = self.all_query
if search_query:
filter_context['search_query'] = search_query
# Field mapping for sorting
field_mapping = {
'name': '"combined"."name"',
'parts': '"combined"."number_of_parts"',
'quantity': '"total_quantity"',
'missing': '"total_missing"',
'damaged': '"total_damaged"',
'sets': '"total_sets"',
'individual': '"total_individual"'
}
# Use the base pagination method
return self.paginate(
page=page,
per_page=per_page,
sort_field=sort_field,
sort_order=sort_order,
list_query=list_query,
field_mapping=field_mapping,
**filter_context
)
# Minifigures with a part damaged part # Minifigures with a part damaged part
def damaged_part(self, part: str, color: int, /) -> Self: def damaged_part(self, part: str, color: int, /) -> Self:
# Save the parameters to the fields # Save the parameters to the fields
@@ -59,7 +113,7 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
if current_app.config['RANDOM']: if current_app.config['RANDOM']:
order = 'RANDOM()' order = 'RANDOM()'
else: else:
order = '"bricktracker_minifigures"."rowid" DESC' order = '"combined"."rowid" DESC'
self.list(override_query=self.last_query, order=order, limit=limit) self.list(override_query=self.last_query, order=order, limit=limit)
@@ -83,11 +137,20 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
else: else:
brickset = None brickset = None
# Prepare template context for owner filtering
context_vars = {}
if hasattr(self.fields, 'owner_id') and self.fields.owner_id is not None:
context_vars['owner_id'] = self.fields.owner_id
# Merge with any additional context passed in
context_vars.update(context)
# Load the sets from the database # Load the sets from the database
for record in super().select( for record in super().select(
override_query=override_query, override_query=override_query,
order=order, order=order,
limit=limit, limit=limit,
**context_vars
): ):
minifigure = BrickMinifigure(brickset=brickset, record=record) minifigure = BrickMinifigure(brickset=brickset, record=record)
@@ -132,6 +195,10 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
if self.brickset is not None: if self.brickset is not None:
parameters['id'] = self.brickset.fields.id parameters['id'] = self.brickset.fields.id
# Add owner_id parameter for owner filtering
if hasattr(self.fields, 'owner_id') and self.fields.owner_id is not None:
parameters['owner_id'] = self.fields.owner_id
return parameters return parameters
# Import the minifigures from Rebrickable # Import the minifigures from Rebrickable
+1
View File
@@ -15,6 +15,7 @@ NAVBAR: Final[list[dict[str, Any]]] = [
{'e': 'minifigure.list', 't': 'Minifigures', 'i': 'group-line', 'f': 'HIDE_ALL_MINIFIGURES'}, # noqa: E501 {'e': 'minifigure.list', 't': 'Minifigures', 'i': 'group-line', 'f': 'HIDE_ALL_MINIFIGURES'}, # noqa: E501
{'e': 'instructions.list', 't': 'Instructions', 'i': 'file-line', 'f': 'HIDE_ALL_INSTRUCTIONS'}, # noqa: E501 {'e': 'instructions.list', 't': 'Instructions', 'i': 'file-line', 'f': 'HIDE_ALL_INSTRUCTIONS'}, # noqa: E501
{'e': 'storage.list', 't': 'Storages', 'i': 'archive-2-line', 'f': 'HIDE_ALL_STORAGES'}, # noqa: E501 {'e': 'storage.list', 't': 'Storages', 'i': 'archive-2-line', 'f': 'HIDE_ALL_STORAGES'}, # noqa: E501
{'e': 'statistics.overview', 't': 'Statistics', 'i': 'bar-chart-line', 'f': 'HIDE_STATISTICS'}, # noqa: E501
{'e': 'wish.list', 't': 'Wishlist', 'i': 'gift-line', 'f': 'HIDE_WISHES'}, {'e': 'wish.list', 't': 'Wishlist', 'i': 'gift-line', 'f': 'HIDE_WISHES'},
{'e': 'admin.admin', 't': 'Admin', 'i': 'settings-4-line', 'f': 'HIDE_ADMIN'}, # noqa: E501 {'e': 'admin.admin', 't': 'Admin', 'i': 'settings-4-line', 'f': 'HIDE_ADMIN'}, # noqa: E501
] ]
+52
View File
@@ -0,0 +1,52 @@
from flask import current_app, request
from typing import Any, Dict, Tuple
def get_pagination_config(entity_type: str) -> Tuple[int, bool]:
"""Get pagination configuration for an entity type (sets, parts, minifigures)"""
# Check if pagination is enabled for this specific entity type
pagination_key = f'{entity_type.upper()}_SERVER_SIDE_PAGINATION'
use_pagination = current_app.config.get(pagination_key, False)
if not use_pagination:
return 0, False
# Determine page size based on device type and entity
user_agent = request.headers.get('User-Agent', '').lower()
is_mobile = any(device in user_agent for device in ['mobile', 'android', 'iphone', 'ipad'])
# Get appropriate config keys based on entity type
entity_upper = entity_type.upper()
desktop_key = f'{entity_upper}_PAGINATION_SIZE_DESKTOP'
mobile_key = f'{entity_upper}_PAGINATION_SIZE_MOBILE'
per_page = current_app.config[mobile_key] if is_mobile else current_app.config[desktop_key]
return per_page, is_mobile
def build_pagination_context(page: int, per_page: int, total_count: int, is_mobile: bool) -> Dict[str, Any]:
"""Build pagination context for templates"""
total_pages = (total_count + per_page - 1) // per_page if total_count > 0 else 1
has_prev = page > 1
has_next = page < total_pages
return {
'page': page,
'per_page': per_page,
'total_count': total_count,
'total_pages': total_pages,
'has_prev': has_prev,
'has_next': has_next,
'is_mobile': is_mobile
}
def get_request_params() -> Tuple[str, str, str, int]:
"""Extract common request parameters for pagination"""
search_query = request.args.get('search', '').strip()
sort_field = request.args.get('sort', '')
sort_order = request.args.get('order', 'asc')
page = int(request.args.get('page', 1))
return search_query, sort_field, sort_order, page
+25
View File
@@ -35,3 +35,28 @@ def parse_set(set: str, /) -> str:
)) ))
return '{number}-{version}'.format(number=number, version=version) 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
+140 -1
View File
@@ -159,6 +159,54 @@ class BrickPart(RebrickablePart):
return self 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
if json:
checked = json.get('checked', json.get('value', False))
else:
checked = False
checked = bool(checked)
# Update the field
self.fields.checked = checked
BrickSQL().execute_and_commit(
'part/update/checked',
parameters=self.sql_parameters()
)
return checked
# Compute the url for updating checked state
def url_for_checked(self, /) -> str:
# Check if this is an individual minifigure (has minifigure with id field, no brickset)
if self.minifigure is not None and hasattr(self.minifigure.fields, 'id') and self.brickset is None:
# Individual minifigure part
return url_for(
'individual_minifigure.checked_part',
id=self.minifigure.fields.id,
part=self.fields.part,
color=self.fields.color,
spare=self.fields.spare,
)
# Set-based part (with or without minifigure)
if self.minifigure is not None:
figure = self.minifigure.fields.figure
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,
)
# Update a problematic part # Update a problematic part
def update_problem(self, problem: str, json: Any | None, /) -> int: def update_problem(self, problem: str, json: Any | None, /) -> int:
amount: str | int = json.get('value', '') # type: ignore amount: str | int = json.get('value', '') # type: ignore
@@ -191,7 +239,19 @@ class BrickPart(RebrickablePart):
# Compute the url for problematic part # Compute the url for problematic part
def url_for_problem(self, problem: str, /) -> str: def url_for_problem(self, problem: str, /) -> str:
# Different URL for a minifigure part # Check if this is an individual minifigure (has minifigure with id field, no brickset)
if self.minifigure is not None and hasattr(self.minifigure.fields, 'id') and self.brickset is None:
# Individual minifigure part
return url_for(
'individual_minifigure.problem_part',
id=self.minifigure.fields.id,
part=self.fields.part,
color=self.fields.color,
spare=self.fields.spare,
problem=problem,
)
# Set-based part (with or without minifigure)
if self.minifigure is not None: if self.minifigure is not None:
figure = self.minifigure.fields.figure figure = self.minifigure.fields.figure
else: else:
@@ -206,3 +266,82 @@ class BrickPart(RebrickablePart):
spare=self.fields.spare, spare=self.fields.spare,
problem=problem, problem=problem,
) )
# Select a specific part from an individual minifigure
def select_specific_individual_minifigure(
self,
minifigure: 'BrickMinifigure',
part: str,
color: int,
spare: int,
/,
) -> Self:
# Save the parameters to the fields
self.minifigure = minifigure
self.fields.id = minifigure.fields.id
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 {figure} ({id}) was not found in the database'.format(
part=self.fields.part,
color=self.fields.color,
spare=self.fields.spare,
figure=self.minifigure.fields.figure,
id=self.minifigure.fields.id,
),
)
return self
# 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
# 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)
# Update the field
self.fields.checked = checked
BrickSQL().execute_and_commit(
'individual_minifigure/part/update/checked',
parameters=self.sql_parameters()
)
return checked
+180 -4
View File
@@ -23,7 +23,9 @@ class BrickPartList(BrickRecordList[BrickPart]):
# Queries # Queries
all_query: str = 'part/list/all' all_query: str = 'part/list/all'
all_by_owner_query: str = 'part/list/all_by_owner'
different_color_query = 'part/list/with_different_color' different_color_query = 'part/list/with_different_color'
individual_minifigure_query: str = 'individual_minifigure/part/list/from_instance'
last_query: str = 'part/list/last' last_query: str = 'part/list/last'
minifigure_query: str = 'part/list/from_minifigure' minifigure_query: str = 'part/list/from_minifigure'
problem_query: str = 'part/list/problem' problem_query: str = 'part/list/problem'
@@ -46,6 +48,88 @@ class BrickPartList(BrickRecordList[BrickPart]):
return self return self
# Load all parts by owner
def all_by_owner(self, owner_id: str | None = None, /) -> Self:
# Save the owner_id parameter
self.fields.owner_id = owner_id
# Load the parts from the database
self.list(override_query=self.all_by_owner_query)
return self
# Load all parts with filters (owner and/or color)
def all_filtered(self, owner_id: str | None = None, color_id: str | None = None, /) -> Self:
# Save the filter parameters
if owner_id is not None:
self.fields.owner_id = owner_id
if color_id is not None:
self.fields.color_id = color_id
# Choose query based on whether owner filtering is needed
if owner_id and owner_id != 'all':
query = self.all_by_owner_query
else:
query = self.all_query
# Prepare context for query
context = {}
if current_app.config.get('SKIP_SPARE_PARTS', False):
context['skip_spare_parts'] = True
# Load the parts from the database
self.list(override_query=query, **context)
return self
# Load parts with pagination support
def all_filtered_paginated(
self,
owner_id: str | None = None,
color_id: str | None = None,
search_query: str | None = None,
page: int = 1,
per_page: int = 50,
sort_field: str | None = None,
sort_order: str = 'asc'
) -> tuple[Self, int]:
# Prepare filter context
filter_context = {}
if owner_id and owner_id != 'all':
filter_context['owner_id'] = owner_id
list_query = self.all_by_owner_query
else:
list_query = self.all_query
if color_id and color_id != 'all':
filter_context['color_id'] = color_id
if search_query:
filter_context['search_query'] = search_query
if current_app.config.get('SKIP_SPARE_PARTS', False):
filter_context['skip_spare_parts'] = True
# Field mapping for sorting
field_mapping = {
'name': '"rebrickable_parts"."name"',
'color': '"rebrickable_parts"."color_name"',
'quantity': '"total_quantity"',
'missing': '"total_missing"',
'damaged': '"total_damaged"',
'sets': '"total_sets"',
'minifigures': '"total_minifigures"'
}
# Use the base pagination method
return self.paginate(
page=page,
per_page=per_page,
sort_field=sort_field,
sort_order=sort_order,
list_query=list_query,
field_mapping=field_mapping,
**filter_context
)
# Base part list # Base part list
def list( def list(
self, self,
@@ -54,6 +138,7 @@ class BrickPartList(BrickRecordList[BrickPart]):
override_query: str | None = None, override_query: str | None = None,
order: str | None = None, order: str | None = None,
limit: int | None = None, limit: int | None = None,
offset: int | None = None,
**context: Any, **context: Any,
) -> None: ) -> None:
if order is None: if order is None:
@@ -69,11 +154,25 @@ class BrickPartList(BrickRecordList[BrickPart]):
else: else:
minifigure = None minifigure = None
# Prepare template context for filtering
context_vars = {}
if hasattr(self.fields, 'owner_id') and self.fields.owner_id is not None:
context_vars['owner_id'] = self.fields.owner_id
if hasattr(self.fields, 'color_id') and self.fields.color_id is not None:
context_vars['color_id'] = self.fields.color_id
if hasattr(self.fields, 'search_query') and self.fields.search_query:
context_vars['search_query'] = self.fields.search_query
# Merge with any additional context passed in
context_vars.update(context)
# Load the sets from the database # Load the sets from the database
for record in super().select( for record in super().select(
override_query=override_query, override_query=override_query,
order=order, order=order,
limit=limit, limit=limit,
offset=offset,
**context_vars
): ):
part = BrickPart( part = BrickPart(
brickset=brickset, brickset=brickset,
@@ -81,9 +180,6 @@ class BrickPartList(BrickRecordList[BrickPart]):
record=record, record=record,
) )
if current_app.config['SKIP_SPARE_PARTS'] and part.fields.spare:
continue
self.records.append(part) self.records.append(part)
# List specific parts from a brickset or minifigure # List specific parts from a brickset or minifigure
@@ -117,6 +213,20 @@ class BrickPartList(BrickRecordList[BrickPart]):
return self return self
# Load parts from an individual minifigure instance
def from_individual_minifigure(
self,
minifigure: 'BrickMinifigure',
/,
) -> Self:
# Save the minifigure
self.minifigure = minifigure
# Load the parts from the database using the instance-specific query
self.list(override_query=self.individual_minifigure_query)
return self
# Load generic parts from a print # Load generic parts from a print
def from_print( def from_print(
self, self,
@@ -143,13 +253,79 @@ class BrickPartList(BrickRecordList[BrickPart]):
return self return self
def problem_filtered(self, owner_id: str | None = None, color_id: str | None = None, /) -> Self:
# Save the filter parameters for client-side filtering
if owner_id is not None:
self.fields.owner_id = owner_id
if color_id is not None:
self.fields.color_id = color_id
# Prepare context for query
context = {}
if owner_id and owner_id != 'all':
context['owner_id'] = owner_id
if color_id and color_id != 'all':
context['color_id'] = color_id
if current_app.config.get('SKIP_SPARE_PARTS', False):
context['skip_spare_parts'] = True
# Load the problematic parts from the database
self.list(override_query=self.problem_query, **context)
return self
def problem_paginated(
self,
owner_id: str | None = None,
color_id: str | None = None,
search_query: str | None = None,
page: int = 1,
per_page: int = 50,
sort_field: str | None = None,
sort_order: str = 'asc'
) -> tuple[Self, int]:
# Prepare filter context
filter_context = {}
if owner_id and owner_id != 'all':
filter_context['owner_id'] = owner_id
if color_id and color_id != 'all':
filter_context['color_id'] = color_id
if search_query:
filter_context['search_query'] = search_query
if current_app.config.get('SKIP_SPARE_PARTS', False):
filter_context['skip_spare_parts'] = True
# Field mapping for sorting
field_mapping = {
'name': '"rebrickable_parts"."name"',
'color': '"rebrickable_parts"."color_name"',
'quantity': '"total_quantity"',
'missing': '"total_missing"',
'damaged': '"total_damaged"',
'sets': '"total_sets"',
'minifigures': '"total_minifigures"'
}
# Use the base pagination method with problem query
return self.paginate(
page=page,
per_page=per_page,
sort_field=sort_field,
sort_order=sort_order,
list_query=self.problem_query,
field_mapping=field_mapping,
**filter_context
)
# Return a dict with common SQL parameters for a parts list # Return a dict with common SQL parameters for a parts list
def sql_parameters(self, /) -> dict[str, Any]: def sql_parameters(self, /) -> dict[str, Any]:
parameters: dict[str, Any] = super().sql_parameters() parameters: dict[str, Any] = super().sql_parameters()
# Set id # Set id - prioritize brickset, then check minifigure
if self.brickset is not None: if self.brickset is not None:
parameters['id'] = self.brickset.fields.id parameters['id'] = self.brickset.fields.id
elif self.minifigure is not None and hasattr(self.minifigure.fields, 'id'):
parameters['id'] = self.minifigure.fields.id
# Use the minifigure number if present, # Use the minifigure number if present,
if self.minifigure is not None: if self.minifigure is not None:
+437
View File
@@ -0,0 +1,437 @@
import hashlib
import logging
import os
from pathlib import Path
import time
from typing import Any, NamedTuple, TYPE_CHECKING
from urllib.parse import urljoin
from bs4 import BeautifulSoup
import cloudscraper
from flask import current_app, url_for
import requests
from .exceptions import ErrorException
if TYPE_CHECKING:
from .socket import BrickSocket
logger = logging.getLogger(__name__)
def get_peeron_user_agent():
"""Get the User-Agent string for Peeron requests from config"""
return current_app.config.get('REBRICKABLE_USER_AGENT',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36')
def get_peeron_download_delay():
"""Get the delay in milliseconds between Peeron page downloads from config"""
return current_app.config.get('PEERON_DOWNLOAD_DELAY', 1000)
def get_min_image_size():
"""Get the minimum image size for valid Peeron instruction pages from config"""
return current_app.config.get('PEERON_MIN_IMAGE_SIZE', 100)
def get_peeron_instruction_url(set_number: str, version_number: str):
"""Get the Peeron instruction page URL using the configured pattern"""
pattern = current_app.config.get('PEERON_INSTRUCTION_PATTERN', 'http://peeron.com/scans/{set_number}-{version_number}')
return pattern.format(set_number=set_number, version_number=version_number)
def get_peeron_thumbnail_url(set_number: str, version_number: str):
"""Get the Peeron thumbnail base URL using the configured pattern"""
pattern = current_app.config.get('PEERON_THUMBNAIL_PATTERN', 'http://belay.peeron.com/thumbs/{set_number}-{version_number}/')
return pattern.format(set_number=set_number, version_number=version_number)
def get_peeron_scan_url(set_number: str, version_number: str):
"""Get the Peeron scan base URL using the configured pattern"""
pattern = current_app.config.get('PEERON_SCAN_PATTERN', 'http://belay.peeron.com/scans/{set_number}-{version_number}/')
return pattern.format(set_number=set_number, version_number=version_number)
def create_peeron_scraper():
"""Create a cloudscraper instance configured for Peeron"""
scraper = cloudscraper.create_scraper()
scraper.headers.update({
"User-Agent": get_peeron_user_agent()
})
return scraper
def get_peeron_cache_dir():
"""Get the base directory for Peeron caching"""
static_dir = Path(current_app.static_folder)
cache_dir = static_dir / 'images' / 'peeron_cache'
cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir
def get_set_cache_dir(set_number: str, version_number: str) -> tuple[Path, Path]:
"""Get cache directories for a specific set"""
base_cache_dir = get_peeron_cache_dir()
set_cache_key = f"{set_number}-{version_number}"
full_cache_dir = base_cache_dir / 'full' / set_cache_key
thumb_cache_dir = base_cache_dir / 'thumbs' / set_cache_key
full_cache_dir.mkdir(parents=True, exist_ok=True)
thumb_cache_dir.mkdir(parents=True, exist_ok=True)
return full_cache_dir, thumb_cache_dir
def cache_full_image_and_generate_thumbnail(image_url: str, page_number: str, set_number: str, version_number: str, session=None) -> tuple[str | None, str | None]:
"""
Download and cache full-size image, then generate a thumbnail preview.
Uses the full-size scan URLs from Peeron.
Returns (cached_image_path, thumbnail_url) or (None, None) if caching fails.
"""
try:
full_cache_dir, thumb_cache_dir = get_set_cache_dir(set_number, version_number)
full_filename = f"{page_number}.jpg"
thumb_filename = f"{page_number}.jpg"
full_cache_path = full_cache_dir / full_filename
thumb_cache_path = thumb_cache_dir / thumb_filename
# Return existing cached files if they exist
if full_cache_path.exists() and thumb_cache_path.exists():
set_cache_key = f"{set_number}-{version_number}"
thumbnail_url = url_for('static', filename=f'images/peeron_cache/thumbs/{set_cache_key}/{thumb_filename}')
return str(full_cache_path), thumbnail_url
# Download the full-size image using provided session or create new one
if session is None:
session = create_peeron_scraper()
response = session.get(image_url, timeout=30)
if response.status_code == 200 and len(response.content) > 0:
# Validate it's actually an image by checking minimum size
min_size = get_min_image_size()
if len(response.content) < min_size:
logger.warning(f"Image too small, skipping cache: {image_url}")
return None, None
# Write full-size image to cache
with open(full_cache_path, 'wb') as f:
f.write(response.content)
logger.debug(f"Cached full image: {image_url} -> {full_cache_path}")
# Generate thumbnail from the cached full image
try:
from PIL import Image
with Image.open(full_cache_path) as img:
# Create thumbnail (max 150px on longest side to match template)
img.thumbnail((150, 150), Image.Resampling.LANCZOS)
img.save(thumb_cache_path, 'JPEG', quality=85)
logger.debug(f"Generated thumbnail: {full_cache_path} -> {thumb_cache_path}")
set_cache_key = f"{set_number}-{version_number}"
thumbnail_url = url_for('static', filename=f'images/peeron_cache/thumbs/{set_cache_key}/{thumb_filename}')
return str(full_cache_path), thumbnail_url
except Exception as thumb_error:
logger.error(f"Failed to generate thumbnail for {page_number}: {thumb_error}")
# Clean up the full image if thumbnail generation failed
if full_cache_path.exists():
full_cache_path.unlink()
return None, None
else:
logger.warning(f"Failed to download full image: {image_url}")
return None, None
except Exception as e:
logger.error(f"Error caching full image {image_url}: {e}")
return None, None
def clear_set_cache(set_number: str, version_number: str) -> int:
"""
Clear all cached files for a specific set after PDF generation.
Returns the number of files deleted.
"""
try:
full_cache_dir, thumb_cache_dir = get_set_cache_dir(set_number, version_number)
deleted_count = 0
# Delete full images
if full_cache_dir.exists():
for cache_file in full_cache_dir.glob('*.jpg'):
try:
cache_file.unlink()
deleted_count += 1
logger.debug(f"Deleted cached full image: {cache_file}")
except OSError as e:
logger.warning(f"Failed to delete cache file {cache_file}: {e}")
# Remove directory if empty
try:
full_cache_dir.rmdir()
except OSError:
pass # Directory not empty or other error
# Delete thumbnails
if thumb_cache_dir.exists():
for cache_file in thumb_cache_dir.glob('*.jpg'):
try:
cache_file.unlink()
deleted_count += 1
logger.debug(f"Deleted cached thumbnail: {cache_file}")
except OSError as e:
logger.warning(f"Failed to delete cache file {cache_file}: {e}")
# Remove directory if empty
try:
thumb_cache_dir.rmdir()
except OSError:
pass # Directory not empty or other error
# Try to remove set directory if empty
try:
set_cache_key = f"{set_number}-{version_number}"
full_cache_dir.parent.rmdir() if full_cache_dir.parent.name == set_cache_key else None
thumb_cache_dir.parent.rmdir() if thumb_cache_dir.parent.name == set_cache_key else None
except OSError:
pass # Directory not empty or other error
logger.info(f"Set cache cleanup completed for {set_number}-{version_number}: {deleted_count} files deleted")
return deleted_count
except Exception as e:
logger.error(f"Error during set cache cleanup for {set_number}-{version_number}: {e}")
return 0
def clear_old_cache(max_age_days: int = 7) -> int:
"""
Clear old cache files across all sets.
Returns the number of files deleted.
"""
try:
base_cache_dir = get_peeron_cache_dir()
if not base_cache_dir.exists():
return 0
deleted_count = 0
max_age_seconds = max_age_days * 24 * 60 * 60
current_time = time.time()
# Clean both full and thumbs directories
for cache_type in ['full', 'thumbs']:
cache_type_dir = base_cache_dir / cache_type
if cache_type_dir.exists():
for set_dir in cache_type_dir.iterdir():
if set_dir.is_dir():
for cache_file in set_dir.glob('*.jpg'):
file_age = current_time - os.path.getmtime(cache_file)
if file_age > max_age_seconds:
try:
cache_file.unlink()
deleted_count += 1
logger.debug(f"Deleted old cache file: {cache_file}")
except OSError as e:
logger.warning(f"Failed to delete cache file {cache_file}: {e}")
# Remove empty directories
try:
if not any(set_dir.iterdir()):
set_dir.rmdir()
except OSError:
pass
logger.info(f"Old cache cleanup completed: {deleted_count} files deleted")
return deleted_count
except Exception as e:
logger.error(f"Error during old cache cleanup: {e}")
return 0
class PeeronPage(NamedTuple):
"""Represents a single instruction page from Peeron"""
page_number: str
original_image_url: str # Original Peeron full-size image URL
cached_full_image_path: str # Local full-size cached image path
cached_thumbnail_url: str # Local thumbnail URL for preview
alt_text: str
rotation: int = 0 # Rotation in degrees (0, 90, 180, 270)
# Peeron instruction scraper
class PeeronInstructions(object):
socket: 'BrickSocket | None'
set_number: str
version_number: str
pages: list[PeeronPage]
def __init__(
self,
set_number: str,
version_number: str = '1',
/,
*,
socket: 'BrickSocket | None' = None,
):
# Save the socket
self.socket = socket
# Parse set number (handle both "4011" and "4011-1" formats)
if '-' in set_number:
parts = set_number.split('-', 1)
self.set_number = parts[0]
self.version_number = parts[1] if len(parts) > 1 else '1'
else:
self.set_number = set_number
self.version_number = version_number
# Placeholder for pages
self.pages = []
# Check if instructions exist on Peeron (lightweight)
def exists(self, /) -> bool:
"""Check if the set exists on Peeron without caching thumbnails"""
try:
base_url = get_peeron_instruction_url(self.set_number, self.version_number)
scraper = create_peeron_scraper()
response = scraper.get(base_url)
if response.status_code != 200:
return False
soup = BeautifulSoup(response.text, 'html.parser')
# Check for "Browse instruction library" header (set not found)
if soup.find('h1', string="Browse instruction library"):
return False
# Look for thumbnail images to confirm instructions exist
thumbnails = soup.select('table[cellspacing="5"] a img[src^="http://belay.peeron.com/thumbs/"]')
return len(thumbnails) > 0
except Exception:
return False
# Find all available instruction pages on Peeron
def find_pages(self, /) -> list[PeeronPage]:
"""
Scrape Peeron's HTML and return a list of available instruction pages.
Similar to BrickInstructions.find_instructions() but for Peeron.
"""
base_url = get_peeron_instruction_url(self.set_number, self.version_number)
thumb_base_url = get_peeron_thumbnail_url(self.set_number, self.version_number)
scan_base_url = get_peeron_scan_url(self.set_number, self.version_number)
logger.debug(f"[find_pages] fetching HTML from {base_url!r}")
# Set up session with persistent cookies for Peeron (like working dl_peeron.py)
scraper = create_peeron_scraper()
# Download the main HTML page to establish session and cookies
try:
logger.debug(f"[find_pages] Establishing session by visiting: {base_url}")
response = scraper.get(base_url)
logger.debug(f"[find_pages] Main page visit: HTTP {response.status_code}")
if response.status_code != 200:
raise ErrorException(f'Failed to load Peeron page for {self.set_number}-{self.version_number}. HTTP {response.status_code}')
except requests.exceptions.RequestException as e:
raise ErrorException(f'Failed to connect to Peeron: {e}')
# Parse HTML to locate instruction pages
soup = BeautifulSoup(response.text, 'html.parser')
# Check for "Browse instruction library" header (set not found)
if soup.find('h1', string="Browse instruction library"):
raise ErrorException(f'Set {self.set_number}-{self.version_number} not found on Peeron')
# Locate all thumbnail images in the expected table structure
# Use the configured thumbnail pattern to build the expected URL prefix
thumb_base_url = get_peeron_thumbnail_url(self.set_number, self.version_number)
thumbnails = soup.select(f'table[cellspacing="5"] a img[src^="{thumb_base_url}"]')
if not thumbnails:
raise ErrorException(f'No instruction pages found for {self.set_number}-{self.version_number} on Peeron')
pages: list[PeeronPage] = []
total_thumbnails = len(thumbnails)
# Initialize progress if socket is available
if self.socket:
self.socket.progress_total = total_thumbnails
self.socket.progress_count = 0
self.socket.progress(message=f"Starting to cache {total_thumbnails} full images")
for idx, img in enumerate(thumbnails, 1):
thumb_url = img['src']
# Extract the page number from the thumbnail URL
page_number = thumb_url.split('/')[-2]
# Build the full-size scan URL using the page number
full_size_url = f"{scan_base_url}{page_number}/"
logger.debug(f"[find_pages] Page {page_number}: thumb={thumb_url}, full_size={full_size_url}")
# Create alt text for the page
alt_text = f"LEGO Instructions {self.set_number}-{self.version_number} Page {page_number}"
# Report progress if socket is available
if self.socket:
self.socket.progress_count = idx
self.socket.progress(message=f"Caching full image {idx} of {total_thumbnails}")
# Cache the full-size image and generate thumbnail preview using established session
cached_full_path, cached_thumb_url = cache_full_image_and_generate_thumbnail(
full_size_url, page_number, self.set_number, self.version_number, session=scraper
)
# Skip this page if caching failed
if not cached_full_path or not cached_thumb_url:
logger.warning(f"[find_pages] Skipping page {page_number} due to caching failure")
continue
page = PeeronPage(
page_number=page_number,
original_image_url=full_size_url,
cached_full_image_path=cached_full_path,
cached_thumbnail_url=cached_thumb_url,
alt_text=alt_text
)
pages.append(page)
# Cache the pages for later use
self.pages = pages
logger.debug(f"[find_pages] found {len(pages)} pages for {self.set_number}-{self.version_number}")
return pages
# Find instructions with fallback to Peeron
@staticmethod
def find_instructions_with_peeron_fallback(set: str, /) -> tuple[list[tuple[str, str]], list[PeeronPage] | None]:
"""
Enhanced version of BrickInstructions.find_instructions() that falls back to Peeron.
Returns (rebrickable_instructions, peeron_pages).
If rebrickable_instructions is empty, peeron_pages will contain Peeron data.
"""
from .instructions import BrickInstructions
# First try Rebrickable
try:
rebrickable_instructions = BrickInstructions.find_instructions(set)
return rebrickable_instructions, None
except ErrorException as e:
logger.info(f"Rebrickable failed for {set}: {e}. Trying Peeron fallback...")
# Fallback to Peeron
try:
peeron = PeeronInstructions(set)
peeron_pages = peeron.find_pages()
return [], peeron_pages
except ErrorException as peeron_error:
# Both failed, re-raise original Rebrickable error
logger.info(f"Peeron also failed for {set}: {peeron_error}")
raise e from peeron_error
+200
View File
@@ -0,0 +1,200 @@
import logging
import os
import tempfile
import time
from typing import Any, TYPE_CHECKING
import cloudscraper
from flask import current_app
from PIL import Image
from .exceptions import DownloadException, ErrorException
from .instructions import BrickInstructions
from .peeron_instructions import PeeronPage, get_min_image_size, get_peeron_download_delay, get_peeron_instruction_url, create_peeron_scraper
if TYPE_CHECKING:
from .socket import BrickSocket
logger = logging.getLogger(__name__)
# PDF generator for Peeron instruction pages
class PeeronPDF(object):
socket: 'BrickSocket'
set_number: str
version_number: str
pages: list[PeeronPage]
filename: str
def __init__(
self,
set_number: str,
version_number: str,
pages: list[PeeronPage],
/,
*,
socket: 'BrickSocket',
):
# Save the socket
self.socket = socket
# Save set information
self.set_number = set_number
self.version_number = version_number
self.pages = pages
# Generate filename following BrickTracker conventions
self.filename = f"{set_number}-{version_number}_peeron.pdf"
# Download pages and create PDF
def create_pdf(self, /) -> None:
"""
Downloads selected Peeron pages and merges them into a PDF.
Uses progress updates via socket similar to BrickInstructions.download()
"""
try:
target_path = self._get_target_path()
# Skip if we already have it
if os.path.isfile(target_path):
# Create BrickInstructions instance to get PDF URL
instructions = BrickInstructions(self.filename)
pdf_url = instructions.url()
return self.socket.complete(
message=f'File {self.filename} already exists, skipped - <a href="{pdf_url}" target="_blank" class="btn btn-sm btn-primary ms-2"><i class="ri-external-link-line"></i> Open PDF</a>'
)
# Set up progress tracking
total_pages = len(self.pages)
self.socket.update_total(total_pages)
self.socket.progress_count = 0
self.socket.progress(message=f"Starting PDF creation from {total_pages} cached pages")
# Use cached images directly - no downloads needed!
cached_files_with_rotation = []
missing_pages = []
for i, page in enumerate(self.pages):
# Check if cached file exists
if os.path.isfile(page.cached_full_image_path):
cached_files_with_rotation.append((page.cached_full_image_path, page.rotation))
# Update progress
self.socket.progress_count += 1
self.socket.progress(
message=f"Processing cached page {page.page_number} ({i + 1}/{total_pages})"
)
else:
missing_pages.append(page.page_number)
logger.warning(f"Cached image missing for page {page.page_number}: {page.cached_full_image_path}")
if not cached_files_with_rotation:
raise DownloadException(f"No cached images available for set {self.set_number}-{self.version_number}. Cache may have been cleared.")
elif len(cached_files_with_rotation) < total_pages:
# Partial success
error_msg = f"Only found {len(cached_files_with_rotation)}/{total_pages} cached images."
if missing_pages:
error_msg += f" Missing pages: {', '.join(missing_pages)}."
logger.warning(error_msg)
# Create PDF from cached images with rotation
self._create_pdf_from_images(cached_files_with_rotation, target_path)
# Success
logger.info(f"Created PDF {self.filename} with {len(cached_files_with_rotation)} pages")
# Create BrickInstructions instance to get PDF URL
instructions = BrickInstructions(self.filename)
pdf_url = instructions.url()
self.socket.complete(
message=f'PDF {self.filename} created with {len(cached_files_with_rotation)} pages - <a href="{pdf_url}" target="_blank" class="btn btn-sm btn-primary ms-2"><i class="ri-external-link-line"></i> Open PDF</a>'
)
# Clean up set cache after successful PDF creation
try:
from .peeron_instructions import clear_set_cache
deleted_count = clear_set_cache(self.set_number, self.version_number)
if deleted_count > 0:
logger.info(f"[create_pdf] Cleaned up {deleted_count} cache files for set {self.set_number}-{self.version_number}")
except Exception as e:
logger.warning(f"[create_pdf] Failed to clean set cache: {e}")
except Exception as e:
logger.error(f"Error creating PDF {self.filename}: {e}")
self.socket.fail(
message=f"Error creating PDF {self.filename}: {e}"
)
# Create PDF from downloaded images
def _create_pdf_from_images(self, image_paths_and_rotations: list[tuple[str, int]], output_path: str, /) -> None:
"""Create a PDF from a list of image files with their rotations"""
try:
# Import FPDF (should be available from requirements)
from fpdf import FPDF
except ImportError:
raise ErrorException("FPDF library not available. Install with: pip install fpdf2")
pdf = FPDF()
for i, (img_path, rotation) in enumerate(image_paths_and_rotations):
try:
# Open image and apply rotation if needed
with Image.open(img_path) as image:
# Apply rotation if specified
if rotation != 0:
# PIL rotation is counter-clockwise, so we negate for clockwise rotation
image = image.rotate(-rotation, expand=True)
width, height = image.size
# Add page with image dimensions (convert pixels to mm)
# 1 pixel = 0.264583 mm (assuming 96 DPI)
page_width = width * 0.264583
page_height = height * 0.264583
pdf.add_page(format=(page_width, page_height))
# Save rotated image to temporary file for FPDF
temp_rotated_path = None
if rotation != 0:
import tempfile
temp_fd, temp_rotated_path = tempfile.mkstemp(suffix='.jpg', prefix=f'peeron_rotated_{i}_')
try:
os.close(temp_fd) # Close file descriptor, we'll use the path
image.save(temp_rotated_path, 'JPEG', quality=95)
pdf.image(temp_rotated_path, x=0, y=0, w=page_width, h=page_height)
finally:
# Clean up rotated temp file
if temp_rotated_path and os.path.exists(temp_rotated_path):
os.remove(temp_rotated_path)
else:
pdf.image(img_path, x=0, y=0, w=page_width, h=page_height)
# Update progress
progress_msg = f"Processing page {i + 1}/{len(image_paths_and_rotations)} into PDF"
if rotation != 0:
progress_msg += f" (rotated {rotation}°)"
self.socket.progress(message=progress_msg)
except Exception as e:
logger.warning(f"Failed to add image {img_path} to PDF: {e}")
continue
# Save the PDF
pdf.output(output_path)
# Get target file path
def _get_target_path(self, /) -> str:
"""Get the full path where the PDF should be saved"""
instructions_folder = os.path.join(
current_app.static_folder, # type: ignore
current_app.config['INSTRUCTIONS_FOLDER']
)
return os.path.join(instructions_folder, self.filename)
# Create BrickInstructions instance for the generated PDF
def get_instructions(self, /) -> BrickInstructions:
"""Return a BrickInstructions instance for the generated PDF"""
return BrickInstructions(self.filename)
+93 -3
View File
@@ -91,8 +91,17 @@ class RebrickablePart(BrickRecord):
def url_for_bricklink(self, /) -> str: def url_for_bricklink(self, /) -> str:
if current_app.config['BRICKLINK_LINKS']: if current_app.config['BRICKLINK_LINKS']:
try: try:
# Use BrickLink part number if available and not None/empty, otherwise fall back to Rebrickable part
bricklink_part = getattr(self.fields, 'bricklink_part_num', None)
part_param = bricklink_part if bricklink_part else self.fields.part
# Use BrickLink color ID if available and not None, otherwise fall back to Rebrickable color
bricklink_color = getattr(self.fields, 'bricklink_color_id', None)
color_param = bricklink_color if bricklink_color is not None else self.fields.color
# print(f'BrickLink URL parameters: part={part_param}, color={color_param}') # Debugging line, can be removed later
return current_app.config['BRICKLINK_LINK_PART_PATTERN'].format( # noqa: E501 return current_app.config['BRICKLINK_LINK_PART_PATTERN'].format( # noqa: E501
part=self.fields.part, part=part_param,
color=color_param,
) )
except Exception: except Exception:
pass pass
@@ -105,7 +114,19 @@ class RebrickablePart(BrickRecord):
if self.fields.image is None: if self.fields.image is None:
file = RebrickableImage.nil_name() file = RebrickableImage.nil_name()
else: else:
file = self.fields.image_id # Use image_id if available, otherwise extract from image URL
if hasattr(self.fields, 'image_id') and self.fields.image_id:
file = self.fields.image_id
else:
# Extract image_id from URL on-the-fly
from urllib.parse import urlparse
import os
image_id, _ = os.path.splitext(
os.path.basename(
urlparse(self.fields.image).path
)
)
file = image_id if image_id else RebrickableImage.nil_name()
return RebrickableImage.static_url(file, 'PARTS_FOLDER') return RebrickableImage.static_url(file, 'PARTS_FOLDER')
else: else:
@@ -168,6 +189,9 @@ class RebrickablePart(BrickRecord):
'color_name': data['color']['name'], 'color_name': data['color']['name'],
'color_rgb': data['color']['rgb'], 'color_rgb': data['color']['rgb'],
'color_transparent': data['color']['is_trans'], 'color_transparent': data['color']['is_trans'],
'bricklink_color_id': None,
'bricklink_color_name': None,
'bricklink_part_num': None,
'name': data['part']['name'], 'name': data['part']['name'],
'category': data['part']['part_cat_id'], 'category': data['part']['part_cat_id'],
'image': data['part']['part_img_url'], 'image': data['part']['part_img_url'],
@@ -176,6 +200,72 @@ class RebrickablePart(BrickRecord):
'print': data['part']['print_of'] 'print': data['part']['print_of']
} }
# Extract BrickLink color info if available in external_ids
if 'color' in data and 'external_ids' in data['color']:
external_ids = data['color']['external_ids']
if 'BrickLink' in external_ids and external_ids['BrickLink']:
bricklink_data = external_ids['BrickLink']
# Extract BrickLink color ID and name from the nested structure
if isinstance(bricklink_data, dict):
if 'ext_ids' in bricklink_data and bricklink_data['ext_ids']:
record['bricklink_color_id'] = bricklink_data['ext_ids'][0]
if 'ext_descrs' in bricklink_data and bricklink_data['ext_descrs']:
# ext_descrs is a list of lists, get the first description from the first list
if len(bricklink_data['ext_descrs']) > 0 and len(bricklink_data['ext_descrs'][0]) > 0:
record['bricklink_color_name'] = bricklink_data['ext_descrs'][0][0]
# Cache color information in rebrickable_colors table for future lookups
# This builds the translation table automatically as sets are imported
if 'color' in data:
try:
from .sql import BrickSQL
sql = BrickSQL()
# Check if color already exists in cache
check_query = """
SELECT COUNT(*) FROM "rebrickable_colors"
WHERE "color_id" = :color_id
"""
sql.cursor.execute(check_query, {'color_id': record['color_id']})
exists = sql.cursor.fetchone()[0] > 0
if not exists:
# Insert color into cache
insert_query = """
INSERT OR IGNORE INTO "rebrickable_colors" (
"color_id", "name", "rgb", "is_trans",
"bricklink_color_id", "bricklink_color_name"
) VALUES (
:color_id, :name, :rgb, :is_trans,
:bricklink_color_id, :bricklink_color_name
)
"""
sql.cursor.execute(insert_query, {
'color_id': record['color_id'],
'name': record['color_name'],
'rgb': record['color_rgb'],
'is_trans': record['color_transparent'],
'bricklink_color_id': record['bricklink_color_id'],
'bricklink_color_name': record['bricklink_color_name']
})
# Commit is handled by parent transaction
except Exception as e:
# Don't fail part import if color caching fails
import logging
logger = logging.getLogger(__name__)
logger.debug(f'Could not cache color {record["color_id"]}: {e}')
# Extract BrickLink part number if available
if 'part' in data and 'external_ids' in data['part']:
part_external_ids = data['part']['external_ids']
if 'BrickLink' in part_external_ids and part_external_ids['BrickLink']:
bricklink_parts = part_external_ids['BrickLink']
if isinstance(bricklink_parts, list) and len(bricklink_parts) > 0:
record['bricklink_part_num'] = bricklink_parts[0]
if brickset is not None: if brickset is not None:
record['id'] = brickset.fields.id record['id'] = brickset.fields.id
@@ -190,7 +280,7 @@ class RebrickablePart(BrickRecord):
) )
) )
if image_id is not None or image_id != '': if image_id is not None and image_id != '':
record['image_id'] = image_id record['image_id'] = image_id
return record return record
+9
View File
@@ -179,6 +179,15 @@ class RebrickableSet(BrickRecord):
return '' return ''
# Compute the url for the bricklink page
def url_for_bricklink(self, /) -> str:
if current_app.config['BRICKLINK_LINKS']:
return current_app.config['BRICKLINK_LINK_SET_PATTERN'].format(
set_num=self.fields.set
)
return ''
# Compute the url for the refresh button # Compute the url for the refresh button
def url_for_refresh(self, /) -> str: def url_for_refresh(self, /) -> str:
return url_for('set.refresh', set=self.fields.set) return url_for('set.refresh', set=self.fields.set)
+7 -12
View File
@@ -11,24 +11,19 @@ class RebrickableSetList(BrickRecordList[RebrickableSet]):
select_query: str = 'rebrickable/set/list' select_query: str = 'rebrickable/set/list'
refresh_query: str = 'rebrickable/set/need_refresh' refresh_query: str = 'rebrickable/set/need_refresh'
# All the sets # Implementation of abstract list method
def all(self, /) -> Self: def list(self, /, *, override_query: str | None = None, **context) -> None:
# Load the sets from the database # Load the sets from the database
for record in self.select(): for record in self.select(override_query=override_query, **context):
rebrickable_set = RebrickableSet(record=record) rebrickable_set = RebrickableSet(record=record)
self.records.append(rebrickable_set) self.records.append(rebrickable_set)
# All the sets
def all(self, /) -> Self:
self.list()
return self return self
# Sets needing refresh # Sets needing refresh
def need_refresh(self, /) -> Self: def need_refresh(self, /) -> Self:
# Load the sets from the database self.list(override_query=self.refresh_query)
for record in self.select(
override_query=self.refresh_query
):
rebrickable_set = RebrickableSet(record=record)
self.records.append(rebrickable_set)
return self return self
+19
View File
@@ -1,3 +1,4 @@
from datetime import datetime
from sqlite3 import Row from sqlite3 import Row
from typing import Any, ItemsView from typing import Any, ItemsView
@@ -5,6 +6,24 @@ from .fields import BrickRecordFields
from .sql import BrickSQL from .sql import BrickSQL
def format_timestamp(timestamp: float | None, format_key: str = 'PURCHASE_DATE_FORMAT') -> str:
"""
Format a timestamp for display.
Args:
timestamp: Unix timestamp (float) or None
format_key: Config key for date format string
Returns:
Formatted date string or empty string if timestamp is None
"""
if timestamp is not None:
from flask import current_app
time = datetime.fromtimestamp(timestamp)
return time.strftime(current_app.config.get(format_key, '%Y/%m/%d'))
return ''
# SQLite record # SQLite record
class BrickRecord(object): class BrickRecord(object):
select_query: str select_query: str
+86 -1
View File
@@ -1,5 +1,6 @@
import re
from sqlite3 import Row from sqlite3 import Row
from typing import Any, Generator, Generic, ItemsView, TypeVar, TYPE_CHECKING from typing import Any, Generator, Generic, ItemsView, Self, TypeVar, TYPE_CHECKING
from .fields import BrickRecordFields from .fields import BrickRecordFields
from .sql import BrickSQL from .sql import BrickSQL
@@ -72,6 +73,90 @@ class BrickRecordList(Generic[T]):
**context **context
) )
# Generic pagination method for all record lists
def paginate(
self,
page: int = 1,
per_page: int = 50,
sort_field: str | None = None,
sort_order: str = 'asc',
count_query: str | None = None,
list_query: str | None = None,
field_mapping: dict[str, str] | None = None,
**filter_context: Any
) -> tuple['Self', int]:
"""Generic pagination implementation for all record lists"""
from .sql import BrickSQL
# Use provided queries or fall back to defaults
list_query = list_query or getattr(self, 'all_query', None)
if not list_query:
raise NotImplementedError("Subclass must define all_query")
# Calculate offset
offset = (page - 1) * per_page
# Get total count by wrapping the main query
if count_query:
# Use provided count query
count_result = BrickSQL().fetchone(count_query, **filter_context)
total_count = count_result['total_count'] if count_result else 0
else:
# Generate count by wrapping the main query (without ORDER BY, LIMIT, OFFSET)
count_context = {k: v for k, v in filter_context.items()
if k not in ['order', 'limit', 'offset']}
# Get the main query SQL without pagination clauses
main_sql = BrickSQL().load_query(list_query, **count_context)
# Remove ORDER BY, LIMIT, OFFSET clauses for counting
# Remove ORDER BY clause and everything after it that's not part of subqueries
count_sql = re.sub(r'\s+ORDER\s+BY\s+[^)]*?(\s+LIMIT|\s+OFFSET|$)', r'\1', main_sql, flags=re.IGNORECASE)
# Remove LIMIT and OFFSET
count_sql = re.sub(r'\s+LIMIT\s+\d+', '', count_sql, flags=re.IGNORECASE)
count_sql = re.sub(r'\s+OFFSET\s+\d+', '', count_sql, flags=re.IGNORECASE)
# Wrap in COUNT(*)
wrapped_sql = f"SELECT COUNT(*) as total_count FROM ({count_sql.strip()})"
count_result = BrickSQL().raw_execute(wrapped_sql, {}).fetchone()
total_count = count_result['total_count'] if count_result else 0
# Prepare sort order
order_clause = None
if sort_field and field_mapping and sort_field in field_mapping:
sql_field = field_mapping[sort_field]
direction = 'DESC' if sort_order.lower() == 'desc' else 'ASC'
order_clause = f'{sql_field} {direction}'
# Build pagination context
pagination_context = {
'limit': per_page,
'offset': offset,
'order': order_clause or getattr(self, 'order', None),
**filter_context
}
# Load paginated results using the existing list() method
# Check if this is a set list that needs do_theme parameter
if hasattr(self, 'themes'): # Only BrickSetList has this attribute
self.list(override_query=list_query, do_theme=True, **pagination_context)
else:
self.list(override_query=list_query, **pagination_context)
return self, total_count
# Base method that subclasses can override
def list(
self,
/,
*,
override_query: str | None = None,
**context: Any,
) -> None:
"""Load records from database - should be implemented by subclasses that use pagination"""
raise NotImplementedError("Subclass must implement list() method")
# Generic SQL parameters from fields # Generic SQL parameters from fields
def sql_parameters(self, /) -> dict[str, Any]: def sql_parameters(self, /) -> dict[str, Any]:
parameters: dict[str, Any] = {} parameters: dict[str, Any] = {}
+14
View File
@@ -169,6 +169,20 @@ class BrickSet(RebrickableSet):
else: else:
return '' return ''
# Purchase date max formatted for consolidated sets
def purchase_date_max_formatted(self, /, *, standard: bool = False) -> str:
if hasattr(self.fields, 'purchase_date_max') and self.fields.purchase_date_max is not None:
time = datetime.fromtimestamp(self.fields.purchase_date_max)
if standard:
return time.strftime('%Y/%m/%d')
else:
return time.strftime(
current_app.config['PURCHASE_DATE_FORMAT']
)
else:
return ''
# Purchase price with currency # Purchase price with currency
def purchase_price(self, /) -> str: def purchase_price(self, /) -> str:
if self.fields.purchase_price is not None: if self.fields.purchase_price is not None:
+526 -6
View File
@@ -13,14 +13,19 @@ from .set_storage_list import BrickSetStorageList
from .set_tag import BrickSetTag from .set_tag import BrickSetTag
from .set_tag_list import BrickSetTagList from .set_tag_list import BrickSetTagList
from .set import BrickSet from .set import BrickSet
from .theme_list import BrickThemeList
from .instructions_list import BrickInstructionsList
# All the sets from the database # All the sets from the database
class BrickSetList(BrickRecordList[BrickSet]): class BrickSetList(BrickRecordList[BrickSet]):
themes: list[str] themes: list[str]
years: list[int]
order: str order: str
# Queries # Queries
all_query: str = 'set/list/all'
consolidated_query: str = 'set/list/consolidated'
damaged_minifigure_query: str = 'set/list/damaged_minifigure' damaged_minifigure_query: str = 'set/list/damaged_minifigure'
damaged_part_query: str = 'set/list/damaged_part' damaged_part_query: str = 'set/list/damaged_part'
generic_query: str = 'set/list/generic' generic_query: str = 'set/list/generic'
@@ -31,23 +36,517 @@ class BrickSetList(BrickRecordList[BrickSet]):
using_minifigure_query: str = 'set/list/using_minifigure' using_minifigure_query: str = 'set/list/using_minifigure'
using_part_query: str = 'set/list/using_part' using_part_query: str = 'set/list/using_part'
using_storage_query: str = 'set/list/using_storage' using_storage_query: str = 'set/list/using_storage'
without_storage_query: str = 'set/list/without_storage'
def __init__(self, /): def __init__(self, /):
super().__init__() super().__init__()
# Placeholders # Placeholders
self.themes = [] self.themes = []
self.years = []
# Store the order for this list # Store the order for this list
self.order = current_app.config['SETS_DEFAULT_ORDER'] self.order = current_app.config['SETS_DEFAULT_ORDER']
# All the sets # All the sets
def all(self, /) -> Self: def all(self, /) -> Self:
# Load the sets from the database # Load the sets from the database with metadata context for filtering
self.list(do_theme=True) filter_context = {
'owners': BrickSetOwnerList.as_columns(),
'statuses': BrickSetStatusList.as_columns(),
'tags': BrickSetTagList.as_columns(),
}
self.list(do_theme=True, **filter_context)
return self return self
# All sets in consolidated/grouped view
def all_consolidated(self, /) -> Self:
# Load the sets from the database using consolidated query with metadata context
filter_context = {
'owners_dict': BrickSetOwnerList.as_column_mapping(),
'statuses_dict': BrickSetStatusList.as_column_mapping(),
'tags_dict': BrickSetTagList.as_column_mapping(),
}
self.list(override_query=self.consolidated_query, do_theme=True, **filter_context)
return self
# All sets with pagination and filtering
def all_filtered_paginated(
self,
search_query: str | None = None,
page: int = 1,
per_page: int = 50,
sort_field: str | None = None,
sort_order: str = 'asc',
status_filter: str | None = None,
theme_filter: str | None = None,
owner_filter: str | None = None,
purchase_location_filter: str | None = None,
storage_filter: str | None = None,
tag_filter: str | None = None,
year_filter: str | None = None,
duplicate_filter: bool = False,
use_consolidated: bool = True
) -> tuple[Self, int]:
# 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 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])
# Prepare filter context
filter_context = {
'search_query': search_query,
'status_filter': status_filter,
'theme_filter': theme_id_filter, # Use converted theme ID
'owner_filter': owner_filter,
'purchase_location_filter': purchase_location_filter,
'storage_filter': storage_filter,
'tag_filter': tag_filter,
'year_filter': year_filter,
'duplicate_filter': duplicate_filter,
'owners': BrickSetOwnerList.as_columns(),
'statuses': BrickSetStatusList.as_columns(),
'tags': BrickSetTagList.as_columns(),
'owners_dict': BrickSetOwnerList.as_column_mapping(),
'statuses_dict': BrickSetStatusList.as_column_mapping(),
'tags_dict': BrickSetTagList.as_column_mapping(),
}
# Field mapping for sorting
if use_consolidated:
field_mapping = {
'set': '"rebrickable_sets"."number", "rebrickable_sets"."version"',
'name': '"rebrickable_sets"."name"',
'year': '"rebrickable_sets"."year"',
'parts': '"rebrickable_sets"."number_of_parts"',
'theme': '"rebrickable_sets"."theme_id"',
'minifigures': '"total_minifigures"',
'missing': '"total_missing"',
'damaged': '"total_damaged"',
'instances': '"instance_count"', # New field for consolidated view
'purchase-date': '"purchase_date"', # Use the MIN aggregated value
'purchase-price': '"purchase_price"' # Use the MIN aggregated value
}
else:
field_mapping = {
'set': '"rebrickable_sets"."number", "rebrickable_sets"."version"',
'name': '"rebrickable_sets"."name"',
'year': '"rebrickable_sets"."year"',
'parts': '"rebrickable_sets"."number_of_parts"',
'theme': '"rebrickable_sets"."theme_id"',
'minifigures': '"total_minifigures"', # Use the alias from the SQL query
'missing': '"total_missing"', # Use the alias from the SQL query
'damaged': '"total_damaged"', # Use the alias from the SQL query
'purchase-date': '"bricktracker_sets"."purchase_date"',
'purchase-price': '"bricktracker_sets"."purchase_price"'
}
# Choose query based on consolidation preference and filter complexity
# Owner/tag filters still need to fall back to non-consolidated for now
# due to complex aggregation requirements
complex_filters = [owner_filter, tag_filter]
if use_consolidated and not any(complex_filters):
query_to_use = self.consolidated_query
else:
# Use filtered query when consolidation is disabled or complex filters applied
query_to_use = 'set/list/all_filtered'
# Handle instructions filtering
if status_filter in ['has-missing-instructions', '-has-missing-instructions']:
# For instructions filter, we need to load all sets first, then filter and paginate
return self._all_filtered_paginated_with_instructions(
search_query, page, per_page, sort_field, sort_order,
status_filter, theme_id_filter, owner_filter,
purchase_location_filter, storage_filter, tag_filter
)
# Handle special case for set sorting with multiple columns
if sort_field == 'set' and field_mapping:
# Create custom order clause for set sorting
direction = 'DESC' if sort_order.lower() == 'desc' else 'ASC'
custom_order = f'"rebrickable_sets"."number" {direction}, "rebrickable_sets"."version" {direction}'
filter_context['order'] = custom_order
# Remove set from field mapping to avoid double-processing
field_mapping_copy = field_mapping.copy()
field_mapping_copy.pop('set', None)
field_mapping = field_mapping_copy
sort_field = None # Disable automatic ORDER BY construction
# Normal SQL-based filtering and pagination
result, total_count = self.paginate(
page=page,
per_page=per_page,
sort_field=sort_field,
sort_order=sort_order,
list_query=query_to_use,
field_mapping=field_mapping,
**filter_context
)
# Populate themes and years for filter dropdown from filtered dataset (not just current page)
# For themes dropdown, exclude theme_filter to show ALL available themes
themes_context = filter_context.copy()
themes_context.pop('theme_filter', None)
result._populate_themes_from_filtered_dataset(
query_to_use,
**themes_context
)
# For years dropdown, exclude ALL filters to show ALL available years
years_context = {
'search_query': filter_context.get('search_query'),
}
result._populate_years_from_filtered_dataset(
query_to_use,
**years_context
)
return result, total_count
def _populate_themes(self) -> None:
"""Populate themes list from the current records"""
themes = set()
for record in self.records:
if hasattr(record, 'theme') and hasattr(record.theme, 'name'):
themes.add(record.theme.name)
self.themes = list(themes)
self.themes.sort()
def _populate_years(self) -> None:
"""Populate years list from the current records"""
years = set()
for record in self.records:
if hasattr(record, 'fields') and hasattr(record.fields, 'year') and record.fields.year:
years.add(record.fields.year)
self.years = list(years)
self.years.sort(reverse=True) # Most recent years first
def _theme_name_to_id(self, theme_name_or_id: str) -> str | None:
"""Convert a theme name or ID to theme ID for filtering"""
try:
# Check if the input is already a numeric theme ID
if theme_name_or_id.isdigit():
# Input is already a theme ID, validate it exists
theme_list = BrickThemeList()
theme_id = int(theme_name_or_id)
if theme_id in theme_list.themes:
return str(theme_id)
else:
return None
# Input is a theme name, convert to ID
from .sql import BrickSQL
theme_list = BrickThemeList()
# Find all theme IDs that match the name
matching_theme_ids = []
for theme_id, theme in theme_list.themes.items():
if theme.name.lower() == theme_name_or_id.lower():
matching_theme_ids.append(str(theme_id))
if not matching_theme_ids:
return None
# If only one match, return it
if len(matching_theme_ids) == 1:
return matching_theme_ids[0]
# Multiple matches - check which theme ID actually has sets in the user's collection
sql = BrickSQL()
for theme_id in matching_theme_ids:
result = sql.fetchone(
'set/check_theme_exists',
theme_id=theme_id
)
count = result['count'] if result else 0
if count > 0:
return theme_id
# If none have sets, return the first match (fallback)
return matching_theme_ids[0]
except Exception:
# If themes can't be loaded, return None to disable theme filtering
return None
def _theme_id_to_name(self, theme_id: str) -> str | None:
"""Convert a theme ID to theme name (lowercase) for dropdown display"""
try:
if not theme_id or not theme_id.isdigit():
return None
from .theme_list import BrickThemeList
theme_list = BrickThemeList()
theme_id_int = int(theme_id)
if theme_id_int in theme_list.themes:
return theme_list.themes[theme_id_int].name.lower()
return None
except Exception as e:
# For debugging - log the exception
import logging
logger = logging.getLogger(__name__)
logger.warning(f"Failed to convert theme ID {theme_id} to name: {e}")
return None
def _all_filtered_paginated_with_instructions(
self,
search_query: str | None,
page: int,
per_page: int,
sort_field: str | None,
sort_order: str,
status_filter: str,
theme_id_filter: str | None,
owner_filter: str | None,
purchase_location_filter: str | None,
storage_filter: str | None,
tag_filter: str | None
) -> tuple[Self, int]:
"""Handle filtering when instructions filter is involved"""
try:
# Load all sets first (without pagination) with full metadata
all_sets = BrickSetList()
filter_context = {
'owners': BrickSetOwnerList.as_columns(),
'statuses': BrickSetStatusList.as_columns(),
'tags': BrickSetTagList.as_columns(),
}
all_sets.list(do_theme=True, **filter_context)
# Load instructions list
instructions_list = BrickInstructionsList()
instruction_sets = set(instructions_list.sets.keys())
# Apply all filters manually
filtered_records = []
for record in all_sets.records:
# Apply instructions filter
set_id = record.fields.set
has_instructions = set_id in instruction_sets
if status_filter == 'has-missing-instructions' and has_instructions:
continue # Skip sets that have instructions
elif status_filter == '-has-missing-instructions' and not has_instructions:
continue # Skip sets that don't have instructions
# Apply other filters manually
if search_query and not self._matches_search(record, search_query):
continue
if theme_id_filter and not self._matches_theme(record, theme_id_filter):
continue
if owner_filter and not self._matches_owner(record, owner_filter):
continue
if purchase_location_filter and not self._matches_purchase_location(record, purchase_location_filter):
continue
if storage_filter and not self._matches_storage(record, storage_filter):
continue
if tag_filter and not self._matches_tag(record, tag_filter):
continue
filtered_records.append(record)
# Apply sorting
if sort_field:
filtered_records = self._sort_records(filtered_records, sort_field, sort_order)
# Calculate pagination
total_count = len(filtered_records)
start_index = (page - 1) * per_page
end_index = start_index + per_page
paginated_records = filtered_records[start_index:end_index]
# Create result
result = BrickSetList()
result.records = paginated_records
# Copy themes and years from the source that has all sets
result.themes = all_sets.themes if hasattr(all_sets, 'themes') else []
result.years = all_sets.years if hasattr(all_sets, 'years') else []
# If themes or years weren't populated, populate them from current records
if not result.themes:
result._populate_themes()
if not result.years:
result._populate_years()
return result, total_count
except Exception:
# Fall back to normal pagination without instructions filter
return self.all_filtered_paginated(
search_query, page, per_page, sort_field, sort_order,
None, theme_id_filter, owner_filter,
purchase_location_filter, storage_filter, tag_filter
)
def _populate_years_from_filtered_dataset(self, query_name: str, **filter_context) -> None:
"""Populate years list from all available records in filtered dataset"""
try:
# Use a simplified query to get just distinct years
years_context = dict(filter_context)
years_context.pop('limit', None)
years_context.pop('offset', None)
# Use a special lightweight query for years
year_records = super().select(
override_query='set/list/years_only',
**years_context
)
# Extract years from records
years = set()
for record in year_records:
year = record['year'] if 'year' in record.keys() else None
if year:
years.add(year)
if years:
self.years = list(years)
self.years.sort(reverse=True) # Most recent years first
else:
import logging
logger = logging.getLogger(__name__)
logger.warning("No years found in filtered dataset, falling back to current page")
self._populate_years()
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Exception in _populate_years_from_filtered_dataset: {e}")
self._populate_years()
def _populate_themes_from_filtered_dataset(self, query_name: str, **filter_context) -> None:
"""Populate themes list from filtered dataset (all pages, not just current page)"""
try:
from .theme_list import BrickThemeList
# Use a simplified query to get just distinct theme_ids
theme_context = dict(filter_context)
theme_context.pop('limit', None)
theme_context.pop('offset', None)
# Use a special lightweight query for themes
theme_records = super().select(
override_query='set/list/themes_only',
**theme_context
)
# Convert to theme names
theme_list = BrickThemeList()
themes = set()
for record in theme_records:
theme_id = record.get('theme_id')
if theme_id:
theme = theme_list.get(theme_id)
if theme and hasattr(theme, 'name'):
themes.add(theme.name)
self.themes = list(themes)
self.themes.sort()
except Exception:
# Fall back to simpler approach: get themes from ALL sets (ignoring filters)
# This is better than showing only current page themes
try:
from .theme_list import BrickThemeList
all_sets = BrickSetList()
all_sets.list(do_theme=True)
themes = set()
years = set()
for record in all_sets.records:
if hasattr(record, 'theme') and hasattr(record.theme, 'name'):
themes.add(record.theme.name)
if hasattr(record, 'fields') and hasattr(record.fields, 'year') and record.fields.year:
years.add(record.fields.year)
self.themes = list(themes)
self.themes.sort()
self.years = list(years)
self.years.sort(reverse=True)
except Exception:
# Final fallback to current page themes
self._populate_themes()
self._populate_years()
def _matches_search(self, record, search_query: str) -> bool:
"""Check if record matches search query"""
search_lower = search_query.lower()
return (search_lower in record.fields.name.lower() or
search_lower in record.fields.set.lower())
def _matches_theme(self, record, theme_id: str) -> bool:
"""Check if record matches theme filter"""
return str(record.fields.theme_id) == theme_id
def _matches_owner(self, record, owner_filter: str) -> bool:
"""Check if record matches owner filter"""
if not owner_filter.startswith('owner-'):
return True
# Convert owner-uuid format to owner_uuid column name
owner_column = owner_filter.replace('-', '_')
# Check if record has this owner attribute set to 1
return hasattr(record.fields, owner_column) and getattr(record.fields, owner_column) == 1
def _matches_purchase_location(self, record, location_filter: str) -> bool:
"""Check if record matches purchase location filter"""
return record.fields.purchase_location == location_filter
def _matches_storage(self, record, storage_filter: str) -> bool:
"""Check if record matches storage filter"""
return record.fields.storage == storage_filter
def _matches_tag(self, record, tag_filter: str) -> bool:
"""Check if record matches tag filter"""
if not tag_filter.startswith('tag-'):
return True
# Convert tag-uuid format to tag_uuid column name
tag_column = tag_filter.replace('-', '_')
# Check if record has this tag attribute set to 1
return hasattr(record.fields, tag_column) and getattr(record.fields, tag_column) == 1
def _sort_records(self, records, sort_field: str, sort_order: str):
"""Sort records manually"""
reverse = sort_order == 'desc'
if sort_field == 'set':
return sorted(records, key=lambda r: self._set_sort_key(r.fields.set), reverse=reverse)
elif sort_field == 'name':
return sorted(records, key=lambda r: r.fields.name, reverse=reverse)
elif sort_field == 'year':
return sorted(records, key=lambda r: r.fields.year, reverse=reverse)
elif sort_field == 'parts':
return sorted(records, key=lambda r: r.fields.number_of_parts, reverse=reverse)
# Add more sort fields as needed
return records
def _set_sort_key(self, set_number: str) -> tuple:
"""Generate sort key for set numbers like '10121-1' -> (10121, 1)"""
try:
if '-' in set_number:
main_part, version_part = set_number.split('-', 1)
return (int(main_part), int(version_part))
else:
return (int(set_number), 0)
except (ValueError, TypeError):
# Fallback to string sorting if parsing fails
return (float('inf'), set_number)
# Sets with a minifigure part damaged # Sets with a minifigure part damaged
def damaged_minifigure(self, figure: str, /) -> Self: def damaged_minifigure(self, figure: str, /) -> Self:
# Save the parameters to the fields # Save the parameters to the fields
@@ -93,6 +592,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
**context: Any, **context: Any,
) -> None: ) -> None:
themes = set() themes = set()
years = set()
if order is None: if order is None:
order = self.order order = self.order
@@ -102,20 +602,22 @@ class BrickSetList(BrickRecordList[BrickSet]):
override_query=override_query, override_query=override_query,
order=order, order=order,
limit=limit, limit=limit,
owners=BrickSetOwnerList.as_columns(), **context
statuses=BrickSetStatusList.as_columns(),
tags=BrickSetTagList.as_columns(),
): ):
brickset = BrickSet(record=record) brickset = BrickSet(record=record)
self.records.append(brickset) self.records.append(brickset)
if do_theme: if do_theme:
themes.add(brickset.theme.name) themes.add(brickset.theme.name)
if hasattr(brickset, 'fields') and hasattr(brickset.fields, 'year') and brickset.fields.year:
years.add(brickset.fields.year)
# Convert the set into a list and sort it # Convert the set into a list and sort it
if do_theme: if do_theme:
self.themes = list(themes) self.themes = list(themes)
self.themes.sort() self.themes.sort()
self.years = list(years)
self.years.sort(reverse=True) # Most recent years first
# Sets missing a minifigure part # Sets missing a minifigure part
def missing_minifigure(self, figure: str, /) -> Self: def missing_minifigure(self, figure: str, /) -> Self:
@@ -169,10 +671,17 @@ class BrickSetList(BrickRecordList[BrickSet]):
return self return self
def without_storage(self, /) -> Self:
# Load sets with no storage
self.list(override_query=self.without_storage_query)
return self
# Helper to build the metadata lists # Helper to build the metadata lists
def set_metadata_lists( def set_metadata_lists(
as_class: bool = False as_class: bool = False,
hardcoded_statuses_only: bool = False
) -> dict[ ) -> dict[
str, str,
Union[ Union[
@@ -184,9 +693,20 @@ def set_metadata_lists(
list[BrickSetTag] list[BrickSetTag]
] ]
]: ]:
# Get all statuses
all_statuses = BrickSetStatusList.list(all=True)
# Filter to only hardcoded statuses if requested (for individual minifigures)
if hardcoded_statuses_only:
hardcoded_status_ids = ['minifigures_collected', 'set_checked', 'set_collected']
statuses = [s for s in all_statuses if s.fields.id in hardcoded_status_ids]
else:
statuses = all_statuses
return { return {
'brickset_owners': BrickSetOwnerList.list(), 'brickset_owners': BrickSetOwnerList.list(),
'brickset_purchase_locations': BrickSetPurchaseLocationList.list(as_class=as_class), # noqa: E501 'brickset_purchase_locations': BrickSetPurchaseLocationList.list(as_class=as_class), # noqa: E501
'brickset_statuses': statuses,
'brickset_storages': BrickSetStorageList.list(as_class=as_class), 'brickset_storages': BrickSetStorageList.list(as_class=as_class),
'brickset_tags': BrickSetTagList.list(), 'brickset_tags': BrickSetTagList.list(),
} }
+5 -1
View File
@@ -5,8 +5,10 @@ from .metadata import BrickMetadata
class BrickSetOwner(BrickMetadata): class BrickSetOwner(BrickMetadata):
kind: str = 'owner' kind: str = 'owner'
# Set state endpoint # Endpoints
set_state_endpoint: str = 'set.update_owner' set_state_endpoint: str = 'set.update_owner'
individual_minifigure_state_endpoint: str = 'individual_minifigure.update_owner'
individual_part_state_endpoint: str = 'individual_part.update_owner'
# Queries # Queries
delete_query: str = 'set/metadata/owner/delete' delete_query: str = 'set/metadata/owner/delete'
@@ -14,3 +16,5 @@ class BrickSetOwner(BrickMetadata):
select_query: str = 'set/metadata/owner/select' select_query: str = 'set/metadata/owner/select'
update_field_query: str = 'set/metadata/owner/update/field' update_field_query: str = 'set/metadata/owner/update/field'
update_set_state_query: str = 'set/metadata/owner/update/state' update_set_state_query: str = 'set/metadata/owner/update/state'
update_individual_minifigure_state_query: str = 'individual_minifigure/metadata/owner/update/state'
update_individual_part_state_query: str = 'individual_part/metadata/owner/update/state'
+3
View File
@@ -15,6 +15,9 @@ class BrickSetOwnerList(BrickMetadataList[BrickSetOwner]):
# Queries # Queries
select_query = 'set/metadata/owner/list' select_query = 'set/metadata/owner/list'
# Endpoints
set_state_endpoint: str = 'set.update_owner'
# Instantiate the list with the proper class # Instantiate the list with the proper class
@classmethod @classmethod
def new(cls, /, *, force: bool = False) -> Self: def new(cls, /, *, force: bool = False) -> Self:
+4
View File
@@ -5,9 +5,13 @@ from .metadata import BrickMetadata
class BrickSetPurchaseLocation(BrickMetadata): class BrickSetPurchaseLocation(BrickMetadata):
kind: str = 'purchase location' kind: str = 'purchase location'
# Endpoints
individual_minifigure_value_endpoint: str = 'individual_minifigure.update_purchase_location'
# Queries # Queries
delete_query: str = 'set/metadata/purchase_location/delete' delete_query: str = 'set/metadata/purchase_location/delete'
insert_query: str = 'set/metadata/purchase_location/insert' insert_query: str = 'set/metadata/purchase_location/insert'
select_query: str = 'set/metadata/purchase_location/select' select_query: str = 'set/metadata/purchase_location/select'
update_field_query: str = 'set/metadata/purchase_location/update/field' update_field_query: str = 'set/metadata/purchase_location/update/field'
update_set_value_query: str = 'set/metadata/purchase_location/update/value' update_set_value_query: str = 'set/metadata/purchase_location/update/value'
update_individual_minifigure_value_query: str = 'individual_minifigure/metadata/purchase_location/update/value'
@@ -22,6 +22,9 @@ class BrickSetPurchaseLocationList(
# Set value endpoint # Set value endpoint
set_value_endpoint: str = 'set.update_purchase_location' set_value_endpoint: str = 'set.update_purchase_location'
# Individual minifigure value endpoint
individual_minifigure_value_endpoint: str = 'individual_minifigure.update_purchase_location'
# Load all purchase locations # Load all purchase locations
@classmethod @classmethod
def all(cls, /) -> Self: def all(cls, /) -> Self:
+5 -1
View File
@@ -7,8 +7,10 @@ from .metadata import BrickMetadata
class BrickSetStatus(BrickMetadata): class BrickSetStatus(BrickMetadata):
kind: str = 'status' kind: str = 'status'
# Set state endpoint # Endpoints
set_state_endpoint: str = 'set.update_status' set_state_endpoint: str = 'set.update_status'
individual_minifigure_state_endpoint: str = 'individual_minifigure.update_status'
individual_part_state_endpoint: str = 'individual_part.update_status'
# Queries # Queries
delete_query: str = 'set/metadata/status/delete' delete_query: str = 'set/metadata/status/delete'
@@ -16,6 +18,8 @@ class BrickSetStatus(BrickMetadata):
select_query: str = 'set/metadata/status/select' select_query: str = 'set/metadata/status/select'
update_field_query: str = 'set/metadata/status/update/field' update_field_query: str = 'set/metadata/status/update/field'
update_set_state_query: str = 'set/metadata/status/update/state' update_set_state_query: str = 'set/metadata/status/update/state'
update_individual_minifigure_state_query: str = 'individual_minifigure/metadata/status/update/state'
update_individual_part_state_query: str = 'individual_part/metadata/status/update/state'
# Grab data from a form # Grab data from a form
def from_form(self, form: dict[str, str], /) -> Self: def from_form(self, form: dict[str, str], /) -> Self:
+3
View File
@@ -15,6 +15,9 @@ class BrickSetStatusList(BrickMetadataList[BrickSetStatus]):
# Queries # Queries
select_query = 'set/metadata/status/list' select_query = 'set/metadata/status/list'
# Endpoints
set_state_endpoint: str = 'set.update_status'
# Filter the list of set status # Filter the list of set status
def filter(self, all: bool = False) -> list[BrickSetStatus]: def filter(self, all: bool = False) -> list[BrickSetStatus]:
return [ return [
+4
View File
@@ -7,12 +7,16 @@ from flask import url_for
class BrickSetStorage(BrickMetadata): class BrickSetStorage(BrickMetadata):
kind: str = 'storage' kind: str = 'storage'
# Endpoints
individual_minifigure_value_endpoint: str = 'individual_minifigure.update_storage'
# Queries # Queries
delete_query: str = 'set/metadata/storage/delete' delete_query: str = 'set/metadata/storage/delete'
insert_query: str = 'set/metadata/storage/insert' insert_query: str = 'set/metadata/storage/insert'
select_query: str = 'set/metadata/storage/select' select_query: str = 'set/metadata/storage/select'
update_field_query: str = 'set/metadata/storage/update/field' update_field_query: str = 'set/metadata/storage/update/field'
update_set_value_query: str = 'set/metadata/storage/update/value' update_set_value_query: str = 'set/metadata/storage/update/value'
update_individual_minifigure_value_query: str = 'individual_minifigure/metadata/storage/update/value'
# Self url # Self url
def url(self, /) -> str: def url(self, /) -> str:
+3
View File
@@ -20,6 +20,9 @@ class BrickSetStorageList(BrickMetadataList[BrickSetStorage]):
# Set value endpoint # Set value endpoint
set_value_endpoint: str = 'set.update_storage' set_value_endpoint: str = 'set.update_storage'
# Individual minifigure value endpoint
individual_minifigure_value_endpoint: str = 'individual_minifigure.update_storage'
# Load all storages # Load all storages
@classmethod @classmethod
def all(cls, /) -> Self: def all(cls, /) -> Self:
+5 -1
View File
@@ -5,8 +5,10 @@ from .metadata import BrickMetadata
class BrickSetTag(BrickMetadata): class BrickSetTag(BrickMetadata):
kind: str = 'tag' kind: str = 'tag'
# Set state endpoint # Endpoints
set_state_endpoint: str = 'set.update_tag' set_state_endpoint: str = 'set.update_tag'
individual_minifigure_state_endpoint: str = 'individual_minifigure.update_tag'
individual_part_state_endpoint: str = 'individual_part.update_tag'
# Queries # Queries
delete_query: str = 'set/metadata/tag/delete' delete_query: str = 'set/metadata/tag/delete'
@@ -14,3 +16,5 @@ class BrickSetTag(BrickMetadata):
select_query: str = 'set/metadata/tag/select' select_query: str = 'set/metadata/tag/select'
update_field_query: str = 'set/metadata/tag/update/field' update_field_query: str = 'set/metadata/tag/update/field'
update_set_state_query: str = 'set/metadata/tag/update/state' update_set_state_query: str = 'set/metadata/tag/update/state'
update_individual_minifigure_state_query: str = 'individual_minifigure/metadata/tag/update/state'
update_individual_part_state_query: str = 'individual_part/metadata/tag/update/state'
+3
View File
@@ -15,6 +15,9 @@ class BrickSetTagList(BrickMetadataList[BrickSetTag]):
# Queries # Queries
select_query: str = 'set/metadata/tag/list' select_query: str = 'set/metadata/tag/list'
# Endpoints
set_state_endpoint: str = 'set.update_tag'
# Instantiate the list with the proper class # Instantiate the list with the proper class
@classmethod @classmethod
def new(cls, /, *, force: bool = False) -> Self: def new(cls, /, *, force: bool = False) -> Self:
+142 -1
View File
@@ -6,6 +6,8 @@ from flask_socketio import SocketIO
from .instructions import BrickInstructions from .instructions import BrickInstructions
from .instructions_list import BrickInstructionsList from .instructions_list import BrickInstructionsList
from .peeron_instructions import PeeronInstructions, PeeronPage
from .peeron_pdf import PeeronPDF
from .set import BrickSet from .set import BrickSet
from .socket_decorator import authenticated_socket, rebrickable_socket from .socket_decorator import authenticated_socket, rebrickable_socket
from .sql import close as sql_close from .sql import close as sql_close
@@ -16,11 +18,21 @@ logger = logging.getLogger(__name__)
MESSAGES: Final[dict[str, str]] = { MESSAGES: Final[dict[str, str]] = {
'COMPLETE': 'complete', 'COMPLETE': 'complete',
'CONNECT': 'connect', 'CONNECT': 'connect',
'CREATE_LOT': 'create_lot',
'DISCONNECT': 'disconnect', 'DISCONNECT': 'disconnect',
'DOWNLOAD_INSTRUCTIONS': 'download_instructions', 'DOWNLOAD_INSTRUCTIONS': 'download_instructions',
'DOWNLOAD_PEERON_PAGES': 'download_peeron_pages',
'FAIL': 'fail', 'FAIL': 'fail',
'IMPORT_MINIFIGURE': 'import_minifigure',
'IMPORT_SET': 'import_set', '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', 'LOAD_SET': 'load_set',
'MINIFIGURE_LOADED': 'minifigure_loaded',
'PART_COLORS_LOADED': 'part_colors_loaded',
'PART_LOADED': 'part_loaded',
'PROGRESS': 'progress', 'PROGRESS': 'progress',
'SET_LOADED': 'set_loaded', 'SET_LOADED': 'set_loaded',
} }
@@ -70,7 +82,7 @@ class BrickSocket(object):
*args, *args,
**kwargs, **kwargs,
path=app.config['SOCKET_PATH'], path=app.config['SOCKET_PATH'],
async_mode='eventlet', async_mode='gevent',
) )
# Store the socket in the app config # Store the socket in the app config
@@ -106,6 +118,84 @@ class BrickSocket(object):
BrickInstructionsList(force=True) BrickInstructionsList(force=True)
@self.socket.on(MESSAGES['LOAD_PEERON_PAGES'], namespace=self.namespace) # noqa: E501
def load_peeron_pages(data: dict[str, Any], /) -> None:
logger.debug('Socket: LOAD_PEERON_PAGES={data} (from: {fr})'.format(
data=data, fr=request.remote_addr))
try:
set_number = data.get('set', '')
if not set_number:
self.fail(message="Set number is required")
return
# Create Peeron instructions instance with socket for progress reporting
peeron = PeeronInstructions(set_number, socket=self)
# Find pages (this will report progress for thumbnail caching)
pages = peeron.find_pages()
# Complete the operation (JavaScript will handle redirect)
self.complete(message=f"Found {len(pages)} instruction pages on Peeron")
except Exception as e:
logger.error(f"Error in load_peeron_pages: {e}")
self.fail(message=f"Error loading Peeron pages: {e}")
@self.socket.on(MESSAGES['DOWNLOAD_PEERON_PAGES'], namespace=self.namespace) # noqa: E501
@authenticated_socket(self)
def download_peeron_pages(data: dict[str, Any], /) -> None:
logger.debug('Socket: DOWNLOAD_PEERON_PAGES={data} (from: {fr})'.format(
data=data,
fr=request.sid, # type: ignore
))
try:
# Extract data from the request
set_number = data.get('set', '')
pages_data = data.get('pages', [])
if not set_number:
raise ValueError("Set number is required")
if not pages_data:
raise ValueError("No pages selected")
# Parse set number
if '-' in set_number:
parts = set_number.split('-', 1)
set_num = parts[0]
version_num = parts[1] if len(parts) > 1 else '1'
else:
set_num = set_number
version_num = '1'
# Convert page data to PeeronPage objects
pages = []
for page_data in pages_data:
page = PeeronPage(
page_number=page_data.get('page_number', ''),
original_image_url=page_data.get('original_image_url', ''),
cached_full_image_path=page_data.get('cached_full_image_path', ''),
cached_thumbnail_url='', # Not needed for PDF generation
alt_text=page_data.get('alt_text', ''),
rotation=page_data.get('rotation', 0)
)
pages.append(page)
# Create PDF generator and start download
pdf_generator = PeeronPDF(set_num, version_num, pages, socket=self)
pdf_generator.create_pdf()
# Note: Cache cleanup is handled automatically by pdf_generator.create_pdf()
# Refresh instructions list to include new PDF
BrickInstructionsList(force=True)
except Exception as e:
logger.error(f"Error in download_peeron_pages: {e}")
self.fail(message=f"Error downloading Peeron pages: {e}")
@self.socket.on(MESSAGES['IMPORT_SET'], namespace=self.namespace) @self.socket.on(MESSAGES['IMPORT_SET'], namespace=self.namespace)
@rebrickable_socket(self) @rebrickable_socket(self)
def import_set(data: dict[str, Any], /) -> None: def import_set(data: dict[str, Any], /) -> None:
@@ -125,6 +215,57 @@ class BrickSocket(object):
BrickSet().load(self, data) 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)
# Update the progress auto-incrementing # Update the progress auto-incrementing
def auto_progress( def auto_progress(
self, self,
+23
View File
@@ -60,6 +60,29 @@ class BrickSQL(object):
# Grab a cursor # Grab a cursor
self.cursor = self.connection.cursor() self.cursor = self.connection.cursor()
# SQLite Performance Optimizations
logger.debug('SQLite3: applying performance optimizations')
# Enable WAL (Write-Ahead Logging) mode for better concurrency
# Allows multiple readers while writer is active
self.connection.execute('PRAGMA journal_mode=WAL')
# Increase cache size for better query performance
# Default is 2000 pages, increase to 10000 pages (~40MB for 4KB pages)
self.connection.execute('PRAGMA cache_size=10000')
# Store temporary tables and indices in memory for speed
self.connection.execute('PRAGMA temp_store=memory')
# Enable foreign key constraints (good practice)
self.connection.execute('PRAGMA foreign_keys=ON')
# Optimize for read performance (trade write speed for read speed)
self.connection.execute('PRAGMA synchronous=NORMAL')
# Analyze database statistics for better query planning
self.connection.execute('ANALYZE')
# Grab the version and check # Grab the version and check
try: try:
version = self.fetchone('schema/get_version') version = self.fetchone('schema/get_version')
@@ -0,0 +1,19 @@
-- Delete individual minifigure parts
DELETE FROM "bricktracker_individual_minifigure_parts"
WHERE "id" = :id;
-- Delete individual minifigure owners
DELETE FROM "bricktracker_individual_minifigure_owners"
WHERE "id" = :id;
-- Delete individual minifigure tags
DELETE FROM "bricktracker_individual_minifigure_tags"
WHERE "id" = :id;
-- Delete individual minifigure statuses
DELETE FROM "bricktracker_individual_minifigure_statuses"
WHERE "id" = :id;
-- Delete the individual minifigure itself
DELETE FROM "bricktracker_individual_minifigures"
WHERE "id" = :id;
@@ -0,0 +1,15 @@
INSERT OR IGNORE INTO "bricktracker_individual_minifigures" (
"id",
"figure",
"quantity",
"description",
"storage",
"purchase_location"
) VALUES (
:id,
:figure,
:quantity,
:description,
:storage,
:purchase_location
)
@@ -0,0 +1,48 @@
-- Get all individual minifigure instances for a specific storage location
SELECT
"bricktracker_individual_minifigures"."id",
"bricktracker_individual_minifigures"."figure",
"bricktracker_individual_minifigures"."quantity",
"bricktracker_individual_minifigures"."description",
"bricktracker_individual_minifigures"."storage",
"bricktracker_individual_minifigures"."purchase_location",
"rebrickable_minifigures"."number",
"rebrickable_minifigures"."name",
"rebrickable_minifigures"."image",
"rebrickable_minifigures"."number_of_parts",
"storage_meta"."name" AS "storage_name",
"purchase_meta"."name" AS "purchase_location_name",
IFNULL("problem_join"."total_missing", 0) AS "total_missing",
IFNULL("problem_join"."total_damaged", 0) AS "total_damaged"
FROM "bricktracker_individual_minifigures"
INNER JOIN "rebrickable_minifigures"
ON "bricktracker_individual_minifigures"."figure" = "rebrickable_minifigures"."figure"
LEFT JOIN "bricktracker_metadata_storages" AS "storage_meta"
ON "bricktracker_individual_minifigures"."storage" = "storage_meta"."id"
LEFT JOIN "bricktracker_metadata_purchase_locations" AS "purchase_meta"
ON "bricktracker_individual_minifigures"."purchase_location" = "purchase_meta"."id"
LEFT JOIN (
SELECT
"bricktracker_individual_minifigure_parts"."id",
SUM("bricktracker_individual_minifigure_parts"."missing") AS "total_missing",
SUM("bricktracker_individual_minifigure_parts"."damaged") AS "total_damaged"
FROM "bricktracker_individual_minifigure_parts"
GROUP BY "bricktracker_individual_minifigure_parts"."id"
) "problem_join"
ON "bricktracker_individual_minifigures"."id" = "problem_join"."id"
WHERE "bricktracker_individual_minifigures"."storage" IS NOT DISTINCT FROM :storage
{% if order %}
ORDER BY {{ order }}
{% else %}
ORDER BY "bricktracker_individual_minifigures"."rowid" DESC
{% endif %}
{% if limit %}
LIMIT {{ limit }}
{% endif %}
@@ -0,0 +1,48 @@
-- Get all individual minifigure instances without storage
SELECT
"bricktracker_individual_minifigures"."id",
"bricktracker_individual_minifigures"."figure",
"bricktracker_individual_minifigures"."quantity",
"bricktracker_individual_minifigures"."description",
"bricktracker_individual_minifigures"."storage",
"bricktracker_individual_minifigures"."purchase_location",
"rebrickable_minifigures"."number",
"rebrickable_minifigures"."name",
"rebrickable_minifigures"."image",
"rebrickable_minifigures"."number_of_parts",
"storage_meta"."name" AS "storage_name",
"purchase_meta"."name" AS "purchase_location_name",
IFNULL("problem_join"."total_missing", 0) AS "total_missing",
IFNULL("problem_join"."total_damaged", 0) AS "total_damaged"
FROM "bricktracker_individual_minifigures"
INNER JOIN "rebrickable_minifigures"
ON "bricktracker_individual_minifigures"."figure" = "rebrickable_minifigures"."figure"
LEFT JOIN "bricktracker_metadata_storages" AS "storage_meta"
ON "bricktracker_individual_minifigures"."storage" = "storage_meta"."id"
LEFT JOIN "bricktracker_metadata_purchase_locations" AS "purchase_meta"
ON "bricktracker_individual_minifigures"."purchase_location" = "purchase_meta"."id"
LEFT JOIN (
SELECT
"bricktracker_individual_minifigure_parts"."id",
SUM("bricktracker_individual_minifigure_parts"."missing") AS "total_missing",
SUM("bricktracker_individual_minifigure_parts"."damaged") AS "total_damaged"
FROM "bricktracker_individual_minifigure_parts"
GROUP BY "bricktracker_individual_minifigure_parts"."id"
) "problem_join"
ON "bricktracker_individual_minifigures"."id" = "problem_join"."id"
WHERE "bricktracker_individual_minifigures"."storage" IS NULL
{% if order %}
ORDER BY {{ order }}
{% else %}
ORDER BY "bricktracker_individual_minifigures"."rowid" DESC
{% endif %}
{% if limit %}
LIMIT {{ limit }}
{% endif %}
@@ -0,0 +1,10 @@
INSERT INTO "bricktracker_individual_minifigure_owners" (
"id",
"{{name}}"
) VALUES (
:id,
:state
)
ON CONFLICT("id")
DO UPDATE SET "{{name}}" = :state
WHERE "bricktracker_individual_minifigure_owners"."id" IS NOT DISTINCT FROM :id
@@ -0,0 +1,10 @@
INSERT INTO "bricktracker_individual_minifigure_statuses" (
"id",
"{{name}}"
) VALUES (
:id,
:state
)
ON CONFLICT("id")
DO UPDATE SET "{{name}}" = :state
WHERE "bricktracker_individual_minifigure_statuses"."id" IS NOT DISTINCT FROM :id
@@ -0,0 +1,10 @@
INSERT INTO "bricktracker_individual_minifigure_tags" (
"id",
"{{name}}"
) VALUES (
:id,
:state
)
ON CONFLICT("id")
DO UPDATE SET "{{name}}" = :state
WHERE "bricktracker_individual_minifigure_tags"."id" IS NOT DISTINCT FROM :id
@@ -0,0 +1,23 @@
INSERT OR IGNORE INTO "bricktracker_individual_minifigure_parts" (
"id",
"part",
"color",
"spare",
"quantity",
"element",
"rebrickable_inventory",
"missing",
"damaged",
"checked"
) VALUES (
:id,
:part,
:color,
:spare,
:quantity,
:element,
:rebrickable_inventory,
0,
0,
0
)
@@ -0,0 +1,38 @@
-- Query parts for a specific individual minifigure instance
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" AS "total_missing",
"bricktracker_individual_minifigure_parts"."damaged" AS "total_damaged",
"bricktracker_individual_minifigure_parts"."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 "bricktracker_individual_minifigure_parts"
INNER JOIN "bricktracker_individual_minifigures"
ON "bricktracker_individual_minifigure_parts"."id" = "bricktracker_individual_minifigures"."id"
INNER JOIN "rebrickable_parts"
ON "bricktracker_individual_minifigure_parts"."part" = "rebrickable_parts"."part"
AND "bricktracker_individual_minifigure_parts"."color" = "rebrickable_parts"."color_id"
WHERE "bricktracker_individual_minifigure_parts"."id" IS NOT DISTINCT FROM :id
{% if order %}
ORDER BY {{ order | replace('"combined"', '"bricktracker_individual_minifigure_parts"') | replace('"bricktracker_parts"', '"bricktracker_individual_minifigure_parts"') }}
{% endif %}
@@ -0,0 +1,33 @@
-- Select a specific part from an individual minifigure instance
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",
"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"
FROM "bricktracker_individual_minifigure_parts"
INNER JOIN "bricktracker_individual_minifigures"
ON "bricktracker_individual_minifigure_parts"."id" = "bricktracker_individual_minifigures"."id"
INNER JOIN "rebrickable_parts"
ON "bricktracker_individual_minifigure_parts"."part" = "rebrickable_parts"."part"
AND "bricktracker_individual_minifigure_parts"."color" = "rebrickable_parts"."color_id"
WHERE "bricktracker_individual_minifigure_parts"."id" IS NOT DISTINCT FROM :id
AND "bricktracker_individual_minifigure_parts"."part" IS NOT DISTINCT FROM :part
AND "bricktracker_individual_minifigure_parts"."color" IS NOT DISTINCT FROM :color
AND "bricktracker_individual_minifigure_parts"."spare" IS NOT DISTINCT FROM :spare
@@ -0,0 +1,6 @@
UPDATE "bricktracker_individual_minifigure_parts"
SET "checked" = :checked
WHERE "bricktracker_individual_minifigure_parts"."id" IS NOT DISTINCT FROM :id
AND "bricktracker_individual_minifigure_parts"."part" IS NOT DISTINCT FROM :part
AND "bricktracker_individual_minifigure_parts"."color" IS NOT DISTINCT FROM :color
AND "bricktracker_individual_minifigure_parts"."spare" IS NOT DISTINCT FROM :spare
@@ -0,0 +1,6 @@
UPDATE "bricktracker_individual_minifigure_parts"
SET "damaged" = :damaged
WHERE "bricktracker_individual_minifigure_parts"."id" IS NOT DISTINCT FROM :id
AND "bricktracker_individual_minifigure_parts"."part" IS NOT DISTINCT FROM :part
AND "bricktracker_individual_minifigure_parts"."color" IS NOT DISTINCT FROM :color
AND "bricktracker_individual_minifigure_parts"."spare" IS NOT DISTINCT FROM :spare
@@ -0,0 +1,6 @@
UPDATE "bricktracker_individual_minifigure_parts"
SET "missing" = :missing
WHERE "bricktracker_individual_minifigure_parts"."id" IS NOT DISTINCT FROM :id
AND "bricktracker_individual_minifigure_parts"."part" IS NOT DISTINCT FROM :part
AND "bricktracker_individual_minifigure_parts"."color" IS NOT DISTINCT FROM :color
AND "bricktracker_individual_minifigure_parts"."spare" IS NOT DISTINCT FROM :spare
@@ -0,0 +1,35 @@
-- Get a specific individual minifigure instance by ID
SELECT
"bricktracker_individual_minifigures"."id",
"bricktracker_individual_minifigures"."figure",
"bricktracker_individual_minifigures"."quantity",
"bricktracker_individual_minifigures"."description",
"bricktracker_individual_minifigures"."storage",
"bricktracker_individual_minifigures"."purchase_location",
"rebrickable_minifigures"."number",
"rebrickable_minifigures"."name",
"rebrickable_minifigures"."image",
"rebrickable_minifigures"."number_of_parts",
"storage_meta"."name" AS "storage_name",
"purchase_meta"."name" AS "purchase_location_name"{{ owners }}{{ statuses }}{{ tags }}
FROM "bricktracker_individual_minifigures"
INNER JOIN "rebrickable_minifigures"
ON "bricktracker_individual_minifigures"."figure" = "rebrickable_minifigures"."figure"
LEFT JOIN "bricktracker_metadata_storages" AS "storage_meta"
ON "bricktracker_individual_minifigures"."storage" = "storage_meta"."id"
LEFT JOIN "bricktracker_metadata_purchase_locations" AS "purchase_meta"
ON "bricktracker_individual_minifigures"."purchase_location" = "purchase_meta"."id"
LEFT JOIN "bricktracker_individual_minifigure_owners"
ON "bricktracker_individual_minifigures"."id" IS NOT DISTINCT FROM "bricktracker_individual_minifigure_owners"."id"
LEFT JOIN "bricktracker_individual_minifigure_statuses"
ON "bricktracker_individual_minifigures"."id" IS NOT DISTINCT FROM "bricktracker_individual_minifigure_statuses"."id"
LEFT JOIN "bricktracker_individual_minifigure_tags"
ON "bricktracker_individual_minifigures"."id" IS NOT DISTINCT FROM "bricktracker_individual_minifigure_tags"."id"
WHERE "bricktracker_individual_minifigures"."id" = :id
@@ -0,0 +1,52 @@
-- Get all individual minifigure instances for a specific figure
SELECT
"bricktracker_individual_minifigures"."id",
"bricktracker_individual_minifigures"."figure",
"bricktracker_individual_minifigures"."quantity",
"bricktracker_individual_minifigures"."description",
"bricktracker_individual_minifigures"."storage",
"bricktracker_individual_minifigures"."purchase_location",
"rebrickable_minifigures"."number",
"rebrickable_minifigures"."name",
"rebrickable_minifigures"."image",
"rebrickable_minifigures"."number_of_parts",
"storage_meta"."name" AS "storage_name",
"purchase_meta"."name" AS "purchase_location_name",
{{ owners }},
{{ statuses }},
{{ tags }},
IFNULL("problem_join"."total_missing", 0) AS "total_missing",
IFNULL("problem_join"."total_damaged", 0) AS "total_damaged"
FROM "bricktracker_individual_minifigures"
INNER JOIN "rebrickable_minifigures"
ON "bricktracker_individual_minifigures"."figure" = "rebrickable_minifigures"."figure"
LEFT JOIN "bricktracker_metadata_storages" AS "storage_meta"
ON "bricktracker_individual_minifigures"."storage" = "storage_meta"."id"
LEFT JOIN "bricktracker_metadata_purchase_locations" AS "purchase_meta"
ON "bricktracker_individual_minifigures"."purchase_location" = "purchase_meta"."id"
LEFT JOIN "bricktracker_individual_minifigure_owners"
ON "bricktracker_individual_minifigures"."id" = "bricktracker_individual_minifigure_owners"."id"
LEFT JOIN "bricktracker_individual_minifigure_statuses"
ON "bricktracker_individual_minifigures"."id" = "bricktracker_individual_minifigure_statuses"."id"
LEFT JOIN "bricktracker_individual_minifigure_tags"
ON "bricktracker_individual_minifigures"."id" = "bricktracker_individual_minifigure_tags"."id"
LEFT JOIN (
SELECT
"bricktracker_individual_minifigure_parts"."id",
SUM("bricktracker_individual_minifigure_parts"."missing") AS "total_missing",
SUM("bricktracker_individual_minifigure_parts"."damaged") AS "total_damaged"
FROM "bricktracker_individual_minifigure_parts"
GROUP BY "bricktracker_individual_minifigure_parts"."id"
) "problem_join"
ON "bricktracker_individual_minifigures"."id" = "problem_join"."id"
WHERE "bricktracker_individual_minifigures"."figure" = :figure
ORDER BY "bricktracker_individual_minifigures"."rowid" DESC
@@ -0,0 +1,7 @@
UPDATE "bricktracker_individual_minifigures"
SET
"quantity" = :quantity,
"description" = :description,
"storage" = :storage,
"purchase_location" = :purchase_location
WHERE "id" = :id
@@ -0,0 +1,13 @@
-- Delete metadata first (foreign keys with CASCADE will handle this, but being explicit)
DELETE FROM "bricktracker_individual_part_owners"
WHERE "id" = '{{ id }}';
DELETE FROM "bricktracker_individual_part_tags"
WHERE "id" = '{{ id }}';
DELETE FROM "bricktracker_individual_part_statuses"
WHERE "id" = '{{ id }}';
-- Delete the individual part itself
DELETE FROM "bricktracker_individual_parts"
WHERE "id" = '{{ id }}';
@@ -0,0 +1,27 @@
INSERT INTO "bricktracker_individual_parts" (
"id",
"part",
"color",
"quantity",
"missing",
"damaged",
"checked",
"description",
"storage",
"purchase_location",
"purchase_date",
"purchase_price"
) VALUES (
:id,
:part,
:color,
:quantity,
:missing,
:damaged,
:checked,
:description,
:storage,
:purchase_location,
:purchase_date,
:purchase_price
)
@@ -0,0 +1,30 @@
SELECT
"bricktracker_individual_parts"."id",
"bricktracker_individual_parts"."part",
"bricktracker_individual_parts"."color",
"bricktracker_individual_parts"."quantity",
"bricktracker_individual_parts"."missing",
"bricktracker_individual_parts"."damaged",
"bricktracker_individual_parts"."checked",
"bricktracker_individual_parts"."description",
"bricktracker_individual_parts"."storage",
"bricktracker_individual_parts"."purchase_location",
"bricktracker_individual_parts"."purchase_date",
"bricktracker_individual_parts"."purchase_price",
"rebrickable_parts"."name",
"rebrickable_parts"."color_name",
"rebrickable_parts"."color_rgb",
"rebrickable_parts"."color_transparent",
"rebrickable_parts"."image",
"rebrickable_parts"."url",
"bricktracker_metadata_storages"."name" AS "storage_name",
"bricktracker_metadata_purchase_locations"."name" AS "purchase_location_name"
FROM "bricktracker_individual_parts"
INNER JOIN "rebrickable_parts"
ON "bricktracker_individual_parts"."part" = "rebrickable_parts"."part"
AND "bricktracker_individual_parts"."color" = "rebrickable_parts"."color_id"
LEFT JOIN "bricktracker_metadata_storages"
ON "bricktracker_individual_parts"."storage" IS NOT DISTINCT FROM "bricktracker_metadata_storages"."id"
LEFT JOIN "bricktracker_metadata_purchase_locations"
ON "bricktracker_individual_parts"."purchase_location" IS NOT DISTINCT FROM "bricktracker_metadata_purchase_locations"."id"
ORDER BY "bricktracker_individual_parts"."part", "bricktracker_individual_parts"."color"
@@ -0,0 +1,31 @@
SELECT
"bricktracker_individual_parts"."id",
"bricktracker_individual_parts"."part",
"bricktracker_individual_parts"."color",
"bricktracker_individual_parts"."quantity",
"bricktracker_individual_parts"."missing",
"bricktracker_individual_parts"."damaged",
"bricktracker_individual_parts"."checked",
"bricktracker_individual_parts"."description",
"bricktracker_individual_parts"."storage",
"bricktracker_individual_parts"."purchase_location",
"bricktracker_individual_parts"."purchase_date",
"bricktracker_individual_parts"."purchase_price",
"rebrickable_parts"."name",
"rebrickable_parts"."color_name",
"rebrickable_parts"."color_rgb",
"rebrickable_parts"."color_transparent",
"rebrickable_parts"."image",
"rebrickable_parts"."url",
"bricktracker_metadata_storages"."name" AS "storage_name",
"bricktracker_metadata_purchase_locations"."name" AS "purchase_location_name"
FROM "bricktracker_individual_parts"
INNER JOIN "rebrickable_parts"
ON "bricktracker_individual_parts"."part" = "rebrickable_parts"."part"
AND "bricktracker_individual_parts"."color" = "rebrickable_parts"."color_id"
LEFT JOIN "bricktracker_metadata_storages"
ON "bricktracker_individual_parts"."storage" IS NOT DISTINCT FROM "bricktracker_metadata_storages"."id"
LEFT JOIN "bricktracker_metadata_purchase_locations"
ON "bricktracker_individual_parts"."purchase_location" IS NOT DISTINCT FROM "bricktracker_metadata_purchase_locations"."id"
WHERE "bricktracker_individual_parts"."color" = :color
ORDER BY "bricktracker_individual_parts"."part"
@@ -0,0 +1,31 @@
SELECT
"bricktracker_individual_parts"."id",
"bricktracker_individual_parts"."part",
"bricktracker_individual_parts"."color",
"bricktracker_individual_parts"."quantity",
"bricktracker_individual_parts"."missing",
"bricktracker_individual_parts"."damaged",
"bricktracker_individual_parts"."checked",
"bricktracker_individual_parts"."description",
"bricktracker_individual_parts"."storage",
"bricktracker_individual_parts"."purchase_location",
"bricktracker_individual_parts"."purchase_date",
"bricktracker_individual_parts"."purchase_price",
"rebrickable_parts"."name",
"rebrickable_parts"."color_name",
"rebrickable_parts"."color_rgb",
"rebrickable_parts"."color_transparent",
"rebrickable_parts"."image",
"rebrickable_parts"."url",
"bricktracker_metadata_storages"."name" AS "storage_name",
"bricktracker_metadata_purchase_locations"."name" AS "purchase_location_name"
FROM "bricktracker_individual_parts"
INNER JOIN "rebrickable_parts"
ON "bricktracker_individual_parts"."part" = "rebrickable_parts"."part"
AND "bricktracker_individual_parts"."color" = "rebrickable_parts"."color_id"
LEFT JOIN "bricktracker_metadata_storages"
ON "bricktracker_individual_parts"."storage" IS NOT DISTINCT FROM "bricktracker_metadata_storages"."id"
LEFT JOIN "bricktracker_metadata_purchase_locations"
ON "bricktracker_individual_parts"."purchase_location" IS NOT DISTINCT FROM "bricktracker_metadata_purchase_locations"."id"
WHERE "bricktracker_individual_parts"."part" = :part
ORDER BY "bricktracker_individual_parts"."color"
@@ -0,0 +1,32 @@
SELECT
"bricktracker_individual_parts"."id",
"bricktracker_individual_parts"."part",
"bricktracker_individual_parts"."color",
"bricktracker_individual_parts"."quantity",
"bricktracker_individual_parts"."missing",
"bricktracker_individual_parts"."damaged",
"bricktracker_individual_parts"."checked",
"bricktracker_individual_parts"."description",
"bricktracker_individual_parts"."storage",
"bricktracker_individual_parts"."purchase_location",
"bricktracker_individual_parts"."purchase_date",
"bricktracker_individual_parts"."purchase_price",
"rebrickable_parts"."name",
"rebrickable_parts"."color_name",
"rebrickable_parts"."color_rgb",
"rebrickable_parts"."color_transparent",
"rebrickable_parts"."image",
"rebrickable_parts"."url",
"bricktracker_metadata_storages"."name" AS "storage_name",
"bricktracker_metadata_purchase_locations"."name" AS "purchase_location_name"
FROM "bricktracker_individual_parts"
INNER JOIN "rebrickable_parts"
ON "bricktracker_individual_parts"."part" = "rebrickable_parts"."part"
AND "bricktracker_individual_parts"."color" = "rebrickable_parts"."color_id"
LEFT JOIN "bricktracker_metadata_storages"
ON "bricktracker_individual_parts"."storage" IS NOT DISTINCT FROM "bricktracker_metadata_storages"."id"
LEFT JOIN "bricktracker_metadata_purchase_locations"
ON "bricktracker_individual_parts"."purchase_location" IS NOT DISTINCT FROM "bricktracker_metadata_purchase_locations"."id"
WHERE "bricktracker_individual_parts"."part" = :part
AND "bricktracker_individual_parts"."color" = :color
ORDER BY "bricktracker_individual_parts"."id"
@@ -0,0 +1,31 @@
SELECT
"bricktracker_individual_parts"."id",
"bricktracker_individual_parts"."part",
"bricktracker_individual_parts"."color",
"bricktracker_individual_parts"."quantity",
"bricktracker_individual_parts"."missing",
"bricktracker_individual_parts"."damaged",
"bricktracker_individual_parts"."checked",
"bricktracker_individual_parts"."description",
"bricktracker_individual_parts"."storage",
"bricktracker_individual_parts"."purchase_location",
"bricktracker_individual_parts"."purchase_date",
"bricktracker_individual_parts"."purchase_price",
"rebrickable_parts"."name",
"rebrickable_parts"."color_name",
"rebrickable_parts"."color_rgb",
"rebrickable_parts"."color_transparent",
"rebrickable_parts"."image",
"rebrickable_parts"."url",
"bricktracker_metadata_storages"."name" AS "storage_name",
"bricktracker_metadata_purchase_locations"."name" AS "purchase_location_name"
FROM "bricktracker_individual_parts"
INNER JOIN "rebrickable_parts"
ON "bricktracker_individual_parts"."part" = "rebrickable_parts"."part"
AND "bricktracker_individual_parts"."color" = "rebrickable_parts"."color_id"
LEFT JOIN "bricktracker_metadata_storages"
ON "bricktracker_individual_parts"."storage" IS NOT DISTINCT FROM "bricktracker_metadata_storages"."id"
LEFT JOIN "bricktracker_metadata_purchase_locations"
ON "bricktracker_individual_parts"."purchase_location" IS NOT DISTINCT FROM "bricktracker_metadata_purchase_locations"."id"
WHERE "bricktracker_individual_parts"."storage" = :storage
ORDER BY "bricktracker_individual_parts"."part", "bricktracker_individual_parts"."color"
@@ -0,0 +1,32 @@
SELECT
"bricktracker_individual_parts"."id",
"bricktracker_individual_parts"."part",
"bricktracker_individual_parts"."color",
"bricktracker_individual_parts"."quantity",
"bricktracker_individual_parts"."missing",
"bricktracker_individual_parts"."damaged",
"bricktracker_individual_parts"."checked",
"bricktracker_individual_parts"."description",
"bricktracker_individual_parts"."storage",
"bricktracker_individual_parts"."purchase_location",
"bricktracker_individual_parts"."purchase_date",
"bricktracker_individual_parts"."purchase_price",
"rebrickable_parts"."name",
"rebrickable_parts"."color_name",
"rebrickable_parts"."color_rgb",
"rebrickable_parts"."color_transparent",
"rebrickable_parts"."image",
"rebrickable_parts"."url",
"bricktracker_metadata_storages"."name" AS "storage_name",
"bricktracker_metadata_purchase_locations"."name" AS "purchase_location_name"
FROM "bricktracker_individual_parts"
INNER JOIN "rebrickable_parts"
ON "bricktracker_individual_parts"."part" = "rebrickable_parts"."part"
AND "bricktracker_individual_parts"."color" = "rebrickable_parts"."color_id"
LEFT JOIN "bricktracker_metadata_storages"
ON "bricktracker_individual_parts"."storage" IS NOT DISTINCT FROM "bricktracker_metadata_storages"."id"
LEFT JOIN "bricktracker_metadata_purchase_locations"
ON "bricktracker_individual_parts"."purchase_location" IS NOT DISTINCT FROM "bricktracker_metadata_purchase_locations"."id"
WHERE "bricktracker_individual_parts"."missing" > 0
OR "bricktracker_individual_parts"."damaged" > 0
ORDER BY "bricktracker_individual_parts"."part", "bricktracker_individual_parts"."color"
@@ -0,0 +1,31 @@
SELECT
"bricktracker_individual_parts"."id",
"bricktracker_individual_parts"."part",
"bricktracker_individual_parts"."color",
"bricktracker_individual_parts"."quantity",
"bricktracker_individual_parts"."missing",
"bricktracker_individual_parts"."damaged",
"bricktracker_individual_parts"."checked",
"bricktracker_individual_parts"."description",
"bricktracker_individual_parts"."storage",
"bricktracker_individual_parts"."purchase_location",
"bricktracker_individual_parts"."purchase_date",
"bricktracker_individual_parts"."purchase_price",
"rebrickable_parts"."name",
"rebrickable_parts"."color_name",
"rebrickable_parts"."color_rgb",
"rebrickable_parts"."color_transparent",
"rebrickable_parts"."image",
"rebrickable_parts"."url",
"bricktracker_metadata_storages"."name" AS "storage_name",
"bricktracker_metadata_purchase_locations"."name" AS "purchase_location_name"
FROM "bricktracker_individual_parts"
INNER JOIN "rebrickable_parts"
ON "bricktracker_individual_parts"."part" = "rebrickable_parts"."part"
AND "bricktracker_individual_parts"."color" = "rebrickable_parts"."color_id"
LEFT JOIN "bricktracker_metadata_storages"
ON "bricktracker_individual_parts"."storage" IS NOT DISTINCT FROM "bricktracker_metadata_storages"."id"
LEFT JOIN "bricktracker_metadata_purchase_locations"
ON "bricktracker_individual_parts"."purchase_location" IS NOT DISTINCT FROM "bricktracker_metadata_purchase_locations"."id"
WHERE "bricktracker_individual_parts"."storage" IS NOT DISTINCT FROM :storage
ORDER BY "bricktracker_individual_parts"."part", "bricktracker_individual_parts"."color"
@@ -0,0 +1,31 @@
SELECT
"bricktracker_individual_parts"."id",
"bricktracker_individual_parts"."part",
"bricktracker_individual_parts"."color",
"bricktracker_individual_parts"."quantity",
"bricktracker_individual_parts"."missing",
"bricktracker_individual_parts"."damaged",
"bricktracker_individual_parts"."checked",
"bricktracker_individual_parts"."description",
"bricktracker_individual_parts"."storage",
"bricktracker_individual_parts"."purchase_location",
"bricktracker_individual_parts"."purchase_date",
"bricktracker_individual_parts"."purchase_price",
"rebrickable_parts"."name",
"rebrickable_parts"."color_name",
"rebrickable_parts"."color_rgb",
"rebrickable_parts"."color_transparent",
"rebrickable_parts"."image",
"rebrickable_parts"."url",
"bricktracker_metadata_storages"."name" AS "storage_name",
"bricktracker_metadata_purchase_locations"."name" AS "purchase_location_name"
FROM "bricktracker_individual_parts"
INNER JOIN "rebrickable_parts"
ON "bricktracker_individual_parts"."part" = "rebrickable_parts"."part"
AND "bricktracker_individual_parts"."color" = "rebrickable_parts"."color_id"
LEFT JOIN "bricktracker_metadata_storages"
ON "bricktracker_individual_parts"."storage" IS NOT DISTINCT FROM "bricktracker_metadata_storages"."id"
LEFT JOIN "bricktracker_metadata_purchase_locations"
ON "bricktracker_individual_parts"."purchase_location" IS NOT DISTINCT FROM "bricktracker_metadata_purchase_locations"."id"
WHERE "bricktracker_individual_parts"."storage" IS NULL
ORDER BY "bricktracker_individual_parts"."part", "bricktracker_individual_parts"."color"
@@ -0,0 +1,10 @@
INSERT INTO "bricktracker_individual_part_owners" (
"id",
"{{name}}"
) VALUES (
:id,
:state
)
ON CONFLICT("id")
DO UPDATE SET "{{name}}" = :state
WHERE "bricktracker_individual_part_owners"."id" IS NOT DISTINCT FROM :id
@@ -0,0 +1,10 @@
INSERT INTO "bricktracker_individual_part_statuses" (
"id",
"{{name}}"
) VALUES (
:id,
:state
)
ON CONFLICT("id")
DO UPDATE SET "{{name}}" = :state
WHERE "bricktracker_individual_part_statuses"."id" IS NOT DISTINCT FROM :id
@@ -0,0 +1,10 @@
INSERT INTO "bricktracker_individual_part_tags" (
"id",
"{{name}}"
) VALUES (
:id,
:state
)
ON CONFLICT("id")
DO UPDATE SET "{{name}}" = :state
WHERE "bricktracker_individual_part_tags"."id" IS NOT DISTINCT FROM :id
@@ -0,0 +1,31 @@
SELECT
"bricktracker_individual_parts"."id",
"bricktracker_individual_parts"."part",
"bricktracker_individual_parts"."color",
"bricktracker_individual_parts"."quantity",
"bricktracker_individual_parts"."missing",
"bricktracker_individual_parts"."damaged",
"bricktracker_individual_parts"."checked",
"bricktracker_individual_parts"."description",
"bricktracker_individual_parts"."storage",
"bricktracker_individual_parts"."purchase_location",
"bricktracker_individual_parts"."purchase_date",
"bricktracker_individual_parts"."purchase_price",
"bricktracker_individual_parts"."lot_id",
"rebrickable_parts"."name",
"rebrickable_parts"."color_name",
"rebrickable_parts"."color_rgb",
"rebrickable_parts"."color_transparent",
"rebrickable_parts"."image",
"rebrickable_parts"."url",
"bricktracker_metadata_storages"."name" AS "storage_name",
"bricktracker_metadata_purchase_locations"."name" AS "purchase_location_name"
FROM "bricktracker_individual_parts"
INNER JOIN "rebrickable_parts"
ON "bricktracker_individual_parts"."part" = "rebrickable_parts"."part"
AND "bricktracker_individual_parts"."color" = "rebrickable_parts"."color_id"
LEFT JOIN "bricktracker_metadata_storages"
ON "bricktracker_individual_parts"."storage" IS NOT DISTINCT FROM "bricktracker_metadata_storages"."id"
LEFT JOIN "bricktracker_metadata_purchase_locations"
ON "bricktracker_individual_parts"."purchase_location" IS NOT DISTINCT FROM "bricktracker_metadata_purchase_locations"."id"
WHERE "bricktracker_individual_parts"."id" = :id
@@ -0,0 +1,3 @@
UPDATE "bricktracker_individual_parts"
SET "{{ field }}" = :value
WHERE "id" = :id
@@ -0,0 +1,3 @@
UPDATE "bricktracker_individual_parts"
SET "checked" = :checked
WHERE "id" = :id
@@ -0,0 +1,3 @@
UPDATE "bricktracker_individual_parts"
SET "damaged" = :damaged
WHERE "id" = :id
@@ -0,0 +1,3 @@
UPDATE "bricktracker_individual_parts"
SET "missing" = :missing
WHERE "id" = :id
@@ -0,0 +1,9 @@
UPDATE "bricktracker_individual_parts"
SET
"quantity" = :quantity,
"description" = :description,
"storage" = :storage,
"purchase_location" = :purchase_location,
"purchase_date" = :purchase_date,
"purchase_price" = :purchase_price
WHERE "id" = :id
@@ -0,0 +1,15 @@
-- Delete all individual parts associated with this lot
DELETE FROM "bricktracker_individual_parts"
WHERE "lot_id" = :id;
-- Delete lot owners
DELETE FROM "bricktracker_individual_part_lot_owners"
WHERE "id" = :id;
-- Delete lot tags
DELETE FROM "bricktracker_individual_part_lot_tags"
WHERE "id" = :id;
-- Delete the lot itself
DELETE FROM "bricktracker_individual_part_lots"
WHERE "id" = :id;
@@ -0,0 +1,19 @@
INSERT INTO "bricktracker_individual_part_lots" (
"id",
"name",
"description",
"created_date",
"storage",
"purchase_location",
"purchase_date",
"purchase_price"
) VALUES (
:id,
:name,
:description,
:created_date,
:storage,
:purchase_location,
:purchase_date,
:purchase_price
)
@@ -0,0 +1,21 @@
SELECT
"bricktracker_individual_part_lots"."id",
"bricktracker_individual_part_lots"."name",
"bricktracker_individual_part_lots"."description",
"bricktracker_individual_part_lots"."created_date",
"bricktracker_individual_part_lots"."storage",
"bricktracker_individual_part_lots"."purchase_location",
"bricktracker_individual_part_lots"."purchase_date",
"bricktracker_individual_part_lots"."purchase_price",
"bricktracker_metadata_storages"."name" AS "storage_name",
"bricktracker_metadata_purchase_locations"."name" AS "purchase_location_name",
COUNT("bricktracker_individual_parts"."id") AS "part_count"
FROM "bricktracker_individual_part_lots"
LEFT JOIN "bricktracker_metadata_storages"
ON "bricktracker_individual_part_lots"."storage" IS NOT DISTINCT FROM "bricktracker_metadata_storages"."id"
LEFT JOIN "bricktracker_metadata_purchase_locations"
ON "bricktracker_individual_part_lots"."purchase_location" IS NOT DISTINCT FROM "bricktracker_metadata_purchase_locations"."id"
LEFT JOIN "bricktracker_individual_parts"
ON "bricktracker_individual_part_lots"."id" = "bricktracker_individual_parts"."lot_id"
GROUP BY "bricktracker_individual_part_lots"."id"
ORDER BY "bricktracker_individual_part_lots"."created_date" DESC
@@ -0,0 +1,26 @@
SELECT
"bricktracker_individual_parts"."id",
"bricktracker_individual_parts"."part",
"bricktracker_individual_parts"."color",
"bricktracker_individual_parts"."quantity",
"bricktracker_individual_parts"."missing",
"bricktracker_individual_parts"."damaged",
"bricktracker_individual_parts"."checked",
"bricktracker_individual_parts"."description",
"bricktracker_individual_parts"."storage",
"bricktracker_individual_parts"."purchase_location",
"bricktracker_individual_parts"."purchase_date",
"bricktracker_individual_parts"."purchase_price",
"bricktracker_individual_parts"."lot_id",
"rebrickable_parts"."name",
"rebrickable_parts"."color_name",
"rebrickable_parts"."color_rgb",
"rebrickable_parts"."color_transparent",
"rebrickable_parts"."image",
"rebrickable_parts"."url"
FROM "bricktracker_individual_parts"
INNER JOIN "rebrickable_parts"
ON "bricktracker_individual_parts"."part" = "rebrickable_parts"."part"
AND "bricktracker_individual_parts"."color" = "rebrickable_parts"."color_id"
WHERE "bricktracker_individual_parts"."lot_id" = :lot_id
ORDER BY "rebrickable_parts"."name" ASC, "bricktracker_individual_parts"."color" ASC
@@ -0,0 +1,17 @@
SELECT
"bricktracker_individual_part_lots"."id",
"bricktracker_individual_part_lots"."name",
"bricktracker_individual_part_lots"."description",
"bricktracker_individual_part_lots"."created_date",
"bricktracker_individual_part_lots"."storage",
"bricktracker_individual_part_lots"."purchase_location",
"bricktracker_individual_part_lots"."purchase_date",
"bricktracker_individual_part_lots"."purchase_price",
"bricktracker_metadata_storages"."name" AS "storage_name",
"bricktracker_metadata_purchase_locations"."name" AS "purchase_location_name"
FROM "bricktracker_individual_part_lots"
LEFT JOIN "bricktracker_metadata_storages"
ON "bricktracker_individual_part_lots"."storage" IS NOT DISTINCT FROM "bricktracker_metadata_storages"."id"
LEFT JOIN "bricktracker_metadata_purchase_locations"
ON "bricktracker_individual_part_lots"."purchase_location" IS NOT DISTINCT FROM "bricktracker_metadata_purchase_locations"."id"
WHERE "bricktracker_individual_part_lots"."id" = :id
+9
View File
@@ -0,0 +1,9 @@
-- description: Add BrickLink color fields to rebrickable_parts table
BEGIN TRANSACTION;
-- Add BrickLink color fields to the rebrickable_parts table
ALTER TABLE "rebrickable_parts" ADD COLUMN "bricklink_color_id" INTEGER;
ALTER TABLE "rebrickable_parts" ADD COLUMN "bricklink_color_name" TEXT;
COMMIT;
+8
View File
@@ -0,0 +1,8 @@
-- description: Add BrickLink part number field to rebrickable_parts table
BEGIN TRANSACTION;
-- Add BrickLink part number field to the rebrickable_parts table
ALTER TABLE "rebrickable_parts" ADD COLUMN "bricklink_part_num" TEXT;
COMMIT;
+9
View File
@@ -0,0 +1,9 @@
-- description: Add checked field to bricktracker_parts table for part walkthrough tracking
BEGIN TRANSACTION;
-- Add checked field to the bricktracker_parts table
-- This allows users to track which parts they have checked during walkthroughs
ALTER TABLE "bricktracker_parts" ADD COLUMN "checked" BOOLEAN DEFAULT 0;
COMMIT;
+56
View File
@@ -0,0 +1,56 @@
-- Migration 0019: Performance optimization indexes
-- High-impact composite index for problem parts aggregation
-- Used in set listings, statistics, and problem reports
CREATE INDEX IF NOT EXISTS idx_bricktracker_parts_id_missing_damaged
ON bricktracker_parts(id, missing, damaged);
-- Composite index for parts lookup by part and color
-- Used in part listings and filtering operations
CREATE INDEX IF NOT EXISTS idx_bricktracker_parts_part_color_spare
ON bricktracker_parts(part, color, spare);
-- Composite index for set storage filtering
-- Used in set listings filtered by storage location
CREATE INDEX IF NOT EXISTS idx_bricktracker_sets_set_storage
ON bricktracker_sets("set", storage);
-- Search optimization index for set names
-- Improves text search performance on set listings
CREATE INDEX IF NOT EXISTS idx_rebrickable_sets_name_lower
ON rebrickable_sets(LOWER(name));
-- Search optimization index for part names
-- Improves text search performance on part listings
CREATE INDEX IF NOT EXISTS idx_rebrickable_parts_name_lower
ON rebrickable_parts(LOWER(name));
-- Additional indexes for common join patterns
-- Set purchase filtering
CREATE INDEX IF NOT EXISTS idx_bricktracker_sets_purchase_location
ON bricktracker_sets(purchase_location);
-- Parts quantity filtering
CREATE INDEX IF NOT EXISTS idx_bricktracker_parts_quantity
ON bricktracker_parts(quantity);
-- Year-based filtering optimization
CREATE INDEX IF NOT EXISTS idx_rebrickable_sets_year
ON rebrickable_sets(year);
-- Theme-based filtering optimization
CREATE INDEX IF NOT EXISTS idx_rebrickable_sets_theme_id
ON rebrickable_sets(theme_id);
-- Rebrickable sets number and version for sorting
CREATE INDEX IF NOT EXISTS idx_rebrickable_sets_number_version
ON rebrickable_sets(number, version);
-- Purchase date filtering and sorting
CREATE INDEX IF NOT EXISTS idx_bricktracker_sets_purchase_date
ON bricktracker_sets(purchase_date);
-- Minifigures aggregation optimization
CREATE INDEX IF NOT EXISTS idx_bricktracker_minifigures_id_quantity
ON bricktracker_minifigures(id, quantity);
+132
View File
@@ -0,0 +1,132 @@
-- description: Add individual minifigures and individual parts tables
-- Individual minifigures table - tracks individual minifigures not associated with sets
CREATE TABLE IF NOT EXISTS "bricktracker_individual_minifigures" (
"id" TEXT NOT NULL,
"figure" TEXT NOT NULL,
"quantity" INTEGER NOT NULL DEFAULT 1,
"description" TEXT,
"storage" TEXT, -- Storage bin location
"purchase_date" REAL, -- Purchase date
"purchase_location" TEXT, -- Purchase location
"purchase_price" REAL, -- Purchase price
PRIMARY KEY("id"),
FOREIGN KEY("figure") REFERENCES "rebrickable_minifigures"("figure"),
FOREIGN KEY("storage") REFERENCES "bricktracker_metadata_storages"("id"),
FOREIGN KEY("purchase_location") REFERENCES "bricktracker_metadata_purchase_locations"("id")
);
-- Individual minifigure statuses
CREATE TABLE IF NOT EXISTS "bricktracker_individual_minifigure_statuses" (
"id" TEXT NOT NULL,
"status_minifigures_collected" BOOLEAN NOT NULL DEFAULT 0,
"status_set_checked" BOOLEAN NOT NULL DEFAULT 0,
"status_set_collected" BOOLEAN NOT NULL DEFAULT 0,
PRIMARY KEY("id"),
FOREIGN KEY("id") REFERENCES "bricktracker_individual_minifigures"("id")
);
-- Individual minifigure owners
CREATE TABLE IF NOT EXISTS "bricktracker_individual_minifigure_owners" (
"id" TEXT NOT NULL,
PRIMARY KEY("id"),
FOREIGN KEY("id") REFERENCES "bricktracker_individual_minifigures"("id")
);
-- Individual minifigure tags
CREATE TABLE IF NOT EXISTS "bricktracker_individual_minifigure_tags" (
"id" TEXT NOT NULL,
PRIMARY KEY("id"),
FOREIGN KEY("id") REFERENCES "bricktracker_individual_minifigures"("id")
);
-- Parts table for individual minifigures - tracks constituent parts
CREATE TABLE IF NOT EXISTS "bricktracker_individual_minifigure_parts" (
"id" TEXT NOT NULL,
"part" TEXT NOT NULL,
"color" INTEGER NOT NULL,
"spare" BOOLEAN NOT NULL,
"quantity" INTEGER NOT NULL,
"element" INTEGER,
"rebrickable_inventory" INTEGER NOT NULL,
"missing" INTEGER NOT NULL DEFAULT 0,
"damaged" INTEGER NOT NULL DEFAULT 0,
"checked" BOOLEAN DEFAULT 0,
PRIMARY KEY("id", "part", "color", "spare"),
FOREIGN KEY("id") REFERENCES "bricktracker_individual_minifigures"("id"),
FOREIGN KEY("part", "color") REFERENCES "rebrickable_parts"("part", "color_id")
);
-- Individual parts table - tracks individual parts not associated with sets
CREATE TABLE IF NOT EXISTS "bricktracker_individual_parts" (
"id" TEXT NOT NULL,
"part" TEXT NOT NULL,
"color" INTEGER NOT NULL,
"quantity" INTEGER NOT NULL DEFAULT 1,
"description" TEXT,
"storage" TEXT, -- Storage bin location
"purchase_date" REAL, -- Purchase date
"purchase_location" TEXT, -- Purchase location
"purchase_price" REAL, -- Purchase price
PRIMARY KEY("id"),
FOREIGN KEY("part", "color") REFERENCES "rebrickable_parts"("part", "color_id"),
FOREIGN KEY("storage") REFERENCES "bricktracker_metadata_storages"("id"),
FOREIGN KEY("purchase_location") REFERENCES "bricktracker_metadata_purchase_locations"("id")
);
-- Individual part owners
CREATE TABLE IF NOT EXISTS "bricktracker_individual_part_owners" (
"id" TEXT NOT NULL,
PRIMARY KEY("id"),
FOREIGN KEY("id") REFERENCES "bricktracker_individual_parts"("id")
);
-- Individual part tags
CREATE TABLE IF NOT EXISTS "bricktracker_individual_part_tags" (
"id" TEXT NOT NULL,
PRIMARY KEY("id"),
FOREIGN KEY("id") REFERENCES "bricktracker_individual_parts"("id")
);
-- Individual part statuses
CREATE TABLE IF NOT EXISTS "bricktracker_individual_part_statuses" (
"id" TEXT NOT NULL,
"status_minifigures_collected" BOOLEAN NOT NULL DEFAULT 0,
"status_set_checked" BOOLEAN NOT NULL DEFAULT 0,
"status_set_collected" BOOLEAN NOT NULL DEFAULT 0,
PRIMARY KEY("id"),
FOREIGN KEY("id") REFERENCES "bricktracker_individual_parts"("id")
);
-- Indexes for individual minifigures
CREATE INDEX IF NOT EXISTS idx_bricktracker_individual_minifigures_figure
ON bricktracker_individual_minifigures(figure);
CREATE INDEX IF NOT EXISTS idx_bricktracker_individual_minifigures_storage
ON bricktracker_individual_minifigures(storage);
CREATE INDEX IF NOT EXISTS idx_bricktracker_individual_minifigures_purchase_location
ON bricktracker_individual_minifigures(purchase_location);
CREATE INDEX IF NOT EXISTS idx_bricktracker_individual_minifigures_purchase_date
ON bricktracker_individual_minifigures(purchase_date);
-- Indexes for individual minifigure parts
CREATE INDEX IF NOT EXISTS idx_bricktracker_individual_minifigure_parts_id_missing_damaged
ON bricktracker_individual_minifigure_parts(id, missing, damaged);
CREATE INDEX IF NOT EXISTS idx_bricktracker_individual_minifigure_parts_part_color
ON bricktracker_individual_minifigure_parts(part, color);
-- Indexes for individual parts
CREATE INDEX IF NOT EXISTS idx_bricktracker_individual_parts_part_color
ON bricktracker_individual_parts(part, color);
CREATE INDEX IF NOT EXISTS idx_bricktracker_individual_parts_storage
ON bricktracker_individual_parts(storage);
CREATE INDEX IF NOT EXISTS idx_bricktracker_individual_parts_purchase_location
ON bricktracker_individual_parts(purchase_location);
CREATE INDEX IF NOT EXISTS idx_bricktracker_individual_parts_purchase_date
ON bricktracker_individual_parts(purchase_date);
+15
View File
@@ -0,0 +1,15 @@
-- description: Populate missing image_id values in rebrickable_parts from image URLs
-- Extract image_id from image URL for records with 'elements/' path
-- Note: The url_for_image() method now handles extraction on-the-fly for missing values,
-- so this migration only needs to handle the common case to improve performance
-- For images with 'elements/' in the path, extract the element ID (e.g., 300126 from .../elements/300126.jpg)
UPDATE "rebrickable_parts"
SET "image_id" = SUBSTR(
"image",
INSTR("image", 'elements/') + 9,
INSTR(SUBSTR("image", INSTR("image", 'elements/') + 9), '.') - 1
)
WHERE "image" IS NOT NULL
AND ("image_id" IS NULL OR "image_id" = '')
AND "image" LIKE '%elements/%';
+16
View File
@@ -0,0 +1,16 @@
-- description: Create rebrickable_colors translation table for BrickLink color ID mapping
-- This table caches color information from Rebrickable API to avoid repeated API calls
-- and provides mapping between Rebrickable and BrickLink color IDs
CREATE TABLE IF NOT EXISTS "rebrickable_colors" (
"color_id" INTEGER PRIMARY KEY,
"name" TEXT NOT NULL,
"rgb" TEXT,
"is_trans" BOOLEAN,
"bricklink_color_id" INTEGER,
"bricklink_color_name" TEXT
);
-- Create index for faster lookups
CREATE INDEX IF NOT EXISTS "idx_rebrickable_colors_bricklink"
ON "rebrickable_colors"("bricklink_color_id");
+54
View File
@@ -0,0 +1,54 @@
-- description: Add individual part lots system for bulk/cart adding of parts
BEGIN TRANSACTION;
-- Create individual part lots table
CREATE TABLE IF NOT EXISTS "bricktracker_individual_part_lots" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT,
"description" TEXT,
"created_date" REAL NOT NULL,
"storage" TEXT,
"purchase_location" TEXT,
"purchase_date" REAL,
"purchase_price" REAL,
FOREIGN KEY("storage") REFERENCES "bricktracker_metadata_storages"("id") ON DELETE SET NULL,
FOREIGN KEY("purchase_location") REFERENCES "bricktracker_metadata_purchase_locations"("id") ON DELETE SET NULL
);
-- Create index for faster lookups
CREATE INDEX IF NOT EXISTS "idx_individual_part_lots_created_date"
ON "bricktracker_individual_part_lots"("created_date");
-- Add missing/damaged/checked fields to individual parts table
ALTER TABLE "bricktracker_individual_parts"
ADD COLUMN "missing" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "bricktracker_individual_parts"
ADD COLUMN "damaged" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "bricktracker_individual_parts"
ADD COLUMN "checked" BOOLEAN NOT NULL DEFAULT 0;
-- Add lot_id column to individual parts table
ALTER TABLE "bricktracker_individual_parts"
ADD COLUMN "lot_id" TEXT;
CREATE INDEX IF NOT EXISTS "idx_individual_parts_lot_id"
ON "bricktracker_individual_parts"("lot_id");
-- Create lot owners junction table
CREATE TABLE IF NOT EXISTS "bricktracker_individual_part_lot_owners" (
"id" TEXT NOT NULL,
PRIMARY KEY("id"),
FOREIGN KEY("id") REFERENCES "bricktracker_individual_part_lots"("id") ON DELETE CASCADE
);
-- Create lot tags junction table
CREATE TABLE IF NOT EXISTS "bricktracker_individual_part_lot_tags" (
"id" TEXT NOT NULL,
PRIMARY KEY("id"),
FOREIGN KEY("id") REFERENCES "bricktracker_individual_part_lots"("id") ON DELETE CASCADE
);
COMMIT;
+13
View File
@@ -0,0 +1,13 @@
-- description: Add missing indexes for individual part lots optimization
BEGIN TRANSACTION;
-- Add storage index for lots table (for filtering by storage)
CREATE INDEX IF NOT EXISTS "idx_individual_part_lots_storage"
ON "bricktracker_individual_part_lots"("storage");
-- Add purchase location index for lots table (for filtering by purchase location)
CREATE INDEX IF NOT EXISTS "idx_individual_part_lots_purchase_location"
ON "bricktracker_individual_part_lots"("purchase_location");
COMMIT;
+47 -10
View File
@@ -1,10 +1,11 @@
-- Combined query for both set-based and individual minifigures
SELECT SELECT
"bricktracker_minifigures"."quantity", "combined"."quantity",
"rebrickable_minifigures"."figure", "combined"."figure",
"rebrickable_minifigures"."number", "combined"."number",
"rebrickable_minifigures"."number_of_parts", "combined"."number_of_parts",
"rebrickable_minifigures"."name", "combined"."name",
"rebrickable_minifigures"."image", "combined"."image",
{% block total_missing %} {% block total_missing %}
NULL AS "total_missing", -- dummy for order: total_missing NULL AS "total_missing", -- dummy for order: total_missing
{% endblock %} {% endblock %}
@@ -15,12 +16,44 @@ SELECT
NULL AS "total_quantity", -- dummy for order: total_quantity NULL AS "total_quantity", -- dummy for order: total_quantity
{% endblock %} {% endblock %}
{% block total_sets %} {% block total_sets %}
NULL AS "total_sets" -- dummy for order: total_sets NULL AS "total_sets", -- dummy for order: total_sets
{% endblock %} {% endblock %}
FROM "bricktracker_minifigures" {% block total_individual %}
NULL AS "total_individual" -- dummy for order: total_individual
{% endblock %}
FROM (
-- Set-based minifigures
SELECT
"bricktracker_minifigures"."id",
"bricktracker_minifigures"."quantity",
"rebrickable_minifigures"."figure",
"rebrickable_minifigures"."number",
"rebrickable_minifigures"."number_of_parts",
"rebrickable_minifigures"."name",
"rebrickable_minifigures"."image",
"bricktracker_minifigures"."rowid" AS "rowid",
'set' AS "source_type"
FROM "bricktracker_minifigures"
INNER JOIN "rebrickable_minifigures"
ON "bricktracker_minifigures"."figure" IS NOT DISTINCT FROM "rebrickable_minifigures"."figure"
INNER JOIN "rebrickable_minifigures" UNION ALL
ON "bricktracker_minifigures"."figure" IS NOT DISTINCT FROM "rebrickable_minifigures"."figure"
-- Individual minifigures
SELECT
"bricktracker_individual_minifigures"."id",
"bricktracker_individual_minifigures"."quantity",
"rebrickable_minifigures"."figure",
"rebrickable_minifigures"."number",
"rebrickable_minifigures"."number_of_parts",
"rebrickable_minifigures"."name",
"rebrickable_minifigures"."image",
"bricktracker_individual_minifigures"."rowid" AS "rowid",
'individual' AS "source_type"
FROM "bricktracker_individual_minifigures"
INNER JOIN "rebrickable_minifigures"
ON "bricktracker_individual_minifigures"."figure" IS NOT DISTINCT FROM "rebrickable_minifigures"."figure"
) AS "combined"
{% block join %}{% endblock %} {% block join %}{% endblock %}
@@ -35,3 +68,7 @@ ORDER BY {{ order }}
{% if limit %} {% if limit %}
LIMIT {{ limit }} LIMIT {{ limit }}
{% endif %} {% endif %}
{% if offset %}
OFFSET {{ offset }}
{% endif %}
+32 -5
View File
@@ -9,16 +9,22 @@ SUM(IFNULL("problem_join"."total_damaged", 0)) AS "total_damaged",
{% endblock %} {% endblock %}
{% block total_quantity %} {% block total_quantity %}
SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_quantity", SUM(IFNULL("combined"."quantity", 0)) AS "total_quantity",
{% endblock %} {% endblock %}
{% block total_sets %} {% block total_sets %}
IFNULL(COUNT("bricktracker_minifigures"."id"), 0) AS "total_sets" SUM(CASE WHEN "combined"."source_type" = 'set' THEN 1 ELSE 0 END) AS "total_sets",
{% endblock %}
{% block total_individual %}
SUM(CASE WHEN "combined"."source_type" = 'individual' THEN 1 ELSE 0 END) AS "total_individual"
{% endblock %} {% endblock %}
{% block join %} {% block join %}
-- LEFT JOIN + SELECT to avoid messing the total -- LEFT JOIN + SELECT to avoid messing the total
-- Combine parts from both set-based and individual minifigures
LEFT JOIN ( LEFT JOIN (
-- Set-based minifigure parts
SELECT SELECT
"bricktracker_parts"."id", "bricktracker_parts"."id",
"bricktracker_parts"."figure", "bricktracker_parts"."figure",
@@ -29,12 +35,33 @@ LEFT JOIN (
GROUP BY GROUP BY
"bricktracker_parts"."id", "bricktracker_parts"."id",
"bricktracker_parts"."figure" "bricktracker_parts"."figure"
UNION ALL
-- Individual minifigure parts
SELECT
"bricktracker_individual_minifigure_parts"."id",
"combined"."figure",
SUM("bricktracker_individual_minifigure_parts"."missing") AS "total_missing",
SUM("bricktracker_individual_minifigure_parts"."damaged") AS "total_damaged"
FROM "bricktracker_individual_minifigure_parts"
INNER JOIN "bricktracker_individual_minifigures" ON "bricktracker_individual_minifigure_parts"."id" = "bricktracker_individual_minifigures"."id"
INNER JOIN "rebrickable_minifigures" AS "combined" ON "bricktracker_individual_minifigures"."figure" = "combined"."figure"
GROUP BY
"bricktracker_individual_minifigure_parts"."id",
"combined"."figure"
) "problem_join" ) "problem_join"
ON "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "problem_join"."id" ON "combined"."id" IS NOT DISTINCT FROM "problem_join"."id"
AND "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "problem_join"."figure" AND "combined"."figure" IS NOT DISTINCT FROM "problem_join"."figure"
{% endblock %}
{% block where %}
{% if search_query %}
WHERE (LOWER("combined"."name") LIKE LOWER('%{{ search_query }}%'))
{% endif %}
{% endblock %} {% endblock %}
{% block group %} {% block group %}
GROUP BY GROUP BY
"rebrickable_minifigures"."figure" "combined"."figure"
{% endblock %} {% endblock %}
@@ -0,0 +1,122 @@
{% extends 'minifigure/base/base.sql' %}
{% block total_missing %}
SUM(IFNULL("problem_join"."total_missing", 0)) AS "total_missing",
{% endblock %}
{% block total_damaged %}
SUM(IFNULL("problem_join"."total_damaged", 0)) AS "total_damaged",
{% endblock %}
{% block total_quantity %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE
WHEN "combined"."source_type" = 'set' AND "set_owners"."owner_{{ owner_id }}" = 1 THEN IFNULL("combined"."quantity", 0)
WHEN "combined"."source_type" = 'individual' AND "individual_owners"."owner_{{ owner_id }}" = 1 THEN IFNULL("combined"."quantity", 0)
ELSE 0
END) AS "total_quantity",
{% else %}
SUM(IFNULL("combined"."quantity", 0)) AS "total_quantity",
{% endif %}
{% endblock %}
{% block total_sets %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE
WHEN "combined"."source_type" = 'set' AND "set_owners"."owner_{{ owner_id }}" = 1 THEN 1
ELSE 0
END) AS "total_sets",
{% else %}
SUM(CASE WHEN "combined"."source_type" = 'set' THEN 1 ELSE 0 END) AS "total_sets",
{% endif %}
{% endblock %}
{% block total_individual %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE
WHEN "combined"."source_type" = 'individual' AND "individual_owners"."owner_{{ owner_id }}" = 1 THEN 1
ELSE 0
END) AS "total_individual"
{% else %}
SUM(CASE WHEN "combined"."source_type" = 'individual' THEN 1 ELSE 0 END) AS "total_individual"
{% endif %}
{% endblock %}
{% block join %}
-- Join with set owners for set-based minifigures
LEFT JOIN "bricktracker_sets"
ON "combined"."id" = "bricktracker_sets"."id" AND "combined"."source_type" = 'set'
LEFT JOIN "bricktracker_set_owners" AS "set_owners"
ON "bricktracker_sets"."id" = "set_owners"."id"
-- Join with individual minifigure owners for individual minifigures
LEFT JOIN "bricktracker_individual_minifigure_owners" AS "individual_owners"
ON "combined"."id" = "individual_owners"."id" AND "combined"."source_type" = 'individual'
-- LEFT JOIN + SELECT to avoid messing the total
LEFT JOIN (
-- Set-based minifigure parts
SELECT
"bricktracker_parts"."id",
"bricktracker_parts"."figure",
{% if owner_id and owner_id != 'all' %}
SUM(CASE WHEN "owner_parts"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."missing" ELSE 0 END) AS "total_missing",
SUM(CASE WHEN "owner_parts"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."damaged" ELSE 0 END) AS "total_damaged"
{% else %}
SUM("bricktracker_parts"."missing") AS "total_missing",
SUM("bricktracker_parts"."damaged") AS "total_damaged"
{% endif %}
FROM "bricktracker_parts"
INNER JOIN "bricktracker_sets" AS "parts_sets"
ON "bricktracker_parts"."id" = "parts_sets"."id"
LEFT JOIN "bricktracker_set_owners" AS "owner_parts"
ON "parts_sets"."id" = "owner_parts"."id"
WHERE "bricktracker_parts"."figure" IS NOT NULL
GROUP BY
"bricktracker_parts"."id",
"bricktracker_parts"."figure"
UNION ALL
-- Individual minifigure parts
SELECT
"bricktracker_individual_minifigure_parts"."id",
"bricktracker_individual_minifigures"."figure",
{% if owner_id and owner_id != 'all' %}
SUM(CASE WHEN "owner_individual"."owner_{{ owner_id }}" = 1 THEN "bricktracker_individual_minifigure_parts"."missing" ELSE 0 END) AS "total_missing",
SUM(CASE WHEN "owner_individual"."owner_{{ owner_id }}" = 1 THEN "bricktracker_individual_minifigure_parts"."damaged" ELSE 0 END) AS "total_damaged"
{% else %}
SUM("bricktracker_individual_minifigure_parts"."missing") AS "total_missing",
SUM("bricktracker_individual_minifigure_parts"."damaged") AS "total_damaged"
{% endif %}
FROM "bricktracker_individual_minifigure_parts"
INNER JOIN "bricktracker_individual_minifigures"
ON "bricktracker_individual_minifigure_parts"."id" = "bricktracker_individual_minifigures"."id"
LEFT JOIN "bricktracker_individual_minifigure_owners" AS "owner_individual"
ON "bricktracker_individual_minifigures"."id" = "owner_individual"."id"
GROUP BY
"bricktracker_individual_minifigure_parts"."id",
"bricktracker_individual_minifigures"."figure"
) "problem_join"
ON "combined"."id" = "problem_join"."id"
AND "combined"."figure" = "problem_join"."figure"
{% endblock %}
{% block where %}
{% set conditions = [] %}
{% if owner_id and owner_id != 'all' %}
{% set _ = conditions.append('(("combined"."source_type" = \'set\' AND "set_owners"."owner_' ~ owner_id ~ '" = 1) OR ("combined"."source_type" = \'individual\' AND "individual_owners"."owner_' ~ owner_id ~ '" = 1))') %}
{% endif %}
{% if search_query %}
{% set _ = conditions.append('(LOWER("combined"."name") LIKE LOWER(\'%' ~ search_query ~ '%\'))') %}
{% endif %}
{% if conditions %}
WHERE {{ conditions | join(' AND ') }}
{% endif %}
{% endblock %}
{% block group %}
GROUP BY
"combined"."figure"
{% endblock %}
@@ -1,28 +1,59 @@
{% extends 'minifigure/base/base.sql' %} {% extends 'minifigure/base/base.sql' %}
{% block total_damaged %} {% block total_damaged %}
SUM("bricktracker_parts"."damaged") AS "total_damaged", SUM("parts_combined"."damaged") AS "total_damaged",
{% endblock %} {% endblock %}
{% block join %} {% block join %}
LEFT JOIN "bricktracker_parts" -- Join with parts from both set-based and individual minifigures
ON "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "bricktracker_parts"."id" LEFT JOIN (
AND "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "bricktracker_parts"."figure" SELECT
"bricktracker_parts"."id",
"bricktracker_parts"."figure",
"bricktracker_parts"."damaged"
FROM "bricktracker_parts"
UNION ALL
SELECT
"bricktracker_individual_minifigure_parts"."id",
"bricktracker_individual_minifigures"."figure",
"bricktracker_individual_minifigure_parts"."damaged"
FROM "bricktracker_individual_minifigure_parts"
INNER JOIN "bricktracker_individual_minifigures"
ON "bricktracker_individual_minifigure_parts"."id" = "bricktracker_individual_minifigures"."id"
) AS "parts_combined"
ON "combined"."id" IS NOT DISTINCT FROM "parts_combined"."id"
AND "combined"."figure" IS NOT DISTINCT FROM "parts_combined"."figure"
{% endblock %} {% endblock %}
{% block where %} {% block where %}
WHERE "rebrickable_minifigures"."figure" IN ( WHERE "combined"."figure" IN (
SELECT "bricktracker_parts"."figure" -- Find figures with damaged parts from both sources
FROM "bricktracker_parts" SELECT "figure"
WHERE "bricktracker_parts"."part" IS NOT DISTINCT FROM :part FROM (
AND "bricktracker_parts"."color" IS NOT DISTINCT FROM :color SELECT "bricktracker_parts"."figure"
AND "bricktracker_parts"."figure" IS NOT NULL FROM "bricktracker_parts"
AND "bricktracker_parts"."damaged" > 0 WHERE "bricktracker_parts"."part" IS NOT DISTINCT FROM :part
GROUP BY "bricktracker_parts"."figure" AND "bricktracker_parts"."color" IS NOT DISTINCT FROM :color
AND "bricktracker_parts"."figure" IS NOT NULL
AND "bricktracker_parts"."damaged" > 0
UNION
SELECT "bricktracker_individual_minifigures"."figure"
FROM "bricktracker_individual_minifigure_parts"
INNER JOIN "bricktracker_individual_minifigures"
ON "bricktracker_individual_minifigure_parts"."id" = "bricktracker_individual_minifigures"."id"
WHERE "bricktracker_individual_minifigure_parts"."part" IS NOT DISTINCT FROM :part
AND "bricktracker_individual_minifigure_parts"."color" IS NOT DISTINCT FROM :color
AND "bricktracker_individual_minifigure_parts"."damaged" > 0
) AS "damaged_figures"
GROUP BY "figure"
) )
{% endblock %} {% endblock %}
{% block group %} {% block group %}
GROUP BY GROUP BY
"rebrickable_minifigures"."figure" "combined"."figure"
{% endblock %} {% endblock %}
@@ -1,5 +1,5 @@
{% extends 'minifigure/base/base.sql' %} {% extends 'minifigure/base/base.sql' %}
{% block where %} {% block where %}
WHERE "bricktracker_minifigures"."id" IS NOT DISTINCT FROM :id WHERE "combined"."id" IS NOT DISTINCT FROM :id AND "combined"."source_type" = 'set'
{% endblock %} {% endblock %}
+26 -7
View File
@@ -1,21 +1,40 @@
{% extends 'minifigure/base/base.sql' %} {% extends 'minifigure/base/base.sql' %}
{% block total_missing %} {% block total_missing %}
SUM("bricktracker_parts"."missing") AS "total_missing", SUM("parts_combined"."missing") AS "total_missing",
{% endblock %} {% endblock %}
{% block total_damaged %} {% block total_damaged %}
SUM("bricktracker_parts"."damaged") AS "total_damaged", SUM("parts_combined"."damaged") AS "total_damaged",
{% endblock %} {% endblock %}
{% block join %} {% block join %}
LEFT JOIN "bricktracker_parts" -- Join with parts from both set-based and individual minifigures
ON "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "bricktracker_parts"."id" LEFT JOIN (
AND "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "bricktracker_parts"."figure" SELECT
"bricktracker_parts"."id",
"bricktracker_parts"."figure",
"bricktracker_parts"."missing",
"bricktracker_parts"."damaged"
FROM "bricktracker_parts"
UNION ALL
SELECT
"bricktracker_individual_minifigure_parts"."id",
"bricktracker_individual_minifigures"."figure",
"bricktracker_individual_minifigure_parts"."missing",
"bricktracker_individual_minifigure_parts"."damaged"
FROM "bricktracker_individual_minifigure_parts"
INNER JOIN "bricktracker_individual_minifigures"
ON "bricktracker_individual_minifigure_parts"."id" = "bricktracker_individual_minifigures"."id"
) AS "parts_combined"
ON "combined"."id" IS NOT DISTINCT FROM "parts_combined"."id"
AND "combined"."figure" IS NOT DISTINCT FROM "parts_combined"."figure"
{% endblock %} {% endblock %}
{% block group %} {% block group %}
GROUP BY GROUP BY
"rebrickable_minifigures"."figure", "combined"."figure",
"bricktracker_minifigures"."id" "combined"."id"
{% endblock %} {% endblock %}
@@ -1,28 +1,59 @@
{% extends 'minifigure/base/base.sql' %} {% extends 'minifigure/base/base.sql' %}
{% block total_missing %} {% block total_missing %}
SUM("bricktracker_parts"."missing") AS "total_missing", SUM("parts_combined"."missing") AS "total_missing",
{% endblock %} {% endblock %}
{% block join %} {% block join %}
LEFT JOIN "bricktracker_parts" -- Join with parts from both set-based and individual minifigures
ON "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "bricktracker_parts"."id" LEFT JOIN (
AND "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "bricktracker_parts"."figure" SELECT
"bricktracker_parts"."id",
"bricktracker_parts"."figure",
"bricktracker_parts"."missing"
FROM "bricktracker_parts"
UNION ALL
SELECT
"bricktracker_individual_minifigure_parts"."id",
"bricktracker_individual_minifigures"."figure",
"bricktracker_individual_minifigure_parts"."missing"
FROM "bricktracker_individual_minifigure_parts"
INNER JOIN "bricktracker_individual_minifigures"
ON "bricktracker_individual_minifigure_parts"."id" = "bricktracker_individual_minifigures"."id"
) AS "parts_combined"
ON "combined"."id" IS NOT DISTINCT FROM "parts_combined"."id"
AND "combined"."figure" IS NOT DISTINCT FROM "parts_combined"."figure"
{% endblock %} {% endblock %}
{% block where %} {% block where %}
WHERE "rebrickable_minifigures"."figure" IN ( WHERE "combined"."figure" IN (
SELECT "bricktracker_parts"."figure" -- Find figures with missing parts from both sources
FROM "bricktracker_parts" SELECT "figure"
WHERE "bricktracker_parts"."part" IS NOT DISTINCT FROM :part FROM (
AND "bricktracker_parts"."color" IS NOT DISTINCT FROM :color SELECT "bricktracker_parts"."figure"
AND "bricktracker_parts"."figure" IS NOT NULL FROM "bricktracker_parts"
AND "bricktracker_parts"."missing" > 0 WHERE "bricktracker_parts"."part" IS NOT DISTINCT FROM :part
GROUP BY "bricktracker_parts"."figure" AND "bricktracker_parts"."color" IS NOT DISTINCT FROM :color
AND "bricktracker_parts"."figure" IS NOT NULL
AND "bricktracker_parts"."missing" > 0
UNION
SELECT "bricktracker_individual_minifigures"."figure"
FROM "bricktracker_individual_minifigure_parts"
INNER JOIN "bricktracker_individual_minifigures"
ON "bricktracker_individual_minifigure_parts"."id" = "bricktracker_individual_minifigures"."id"
WHERE "bricktracker_individual_minifigure_parts"."part" IS NOT DISTINCT FROM :part
AND "bricktracker_individual_minifigure_parts"."color" IS NOT DISTINCT FROM :color
AND "bricktracker_individual_minifigure_parts"."missing" > 0
) AS "missing_figures"
GROUP BY "figure"
) )
{% endblock %} {% endblock %}
{% block group %} {% block group %}
GROUP BY GROUP BY
"rebrickable_minifigures"."figure" "combined"."figure"
{% endblock %} {% endblock %}

Some files were not shown because too many files have changed in this diff Show More