Compare commits

..

24 Commits

Author SHA1 Message Date
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: #107
2025-10-02 13:36:31 +02:00
113 changed files with 5345 additions and 354 deletions
+42 -9
View File
@@ -32,6 +32,11 @@
# Default: https://www.bricklink.com/v2/catalog/catalogitem.page?P={part}&C={color}
# 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
# Default: false
# BK_BRICKLINK_LINKS=true
@@ -56,6 +61,10 @@
# Default: 25
# 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.
# Default:
# Legacy name: DOMAIN_NAME
@@ -97,6 +106,14 @@
# Default: false
# 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.
# Default: false
# BK_HIDE_ALL_INSTRUCTIONS=true
@@ -105,6 +122,13 @@
# Default: false
# 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: Hide the 'Parts' entry from the menu. Does not disable the route.
# Default: false
# BK_HIDE_ALL_PARTS=true
@@ -147,12 +171,15 @@
# BK_HIDE_WISHES=true
# 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:
# - "rebrickable_minifigures"."figure": minifigure ID (fig-xxxxx)
# - "rebrickable_minifigures"."number": minifigure ID as an integer (xxxxx)
# - "rebrickable_minifigures"."name": minifigure name
# Default: "rebrickable_minifigures"."name" ASC
# BK_MINIFIGURES_DEFAULT_ORDER="rebrickable_minifigures"."name" ASC
# - "combined"."figure": minifigure ID (fig-xxxxx)
# - "combined"."number": minifigure ID as an integer (xxxxx)
# - "combined"."name": minifigure name
# - "combined"."rowid": insertion order (for both set and individual minifigures)
# 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
# Default: minifigs
@@ -165,14 +192,16 @@
# BK_NO_THREADED_SOCKET=true
# 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:
# - "bricktracker_parts"."part": part number
# - "bricktracker_parts"."spare": part is a spare part
# - "combined"."part": part number
# - "combined"."spare": part is a spare part (use "combined" not "bricktracker_parts")
# - "rebrickable_parts"."name": part name
# - "rebrickable_parts"."color_name": part color name
# - "total_missing": number of missing parts
# Default: "rebrickable_parts"."name" ASC, "rebrickable_parts"."color_name" ASC, "bricktracker_parts"."spare" ASC
# BK_PARTS_DEFAULT_ORDER="total_missing" DESC, "rebrickable_parts"."name"."name" ASC
# Default: "rebrickable_parts"."name" ASC, "rebrickable_parts"."color_name" ASC, "combined"."spare" 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
# Default: parts
@@ -350,6 +379,10 @@
# Default: false
# 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
# Default: false
# BK_SKIP_SPARE_PARTS=true
+3
View File
@@ -33,3 +33,6 @@ vitepress/
# Local data
offline/
TODO.md
run-local.sh
test-server.sh
+47 -1
View File
@@ -62,7 +62,53 @@
- 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**
+2
View File
@@ -29,6 +29,7 @@ from bricktracker.views.error import error_404
from bricktracker.views.index import index_page
from bricktracker.views.instructions import instructions_page
from bricktracker.views.login import login_page
from bricktracker.views.individual_minifigure import individual_minifigure_page
from bricktracker.views.minifigure import minifigure_page
from bricktracker.views.part import part_page
from bricktracker.views.set import set_page
@@ -80,6 +81,7 @@ def setup_app(app: Flask) -> None:
app.register_blueprint(index_page)
app.register_blueprint(instructions_page)
app.register_blueprint(login_page)
app.register_blueprint(individual_minifigure_page)
app.register_blueprint(minifigure_page)
app.register_blueprint(part_page)
app.register_blueprint(set_page)
+8 -3
View File
@@ -11,11 +11,14 @@ CONFIG: Final[list[dict[str, Any]]] = [
{'n': 'AUTHENTICATION_PASSWORD', 'd': ''},
{'n': 'AUTHENTICATION_KEY', 'd': ''},
{'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': 'DATABASE_PATH', 'd': './app.db'},
{'n': 'DATABASE_TIMESTAMP_FORMAT', 'd': '%Y-%m-%d-%H-%M-%S'},
{'n': 'DEBUG', 'c': bool},
{'n': 'DEFAULT_TABLE_PER_PAGE', 'd': 25, 'c': int},
{'n': 'DESCRIPTION_BADGE_MAX_LENGTH', 'd': 15, 'c': int},
{'n': 'DISABLE_INDIVIDUAL_MINIFIGURES', 'c': bool},
{'n': 'DOMAIN_NAME', 'e': 'DOMAIN_NAME', 'd': ''},
{'n': 'FILE_DATETIME_FORMAT', 'd': '%d/%m/%Y, %H:%M:%S'},
{'n': 'HOST', 'd': '0.0.0.0'},
@@ -25,6 +28,7 @@ CONFIG: Final[list[dict[str, Any]]] = [
{'n': 'HIDE_ADD_SET', 'c': bool},
{'n': 'HIDE_ADD_BULK_SET', '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_MINIFIGURES', 'c': bool},
{'n': 'HIDE_ALL_PARTS', 'c': bool},
@@ -37,15 +41,15 @@ CONFIG: Final[list[dict[str, Any]]] = [
{'n': 'HIDE_TABLE_MISSING_PARTS', 'c': bool},
{'n': 'HIDE_TABLE_CHECKED_PARTS', 'c': bool},
{'n': 'HIDE_WISHES', 'c': bool},
{'n': 'MINIFIGURES_DEFAULT_ORDER', 'd': '"rebrickable_minifigures"."name" ASC'}, # noqa: E501
{'n': 'MINIFIGURES_FOLDER', 'd': 'minifigs', 's': True},
{'n': 'MINIFIGURES_DEFAULT_ORDER', 'd': '"combined"."name" ASC'}, # noqa: E501
{'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': 'PARTS_SERVER_SIDE_PAGINATION', 'c': bool},
{'n': 'SETS_SERVER_SIDE_PAGINATION', 'c': bool},
{'n': 'PARTS_DEFAULT_ORDER', 'd': '"rebrickable_parts"."name" ASC, "rebrickable_parts"."color_name" ASC, "bricktracker_parts"."spare" ASC'}, # noqa: E501
{'n': 'PARTS_DEFAULT_ORDER', 'd': '"rebrickable_parts"."name" ASC, "rebrickable_parts"."color_name" ASC, "combined"."spare" ASC'}, # noqa: E501
{'n': 'PARTS_FOLDER', 'd': 'parts', 's': True},
{'n': 'PARTS_PAGINATION_SIZE_DESKTOP', 'd': 10, 'c': int},
{'n': 'PARTS_PAGINATION_SIZE_MOBILE', 'd': 5, 'c': int},
@@ -81,6 +85,7 @@ CONFIG: Final[list[dict[str, Any]]] = [
{'n': 'SETS_CONSOLIDATION', 'd': False, 'c': bool},
{'n': 'SHOW_GRID_FILTERS', 'c': bool},
{'n': 'SHOW_GRID_SORT', 'c': bool},
{'n': 'SHOW_SETS_DUPLICATE_FILTER', 'd': True, 'c': bool},
{'n': 'SKIP_SPARE_PARTS', 'c': bool},
{'n': 'SOCKET_NAMESPACE', 'd': 'bricksocket'},
{'n': 'SOCKET_PATH', 'd': '/bricksocket/'},
+314
View File
@@ -0,0 +1,314 @@
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_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')
+492
View File
@@ -0,0 +1,492 @@
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)
# 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)
+83 -4
View File
@@ -9,6 +9,7 @@ from .exceptions import DatabaseException, ErrorException, NotFoundException
from .record import BrickRecord
from .sql import BrickSQL
if TYPE_CHECKING:
from .individual_minifigure import IndividualMinifigure
from .set import BrickSet
logger = logging.getLogger(__name__)
@@ -18,16 +19,20 @@ logger = logging.getLogger(__name__)
class BrickMetadata(BrickRecord):
kind: str
# Set state endpoint
set_state_endpoint: str
# Endpoints (optional, not all metadata types use all of these)
set_state_endpoint: str = ''
individual_minifigure_state_endpoint: str = ''
individual_minifigure_value_endpoint: str = ''
# Queries
delete_query: str
insert_query: str
select_query: str
update_field_query: str
update_set_state_query: str
update_set_value_query: str
update_set_state_query: str = ''
update_set_value_query: str = ''
update_individual_minifigure_state_query: str = ''
update_individual_minifigure_value_query: str = ''
def __init__(
self,
@@ -106,6 +111,21 @@ class BrickMetadata(BrickRecord):
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
)
# Select a specific metadata (with an id)
def select_specific(self, id: str, /) -> Self:
# Save the parameters to the fields
@@ -216,6 +236,65 @@ class BrickMetadata(BrickRecord):
return state
# Check if this metadata has a specific individual minifigure
def has_individual_minifigure(
self,
individual_minifigure: 'IndividualMinifigure',
/,
) -> 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}'
# 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_minifigure.fields.id,)).fetchone()
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:
if state is None and json is not None:
state = json.get('value', False)
parameters = self.sql_parameters()
parameters['id'] = individual_minifigure.fields.id
parameters['state'] = state
rows, _ = BrickSQL().execute_and_commit(
self.update_individual_minifigure_state_query,
parameters=parameters,
name=self.as_column(),
)
if rows != 1:
raise DatabaseException('Could not update the {kind} "{name}" state for individual minifigure {figure} ({id})'.format(
kind=self.kind,
name=self.fields.name,
figure=individual_minifigure.fields.figure,
id=individual_minifigure.fields.id,
))
# Info
logger.info('{kind} "{name}" state changed to "{state}" for individual minifigure {figure} ({id})'.format(
kind=self.kind,
name=self.fields.name,
state=state,
figure=individual_minifigure.fields.figure,
id=individual_minifigure.fields.id,
))
return state
# Update the selected value of this metadata item for a set
def update_set_value(
self,
+17 -5
View File
@@ -39,9 +39,10 @@ class BrickMetadataList(BrickRecordList[T]):
# Queries
select_query: str
# Set endpoints
set_state_endpoint: str
set_value_endpoint: str
# List-specific endpoints (for operations on the list itself)
set_state_endpoint: str = ''
set_value_endpoint: str = ''
individual_minifigure_value_endpoint: str = ''
def __init__(
self,
@@ -99,12 +100,15 @@ class BrickMetadataList(BrickRecordList[T]):
# Return the items as columns for a select
@classmethod
def as_columns(cls, /, **kwargs) -> str:
def as_columns(cls, /, table: str | None = None, **kwargs) -> str:
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([
'"{table}"."{column}"'.format(
table=cls.table,
table=table_name,
column=record.as_column(),
)
for record
@@ -184,3 +188,11 @@ class BrickMetadataList(BrickRecordList[T]):
cls.set_value_endpoint,
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,
)
+5 -4
View File
@@ -76,12 +76,13 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
# Field mapping for sorting
field_mapping = {
'name': '"rebrickable_minifigures"."name"',
'parts': '"rebrickable_minifigures"."number_of_parts"',
'name': '"combined"."name"',
'parts': '"combined"."number_of_parts"',
'quantity': '"total_quantity"',
'missing': '"total_missing"',
'damaged': '"total_damaged"',
'sets': '"total_sets"'
'sets': '"total_sets"',
'individual': '"total_individual"'
}
# Use the base pagination method
@@ -112,7 +113,7 @@ class BrickMinifigureList(BrickRecordList[BrickMinifigure]):
if current_app.config['RANDOM']:
order = 'RANDOM()'
else:
order = '"bricktracker_minifigures"."rowid" DESC'
order = '"combined"."rowid" DESC'
self.list(override_query=self.last_query, order=order, limit=limit)
+25
View File
@@ -35,3 +35,28 @@ def parse_set(set: str, /) -> str:
))
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
+104 -2
View File
@@ -181,7 +181,18 @@ class BrickPart(RebrickablePart):
# Compute the url for updating checked state
def url_for_checked(self, /) -> 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.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:
@@ -228,7 +239,19 @@ class BrickPart(RebrickablePart):
# Compute the url for problematic part
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:
figure = self.minifigure.fields.figure
else:
@@ -243,3 +266,82 @@ class BrickPart(RebrickablePart):
spare=self.fields.spare,
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
+18 -1
View File
@@ -25,6 +25,7 @@ class BrickPartList(BrickRecordList[BrickPart]):
all_query: str = 'part/list/all'
all_by_owner_query: str = 'part/list/all_by_owner'
different_color_query = 'part/list/with_different_color'
individual_minifigure_query: str = 'individual_minifigure/part/list/from_instance'
last_query: str = 'part/list/last'
minifigure_query: str = 'part/list/from_minifigure'
problem_query: str = 'part/list/problem'
@@ -212,6 +213,20 @@ class BrickPartList(BrickRecordList[BrickPart]):
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
def from_print(
self,
@@ -306,9 +321,11 @@ class BrickPartList(BrickRecordList[BrickPart]):
def sql_parameters(self, /) -> dict[str, Any]:
parameters: dict[str, Any] = super().sql_parameters()
# Set id
# Set id - prioritize brickset, then check minifigure
if self.brickset is not None:
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,
if self.minifigure is not None:
+9
View File
@@ -179,6 +179,15 @@ class RebrickableSet(BrickRecord):
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
def url_for_refresh(self, /) -> str:
return url_for('set.refresh', set=self.fields.set)
+57 -17
View File
@@ -36,6 +36,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
using_minifigure_query: str = 'set/list/using_minifigure'
using_part_query: str = 'set/list/using_part'
using_storage_query: str = 'set/list/using_storage'
without_storage_query: str = 'set/list/without_storage'
def __init__(self, /):
super().__init__()
@@ -86,6 +87,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
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
@@ -94,7 +96,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
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])
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 = {
@@ -106,6 +108,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
'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(),
@@ -188,13 +191,20 @@ class BrickSetList(BrickRecordList[BrickSet]):
)
# 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,
**filter_context
**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,
**filter_context
**years_context
)
return result, total_count
@@ -382,24 +392,36 @@ class BrickSetList(BrickRecordList[BrickSet]):
def _populate_years_from_filtered_dataset(self, query_name: str, **filter_context) -> None:
"""Populate years list from all available records in filtered dataset"""
try:
# Get all records matching the current filters (not just current page)
unlimited_context = filter_context.copy()
unlimited_context.pop('limit', None)
unlimited_context.pop('offset', None)
# Use a simplified query to get just distinct years
years_context = dict(filter_context)
years_context.pop('limit', None)
years_context.pop('offset', None)
# Query all records for year extraction
all_sets = self._query_sets(query_name, **unlimited_context)
# Use a special lightweight query for years
year_records = super().select(
override_query='set/list/years_only',
**years_context
)
if all_sets.records:
years = set()
for record in all_sets.records:
if hasattr(record, 'fields') and hasattr(record.fields, 'year') and record.fields.year:
years.add(record.fields.year)
# 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
except Exception:
# Final fallback to current page years
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:
@@ -649,10 +671,17 @@ class BrickSetList(BrickRecordList[BrickSet]):
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
def set_metadata_lists(
as_class: bool = False
as_class: bool = False,
hardcoded_statuses_only: bool = False
) -> dict[
str,
Union[
@@ -664,9 +693,20 @@ def set_metadata_lists(
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 {
'brickset_owners': BrickSetOwnerList.list(),
'brickset_purchase_locations': BrickSetPurchaseLocationList.list(as_class=as_class), # noqa: E501
'brickset_statuses': statuses,
'brickset_storages': BrickSetStorageList.list(as_class=as_class),
'brickset_tags': BrickSetTagList.list(),
}
+3 -1
View File
@@ -5,8 +5,9 @@ from .metadata import BrickMetadata
class BrickSetOwner(BrickMetadata):
kind: str = 'owner'
# Set state endpoint
# Endpoints
set_state_endpoint: str = 'set.update_owner'
individual_minifigure_state_endpoint: str = 'individual_minifigure.update_owner'
# Queries
delete_query: str = 'set/metadata/owner/delete'
@@ -14,3 +15,4 @@ class BrickSetOwner(BrickMetadata):
select_query: str = 'set/metadata/owner/select'
update_field_query: str = 'set/metadata/owner/update/field'
update_set_state_query: str = 'set/metadata/owner/update/state'
update_individual_minifigure_state_query: str = 'individual_minifigure/metadata/owner/update/state'
+3
View File
@@ -15,6 +15,9 @@ class BrickSetOwnerList(BrickMetadataList[BrickSetOwner]):
# Queries
select_query = 'set/metadata/owner/list'
# Endpoints
set_state_endpoint: str = 'set.update_owner'
# Instantiate the list with the proper class
@classmethod
def new(cls, /, *, force: bool = False) -> Self:
+4
View File
@@ -5,9 +5,13 @@ from .metadata import BrickMetadata
class BrickSetPurchaseLocation(BrickMetadata):
kind: str = 'purchase location'
# Endpoints
individual_minifigure_value_endpoint: str = 'individual_minifigure.update_purchase_location'
# Queries
delete_query: str = 'set/metadata/purchase_location/delete'
insert_query: str = 'set/metadata/purchase_location/insert'
select_query: str = 'set/metadata/purchase_location/select'
update_field_query: str = 'set/metadata/purchase_location/update/field'
update_set_value_query: str = 'set/metadata/purchase_location/update/value'
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: str = 'set.update_purchase_location'
# Individual minifigure value endpoint
individual_minifigure_value_endpoint: str = 'individual_minifigure.update_purchase_location'
# Load all purchase locations
@classmethod
def all(cls, /) -> Self:
+3 -1
View File
@@ -7,8 +7,9 @@ from .metadata import BrickMetadata
class BrickSetStatus(BrickMetadata):
kind: str = 'status'
# Set state endpoint
# Endpoints
set_state_endpoint: str = 'set.update_status'
individual_minifigure_state_endpoint: str = 'individual_minifigure.update_status'
# Queries
delete_query: str = 'set/metadata/status/delete'
@@ -16,6 +17,7 @@ class BrickSetStatus(BrickMetadata):
select_query: str = 'set/metadata/status/select'
update_field_query: str = 'set/metadata/status/update/field'
update_set_state_query: str = 'set/metadata/status/update/state'
update_individual_minifigure_state_query: str = 'individual_minifigure/metadata/status/update/state'
# Grab data from a form
def from_form(self, form: dict[str, str], /) -> Self:
+3
View File
@@ -15,6 +15,9 @@ class BrickSetStatusList(BrickMetadataList[BrickSetStatus]):
# Queries
select_query = 'set/metadata/status/list'
# Endpoints
set_state_endpoint: str = 'set.update_status'
# Filter the list of set status
def filter(self, all: bool = False) -> list[BrickSetStatus]:
return [
+4
View File
@@ -7,12 +7,16 @@ from flask import url_for
class BrickSetStorage(BrickMetadata):
kind: str = 'storage'
# Endpoints
individual_minifigure_value_endpoint: str = 'individual_minifigure.update_storage'
# Queries
delete_query: str = 'set/metadata/storage/delete'
insert_query: str = 'set/metadata/storage/insert'
select_query: str = 'set/metadata/storage/select'
update_field_query: str = 'set/metadata/storage/update/field'
update_set_value_query: str = 'set/metadata/storage/update/value'
update_individual_minifigure_value_query: str = 'individual_minifigure/metadata/storage/update/value'
# Self url
def url(self, /) -> str:
+3
View File
@@ -20,6 +20,9 @@ class BrickSetStorageList(BrickMetadataList[BrickSetStorage]):
# Set value endpoint
set_value_endpoint: str = 'set.update_storage'
# Individual minifigure value endpoint
individual_minifigure_value_endpoint: str = 'individual_minifigure.update_storage'
# Load all storages
@classmethod
def all(cls, /) -> Self:
+3 -1
View File
@@ -5,8 +5,9 @@ from .metadata import BrickMetadata
class BrickSetTag(BrickMetadata):
kind: str = 'tag'
# Set state endpoint
# Endpoints
set_state_endpoint: str = 'set.update_tag'
individual_minifigure_state_endpoint: str = 'individual_minifigure.update_tag'
# Queries
delete_query: str = 'set/metadata/tag/delete'
@@ -14,3 +15,4 @@ class BrickSetTag(BrickMetadata):
select_query: str = 'set/metadata/tag/select'
update_field_query: str = 'set/metadata/tag/update/field'
update_set_state_query: str = 'set/metadata/tag/update/state'
update_individual_minifigure_state_query: str = 'individual_minifigure/metadata/tag/update/state'
+3
View File
@@ -15,6 +15,9 @@ class BrickSetTagList(BrickMetadataList[BrickSetTag]):
# Queries
select_query: str = 'set/metadata/tag/list'
# Endpoints
set_state_endpoint: str = 'set.update_tag'
# Instantiate the list with the proper class
@classmethod
def new(cls, /, *, force: bool = False) -> Self:
+24
View File
@@ -22,9 +22,12 @@ MESSAGES: Final[dict[str, str]] = {
'DOWNLOAD_INSTRUCTIONS': 'download_instructions',
'DOWNLOAD_PEERON_PAGES': 'download_peeron_pages',
'FAIL': 'fail',
'IMPORT_MINIFIGURE': 'import_minifigure',
'IMPORT_SET': 'import_set',
'LOAD_MINIFIGURE': 'load_minifigure',
'LOAD_PEERON_PAGES': 'load_peeron_pages',
'LOAD_SET': 'load_set',
'MINIFIGURE_LOADED': 'minifigure_loaded',
'PROGRESS': 'progress',
'SET_LOADED': 'set_loaded',
}
@@ -207,6 +210,27 @@ class BrickSocket(object):
BrickSet().load(self, data)
@self.socket.on(MESSAGES['IMPORT_MINIFIGURE'], namespace=self.namespace)
@rebrickable_socket(self)
def import_minifigure(data: dict[str, Any], /) -> None:
logger.debug('Socket: IMPORT_MINIFIGURE={data} (from: {fr})'.format(
data=data,
fr=request.sid, # type: ignore
))
from .individual_minifigure import IndividualMinifigure
IndividualMinifigure().download(self, data)
@self.socket.on(MESSAGES['LOAD_MINIFIGURE'], namespace=self.namespace)
def load_minifigure(data: dict[str, Any], /) -> None:
logger.debug('Socket: LOAD_MINIFIGURE={data} (from: {fr})'.format(
data=data,
fr=request.sid, # type: ignore
))
from .individual_minifigure import IndividualMinifigure
IndividualMinifigure().load(self, data)
# Update the progress auto-incrementing
def auto_progress(
self,
+23
View File
@@ -60,6 +60,29 @@ class BrickSQL(object):
# Grab a 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
try:
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
+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 @@
-- Migration 0020: 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);
+23
View File
@@ -0,0 +1,23 @@
-- Migration 0021: Add existing owner/tag columns to individual minifigure and individual part metadata tables
-- Add owner columns to individual minifigure owners table
ALTER TABLE "bricktracker_individual_minifigure_owners"
ADD COLUMN "owner_32479d0a_cd3c_43c6_aa16_b3f378915b13" BOOLEAN NOT NULL DEFAULT 0;
ALTER TABLE "bricktracker_individual_minifigure_owners"
ADD COLUMN "owner_2f07518d_40e1_4279_b0d0_aa339f195cbf" BOOLEAN NOT NULL DEFAULT 0;
-- Add tag columns to individual minifigure tags table
ALTER TABLE "bricktracker_individual_minifigure_tags"
ADD COLUMN "tag_b1b5c316_5caf_4b82_a085_ac4c7ab9b8db" BOOLEAN NOT NULL DEFAULT 0;
-- Add owner columns to individual part owners table
ALTER TABLE "bricktracker_individual_part_owners"
ADD COLUMN "owner_32479d0a_cd3c_43c6_aa16_b3f378915b13" BOOLEAN NOT NULL DEFAULT 0;
ALTER TABLE "bricktracker_individual_part_owners"
ADD COLUMN "owner_2f07518d_40e1_4279_b0d0_aa339f195cbf" BOOLEAN NOT NULL DEFAULT 0;
-- Add tag columns to individual part tags table
ALTER TABLE "bricktracker_individual_part_tags"
ADD COLUMN "tag_b1b5c316_5caf_4b82_a085_ac4c7ab9b8db" BOOLEAN NOT NULL DEFAULT 0;
+43 -10
View File
@@ -1,10 +1,11 @@
-- Combined query for both set-based and individual minifigures
SELECT
"bricktracker_minifigures"."quantity",
"rebrickable_minifigures"."figure",
"rebrickable_minifigures"."number",
"rebrickable_minifigures"."number_of_parts",
"rebrickable_minifigures"."name",
"rebrickable_minifigures"."image",
"combined"."quantity",
"combined"."figure",
"combined"."number",
"combined"."number_of_parts",
"combined"."name",
"combined"."image",
{% block total_missing %}
NULL AS "total_missing", -- dummy for order: total_missing
{% endblock %}
@@ -15,12 +16,44 @@ SELECT
NULL AS "total_quantity", -- dummy for order: total_quantity
{% endblock %}
{% block total_sets %}
NULL AS "total_sets" -- dummy for order: total_sets
NULL AS "total_sets", -- dummy for order: total_sets
{% 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"
ON "bricktracker_minifigures"."figure" IS NOT DISTINCT FROM "rebrickable_minifigures"."figure"
UNION ALL
-- 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 %}
+27 -6
View File
@@ -9,16 +9,22 @@ SUM(IFNULL("problem_join"."total_damaged", 0)) AS "total_damaged",
{% endblock %}
{% block total_quantity %}
SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_quantity",
SUM(IFNULL("combined"."quantity", 0)) AS "total_quantity",
{% endblock %}
{% 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 %}
{% block join %}
-- LEFT JOIN + SELECT to avoid messing the total
-- Combine parts from both set-based and individual minifigures
LEFT JOIN (
-- Set-based minifigure parts
SELECT
"bricktracker_parts"."id",
"bricktracker_parts"."figure",
@@ -29,18 +35,33 @@ LEFT JOIN (
GROUP BY
"bricktracker_parts"."id",
"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"
ON "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "problem_join"."id"
AND "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "problem_join"."figure"
ON "combined"."id" IS NOT DISTINCT FROM "problem_join"."id"
AND "combined"."figure" IS NOT DISTINCT FROM "problem_join"."figure"
{% endblock %}
{% block where %}
{% if search_query %}
WHERE (LOWER("rebrickable_minifigures"."name") LIKE LOWER('%{{ search_query }}%'))
WHERE (LOWER("combined"."name") LIKE LOWER('%{{ search_query }}%'))
{% endif %}
{% endblock %}
{% block group %}
GROUP BY
"rebrickable_minifigures"."figure"
"combined"."figure"
{% endblock %}
@@ -10,31 +10,53 @@ SUM(IFNULL("problem_join"."total_damaged", 0)) AS "total_damaged",
{% block total_quantity %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN IFNULL("bricktracker_minifigures"."quantity", 0) ELSE 0 END) AS "total_quantity",
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("bricktracker_minifigures"."quantity", 0)) AS "total_quantity",
SUM(IFNULL("combined"."quantity", 0)) AS "total_quantity",
{% endif %}
{% endblock %}
{% block total_sets %}
{% if owner_id and owner_id != 'all' %}
COUNT(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_minifigures"."id" ELSE NULL END) AS "total_sets"
SUM(CASE
WHEN "combined"."source_type" = 'set' AND "set_owners"."owner_{{ owner_id }}" = 1 THEN 1
ELSE 0
END) AS "total_sets",
{% else %}
COUNT("bricktracker_minifigures"."id") AS "total_sets"
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 sets to get owner information
INNER JOIN "bricktracker_sets"
ON "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
-- 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 with set owners (using dynamic columns)
LEFT JOIN "bricktracker_set_owners"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
LEFT JOIN "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",
@@ -47,25 +69,47 @@ LEFT JOIN (
{% endif %}
FROM "bricktracker_parts"
INNER JOIN "bricktracker_sets" AS "parts_sets"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "parts_sets"."id"
ON "bricktracker_parts"."id" = "parts_sets"."id"
LEFT JOIN "bricktracker_set_owners" AS "owner_parts"
ON "parts_sets"."id" IS NOT DISTINCT FROM "owner_parts"."id"
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 "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "problem_join"."id"
AND "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "problem_join"."figure"
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('"bricktracker_set_owners"."owner_' ~ owner_id ~ '" = 1') %}
{% 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("rebrickable_minifigures"."name") LIKE LOWER(\'%' ~ search_query ~ '%\'))') %}
{% set _ = conditions.append('(LOWER("combined"."name") LIKE LOWER(\'%' ~ search_query ~ '%\'))') %}
{% endif %}
{% if conditions %}
WHERE {{ conditions | join(' AND ') }}
@@ -74,5 +118,5 @@ WHERE {{ conditions | join(' AND ') }}
{% block group %}
GROUP BY
"rebrickable_minifigures"."figure"
"combined"."figure"
{% endblock %}
@@ -1,28 +1,59 @@
{% extends 'minifigure/base/base.sql' %}
{% block total_damaged %}
SUM("bricktracker_parts"."damaged") AS "total_damaged",
SUM("parts_combined"."damaged") AS "total_damaged",
{% endblock %}
{% block join %}
LEFT JOIN "bricktracker_parts"
ON "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "bricktracker_parts"."id"
AND "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "bricktracker_parts"."figure"
-- Join with parts from both set-based and individual minifigures
LEFT JOIN (
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 %}
{% block where %}
WHERE "rebrickable_minifigures"."figure" IN (
SELECT "bricktracker_parts"."figure"
FROM "bricktracker_parts"
WHERE "bricktracker_parts"."part" IS NOT DISTINCT FROM :part
AND "bricktracker_parts"."color" IS NOT DISTINCT FROM :color
AND "bricktracker_parts"."figure" IS NOT NULL
AND "bricktracker_parts"."damaged" > 0
GROUP BY "bricktracker_parts"."figure"
WHERE "combined"."figure" IN (
-- Find figures with damaged parts from both sources
SELECT "figure"
FROM (
SELECT "bricktracker_parts"."figure"
FROM "bricktracker_parts"
WHERE "bricktracker_parts"."part" IS NOT DISTINCT FROM :part
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 %}
{% block group %}
GROUP BY
"rebrickable_minifigures"."figure"
"combined"."figure"
{% endblock %}
@@ -1,5 +1,5 @@
{% extends 'minifigure/base/base.sql' %}
{% 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 %}
+26 -7
View File
@@ -1,21 +1,40 @@
{% extends 'minifigure/base/base.sql' %}
{% block total_missing %}
SUM("bricktracker_parts"."missing") AS "total_missing",
SUM("parts_combined"."missing") AS "total_missing",
{% endblock %}
{% block total_damaged %}
SUM("bricktracker_parts"."damaged") AS "total_damaged",
SUM("parts_combined"."damaged") AS "total_damaged",
{% endblock %}
{% block join %}
LEFT JOIN "bricktracker_parts"
ON "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "bricktracker_parts"."id"
AND "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "bricktracker_parts"."figure"
-- Join with parts from both set-based and individual minifigures
LEFT JOIN (
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 %}
{% block group %}
GROUP BY
"rebrickable_minifigures"."figure",
"bricktracker_minifigures"."id"
"combined"."figure",
"combined"."id"
{% endblock %}
@@ -1,28 +1,59 @@
{% extends 'minifigure/base/base.sql' %}
{% block total_missing %}
SUM("bricktracker_parts"."missing") AS "total_missing",
SUM("parts_combined"."missing") AS "total_missing",
{% endblock %}
{% block join %}
LEFT JOIN "bricktracker_parts"
ON "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "bricktracker_parts"."id"
AND "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "bricktracker_parts"."figure"
-- Join with parts from both set-based and individual minifigures
LEFT JOIN (
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 %}
{% block where %}
WHERE "rebrickable_minifigures"."figure" IN (
SELECT "bricktracker_parts"."figure"
FROM "bricktracker_parts"
WHERE "bricktracker_parts"."part" IS NOT DISTINCT FROM :part
AND "bricktracker_parts"."color" IS NOT DISTINCT FROM :color
AND "bricktracker_parts"."figure" IS NOT NULL
AND "bricktracker_parts"."missing" > 0
GROUP BY "bricktracker_parts"."figure"
WHERE "combined"."figure" IN (
-- Find figures with missing parts from both sources
SELECT "figure"
FROM (
SELECT "bricktracker_parts"."figure"
FROM "bricktracker_parts"
WHERE "bricktracker_parts"."part" IS NOT DISTINCT FROM :part
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 %}
{% block group %}
GROUP BY
"rebrickable_minifigures"."figure"
"combined"."figure"
{% endblock %}
@@ -1,21 +1,34 @@
{% extends 'minifigure/base/base.sql' %}
{% block total_quantity %}
SUM("bricktracker_minifigures"."quantity") AS "total_quantity",
SUM("combined"."quantity") AS "total_quantity",
{% endblock %}
{% block where %}
WHERE "rebrickable_minifigures"."figure" IN (
SELECT "bricktracker_parts"."figure"
FROM "bricktracker_parts"
WHERE "bricktracker_parts"."part" IS NOT DISTINCT FROM :part
AND "bricktracker_parts"."color" IS NOT DISTINCT FROM :color
AND "bricktracker_parts"."figure" IS NOT NULL
GROUP BY "bricktracker_parts"."figure"
WHERE "combined"."figure" IN (
-- Find figures from both set-based and individual minifigure parts
SELECT "figure"
FROM (
SELECT "bricktracker_parts"."figure"
FROM "bricktracker_parts"
WHERE "bricktracker_parts"."part" IS NOT DISTINCT FROM :part
AND "bricktracker_parts"."color" IS NOT DISTINCT FROM :color
AND "bricktracker_parts"."figure" IS NOT NULL
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
) AS "parts_figures"
GROUP BY "figure"
)
{% endblock %}
{% block group %}
GROUP BY
"rebrickable_minifigures"."figure"
"combined"."figure"
{% endblock %}
+23 -5
View File
@@ -9,16 +9,22 @@ IFNULL("problem_join"."total_damaged", 0) AS "total_damaged",
{% endblock %}
{% block total_quantity %}
SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_quantity",
SUM(IFNULL("combined"."quantity", 0)) AS "total_quantity",
{% endblock %}
{% block total_sets %}
IFNULL(COUNT(DISTINCT "bricktracker_minifigures"."id"), 0) AS "total_sets"
IFNULL(COUNT(DISTINCT "combined"."id"), 0) AS "total_sets",
{% endblock %}
{% block total_individual %}
IFNULL(COUNT(DISTINCT "combined"."id"), 0) AS "total_individual"
{% endblock %}
{% block join %}
-- LEFT JOIN + SELECT to avoid messing the total
-- Combine parts from both set-based and individual minifigures
LEFT JOIN (
-- Set-based minifigure parts
SELECT
"bricktracker_parts"."figure",
SUM("bricktracker_parts"."missing") AS "total_missing",
@@ -26,15 +32,27 @@ LEFT JOIN (
FROM "bricktracker_parts"
WHERE "bricktracker_parts"."figure" IS NOT DISTINCT FROM :figure
GROUP BY "bricktracker_parts"."figure"
UNION ALL
-- Individual minifigure parts
SELECT
"bricktracker_individual_minifigures"."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"
WHERE "bricktracker_individual_minifigures"."figure" IS NOT DISTINCT FROM :figure
GROUP BY "bricktracker_individual_minifigures"."figure"
) "problem_join"
ON "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "problem_join"."figure"
ON "combined"."figure" IS NOT DISTINCT FROM "problem_join"."figure"
{% endblock %}
{% block where %}
WHERE "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM :figure
WHERE "combined"."figure" IS NOT DISTINCT FROM :figure
{% endblock %}
{% block group %}
GROUP BY
"rebrickable_minifigures"."figure"
"combined"."figure"
{% endblock %}
@@ -1,6 +1,7 @@
{% extends 'minifigure/base/base.sql' %}
{% block where %}
WHERE "bricktracker_minifigures"."id" IS NOT DISTINCT FROM :id
AND "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM :figure
WHERE "combined"."id" IS NOT DISTINCT FROM :id
AND "combined"."figure" IS NOT DISTINCT FROM :figure
AND "combined"."source_type" = 'set'
{% endblock %}
+47 -17
View File
@@ -1,17 +1,14 @@
SELECT
"bricktracker_parts"."id",
"bricktracker_parts"."figure",
"bricktracker_parts"."part",
"bricktracker_parts"."color",
"bricktracker_parts"."spare",
"bricktracker_parts"."quantity",
"bricktracker_parts"."element",
--"bricktracker_parts"."rebrickable_inventory",
"bricktracker_parts"."missing",
"bricktracker_parts"."damaged",
"bricktracker_parts"."checked",
--"rebrickable_parts"."part",
--"rebrickable_parts"."color_id",
"combined"."id",
"combined"."figure",
"combined"."part",
"combined"."color",
"combined"."spare",
"combined"."quantity",
"combined"."element",
"combined"."missing",
"combined"."damaged",
"combined"."checked",
"rebrickable_parts"."color_name",
"rebrickable_parts"."color_rgb",
"rebrickable_parts"."color_transparent",
@@ -19,7 +16,6 @@ SELECT
"rebrickable_parts"."bricklink_color_name",
"rebrickable_parts"."bricklink_part_num",
"rebrickable_parts"."name",
--"rebrickable_parts"."category",
"rebrickable_parts"."image",
"rebrickable_parts"."image_id",
"rebrickable_parts"."url",
@@ -42,11 +38,45 @@ SELECT
{% block total_minifigures %}
NULL AS "total_minifigures" -- dummy for order: total_minifigures
{% endblock %}
FROM "bricktracker_parts"
FROM (
-- Parts from set-based minifigures
SELECT
"bricktracker_parts"."id",
"bricktracker_parts"."figure",
"bricktracker_parts"."part",
"bricktracker_parts"."color",
"bricktracker_parts"."spare",
"bricktracker_parts"."quantity",
"bricktracker_parts"."element",
"bricktracker_parts"."missing",
"bricktracker_parts"."damaged",
"bricktracker_parts"."checked",
'set' AS "source_type"
FROM "bricktracker_parts"
UNION ALL
-- Parts from individual minifigures
SELECT
"bricktracker_individual_minifigure_parts"."id",
"bricktracker_individual_minifigures"."figure",
"bricktracker_individual_minifigure_parts"."part",
"bricktracker_individual_minifigure_parts"."color",
"bricktracker_individual_minifigure_parts"."spare",
"bricktracker_individual_minifigure_parts"."quantity",
"bricktracker_individual_minifigure_parts"."element",
"bricktracker_individual_minifigure_parts"."missing",
"bricktracker_individual_minifigure_parts"."damaged",
"bricktracker_individual_minifigure_parts"."checked",
'individual' AS "source_type"
FROM "bricktracker_individual_minifigure_parts"
INNER JOIN "bricktracker_individual_minifigures"
ON "bricktracker_individual_minifigure_parts"."id" = "bricktracker_individual_minifigures"."id"
) AS "combined"
INNER JOIN "rebrickable_parts"
ON "bricktracker_parts"."part" IS NOT DISTINCT FROM "rebrickable_parts"."part"
AND "bricktracker_parts"."color" IS NOT DISTINCT FROM "rebrickable_parts"."color_id"
ON "combined"."part" IS NOT DISTINCT FROM "rebrickable_parts"."part"
AND "combined"."color" IS NOT DISTINCT FROM "rebrickable_parts"."color_id"
{% block join %}{% endblock %}
+29 -14
View File
@@ -1,42 +1,57 @@
{% extends 'part/base/base.sql' %}
{% block total_missing %}
SUM("bricktracker_parts"."missing") AS "total_missing",
SUM("combined"."missing") AS "total_missing",
{% endblock %}
{% block total_damaged %}
SUM("bricktracker_parts"."damaged") AS "total_damaged",
SUM("combined"."damaged") AS "total_damaged",
{% endblock %}
{% block total_quantity %}
SUM("bricktracker_parts"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)) AS "total_quantity",
SUM("combined"."quantity" * IFNULL("minifigure_quantities"."quantity", 1)) AS "total_quantity",
{% endblock %}
{% block total_sets %}
IFNULL(COUNT(DISTINCT "bricktracker_parts"."id"), 0) AS "total_sets",
IFNULL(COUNT(DISTINCT "combined"."id"), 0) AS "total_sets",
{% endblock %}
{% block total_minifigures %}
SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_minifigures"
SUM(IFNULL("minifigure_quantities"."quantity", 0)) AS "total_minifigures"
{% endblock %}
{% block join %}
LEFT JOIN "bricktracker_minifigures"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_minifigures"."id"
AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure"
-- Join to get minifigure quantities from both set-based and individual minifigures
LEFT JOIN (
SELECT
"bricktracker_minifigures"."id",
"bricktracker_minifigures"."figure",
"bricktracker_minifigures"."quantity"
FROM "bricktracker_minifigures"
UNION ALL
SELECT
"bricktracker_individual_minifigures"."id",
"bricktracker_individual_minifigures"."figure",
"bricktracker_individual_minifigures"."quantity"
FROM "bricktracker_individual_minifigures"
) AS "minifigure_quantities"
ON "combined"."id" IS NOT DISTINCT FROM "minifigure_quantities"."id"
AND "combined"."figure" IS NOT DISTINCT FROM "minifigure_quantities"."figure"
{% endblock %}
{% block where %}
{% set conditions = [] %}
{% if color_id and color_id != 'all' %}
{% set _ = conditions.append('"bricktracker_parts"."color" = ' ~ color_id) %}
{% set _ = conditions.append('"combined"."color" = ' ~ color_id) %}
{% endif %}
{% if search_query %}
{% set search_condition = '(LOWER("rebrickable_parts"."name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("rebrickable_parts"."color_name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("bricktracker_parts"."part") LIKE LOWER(\'%' ~ search_query ~ '%\'))' %}
{% set search_condition = '(LOWER("rebrickable_parts"."name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("rebrickable_parts"."color_name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("combined"."part") LIKE LOWER(\'%' ~ search_query ~ '%\'))' %}
{% set _ = conditions.append(search_condition) %}
{% endif %}
{% if skip_spare_parts %}
{% set _ = conditions.append('"bricktracker_parts"."spare" = 0') %}
{% set _ = conditions.append('"combined"."spare" = 0') %}
{% endif %}
{% if conditions %}
WHERE {{ conditions | join(' AND ') }}
@@ -45,7 +60,7 @@ WHERE {{ conditions | join(' AND ') }}
{% block group %}
GROUP BY
"bricktracker_parts"."part",
"bricktracker_parts"."color",
"bricktracker_parts"."spare"
"combined"."part",
"combined"."color",
"combined"."spare"
{% endblock %}
+64 -24
View File
@@ -2,73 +2,113 @@
{% block total_missing %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."missing" ELSE 0 END) AS "total_missing",
SUM(CASE
WHEN "combined"."source_type" = 'set' AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."missing"
WHEN "combined"."source_type" = 'individual' AND "bricktracker_individual_minifigure_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."missing"
ELSE 0
END) AS "total_missing",
{% else %}
SUM("bricktracker_parts"."missing") AS "total_missing",
SUM("combined"."missing") AS "total_missing",
{% endif %}
{% endblock %}
{% block total_damaged %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."damaged" ELSE 0 END) AS "total_damaged",
SUM(CASE
WHEN "combined"."source_type" = 'set' AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."damaged"
WHEN "combined"."source_type" = 'individual' AND "bricktracker_individual_minifigure_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."damaged"
ELSE 0
END) AS "total_damaged",
{% else %}
SUM("bricktracker_parts"."damaged") AS "total_damaged",
SUM("combined"."damaged") AS "total_damaged",
{% endif %}
{% endblock %}
{% block total_quantity %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1) ELSE 0 END) AS "total_quantity",
SUM(CASE
WHEN "combined"."source_type" = 'set' AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)
WHEN "combined"."source_type" = 'individual' AND "bricktracker_individual_minifigure_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."quantity"
ELSE 0
END) AS "total_quantity",
{% else %}
SUM("bricktracker_parts"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)) AS "total_quantity",
SUM(CASE
WHEN "combined"."source_type" = 'set' THEN "combined"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)
ELSE "combined"."quantity"
END) AS "total_quantity",
{% endif %}
{% endblock %}
{% block total_sets %}
{% if owner_id and owner_id != 'all' %}
COUNT(DISTINCT CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."id" ELSE NULL END) AS "total_sets",
COUNT(DISTINCT CASE
WHEN "combined"."source_type" = 'set' AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."id"
ELSE NULL
END) AS "total_sets",
{% else %}
COUNT(DISTINCT "bricktracker_parts"."id") AS "total_sets",
COUNT(DISTINCT CASE WHEN "combined"."source_type" = 'set' THEN "combined"."id" ELSE NULL END) AS "total_sets",
{% endif %}
{% endblock %}
{% block total_minifigures %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN IFNULL("bricktracker_minifigures"."quantity", 0) ELSE 0 END) AS "total_minifigures"
SUM(CASE
WHEN "combined"."source_type" = 'set' AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN IFNULL("bricktracker_minifigures"."quantity", 0)
WHEN "combined"."source_type" = 'individual' AND "bricktracker_individual_minifigure_owners"."owner_{{ owner_id }}" = 1 THEN 1
ELSE 0
END) AS "total_minifigures"
{% else %}
SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_minifigures"
SUM(CASE
WHEN "combined"."source_type" = 'set' THEN IFNULL("bricktracker_minifigures"."quantity", 0)
WHEN "combined"."source_type" = 'individual' THEN 1
ELSE 0
END) AS "total_minifigures"
{% endif %}
{% endblock %}
{% block join %}
-- Join with sets to get owner information
INNER JOIN "bricktracker_sets"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
-- Left join with sets (for set-based parts)
LEFT JOIN "bricktracker_sets"
ON "combined"."source_type" = 'set'
AND "combined"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
-- Left join with set owners (using dynamic columns)
LEFT JOIN "bricktracker_set_owners"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
ON "combined"."source_type" = 'set'
AND "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
-- Left join with minifigures
-- Left join with set-based minifigures
LEFT JOIN "bricktracker_minifigures"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_minifigures"."id"
AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure"
ON "combined"."source_type" = 'set'
AND "combined"."id" IS NOT DISTINCT FROM "bricktracker_minifigures"."id"
AND "combined"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure"
-- Left join with individual minifigures (for individual parts)
LEFT JOIN "bricktracker_individual_minifigures"
ON "combined"."source_type" = 'individual'
AND "combined"."id" IS NOT DISTINCT FROM "bricktracker_individual_minifigures"."id"
-- Left join with individual minifigure owners (using dynamic columns)
LEFT JOIN "bricktracker_individual_minifigure_owners"
ON "combined"."source_type" = 'individual'
AND "bricktracker_individual_minifigures"."id" IS NOT DISTINCT FROM "bricktracker_individual_minifigure_owners"."id"
{% endblock %}
{% block where %}
{% set conditions = [] %}
{% if owner_id and owner_id != 'all' %}
{% set _ = conditions.append('"bricktracker_set_owners"."owner_' ~ owner_id ~ '" = 1') %}
{% set owner_condition = '(("combined"."source_type" = \'set\' AND "bricktracker_set_owners"."owner_' ~ owner_id ~ '" = 1) OR ("combined"."source_type" = \'individual\' AND "bricktracker_individual_minifigure_owners"."owner_' ~ owner_id ~ '" = 1))' %}
{% set _ = conditions.append(owner_condition) %}
{% endif %}
{% if color_id and color_id != 'all' %}
{% set _ = conditions.append('"bricktracker_parts"."color" = ' ~ color_id) %}
{% set _ = conditions.append('"combined"."color" = ' ~ color_id) %}
{% endif %}
{% if search_query %}
{% set search_condition = '(LOWER("rebrickable_parts"."name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("rebrickable_parts"."color_name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("bricktracker_parts"."part") LIKE LOWER(\'%' ~ search_query ~ '%\'))' %}
{% set search_condition = '(LOWER("rebrickable_parts"."name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("rebrickable_parts"."color_name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("combined"."part") LIKE LOWER(\'%' ~ search_query ~ '%\'))' %}
{% set _ = conditions.append(search_condition) %}
{% endif %}
{% if skip_spare_parts %}
{% set _ = conditions.append('"bricktracker_parts"."spare" = 0') %}
{% set _ = conditions.append('"combined"."spare" = 0') %}
{% endif %}
{% if conditions %}
WHERE {{ conditions | join(' AND ') }}
@@ -77,7 +117,7 @@ WHERE {{ conditions | join(' AND ') }}
{% block group %}
GROUP BY
"bricktracker_parts"."part",
"bricktracker_parts"."color",
"bricktracker_parts"."spare"
"combined"."part",
"combined"."color",
"combined"."spare"
{% endblock %}
+83 -16
View File
@@ -1,21 +1,88 @@
-- Query parts from both set-based and individual minifigures
SELECT
"parts_combined"."id",
"parts_combined"."figure",
"parts_combined"."part",
"parts_combined"."color",
"parts_combined"."spare",
SUM("parts_combined"."quantity") AS "quantity",
"parts_combined"."element",
SUM("parts_combined"."missing") AS "total_missing",
SUM("parts_combined"."damaged") AS "total_damaged",
MAX("parts_combined"."checked") AS "checked",
"rebrickable_parts"."color_name",
"rebrickable_parts"."color_rgb",
"rebrickable_parts"."color_transparent",
"rebrickable_parts"."bricklink_color_id",
"rebrickable_parts"."bricklink_color_name",
"rebrickable_parts"."bricklink_part_num",
"rebrickable_parts"."name",
"rebrickable_parts"."image",
"rebrickable_parts"."image_id",
"rebrickable_parts"."url",
"rebrickable_parts"."print",
NULL AS "total_quantity",
NULL AS "total_spare",
NULL AS "total_sets",
NULL AS "total_minifigures"
FROM (
-- Set-based minifigure parts
SELECT
"bricktracker_parts"."id",
"bricktracker_parts"."figure",
"bricktracker_parts"."part",
"bricktracker_parts"."color",
"bricktracker_parts"."spare",
"bricktracker_parts"."quantity",
"bricktracker_parts"."element",
"bricktracker_parts"."missing",
"bricktracker_parts"."damaged",
"bricktracker_parts"."checked"
FROM "bricktracker_parts"
WHERE "bricktracker_parts"."figure" IS NOT DISTINCT FROM :figure
{% extends 'part/base/base.sql' %}
UNION ALL
{% block total_missing %}
SUM("bricktracker_parts"."missing") AS "total_missing",
{% endblock %}
-- Individual minifigure parts
SELECT
"bricktracker_individual_minifigure_parts"."id",
"bricktracker_individual_minifigures"."figure",
"bricktracker_individual_minifigure_parts"."part",
"bricktracker_individual_minifigure_parts"."color",
"bricktracker_individual_minifigure_parts"."spare",
"bricktracker_individual_minifigure_parts"."quantity",
"bricktracker_individual_minifigure_parts"."element",
"bricktracker_individual_minifigure_parts"."missing",
"bricktracker_individual_minifigure_parts"."damaged",
"bricktracker_individual_minifigure_parts"."checked"
FROM "bricktracker_individual_minifigure_parts"
INNER JOIN "bricktracker_individual_minifigures"
ON "bricktracker_individual_minifigure_parts"."id" = "bricktracker_individual_minifigures"."id"
WHERE "bricktracker_individual_minifigures"."figure" IS NOT DISTINCT FROM :figure
) AS "parts_combined"
{% block total_damaged %}
SUM("bricktracker_parts"."damaged") AS "total_damaged",
{% endblock %}
INNER JOIN "rebrickable_parts"
ON "parts_combined"."part" = "rebrickable_parts"."part"
AND "parts_combined"."color" = "rebrickable_parts"."color_id"
{% block where %}
WHERE "bricktracker_parts"."figure" IS NOT DISTINCT FROM :figure
{% endblock %}
{% block group %}
GROUP BY
"bricktracker_parts"."part",
"bricktracker_parts"."color",
"bricktracker_parts"."spare"
{% endblock %}
"parts_combined"."part",
"parts_combined"."color",
"parts_combined"."spare",
"parts_combined"."element",
"rebrickable_parts"."color_name",
"rebrickable_parts"."color_rgb",
"rebrickable_parts"."color_transparent",
"rebrickable_parts"."bricklink_color_id",
"rebrickable_parts"."bricklink_color_name",
"rebrickable_parts"."bricklink_part_num",
"rebrickable_parts"."name",
"rebrickable_parts"."image",
"rebrickable_parts"."image_id",
"rebrickable_parts"."url",
"rebrickable_parts"."print"
{% if order %}
-- Replace combined/bricktracker_parts references with parts_combined for this query
ORDER BY {{ order | replace('"combined"', '"parts_combined"') | replace('"bricktracker_parts"', '"parts_combined"') }}
{% endif %}
+4 -4
View File
@@ -7,12 +7,12 @@
{% block where %}
WHERE "rebrickable_parts"."print" IS NOT DISTINCT FROM :print
AND "bricktracker_parts"."color" IS NOT DISTINCT FROM :color
AND "bricktracker_parts"."part" IS DISTINCT FROM :part
AND "combined"."color" IS NOT DISTINCT FROM :color
AND "combined"."part" IS DISTINCT FROM :part
{% endblock %}
{% block group %}
GROUP BY
"bricktracker_parts"."part",
"bricktracker_parts"."color"
"combined"."part",
"combined"."color"
{% endblock %}
+63 -24
View File
@@ -2,82 +2,121 @@
{% block total_missing %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."missing" ELSE 0 END) AS "total_missing",
SUM(CASE
WHEN "combined"."source_type" = 'set' AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."missing"
WHEN "combined"."source_type" = 'individual' AND "bricktracker_individual_minifigure_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."missing"
ELSE 0
END) AS "total_missing",
{% else %}
SUM("bricktracker_parts"."missing") AS "total_missing",
SUM("combined"."missing") AS "total_missing",
{% endif %}
{% endblock %}
{% block total_damaged %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."damaged" ELSE 0 END) AS "total_damaged",
SUM(CASE
WHEN "combined"."source_type" = 'set' AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."damaged"
WHEN "combined"."source_type" = 'individual' AND "bricktracker_individual_minifigure_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."damaged"
ELSE 0
END) AS "total_damaged",
{% else %}
SUM("bricktracker_parts"."damaged") AS "total_damaged",
SUM("combined"."damaged") AS "total_damaged",
{% endif %}
{% endblock %}
{% block total_quantity %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1) ELSE 0 END) AS "total_quantity",
SUM(CASE
WHEN "combined"."source_type" = 'set' AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)
WHEN "combined"."source_type" = 'individual' AND "bricktracker_individual_minifigure_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."quantity" * IFNULL("bricktracker_individual_minifigures"."quantity", 1)
ELSE 0
END) AS "total_quantity",
{% else %}
SUM("bricktracker_parts"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)) AS "total_quantity",
SUM(CASE
WHEN "combined"."source_type" = 'set' THEN "combined"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)
WHEN "combined"."source_type" = 'individual' THEN "combined"."quantity" * IFNULL("bricktracker_individual_minifigures"."quantity", 1)
ELSE "combined"."quantity"
END) AS "total_quantity",
{% endif %}
{% endblock %}
{% block total_sets %}
{% if owner_id and owner_id != 'all' %}
COUNT(DISTINCT CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "bricktracker_parts"."id" ELSE NULL END) AS "total_sets",
COUNT(DISTINCT CASE
WHEN "combined"."source_type" = 'set' AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."id"
ELSE NULL
END) AS "total_sets",
{% else %}
COUNT(DISTINCT "bricktracker_parts"."id") AS "total_sets",
COUNT(DISTINCT CASE WHEN "combined"."source_type" = 'set' THEN "combined"."id" ELSE NULL END) AS "total_sets",
{% endif %}
{% endblock %}
{% block total_minifigures %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE WHEN "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN IFNULL("bricktracker_minifigures"."quantity", 0) ELSE 0 END) AS "total_minifigures"
SUM(CASE
WHEN "combined"."source_type" = 'set' AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN IFNULL("bricktracker_minifigures"."quantity", 0)
WHEN "combined"."source_type" = 'individual' AND "bricktracker_individual_minifigure_owners"."owner_{{ owner_id }}" = 1 THEN IFNULL("bricktracker_individual_minifigures"."quantity", 0)
ELSE 0
END) AS "total_minifigures"
{% else %}
SUM(IFNULL("bricktracker_minifigures"."quantity", 0)) AS "total_minifigures"
SUM(CASE
WHEN "combined"."source_type" = 'set' THEN IFNULL("bricktracker_minifigures"."quantity", 0)
WHEN "combined"."source_type" = 'individual' THEN IFNULL("bricktracker_individual_minifigures"."quantity", 0)
ELSE 0
END) AS "total_minifigures"
{% endif %}
{% endblock %}
{% block join %}
-- Join with sets to get owner information
INNER JOIN "bricktracker_sets"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
-- Left join with sets for set-based parts
LEFT JOIN "bricktracker_sets"
ON "combined"."source_type" = 'set'
AND "combined"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
-- Left join with set owners (using dynamic columns)
LEFT JOIN "bricktracker_set_owners"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
-- Left join with minifigures
-- Left join with set-based minifigures
LEFT JOIN "bricktracker_minifigures"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_minifigures"."id"
AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure"
ON "combined"."source_type" = 'set'
AND "combined"."id" IS NOT DISTINCT FROM "bricktracker_minifigures"."id"
AND "combined"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure"
-- Left join with individual minifigures
LEFT JOIN "bricktracker_individual_minifigures"
ON "combined"."source_type" = 'individual'
AND "combined"."id" IS NOT DISTINCT FROM "bricktracker_individual_minifigures"."id"
-- Left join with individual minifigure owners
LEFT JOIN "bricktracker_individual_minifigure_owners"
ON "bricktracker_individual_minifigures"."id" IS NOT DISTINCT FROM "bricktracker_individual_minifigure_owners"."id"
{% endblock %}
{% block where %}
{% set conditions = [] %}
-- Always filter for problematic parts
{% set _ = conditions.append('("bricktracker_parts"."missing" > 0 OR "bricktracker_parts"."damaged" > 0)') %}
{% set _ = conditions.append('("combined"."missing" > 0 OR "combined"."damaged" > 0)') %}
{% if owner_id and owner_id != 'all' %}
{% set _ = conditions.append('"bricktracker_set_owners"."owner_' ~ owner_id ~ '" = 1') %}
{% set owner_condition = '(("combined"."source_type" = \'set\' AND "bricktracker_set_owners"."owner_' ~ owner_id ~ '" = 1) OR ("combined"."source_type" = \'individual\' AND "bricktracker_individual_minifigure_owners"."owner_' ~ owner_id ~ '" = 1))' %}
{% set _ = conditions.append(owner_condition) %}
{% endif %}
{% if color_id and color_id != 'all' %}
{% set _ = conditions.append('"bricktracker_parts"."color" = ' ~ color_id) %}
{% set _ = conditions.append('"combined"."color" = ' ~ color_id) %}
{% endif %}
{% if search_query %}
{% set search_condition = '(LOWER("rebrickable_parts"."name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("rebrickable_parts"."color_name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("bricktracker_parts"."part") LIKE LOWER(\'%' ~ search_query ~ '%\'))' %}
{% set search_condition = '(LOWER("rebrickable_parts"."name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("rebrickable_parts"."color_name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("combined"."part") LIKE LOWER(\'%' ~ search_query ~ '%\'))' %}
{% set _ = conditions.append(search_condition) %}
{% endif %}
{% if skip_spare_parts %}
{% set _ = conditions.append('"bricktracker_parts"."spare" = 0') %}
{% set _ = conditions.append('"combined"."spare" = 0') %}
{% endif %}
WHERE {{ conditions | join(' AND ') }}
{% endblock %}
{% block group %}
GROUP BY
"bricktracker_parts"."part",
"bricktracker_parts"."color",
"bricktracker_parts"."spare"
"combined"."part",
"combined"."color",
"combined"."spare"
{% endblock %}
+4 -4
View File
@@ -2,14 +2,14 @@
{% extends 'part/base/base.sql' %}
{% block total_missing %}
IFNULL("bricktracker_parts"."missing", 0) AS "total_missing",
IFNULL("combined"."missing", 0) AS "total_missing",
{% endblock %}
{% block total_damaged %}
IFNULL("bricktracker_parts"."damaged", 0) AS "total_damaged",
IFNULL("combined"."damaged", 0) AS "total_damaged",
{% endblock %}
{% block where %}
WHERE "bricktracker_parts"."id" IS NOT DISTINCT FROM :id
AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM :figure
WHERE "combined"."id" IS NOT DISTINCT FROM :id
AND "combined"."figure" IS NOT DISTINCT FROM :figure
{% endblock %}
@@ -6,12 +6,12 @@
{% block total_damaged %}{% endblock %}
{% block where %}
WHERE "bricktracker_parts"."color" IS DISTINCT FROM :color
AND "bricktracker_parts"."part" IS NOT DISTINCT FROM :part
WHERE "combined"."color" IS DISTINCT FROM :color
AND "combined"."part" IS NOT DISTINCT FROM :part
{% endblock %}
{% block group %}
GROUP BY
"bricktracker_parts"."part",
"bricktracker_parts"."color"
"combined"."part",
"combined"."color"
{% endblock %}
+28 -11
View File
@@ -1,34 +1,51 @@
{% extends 'part/base/base.sql' %}
{% block total_missing %}
SUM("bricktracker_parts"."missing") AS "total_missing",
SUM("combined"."missing") AS "total_missing",
{% endblock %}
{% block total_damaged %}
SUM("bricktracker_parts"."damaged") AS "total_damaged",
SUM("combined"."damaged") AS "total_damaged",
{% endblock %}
{% block total_quantity %}
SUM((NOT "bricktracker_parts"."spare") * "bricktracker_parts"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)) AS "total_quantity",
SUM((NOT "combined"."spare") * "combined"."quantity" * IFNULL("minifigure_quantities"."quantity", 1)) AS "total_quantity",
{% endblock %}
{% block total_spare %}
SUM("bricktracker_parts"."spare" * "bricktracker_parts"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)) AS "total_spare",
SUM("combined"."spare" * "combined"."quantity" * IFNULL("minifigure_quantities"."quantity", 1)) AS "total_spare",
{% endblock %}
{% block join %}
LEFT JOIN "bricktracker_minifigures"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_minifigures"."id"
AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure"
-- Join to get minifigure quantities from both set-based and individual minifigures
LEFT JOIN (
-- Set-based minifigure quantities
SELECT
"bricktracker_minifigures"."id",
"bricktracker_minifigures"."figure",
"bricktracker_minifigures"."quantity"
FROM "bricktracker_minifigures"
UNION ALL
-- Individual minifigure quantities
SELECT
"bricktracker_individual_minifigures"."id",
"bricktracker_individual_minifigures"."figure",
"bricktracker_individual_minifigures"."quantity"
FROM "bricktracker_individual_minifigures"
) AS "minifigure_quantities"
ON "combined"."id" IS NOT DISTINCT FROM "minifigure_quantities"."id"
AND "combined"."figure" IS NOT DISTINCT FROM "minifigure_quantities"."figure"
{% endblock %}
{% block where %}
WHERE "bricktracker_parts"."part" IS NOT DISTINCT FROM :part
AND "bricktracker_parts"."color" IS NOT DISTINCT FROM :color
WHERE "combined"."part" IS NOT DISTINCT FROM :part
AND "combined"."color" IS NOT DISTINCT FROM :color
{% endblock %}
{% block group %}
GROUP BY
"bricktracker_parts"."part",
"bricktracker_parts"."color"
"combined"."part",
"combined"."color"
{% endblock %}
+10 -10
View File
@@ -1,18 +1,18 @@
{% extends 'part/base/base.sql' %}
{% block where %}
WHERE "bricktracker_parts"."id" IS NOT DISTINCT FROM :id
AND "bricktracker_parts"."figure" IS NOT DISTINCT FROM :figure
AND "bricktracker_parts"."part" IS NOT DISTINCT FROM :part
AND "bricktracker_parts"."color" IS NOT DISTINCT FROM :color
AND "bricktracker_parts"."spare" IS NOT DISTINCT FROM :spare
WHERE "combined"."id" IS NOT DISTINCT FROM :id
AND "combined"."figure" IS NOT DISTINCT FROM :figure
AND "combined"."part" IS NOT DISTINCT FROM :part
AND "combined"."color" IS NOT DISTINCT FROM :color
AND "combined"."spare" IS NOT DISTINCT FROM :spare
{% endblock %}
{% block group %}
GROUP BY
"bricktracker_parts"."id",
"bricktracker_parts"."figure",
"bricktracker_parts"."part",
"bricktracker_parts"."color",
"bricktracker_parts"."spare"
"combined"."id",
"combined"."figure",
"combined"."part",
"combined"."color",
"combined"."spare"
{% endblock %}
@@ -70,4 +70,12 @@ AND EXISTS (
)
{% endif %}
{% endif %}
{% if duplicate_filter %}
AND (
SELECT COUNT(*)
FROM "bricktracker_sets" as "duplicate_check"
WHERE "duplicate_check"."set" = "bricktracker_sets"."set"
) > 1
{% endif %}
{% endblock %}
+10 -4
View File
@@ -147,15 +147,21 @@ AND NOT EXISTS (
GROUP BY "rebrickable_sets"."set"
{% if status_filter or duplicate_filter %}
HAVING 1=1
{% if status_filter %}
{% if status_filter == 'has-missing' %}
HAVING IFNULL(SUM("problem_join"."total_missing"), 0) > 0
AND IFNULL(SUM("problem_join"."total_missing"), 0) > 0
{% elif status_filter == '-has-missing' %}
HAVING IFNULL(SUM("problem_join"."total_missing"), 0) = 0
AND IFNULL(SUM("problem_join"."total_missing"), 0) = 0
{% elif status_filter == 'has-damaged' %}
HAVING IFNULL(SUM("problem_join"."total_damaged"), 0) > 0
AND IFNULL(SUM("problem_join"."total_damaged"), 0) > 0
{% elif status_filter == '-has-damaged' %}
HAVING IFNULL(SUM("problem_join"."total_damaged"), 0) = 0
AND IFNULL(SUM("problem_join"."total_damaged"), 0) = 0
{% endif %}
{% endif %}
{% if duplicate_filter %}
AND COUNT("bricktracker_sets"."id") > 1
{% endif %}
{% endif %}
@@ -0,0 +1,5 @@
{% extends 'set/base/full.sql' %}
{% block where %}
WHERE "bricktracker_sets"."storage" IS NULL
{% endblock %}
+13
View File
@@ -0,0 +1,13 @@
SELECT DISTINCT "rebrickable_sets"."year"
FROM "bricktracker_sets"
INNER JOIN "rebrickable_sets"
ON "bricktracker_sets"."set" IS NOT DISTINCT FROM "rebrickable_sets"."set"
{% block where %}
WHERE 1=1
{% if search_query %}
AND (LOWER("rebrickable_sets"."name") LIKE LOWER('%{{ search_query }}%')
OR LOWER("rebrickable_sets"."set") LIKE LOWER('%{{ search_query }}%'))
{% endif %}
{% endblock %}
@@ -7,6 +7,14 @@ ADD COLUMN "owner_{{ id }}" BOOLEAN NOT NULL DEFAULT 0;
ALTER TABLE "bricktracker_wish_owners"
ADD COLUMN "owner_{{ id }}" BOOLEAN NOT NULL DEFAULT 0;
-- Also inject into individual minifigures
ALTER TABLE "bricktracker_individual_minifigure_owners"
ADD COLUMN "owner_{{ id }}" BOOLEAN NOT NULL DEFAULT 0;
-- Also inject into individual parts
ALTER TABLE "bricktracker_individual_part_owners"
ADD COLUMN "owner_{{ id }}" BOOLEAN NOT NULL DEFAULT 0;
INSERT INTO "bricktracker_metadata_owners" (
"id",
"name"
@@ -1,12 +1,16 @@
{% extends 'set/metadata/storage/base.sql' %}
{% block total_sets %}
IFNULL(COUNT("bricktracker_sets"."id"), 0) AS "total_sets"
IFNULL(COUNT(DISTINCT "bricktracker_sets"."id"), 0) AS "total_sets",
IFNULL(COUNT(DISTINCT "bricktracker_individual_minifigures"."id"), 0) AS "total_individual_minifigures"
{% endblock %}
{% block join %}
LEFT JOIN "bricktracker_sets"
ON "bricktracker_metadata_storages"."id" IS NOT DISTINCT FROM "bricktracker_sets"."storage"
LEFT JOIN "bricktracker_individual_minifigures"
ON "bricktracker_metadata_storages"."id" IS NOT DISTINCT FROM "bricktracker_individual_minifigures"."storage"
{% endblock %}
{% block group %}
@@ -3,6 +3,14 @@ BEGIN TRANSACTION;
ALTER TABLE "bricktracker_set_tags"
ADD COLUMN "tag_{{ id }}" BOOLEAN NOT NULL DEFAULT 0;
-- Also inject into individual minifigures
ALTER TABLE "bricktracker_individual_minifigure_tags"
ADD COLUMN "tag_{{ id }}" BOOLEAN NOT NULL DEFAULT 0;
-- Also inject into individual parts
ALTER TABLE "bricktracker_individual_part_tags"
ADD COLUMN "tag_{{ id }}" BOOLEAN NOT NULL DEFAULT 0;
INSERT INTO "bricktracker_metadata_tags" (
"id",
"name"
+72 -22
View File
@@ -1,33 +1,83 @@
-- Statistics Overview Query
-- Provides statistics for BrickTracker dashboard
-- Statistics Overview Query (Optimized with CTEs)
-- Provides comprehensive statistics for BrickTracker dashboard
-- Performance improved by consolidating subqueries into CTEs
-- Expected impact: 60-80% performance improvement for dashboard loading
WITH
-- Set statistics aggregation
set_stats AS (
SELECT
COUNT(*) AS total_sets,
COUNT(DISTINCT "set") AS unique_sets,
COUNT(CASE WHEN "purchase_price" IS NOT NULL THEN 1 END) AS sets_with_price,
ROUND(SUM("purchase_price"), 2) AS total_cost,
ROUND(AVG("purchase_price"), 2) AS average_cost,
ROUND(MIN("purchase_price"), 2) AS minimum_cost,
ROUND(MAX("purchase_price"), 2) AS maximum_cost,
COUNT(DISTINCT CASE WHEN "storage" IS NOT NULL THEN "storage" END) AS storage_locations_used,
COUNT(DISTINCT CASE WHEN "purchase_location" IS NOT NULL THEN "purchase_location" END) AS purchase_locations_used,
COUNT(CASE WHEN "storage" IS NOT NULL THEN 1 END) AS sets_with_storage,
COUNT(CASE WHEN "purchase_location" IS NOT NULL THEN 1 END) AS sets_with_purchase_location
FROM "bricktracker_sets"
),
-- Part statistics aggregation
part_stats AS (
SELECT
COUNT(*) AS total_part_instances,
SUM("quantity") AS total_parts_count,
COUNT(DISTINCT "part") AS unique_parts,
SUM("missing") AS total_missing_parts,
SUM("damaged") AS total_damaged_parts
FROM "bricktracker_parts"
),
-- Minifigure statistics aggregation
minifig_stats AS (
SELECT
COUNT(*) AS total_minifigure_instances,
SUM("quantity") AS total_minifigures_count,
COUNT(DISTINCT "figure") AS unique_minifigures
FROM "bricktracker_minifigures"
),
-- Rebrickable sets count (for sets we actually own)
rebrickable_stats AS (
SELECT COUNT(*) AS unique_rebrickable_sets
FROM "rebrickable_sets"
WHERE "set" IN (SELECT DISTINCT "set" FROM "bricktracker_sets")
)
-- Final select combining all statistics
SELECT
-- Basic counts
(SELECT COUNT(*) FROM "bricktracker_sets") AS "total_sets",
(SELECT COUNT(DISTINCT "bricktracker_sets"."set") FROM "bricktracker_sets") AS "unique_sets",
(SELECT COUNT(*) FROM "rebrickable_sets" WHERE "rebrickable_sets"."set" IN (SELECT DISTINCT "set" FROM "bricktracker_sets")) AS "unique_rebrickable_sets",
set_stats.total_sets,
set_stats.unique_sets,
rebrickable_stats.unique_rebrickable_sets,
-- Parts statistics
(SELECT COUNT(*) FROM "bricktracker_parts") AS "total_part_instances",
(SELECT SUM("bricktracker_parts"."quantity") FROM "bricktracker_parts") AS "total_parts_count",
(SELECT COUNT(DISTINCT "bricktracker_parts"."part") FROM "bricktracker_parts") AS "unique_parts",
(SELECT SUM("bricktracker_parts"."missing") FROM "bricktracker_parts") AS "total_missing_parts",
(SELECT SUM("bricktracker_parts"."damaged") FROM "bricktracker_parts") AS "total_damaged_parts",
part_stats.total_part_instances,
part_stats.total_parts_count,
part_stats.unique_parts,
part_stats.total_missing_parts,
part_stats.total_damaged_parts,
-- Minifigures statistics
(SELECT COUNT(*) FROM "bricktracker_minifigures") AS "total_minifigure_instances",
(SELECT SUM("bricktracker_minifigures"."quantity") FROM "bricktracker_minifigures") AS "total_minifigures_count",
(SELECT COUNT(DISTINCT "bricktracker_minifigures"."figure") FROM "bricktracker_minifigures") AS "unique_minifigures",
minifig_stats.total_minifigure_instances,
minifig_stats.total_minifigures_count,
minifig_stats.unique_minifigures,
-- Financial statistics
(SELECT COUNT(*) FROM "bricktracker_sets" WHERE "purchase_price" IS NOT NULL) AS "sets_with_price",
(SELECT ROUND(SUM("purchase_price"), 2) FROM "bricktracker_sets" WHERE "purchase_price" IS NOT NULL) AS "total_cost",
(SELECT ROUND(AVG("purchase_price"), 2) FROM "bricktracker_sets" WHERE "purchase_price" IS NOT NULL) AS "average_cost",
(SELECT ROUND(MIN("purchase_price"), 2) FROM "bricktracker_sets" WHERE "purchase_price" IS NOT NULL) AS "minimum_cost",
(SELECT ROUND(MAX("purchase_price"), 2) FROM "bricktracker_sets" WHERE "purchase_price" IS NOT NULL) AS "maximum_cost",
set_stats.sets_with_price,
set_stats.total_cost,
set_stats.average_cost,
set_stats.minimum_cost,
set_stats.maximum_cost,
-- Storage and location statistics
(SELECT COUNT(DISTINCT "storage") FROM "bricktracker_sets" WHERE "storage" IS NOT NULL) AS "storage_locations_used",
(SELECT COUNT(DISTINCT "purchase_location") FROM "bricktracker_sets" WHERE "purchase_location" IS NOT NULL) AS "purchase_locations_used",
(SELECT COUNT(*) FROM "bricktracker_sets" WHERE "storage" IS NOT NULL) AS "sets_with_storage",
(SELECT COUNT(*) FROM "bricktracker_sets" WHERE "purchase_location" IS NOT NULL) AS "sets_with_purchase_location"
set_stats.storage_locations_used,
set_stats.purchase_locations_used,
set_stats.sets_with_storage,
set_stats.sets_with_purchase_location
FROM set_stats, part_stats, minifig_stats, rebrickable_stats
+1 -1
View File
@@ -1,4 +1,4 @@
from typing import Final
__version__: Final[str] = '1.3.0'
__database_version__: Final[int] = 18
__database_version__: Final[int] = 21
+203 -8
View File
@@ -1,9 +1,11 @@
import logging
from flask import Blueprint, request, render_template
from flask import Blueprint, request, render_template, current_app, jsonify
from flask_login import login_required
from ...configuration_list import BrickConfigurationList
from ...config_manager import ConfigManager
from ...config import CONFIG
from ..exceptions import exception_handler
from ...instructions_list import BrickInstructionsList
from ...rebrickable_image import RebrickableImage
@@ -27,6 +29,68 @@ logger = logging.getLogger(__name__)
admin_page = Blueprint('admin', __name__, url_prefix='/admin')
def get_env_values():
"""Get current environment values, using defaults from config when not set"""
import os
from pathlib import Path
env_values = {}
config_defaults = {}
env_explicit_values = {} # Track which values are explicitly set
# Read .env file if it exists
env_file = Path('.env')
env_from_file = {}
if env_file.exists():
with open(env_file, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#') and '=' in line:
key, value = line.split('=', 1)
env_from_file[key] = value
# Process each config item
for config_item in CONFIG:
env_name = f"BK_{config_item['n']}"
# Store default value (with casting applied)
default_value = config_item.get('d', '')
if 'c' in config_item and default_value is not None:
cast_type = config_item['c']
if cast_type == bool and default_value == '':
default_value = False # Default for booleans is False only if no default specified
elif cast_type == list and isinstance(default_value, str):
default_value = [item.strip() for item in default_value.split(',') if item.strip()]
# For int/other types, keep the original default value
config_defaults[env_name] = default_value
# Check if value is explicitly set in .env file or environment
is_explicitly_set = env_name in env_from_file or env_name in os.environ
env_explicit_values[env_name] = is_explicitly_set
# Get value from .env file, environment, or default
value = env_from_file.get(env_name) or os.environ.get(env_name)
if value is None:
value = default_value
else:
# Apply casting if specified
if 'c' in config_item and value is not None:
cast_type = config_item['c']
if cast_type == bool and isinstance(value, str):
value = value.lower() in ('true', '1', 'yes', 'on')
elif cast_type == int and value != '':
try:
value = int(value)
except (ValueError, TypeError):
value = config_item.get('d', 0)
elif cast_type == list and isinstance(value, str):
value = [item.strip() for item in value.split(',') if item.strip()]
env_values[env_name] = value
return env_values, config_defaults, env_explicit_values
# Admin
@admin_page.route('/', methods=['GET'])
@login_required
@@ -102,18 +166,49 @@ def admin() -> str:
open_tag
)
open_database = (
open_image is None and
open_instructions is None and
open_logout is None and
not open_metadata and
open_retired is None and
open_theme is None
# Get configurable default expanded sections
default_expanded_sections = current_app.config.get('ADMIN_DEFAULT_EXPANDED_SECTIONS', [])
# Helper function to check if section should be expanded
def should_expand(section_name, url_param):
# URL parameter takes priority over default config
if url_param is not None:
return url_param
# Check if section is in default expanded list
return section_name in default_expanded_sections
# Apply configurable default expansion logic
open_database = should_expand('database', request.args.get('open_database', None))
open_image = should_expand('image', open_image)
open_instructions = should_expand('instructions', open_instructions)
open_logout = should_expand('authentication', open_logout)
open_retired = should_expand('retired', open_retired)
open_theme = should_expand('theme', open_theme)
# Metadata sub-sections
open_owner = should_expand('owner', open_owner)
open_purchase_location = should_expand('purchase_location', open_purchase_location)
open_status = should_expand('status', open_status)
open_storage = should_expand('storage', open_storage)
open_tag = should_expand('tag', open_tag)
# Recalculate metadata section based on sub-sections or direct config
open_metadata = (
should_expand('metadata', open_metadata) or
open_owner or
open_purchase_location or
open_status or
open_storage or
open_tag
)
env_values, config_defaults, env_explicit_values = get_env_values()
return render_template(
'admin.html',
configuration=BrickConfigurationList.list(),
env_values=env_values,
config_defaults=config_defaults,
env_explicit_values=env_explicit_values,
database_counters=database_counters,
database_error=request.args.get('database_error'),
database_exception=database_exception,
@@ -149,3 +244,103 @@ def admin() -> str:
tag_error=request.args.get('tag_error'),
theme=BrickThemeList(),
)
# API Endpoints for Configuration Management
@admin_page.route('/api/config/update', methods=['POST'])
@login_required
@exception_handler(__file__)
def update_config() -> str:
"""Update live configuration variables"""
try:
data = request.get_json()
if not data:
return jsonify({
'status': 'error',
'message': 'No JSON data provided'
}), 400
updates = data.get('updates', {})
if not updates:
return jsonify({
'status': 'error',
'message': 'No updates provided'
}), 400
# Use ConfigManager to update live configuration
config_manager = ConfigManager()
results = config_manager.update_config(updates)
# Check if all updates were successful
successful_updates = {k: v for k, v in results.items() if "successfully" in v}
failed_updates = {k: v for k, v in results.items() if "successfully" not in v}
logger.info(f"Configuration update: {len(successful_updates)} successful, {len(failed_updates)} failed")
if failed_updates:
logger.warning(f"Failed updates: {failed_updates}")
return jsonify({
'status': 'success' if not failed_updates else 'partial',
'results': results,
'successful_count': len(successful_updates),
'failed_count': len(failed_updates)
})
except Exception as e:
logger.error(f"Error updating configuration: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
@admin_page.route('/api/config/update-static', methods=['POST'])
@login_required
@exception_handler(__file__)
def update_static_config() -> str:
"""Update static configuration variables (requires restart)"""
try:
data = request.get_json()
if not data:
return jsonify({
'status': 'error',
'message': 'No JSON data provided'
}), 400
updates = data.get('updates', {})
if not updates:
return jsonify({
'status': 'error',
'message': 'No updates provided'
}), 400
# Use ConfigManager to update .env file
config_manager = ConfigManager()
# Update each variable in the .env file
updated_count = 0
for var_name, value in updates.items():
try:
config_manager._update_env_file(var_name, value)
updated_count += 1
logger.info(f"Updated static config: {var_name}")
except Exception as e:
logger.error(f"Failed to update static config {var_name}: {e}")
raise e
logger.info(f"Updated {updated_count} static configuration variables")
return jsonify({
'status': 'success',
'message': f'Successfully updated {updated_count} static configuration variables to .env file',
'updated_count': updated_count
})
except Exception as e:
logger.error(f"Error updating static configuration: {e}")
return jsonify({
'status': 'error',
'message': str(e)
}), 500
-2
View File
@@ -2,7 +2,6 @@ from flask import Blueprint, render_template
from .exceptions import exception_handler
from ..minifigure_list import BrickMinifigureList
from ..set_status_list import BrickSetStatusList
from ..set_list import BrickSetList, set_metadata_lists
index_page = Blueprint('index', __name__)
@@ -15,7 +14,6 @@ def index() -> str:
return render_template(
'index.html',
brickset_collection=BrickSetList().last(),
brickset_statuses=BrickSetStatusList.list(),
minifigure_collection=BrickMinifigureList().last(),
**set_metadata_lists(as_class=True)
)
+281
View File
@@ -0,0 +1,281 @@
import logging
from flask import Blueprint, jsonify, redirect, render_template, request, url_for, Response
from flask_login import login_required
from .exceptions import exception_handler
from ..individual_minifigure import IndividualMinifigure
from ..part import BrickPart
from ..set_list import set_metadata_lists
from ..set_owner_list import BrickSetOwnerList
from ..set_tag_list import BrickSetTagList
from ..set_storage_list import BrickSetStorageList
from ..set_purchase_location_list import BrickSetPurchaseLocationList
from ..sql import BrickSQL
logger = logging.getLogger(__name__)
individual_minifigure_page = Blueprint('individual_minifigure', __name__, url_prefix='/individual-minifigures')
# Individual minifigure instance details/edit
@individual_minifigure_page.route('/<id>')
@exception_handler(__file__)
def details(*, id: str) -> str:
item = IndividualMinifigure().select_by_id(id)
return render_template(
'individual_minifigure/details.html',
item=item,
**set_metadata_lists(as_class=True)
)
# Update individual minifigure instance
@individual_minifigure_page.route('/<id>/update', methods=['POST'])
@exception_handler(__file__)
def update(*, id: str):
item = IndividualMinifigure().select_by_id(id)
# Update basic fields
item.fields.quantity = int(request.form.get('quantity', 1))
item.fields.description = request.form.get('description', '')
item.fields.storage = request.form.get('storage') or None
item.fields.purchase_location = request.form.get('purchase_location') or None
# Update the individual minifigure
from ..sql import BrickSQL
BrickSQL().execute(
'individual_minifigure/update',
parameters={
'id': item.fields.id,
'quantity': item.fields.quantity,
'description': item.fields.description,
'storage': item.fields.storage,
'purchase_location': item.fields.purchase_location,
},
commit=False,
)
# Update owners
owners = request.form.getlist('owners')
for owner in BrickSetOwnerList.list():
owner.update_individual_minifigure_state(item, state=(owner.fields.id in owners))
# Update tags
tags = request.form.getlist('tags')
for tag in BrickSetTagList.list():
tag.update_individual_minifigure_state(item, state=(tag.fields.id in tags))
BrickSQL().commit()
return redirect(url_for('individual_minifigure.details', id=id))
# Update quantity
@individual_minifigure_page.route('/<id>/update/quantity', methods=['POST'])
@login_required
@exception_handler(__file__)
def update_quantity(*, id: str):
item = IndividualMinifigure().select_by_id(id)
item.fields.quantity = int(request.json.get('value', 1))
BrickSQL().execute_and_commit(
'individual_minifigure/update',
parameters={
'id': item.fields.id,
'quantity': item.fields.quantity,
'description': item.fields.description,
'storage': item.fields.storage,
'purchase_location': item.fields.purchase_location,
}
)
return jsonify({'success': True})
# Update description
@individual_minifigure_page.route('/<id>/update/description', methods=['POST'])
@login_required
@exception_handler(__file__)
def update_description(*, id: str):
item = IndividualMinifigure().select_by_id(id)
item.fields.description = request.json.get('value', '')
BrickSQL().execute_and_commit(
'individual_minifigure/update',
parameters={
'id': item.fields.id,
'quantity': item.fields.quantity,
'description': item.fields.description,
'storage': item.fields.storage,
'purchase_location': item.fields.purchase_location,
}
)
return jsonify({'success': True})
# Update owner
@individual_minifigure_page.route('/<id>/update/owner/<metadata_id>', methods=['POST'])
@login_required
@exception_handler(__file__)
def update_owner(*, id: str, metadata_id: str):
item = IndividualMinifigure().select_by_id(id)
owner = BrickSetOwnerList.get(metadata_id)
owner.update_individual_minifigure_state(item, json=request.json)
return jsonify({'success': True})
# Update tag
@individual_minifigure_page.route('/<id>/update/tag/<metadata_id>', methods=['POST'])
@login_required
@exception_handler(__file__)
def update_tag(*, id: str, metadata_id: str):
item = IndividualMinifigure().select_by_id(id)
tag = BrickSetTagList.get(metadata_id)
tag.update_individual_minifigure_state(item, json=request.json)
return jsonify({'success': True})
# Update status
@individual_minifigure_page.route('/<id>/update/status/<metadata_id>', methods=['POST'])
@login_required
@exception_handler(__file__)
def update_status(*, id: str, metadata_id: str):
item = IndividualMinifigure().select_by_id(id)
from ..set_status_list import BrickSetStatusList
status = BrickSetStatusList.get(metadata_id)
status.update_individual_minifigure_state(item, json=request.json)
return jsonify({'success': True})
# Update storage
@individual_minifigure_page.route('/<id>/update/storage', methods=['POST'])
@login_required
@exception_handler(__file__)
def update_storage(*, id: str):
item = IndividualMinifigure().select_by_id(id)
storage_id = request.json.get('value')
BrickSQL().execute_and_commit(
'individual_minifigure/update',
parameters={
'id': item.fields.id,
'quantity': item.fields.quantity,
'description': item.fields.description,
'storage': storage_id if storage_id else None,
'purchase_location': item.fields.purchase_location,
}
)
return jsonify({'success': True})
# Update purchase location
@individual_minifigure_page.route('/<id>/update/purchase_location', methods=['POST'])
@login_required
@exception_handler(__file__)
def update_purchase_location(*, id: str):
item = IndividualMinifigure().select_by_id(id)
location_id = request.json.get('value')
BrickSQL().execute_and_commit(
'individual_minifigure/update',
parameters={
'id': item.fields.id,
'quantity': item.fields.quantity,
'description': item.fields.description,
'storage': item.fields.storage,
'purchase_location': location_id if location_id else None,
}
)
return jsonify({'success': True})
# Update problematic pieces of an individual minifigure
@individual_minifigure_page.route('/<id>/parts/<part>/<int:color>/<int:spare>/<problem>', methods=['POST'])
@login_required
@exception_handler(__file__, json=True)
def problem_part(
*,
id: str,
part: str,
color: int,
spare: int,
problem: str,
) -> Response:
minifigure = IndividualMinifigure().select_by_id(id)
brickpart = BrickPart().select_specific_individual_minifigure(
minifigure,
part,
color,
spare,
)
amount = brickpart.update_problem_individual_minifigure(problem, request.json)
# Info
logger.info('Individual minifigure {figure} ({id}): updated part ({part} color: {color}, spare: {spare}) {problem} count to {amount}'.format(
figure=minifigure.fields.figure,
id=minifigure.fields.id,
part=brickpart.fields.part,
color=brickpart.fields.color,
spare=brickpart.fields.spare,
problem=problem,
amount=amount
))
return jsonify({problem: amount})
# Update checked state of parts
@individual_minifigure_page.route('/<id>/parts/<part>/<int:color>/<int:spare>/checked', methods=['POST'])
@login_required
@exception_handler(__file__, json=True)
def checked_part(
*,
id: str,
part: str,
color: int,
spare: int,
) -> Response:
minifigure = IndividualMinifigure().select_by_id(id)
brickpart = BrickPart().select_specific_individual_minifigure(
minifigure,
part,
color,
spare,
)
checked = brickpart.update_checked_individual_minifigure(request.json)
# Info
logger.info('Individual minifigure {figure} ({id}): updated part ({part} color: {color}, spare: {spare}) checked state to {checked}'.format(
figure=minifigure.fields.figure,
id=minifigure.fields.id,
part=brickpart.fields.part,
color=brickpart.fields.color,
spare=brickpart.fields.spare,
checked=checked
))
return jsonify({'checked': checked})
# Delete individual minifigure instance
@individual_minifigure_page.route('/<id>/delete', methods=['POST'])
@login_required
@exception_handler(__file__)
def delete(*, id: str):
item = IndividualMinifigure().select_by_id(id)
figure = item.fields.figure
item.delete()
return redirect(url_for('minifigure.details', figure=figure))
+2
View File
@@ -3,6 +3,7 @@ from flask import Blueprint, current_app, render_template, request
from .exceptions import exception_handler
from ..minifigure import BrickMinifigure
from ..minifigure_list import BrickMinifigureList
from ..individual_minifigure_list import IndividualMinifigureList
from ..pagination_helper import get_pagination_config, build_pagination_context, get_request_params
from ..set_list import BrickSetList, set_metadata_lists
from ..set_owner_list import BrickSetOwnerList
@@ -72,5 +73,6 @@ def details(*, figure: str) -> str:
using=BrickSetList().using_minifigure(figure),
missing=BrickSetList().missing_minifigure(figure),
damaged=BrickSetList().damaged_minifigure(figure),
individual_instances=IndividualMinifigureList().instances_by_figure(figure),
**set_metadata_lists(as_class=True)
)
+3 -2
View File
@@ -47,6 +47,7 @@ def list() -> str:
storage_filter = request.args.get('storage')
tag_filter = request.args.get('tag')
year_filter = request.args.get('year')
duplicate_filter = request.args.get('duplicate', '').lower() == 'true'
# Get pagination configuration
per_page, is_mobile = get_pagination_config('sets')
@@ -67,6 +68,7 @@ def list() -> str:
storage_filter=storage_filter,
tag_filter=tag_filter,
year_filter=year_filter,
duplicate_filter=duplicate_filter,
use_consolidated=current_app.config['SETS_CONSOLIDATION']
)
@@ -102,6 +104,7 @@ def list() -> str:
'current_storage_filter': storage_filter,
'current_tag_filter': tag_filter,
'current_year_filter': year_filter,
'current_duplicate_filter': duplicate_filter,
'brickset_statuses': BrickSetStatusList.list(),
**set_metadata_lists(as_class=True)
}
@@ -282,7 +285,6 @@ def details(*, id: str) -> str:
item=item,
all_instances=same_set_instances,
open_instructions=request.args.get('open_instructions'),
brickset_statuses=BrickSetStatusList.list(all=True),
**set_metadata_lists(as_class=True)
)
else:
@@ -291,7 +293,6 @@ def details(*, id: str) -> str:
'set.html',
item=item,
open_instructions=request.args.get('open_instructions'),
brickset_statuses=BrickSetStatusList.list(all=True),
**set_metadata_lists(as_class=True)
)
+42
View File
@@ -1,9 +1,11 @@
from flask import Blueprint, render_template
from .exceptions import exception_handler
from ..individual_minifigure_list import IndividualMinifigureList
from ..set_list import BrickSetList, set_metadata_lists
from ..set_storage import BrickSetStorage
from ..set_storage_list import BrickSetStorageList
from ..sql import BrickSQL
storage_page = Blueprint('storage', __name__, url_prefix='/storages')
@@ -12,9 +14,48 @@ storage_page = Blueprint('storage', __name__, url_prefix='/storages')
@storage_page.route('/', methods=['GET'])
@exception_handler(__file__)
def list() -> str:
# Get counts of items with no storage
sql = BrickSQL()
# Count sets with no storage
sets_no_storage_query = 'SELECT COUNT(*) FROM "bricktracker_sets" WHERE "storage" IS NULL'
sql.cursor.execute(sets_no_storage_query)
sets_no_storage = sql.cursor.fetchone()[0]
# Count individual minifigures with no storage
minifigs_no_storage_query = 'SELECT COUNT(*) FROM "bricktracker_individual_minifigures" WHERE "storage" IS NULL'
sql.cursor.execute(minifigs_no_storage_query)
minifigs_no_storage = sql.cursor.fetchone()[0]
return render_template(
'storages.html',
table_collection=BrickSetStorageList.all(),
sets_no_storage=sets_no_storage,
minifigs_no_storage=minifigs_no_storage,
)
# Storage details - no storage
@storage_page.route('/no_storage/details')
@exception_handler(__file__)
def no_storage_details() -> str:
# Create a mock storage object for "no storage"
from ..record import BrickRecord
no_storage = BrickRecord()
no_storage.fields.id = None
no_storage.fields.name = 'Not in a storage location'
# Get sets and individual minifigures with no storage
sets = BrickSetList().without_storage()
individual_minifigures = IndividualMinifigureList().without_storage()
return render_template(
'storage.html',
item=no_storage,
sets=sets,
individual_minifigures=individual_minifigures,
**set_metadata_lists(as_class=True)
)
@@ -28,5 +69,6 @@ def details(*, id: str) -> str:
'storage.html',
item=storage,
sets=BrickSetList().using_storage(storage),
individual_minifigures=IndividualMinifigureList().using_storage(storage),
**set_metadata_lists(as_class=True)
)
+1
View File
@@ -20,3 +20,4 @@ services:
BK_RETIRED_SETS_PATH: /local/retired_sets.csv
BK_SETS_FOLDER: sets
BK_THEMES_PATH: /local/themes.csv
env_file: .env
+1 -1
View File
@@ -66,7 +66,7 @@
| Variable | Purpose | Default | Required |
|----------|---------|----------|-----------|
| `BK_INSTRUCTIONS_FOLDER` | Instructions storage path | `instructions` | No |
| `BK_MINIFIGURES_FOLDER` | Minifigures storage path | `minifigs` | No |
| `BK_MINIFIGURES_FOLDER` | Minifigures storage path | `minifigures` | No |
| `BK_PARTS_FOLDER` | Parts storage path | `parts` | No |
| `BK_SETS_FOLDER` | Sets storage path | `sets` | No |
| `BK_INSTRUCTIONS_ALLOWED_EXTENSIONS` | Allowed instruction file types | `.pdf` | No |
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -a
source .env
set +a
gunicorn --bind "0.0.0.0:3334" "wsgi:application" --worker-class "gevent" --workers 1 --reload "$@"
+80
View File
@@ -0,0 +1,80 @@
// Add page - handles both sets and individual minifigures
document.addEventListener("DOMContentLoaded", () => {
// Get template data from data attributes
const addContainer = document.getElementById('add-set');
if (!addContainer) return;
// Read data from data attributes
const templateData = {
path: addContainer.dataset.path,
namespace: addContainer.dataset.namespace,
messages: {
COMPLETE: addContainer.dataset.msgComplete,
FAIL: addContainer.dataset.msgFail,
IMPORT_SET: addContainer.dataset.msgImportSet,
LOAD_SET: addContainer.dataset.msgLoadSet,
PROGRESS: addContainer.dataset.msgProgress,
SET_LOADED: addContainer.dataset.msgSetLoaded,
IMPORT_MINIFIGURE: addContainer.dataset.msgImportMinifigure,
LOAD_MINIFIGURE: addContainer.dataset.msgLoadMinifigure,
MINIFIGURE_LOADED: addContainer.dataset.msgMinifigureLoaded,
}
};
// Default: create set socket
const setSocket = new BrickSetSocket(
'add',
templateData.path,
templateData.namespace,
{
COMPLETE: templateData.messages.COMPLETE,
FAIL: templateData.messages.FAIL,
IMPORT_SET: templateData.messages.IMPORT_SET,
LOAD_SET: templateData.messages.LOAD_SET,
PROGRESS: templateData.messages.PROGRESS,
SET_LOADED: templateData.messages.SET_LOADED,
},
false,
false
);
// Override the execute method to check for minifigures
const originalExecute = setSocket.execute.bind(setSocket);
let minifigSocket = null;
setSocket.execute = function() {
const inputValue = document.getElementById('add-set').value.trim();
if (inputValue.startsWith('fig-') || inputValue.match(/^fig\d/i)) {
// It's a minifigure - create minifig socket if needed and execute when ready
if (!minifigSocket) {
minifigSocket = new BrickMinifigureSocket(
'add',
templateData.path,
templateData.namespace,
{
COMPLETE: templateData.messages.COMPLETE,
FAIL: templateData.messages.FAIL,
IMPORT_MINIFIGURE: templateData.messages.IMPORT_MINIFIGURE,
LOAD_MINIFIGURE: templateData.messages.LOAD_MINIFIGURE,
MINIFIGURE_LOADED: templateData.messages.MINIFIGURE_LOADED,
PROGRESS: templateData.messages.PROGRESS,
}
);
// Wait for socket to connect before executing
const checkConnection = setInterval(() => {
if (minifigSocket.socket && minifigSocket.socket.connected) {
clearInterval(checkConnection);
minifigSocket.execute();
}
}, 100);
} else {
minifigSocket.execute();
}
} else {
// It's a set - use original execute
originalExecute();
}
};
});
+325
View File
@@ -0,0 +1,325 @@
// Admin Configuration Management
// Handles live environment variable configuration interface
// Initialize form values with current configuration
function initializeConfigValues() {
console.log('Initializing config values with:', window.CURRENT_CONFIG);
Object.keys(window.CURRENT_CONFIG).forEach(varName => {
const value = window.CURRENT_CONFIG[varName];
console.log(`Setting ${varName} = ${value}`);
// Handle live settings (checkboxes and inputs)
const liveToggle = document.getElementById(varName);
if (liveToggle && liveToggle.type === 'checkbox') {
liveToggle.checked = value === true;
console.log(`Set checkbox ${varName} to ${value}`);
}
const liveInputs = document.querySelectorAll(`input[data-var="${varName}"]:not(.config-static)`);
liveInputs.forEach(input => {
if (input.type !== 'checkbox') {
input.value = value !== null && value !== undefined ? value : '';
console.log(`Set input ${varName} to ${input.value}`);
}
});
// Handle static settings
const staticToggle = document.getElementById(`static-${varName}`);
if (staticToggle && staticToggle.type === 'checkbox') {
staticToggle.checked = value === true;
console.log(`Set static checkbox ${varName} to ${value}`);
}
const staticInputs = document.querySelectorAll(`input[data-var="${varName}"].config-static`);
staticInputs.forEach(input => {
if (input.type !== 'checkbox') {
input.value = value !== null && value !== undefined ? value : '';
console.log(`Set static input ${varName} to ${input.value}`);
}
});
});
}
// Handle config change events
function handleConfigChange(element) {
const varName = element.dataset.var;
let newValue;
if (element.type === 'checkbox') {
newValue = element.checked;
} else if (element.type === 'number') {
newValue = parseInt(element.value) || 0;
} else {
newValue = element.value;
}
// Update the badge display
updateConfigBadge(varName, newValue);
// Note: Changes are only saved when "Save All Changes" button is clicked
}
// Update badge display
function updateConfigBadge(varName, value) {
const defaultValue = window.DEFAULT_CONFIG[varName];
const isChanged = JSON.stringify(value) !== JSON.stringify(defaultValue);
// Remove existing badges but keep them inline
const existingBadges = document.querySelectorAll(`[data-badge-var="${varName}"]`);
existingBadges.forEach(badge => {
badge.remove();
});
// Find the label where we should insert new badges
const label = document.querySelector(`label[for="${varName}"], label[for="static-${varName}"]`);
if (!label) return;
// Find the description div (with .text-muted class) to insert badges before it
const descriptionDiv = label.querySelector('.text-muted');
// Create value badge based on new logic
let valueBadge;
if (value === true) {
valueBadge = document.createElement('span');
valueBadge.className = 'badge rounded-pill text-bg-success ms-2';
valueBadge.textContent = 'True';
valueBadge.setAttribute('data-badge-var', varName);
valueBadge.setAttribute('data-badge-type', 'value');
} else if (value === false) {
valueBadge = document.createElement('span');
valueBadge.className = 'badge rounded-pill text-bg-danger ms-2';
valueBadge.textContent = 'False';
valueBadge.setAttribute('data-badge-var', varName);
valueBadge.setAttribute('data-badge-type', 'value');
} else if (JSON.stringify(value) === JSON.stringify(defaultValue)) {
valueBadge = document.createElement('span');
valueBadge.className = 'badge rounded-pill text-bg-light text-dark ms-2';
valueBadge.textContent = `Default: ${defaultValue}`;
valueBadge.setAttribute('data-badge-var', varName);
valueBadge.setAttribute('data-badge-type', 'value');
} else {
// For text/number fields that have been changed, show "Default: X"
valueBadge = document.createElement('span');
valueBadge.className = 'badge rounded-pill text-bg-light text-dark ms-2';
valueBadge.textContent = `Default: ${defaultValue}`;
valueBadge.setAttribute('data-badge-var', varName);
valueBadge.setAttribute('data-badge-type', 'value');
}
// Insert badge before the description div (to keep it on same line as title)
if (descriptionDiv) {
label.insertBefore(valueBadge, descriptionDiv);
} else {
label.appendChild(valueBadge);
}
// Add changed badge if needed
if (isChanged) {
const changedBadge = document.createElement('span');
changedBadge.className = 'badge rounded-pill text-bg-warning ms-1';
changedBadge.textContent = 'Changed';
changedBadge.setAttribute('data-badge-var', varName);
changedBadge.setAttribute('data-badge-type', 'changed');
// Insert changed badge after the value badge
if (descriptionDiv) {
label.insertBefore(changedBadge, descriptionDiv);
} else {
label.appendChild(changedBadge);
}
}
}
// Handle static config save
function saveStaticConfig() {
const staticInputs = document.querySelectorAll('.config-static, .config-static-toggle');
const updates = {};
staticInputs.forEach(input => {
const varName = input.dataset.var;
let value;
if (input.type === 'checkbox') {
value = input.checked;
} else {
value = input.value;
}
updates[varName] = value;
});
console.log('Saving static config:', updates);
// Send to backend via fetch API
fetch('/admin/api/config/update-static', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ updates: updates })
})
.then(response => response.json())
.then(data => {
const statusContainer = document.getElementById('config-status');
if (statusContainer) {
if (data.status === 'success') {
statusContainer.innerHTML = '<div class="alert alert-success"><i class="ri-check-line"></i> Static configuration saved to .env file!</div>';
setTimeout(() => {
statusContainer.innerHTML = '';
}, 3000);
} else {
statusContainer.innerHTML = `<div class="alert alert-danger"><i class="ri-error-warning-line"></i> Error: ${data.message || 'Failed to save static configuration'}</div>`;
}
}
})
.catch(error => {
console.error('Save static config error:', error);
const statusContainer = document.getElementById('config-status');
if (statusContainer) {
statusContainer.innerHTML = '<div class="alert alert-danger"><i class="ri-error-warning-line"></i> Error: Failed to save static configuration</div>';
}
});
}
// Handle button functionality
function setupButtonHandlers() {
// Save All Changes button
const saveAllBtn = document.getElementById('config-save-all');
if (saveAllBtn) {
saveAllBtn.addEventListener('click', () => {
console.log('Save All Changes clicked');
saveLiveConfiguration();
});
}
// Refresh button
const refreshBtn = document.getElementById('config-refresh');
if (refreshBtn) {
refreshBtn.addEventListener('click', () => {
console.log('Refresh clicked');
location.reload();
});
}
// Reset button
const resetBtn = document.getElementById('config-reset');
if (resetBtn) {
resetBtn.addEventListener('click', () => {
console.log('Reset clicked');
if (confirm('Are you sure you want to reset all settings to default values? This action cannot be undone.')) {
resetToDefaults();
}
});
}
// Static config save button
const saveStaticBtn = document.getElementById('config-save-static');
if (saveStaticBtn) {
saveStaticBtn.addEventListener('click', saveStaticConfig);
}
}
// Save live configuration changes
function saveLiveConfiguration() {
const liveInputs = document.querySelectorAll('.config-toggle, .config-number, .config-text');
const updates = {};
liveInputs.forEach(input => {
const varName = input.dataset.var;
let value;
if (input.type === 'checkbox') {
value = input.checked;
} else if (input.type === 'number') {
value = parseInt(input.value) || 0;
} else {
value = input.value;
}
updates[varName] = value;
});
console.log('Saving live configuration:', updates);
// Show status message
const statusContainer = document.getElementById('config-status');
if (statusContainer) {
statusContainer.innerHTML = '<div class="alert alert-info"><i class="ri-loader-4-line"></i> Saving configuration...</div>';
}
// Send to backend via fetch API
fetch('/admin/api/config/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ updates: updates })
})
.then(response => response.json())
.then(data => {
if (statusContainer) {
if (data.status === 'success') {
statusContainer.innerHTML = '<div class="alert alert-success"><i class="ri-check-line"></i> Configuration saved successfully! Reloading page...</div>';
// Reload the page after a short delay
setTimeout(() => {
location.reload();
}, 1000);
} else {
statusContainer.innerHTML = `<div class="alert alert-danger"><i class="ri-error-warning-line"></i> Error: ${data.message || 'Failed to save configuration'}</div>`;
}
}
})
.catch(error => {
console.error('Save error:', error);
if (statusContainer) {
statusContainer.innerHTML = '<div class="alert alert-danger"><i class="ri-error-warning-line"></i> Error: Failed to save configuration</div>';
}
});
}
// Reset all settings to defaults
function resetToDefaults() {
console.log('Resetting to defaults');
// Reset all form inputs
document.querySelectorAll('.config-toggle, .config-number, .config-text').forEach(input => {
if (input.type === 'checkbox') {
input.checked = false;
} else {
input.value = '';
}
});
// Update badges
Object.keys(window.CURRENT_CONFIG).forEach(varName => {
updateConfigBadge(varName, null);
});
// Show status message
const statusContainer = document.getElementById('config-status');
if (statusContainer) {
statusContainer.innerHTML = '<div class="alert alert-warning"><i class="ri-restart-line"></i> Settings reset to defaults. Click "Save All Changes" to apply.</div>';
}
}
// Initialize when DOM is ready
document.addEventListener("DOMContentLoaded", () => {
console.log('DOM loaded, initializing configuration interface');
// Initialize form values
initializeConfigValues();
// Setup button handlers
setupButtonHandlers();
// Set up event listeners for form changes
document.addEventListener('change', (e) => {
if (e.target.matches('[data-var]')) {
handleConfigChange(e.target);
}
});
console.log('Configuration interface initialized - ready for API calls');
});
+20 -6
View File
@@ -25,6 +25,7 @@ class BrickChanger {
switch (this.html_type) {
case "checkbox":
case "text":
case "number":
listener = "change";
break;
@@ -33,6 +34,11 @@ class BrickChanger {
}
break;
case "TEXTAREA":
this.html_type = "textarea";
listener = "change";
break;
case "SELECT":
this.html_type = "select";
listener = "change";
@@ -130,6 +136,8 @@ class BrickChanger {
break;
case "text":
case "number":
case "textarea":
case "select":
value = this.html_element.value;
break;
@@ -185,10 +193,16 @@ class BrickChanger {
// Helper to setup the changer
const setup_changers = () => document.querySelectorAll("*[data-changer-id]").forEach(
el => new BrickChanger(
el.dataset.changerPrefix,
el.dataset.changerId,
el.dataset.changerUrl,
el.dataset.changerParent
)
el => {
try {
new BrickChanger(
el.dataset.changerPrefix,
el.dataset.changerId,
el.dataset.changerUrl,
el.dataset.changerParent
);
} catch (error) {
console.error('Error setting up changer for element:', el, 'Error:', error);
}
}
);
+6 -1
View File
@@ -180,7 +180,12 @@ class BrickGridFilter {
}
// If we passed all filters, we need to display it
current.parentElement.classList.remove("d-none");
// But also check if it's hidden by duplicate filter
if (!current.parentElement.classList.contains("duplicate-filter-hidden")) {
current.parentElement.classList.remove("d-none");
} else {
current.parentElement.classList.add("d-none");
}
});
}
}
+145
View File
@@ -19,6 +19,9 @@ document.addEventListener("DOMContentLoaded", () => {
const searchInput = document.getElementById('grid-search');
const searchClear = document.getElementById('grid-search-clear');
// Initialize duplicate filter functionality
initializeDuplicateFilter();
if (searchInput && searchClear) {
if (isPaginationMode()) {
// PAGINATION MODE - Server-side search
@@ -559,4 +562,146 @@ function removeSetGrouping() {
groupContainers.forEach(container => {
container.remove();
});
}
// Initialize duplicate/consolidated filter functionality
function initializeDuplicateFilter() {
const duplicateFilterButton = document.getElementById('duplicate-filter-toggle');
if (!duplicateFilterButton) return;
// Check if the filter should be active from URL parameters
const urlParams = new URLSearchParams(window.location.search);
const isDuplicateFilterActive = urlParams.get('duplicate') === 'true';
// Set initial button state
if (isDuplicateFilterActive) {
duplicateFilterButton.classList.remove('btn-outline-secondary');
duplicateFilterButton.classList.add('btn-secondary');
}
duplicateFilterButton.addEventListener('click', () => {
const isCurrentlyActive = duplicateFilterButton.classList.contains('btn-secondary');
const newState = !isCurrentlyActive;
// Update button appearance
if (newState) {
duplicateFilterButton.classList.remove('btn-outline-secondary');
duplicateFilterButton.classList.add('btn-secondary');
} else {
duplicateFilterButton.classList.remove('btn-secondary');
duplicateFilterButton.classList.add('btn-outline-secondary');
}
if (isPaginationMode()) {
// SERVER-SIDE MODE - Update URL parameter
performDuplicateFilterServer(newState);
} else {
// CLIENT-SIDE MODE - Apply filtering directly
applyDuplicateFilter(newState);
}
});
}
// Server-side duplicate filter
function performDuplicateFilterServer(showOnlyDuplicates) {
const currentUrl = new URL(window.location);
if (showOnlyDuplicates) {
currentUrl.searchParams.set('duplicate', 'true');
} else {
currentUrl.searchParams.delete('duplicate');
}
// Reset to page 1 when filtering
currentUrl.searchParams.set('page', '1');
window.location.href = currentUrl.toString();
}
// Apply duplicate/consolidated filter
function applyDuplicateFilter(showOnlyDuplicates) {
// Get the grid container and all column containers (not just the cards)
const gridContainer = document.getElementById('grid');
if (!gridContainer) {
console.warn('Grid container not found');
return;
}
// Try multiple selectors to find column containers
let columnContainers = gridContainer.querySelectorAll('.col-md-6');
if (columnContainers.length === 0) {
columnContainers = gridContainer.querySelectorAll('[class*="col-"]');
}
if (!showOnlyDuplicates) {
// Show all column containers by removing the duplicate-filter-hidden class
columnContainers.forEach(col => {
col.classList.remove('duplicate-filter-hidden');
});
// Trigger the existing grid filter to refresh
triggerGridRefresh();
return;
}
// Check if we're in consolidated mode by looking for data-instance-count
const consolidatedMode = document.querySelector('[data-instance-count]') !== null;
if (consolidatedMode) {
// CONSOLIDATED MODE: Show only sets with instance count > 1
columnContainers.forEach(col => {
const card = col.querySelector('[data-set-id]');
if (card) {
const instanceCount = parseInt(card.dataset.instanceCount || '1');
if (instanceCount > 1) {
col.classList.remove('duplicate-filter-hidden');
} else {
col.classList.add('duplicate-filter-hidden');
}
}
});
} else {
// NON-CONSOLIDATED MODE: Show only sets that appear multiple times
const setByCounts = {};
// Count occurrences of each set
columnContainers.forEach(col => {
const card = col.querySelector('[data-set-id]');
if (card) {
const rebrickableId = card.dataset.rebrickableId;
if (rebrickableId) {
setByCounts[rebrickableId] = (setByCounts[rebrickableId] || 0) + 1;
}
}
});
// Show/hide based on count
columnContainers.forEach(col => {
const card = col.querySelector('[data-set-id]');
if (card) {
const rebrickableId = card.dataset.rebrickableId;
if (rebrickableId && setByCounts[rebrickableId] > 1) {
col.classList.remove('duplicate-filter-hidden');
} else {
col.classList.add('duplicate-filter-hidden');
}
}
});
}
// Trigger the existing grid filter to refresh and respect our duplicate filter
triggerGridRefresh();
}
// Helper function to trigger grid filter refresh
function triggerGridRefresh() {
// Check if we have a grid instance with filter capability
if (window.gridInstances) {
const gridElement = document.getElementById('grid');
if (gridElement && window.gridInstances[gridElement.id]) {
const gridInstance = window.gridInstances[gridElement.id];
if (gridInstance.filter) {
// Trigger the existing filter to refresh
gridInstance.filter.filter();
}
}
}
}
+258
View File
@@ -0,0 +1,258 @@
// Minifigure Socket class
class BrickMinifigureSocket extends BrickSocket {
constructor(id, path, namespace, messages) {
super(id, path, namespace, messages, false);
// Listeners
this.add_listener = undefined;
this.input_listener = undefined;
this.confirm_listener = undefined;
// Form elements (built based on the initial id)
this.html_button = document.getElementById(id);
this.html_input = document.getElementById(`${id}-set`);
this.html_no_confim = document.getElementById(`${id}-no-confirm`);
this.html_owners = document.getElementById(`${id}-owners`);
this.html_purchase_location = document.getElementById(`${id}-purchase-location`);
this.html_storage = document.getElementById(`${id}-storage`);
this.html_tags = document.getElementById(`${id}-tags`);
// Card elements
this.html_card = document.getElementById(`${id}-card`);
this.html_card_set = document.getElementById(`${id}-card-set`);
this.html_card_name = document.getElementById(`${id}-card-name`);
this.html_card_image_container = document.getElementById(`${id}-card-image-container`);
this.html_card_image = document.getElementById(`${id}-card-image`);
this.html_card_footer = document.getElementById(`${id}-card-footer`);
this.html_card_confirm = document.getElementById(`${id}-card-confirm`);
this.html_card_dismiss = document.getElementById(`${id}-card-dismiss`);
if (this.html_button) {
this.add_listener = this.html_button.addEventListener("click", ((bricksocket) => (e) => {
bricksocket.execute();
})(this));
this.input_listener = this.html_input.addEventListener("keyup", ((bricksocket) => (e) => {
if (e.key === 'Enter') {
bricksocket.execute();
}
})(this))
}
if (this.html_card_dismiss && this.html_card) {
this.html_card_dismiss.addEventListener("click", ((card) => (e) => {
card.classList.add("d-none");
})(this.html_card));
}
// Setup the socket
this.setup();
}
// Clear form
clear() {
super.clear();
if (this.html_card) {
this.html_card.classList.add("d-none");
}
if (this.html_card_footer) {
this.html_card_footer.classList.add("d-none");
if (this.html_card_confirm) {
this.html_card_footer.classList.add("d-none");
}
}
}
// Execute the action
execute() {
if (!this.disabled && this.socket !== undefined && this.socket.connected) {
this.toggle(false);
if (this.html_no_confim && this.html_no_confim.checked) {
this.import_minifigure(true);
} else {
this.load_minifigure();
}
}
}
// Import a minifigure
import_minifigure(no_confirm, figure) {
if (this.html_input) {
if (no_confirm) {
this.clear();
} else {
this.clear_status();
}
// Grab the owners
const owners = [];
if (this.html_owners) {
this.html_owners.querySelectorAll('input').forEach(input => {
if (input.checked) {
owners.push(input.value);
}
});
}
// Grab the purchase location
let purchase_location = null;
if (this.html_purchase_location) {
purchase_location = this.html_purchase_location.value;
}
// Grab the storage
let storage = null;
if (this.html_storage) {
storage = this.html_storage.value;
}
// Grab the tags
const tags = [];
if (this.html_tags) {
this.html_tags.querySelectorAll('input').forEach(input => {
if (input.checked) {
tags.push(input.value);
}
});
}
this.spinner(true);
if (this.html_progress_bar) {
this.html_progress_bar.scrollIntoView();
}
this.socket.emit(this.messages.IMPORT_MINIFIGURE, {
figure: (figure !== undefined) ? figure : this.html_input.value,
owners: owners,
purchase_location: purchase_location,
storage: storage,
tags: tags,
quantity: 1
});
} else {
this.fail("Could not find the input field for the minifigure number");
}
}
// Load a minifigure
load_minifigure() {
if (this.html_input) {
// Reset the progress
this.clear()
this.spinner(true);
this.socket.emit(this.messages.LOAD_MINIFIGURE, {
figure: this.html_input.value
});
} else {
this.fail("Could not find the input field for the minifigure number");
}
}
// Minifigure is loaded
minifigure_loaded(data) {
if (this.html_card) {
this.html_card.classList.remove("d-none");
if (this.html_card_set) {
this.html_card_set.textContent = data["figure"];
}
if (this.html_card_name) {
this.html_card_name.textContent = data["name"];
}
if (this.html_card_image_container) {
this.html_card_image_container.setAttribute("style", `background-image: url(${data["image"]})`);
}
if (this.html_card_image) {
this.html_card_image.setAttribute("src", data["image"]);
this.html_card_image.setAttribute("alt", data["figure"]);
}
if (this.html_card_footer) {
this.html_card_footer.classList.add("d-none");
if (!data.download) {
this.html_card_footer.classList.remove("d-none");
if (this.html_card_confirm) {
if (this.confirm_listener !== undefined) {
this.html_card_confirm.removeEventListener("click", this.confirm_listener);
}
this.confirm_listener = ((bricksocket, figure) => (e) => {
if (!bricksocket.disabled) {
bricksocket.toggle(false);
bricksocket.import_minifigure(false, figure);
}
})(this, data["figure"]);
this.html_card_confirm.addEventListener("click", this.confirm_listener);
this.html_card_confirm.scrollIntoView();
}
}
}
}
}
// Setup the actual socket
setup() {
super.setup();
if (this.socket !== undefined) {
// Minifigure loaded
this.socket.on(this.messages.MINIFIGURE_LOADED, ((bricksocket) => (data) => {
bricksocket.minifigure_loaded(data);
})(this));
}
}
// Toggle clicking on the button, or sending events
toggle(enabled) {
super.toggle(enabled);
if (this.html_button) {
this.html_button.disabled = !enabled;
}
if (this.html_input) {
this.html_input.disabled = !enabled;
}
if (this.html_no_confim) {
this.html_no_confim.disabled = !enabled;
}
if (this.html_owners) {
this.html_owners.querySelectorAll('input').forEach(input => input.disabled = !enabled);
}
if (this.html_purchase_location) {
this.html_purchase_location.disabled = !enabled;
}
if (this.html_storage) {
this.html_storage.disabled = !enabled;
}
if (this.html_tags) {
this.html_tags.querySelectorAll('input').forEach(input => input.disabled = !enabled);
}
if (this.html_card_confirm) {
this.html_card_confirm.disabled = !enabled;
}
if (this.html_card_dismiss) {
this.html_card_dismiss.disabled = !enabled;
}
}
}
+25 -8
View File
@@ -181,14 +181,31 @@ class BrickSetSocket extends BrickSocket {
this.html_progress_bar.scrollIntoView();
}
this.socket.emit(this.messages.IMPORT_SET, {
set: (set !== undefined) ? set : this.html_input.value,
owners: owners,
purchase_location: purchase_location,
storage: storage,
tags: tags,
refresh: this.refresh
});
// Determine if this is a set or minifigure
const itemValue = (set !== undefined) ? set : this.html_input.value;
const isMinifigure = itemValue.trim().startsWith('fig-') || itemValue.trim().match(/^fig\d/i);
if (isMinifigure) {
// Emit minifigure import message
this.socket.emit(this.messages.IMPORT_MINIFIGURE, {
figure: itemValue,
owners: owners,
purchase_location: purchase_location,
storage: storage,
tags: tags,
quantity: 1
});
} else {
// Emit set import message
this.socket.emit(this.messages.IMPORT_SET, {
set: itemValue,
owners: owners,
purchase_location: purchase_location,
storage: storage,
tags: tags,
refresh: this.refresh
});
}
} else {
this.fail("Could not find the input field for the set number");
}
+18 -1
View File
@@ -178,4 +178,21 @@
border-radius: 2px;
opacity: 0.8;
pointer-events: none;
}
}/* Duplicate filter support */
.duplicate-filter-hidden { display: none !important; }
/* Remove spinner arrows from number inputs */
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
}
/* Remove resize handle from textareas */
textarea {
resize: none;
}
+16 -2
View File
@@ -26,8 +26,20 @@
<div id="add-complete"></div>
{% endif %}
<div class="mb-3">
<label for="add-set" class="form-label">{% if not bulk %}Set number (only one){% else %}List of sets (separated by a comma){% endif %}</label>
<input type="text" class="form-control" id="add-set" placeholder="{% if not bulk %}107-1 or 1642-1 or ...{% else %}107-1, 1642-1, ...{% endif %}">
<label for="add-set" class="form-label">{% if not bulk %}{% if not config['DISABLE_INDIVIDUAL_MINIFIGURES'] %}Set or Minifigure number (only one){% else %}Set number (only one){% endif %}{% else %}List of sets (separated by a comma){% endif %}</label>
<input type="text" class="form-control" id="add-set" placeholder="{% if not bulk %}{% if not config['DISABLE_INDIVIDUAL_MINIFIGURES'] %}107-1 or fig-001234 or ...{% else %}107-1 or ...{% endif %}{% else %}107-1, 1642-1, ...{% endif %}"
data-path="{{ path }}"
data-namespace="{{ namespace }}"
data-msg-complete="{{ messages['COMPLETE'] }}"
data-msg-fail="{{ messages['FAIL'] }}"
data-msg-import-set="{{ messages['IMPORT_SET'] }}"
data-msg-load-set="{{ messages['LOAD_SET'] }}"
data-msg-progress="{{ messages['PROGRESS'] }}"
data-msg-set-loaded="{{ messages['SET_LOADED'] }}"
data-msg-import-minifigure="{{ messages['IMPORT_MINIFIGURE'] }}"
data-msg-load-minifigure="{{ messages['LOAD_MINIFIGURE'] }}"
data-msg-minifigure-loaded="{{ messages['MINIFIGURE_LOADED'] }}">
<div class="form-text">Sets: use format like 107-1{% if not config['DISABLE_INDIVIDUAL_MINIFIGURES'] %}. Minifigures: use format like fig-001234{% endif %}</div>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="add-no-confirm" {% if bulk %}checked disabled{% endif %}>
@@ -141,7 +153,9 @@
</div>
</div>
</div>
{% if bulk %}
{% with id='add', bulk=bulk %}
{% include 'set/socket.html' %}
{% endwith %}
{% endif %}
{% endblock %}
+943 -26
View File
@@ -1,29 +1,946 @@
{% import 'macro/accordion.html' as accordion %}
{{ accordion.header('Configuration variables', 'configuration', 'admin', icon='list-settings-line') }}
<ul class="list-group list-group-flush">
{% for entry in configuration %}
<li class="list-group-item">
<strong>{{ entry.name }}</strong>:
{% if entry.value == none or entry.value == '' %}
<span class="badge rounded-pill text-bg-secondary">Unset</span>
{% elif entry.value == true %}
<span class="badge rounded-pill text-bg-success">True</span>
{% elif entry.value == false %}
<span class="badge rounded-pill text-bg-danger">False</span>
{% else %}
{% if entry.is_secret() %}
<span class="badge rounded-pill text-bg-success">Set</span>
{% else %}
<code>{{ entry.value }}</code>
{% endif %}
{% endif %}
<span class="badge rounded-pill text-bg-light border">Env: {{ entry.env_name }}</span>
{% if entry.extra_name %}<span class="badge rounded-pill text-bg-light border">Env: {{ entry.extra_name }}</span>{% endif %}
{% if entry.is_changed() %}
<span class="badge rounded-pill text-bg-warning">Changed</span>
{% endif %}
</li>
{% endfor %}
</ul>
<!-- Helper macro for config badges with truncation -->
{% macro config_badges(var_name) %}
{% set current_value = env_values[var_name] %}
{% set default_value = config_defaults[var_name] %}
{% set is_explicitly_set = env_explicit_values[var_name] %}
<!-- Value badge -->
{% if not is_explicitly_set %}
<!-- Not in .env file, using default -->
<span class="badge rounded-pill text-bg-secondary ms-2" data-badge-var="{{ var_name }}" data-badge-type="value">Unset</span>
<span class="badge rounded-pill text-bg-light text-dark ms-1" data-badge-var="{{ var_name }}" data-badge-type="default">Default Value</span>
{% elif current_value is sameas true %}
<span class="badge rounded-pill text-bg-success ms-2" data-badge-var="{{ var_name }}" data-badge-type="value">True</span>
{% elif current_value is sameas false %}
<span class="badge rounded-pill text-bg-danger ms-2" data-badge-var="{{ var_name }}" data-badge-type="value">False</span>
{% elif current_value == default_value %}
<!-- Explicitly set to default value -->
<span class="badge rounded-pill text-bg-light text-dark ms-2" data-badge-var="{{ var_name }}" data-badge-type="value">Default: {{ default_value }}</span>
{% else %}
<!-- For text/number fields that have been changed, show "Default: X" -->
<span class="badge rounded-pill text-bg-light text-dark ms-2" data-badge-var="{{ var_name }}" data-badge-type="value">Default: {{ default_value }}</span>
{% endif %}
<!-- Changed badge -->
{% if current_value != default_value %}
<span class="badge rounded-pill text-bg-warning ms-1" data-badge-var="{{ var_name }}" data-badge-type="changed">Changed</span>
{% endif %}
{% endmacro %}
{{ accordion.header('Configuration Management', 'configuration-management', 'admin', icon='settings-4-line') }}
<div class="p-3">
<!-- Configuration Status -->
<div id="config-status" class="mb-3">
<!-- Status indicators -->
</div>
<!-- Badge Legend -->
<div class="alert alert-info mb-3">
<h6 class="mb-2"><i class="ri-information-line"></i> Badge Legend</h6>
<div class="row g-2">
<div class="col-md-6">
<small>
<span class="badge rounded-pill text-bg-success">True</span> Boolean setting enabled<br>
<span class="badge rounded-pill text-bg-danger">False</span> Boolean setting disabled<br>
<span class="badge rounded-pill text-bg-primary">Set</span> Custom value configured
</small>
</div>
<div class="col-md-6">
<small>
<span class="badge rounded-pill text-bg-secondary">Unset</span> Not in .env file<br>
<span class="badge rounded-pill text-bg-light text-dark">Default Value</span> Using default value<br>
<span class="badge rounded-pill text-bg-warning">Changed</span> Modified from default
</small>
</div>
</div>
</div>
<!-- Sub-Drawers -->
<div class="accordion" id="configuration-accordion">
<!-- Live Settings Sub-Drawer -->
<div class="accordion-item">
<h2 class="accordion-header" id="live-settings-heading">
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#live-settings-collapse" aria-expanded="true" aria-controls="live-settings-collapse">
<i class="ri-settings-4-line me-2"></i> Live Settings
<span class="badge text-bg-success ms-2">Changes Applied On Save</span>
</button>
</h2>
<div id="live-settings-collapse" class="accordion-collapse collapse show" aria-labelledby="live-settings-heading" data-bs-parent="#configuration-accordion">
<div class="accordion-body">
<!-- Action buttons -->
<div class="d-flex gap-2 justify-content-end mb-4">
<button id="config-save-all" class="btn btn-success">
<i class="ri-save-line"></i> Save All Changes
</button>
<button id="config-refresh" class="btn btn-outline-secondary">
<i class="ri-refresh-line"></i> Refresh
</button>
<button id="config-reset" class="btn btn-outline-secondary">
<i class="ri-restart-line"></i> Reset to Defaults
</button>
</div>
<!-- Live configuration controls -->
<!-- Menu Visibility -->
<h6 class="fw-bold text-primary border-bottom pb-1 mb-3">Menu Visibility</h6>
<div class="row g-3">
<div class="col-md-6">
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_ADD_SET" data-var="BK_HIDE_ADD_SET">
<label class="form-check-label" for="BK_HIDE_ADD_SET">
BK_HIDE_ADD_SET {{ config_badges('BK_HIDE_ADD_SET') }}
<div class="text-muted small">Hide the "Add Set" menu entry</div>
</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_ADD_BULK_SET" data-var="BK_HIDE_ADD_BULK_SET">
<label class="form-check-label" for="BK_HIDE_ADD_BULK_SET">
BK_HIDE_ADD_BULK_SET {{ config_badges('BK_HIDE_ADD_BULK_SET') }}
<div class="text-muted small">Hide the "Add Bulk Set" menu entry</div>
</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_ADMIN" data-var="BK_HIDE_ADMIN">
<label class="form-check-label" for="BK_HIDE_ADMIN">
BK_HIDE_ADMIN {{ config_badges('BK_HIDE_ADMIN') }}
<div class="text-muted small">Hide the "Admin" menu entry</div>
</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_ALL_INSTRUCTIONS" data-var="BK_HIDE_ALL_INSTRUCTIONS">
<label class="form-check-label" for="BK_HIDE_ALL_INSTRUCTIONS">
BK_HIDE_ALL_INSTRUCTIONS {{ config_badges('BK_HIDE_ALL_INSTRUCTIONS') }}
<div class="text-muted small">Hide the "Instructions" menu entry</div>
</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_ALL_MINIFIGURES" data-var="BK_HIDE_ALL_MINIFIGURES">
<label class="form-check-label" for="BK_HIDE_ALL_MINIFIGURES">
BK_HIDE_ALL_MINIFIGURES {{ config_badges('BK_HIDE_ALL_MINIFIGURES') }}
<div class="text-muted small">Hide the "Minifigures" menu entry</div>
</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_ALL_PARTS" data-var="BK_HIDE_ALL_PARTS">
<label class="form-check-label" for="BK_HIDE_ALL_PARTS">
BK_HIDE_ALL_PARTS {{ config_badges('BK_HIDE_ALL_PARTS') }}
<div class="text-muted small">Hide the "Parts" menu entry</div>
</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_ALL_PROBLEMS_PARTS" data-var="BK_HIDE_ALL_PROBLEMS_PARTS">
<label class="form-check-label" for="BK_HIDE_ALL_PROBLEMS_PARTS">
BK_HIDE_ALL_PROBLEMS_PARTS {{ config_badges('BK_HIDE_ALL_PROBLEMS_PARTS') }}
<div class="text-muted small">Hide the "Problems" menu entry</div>
</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_ALL_SETS" data-var="BK_HIDE_ALL_SETS">
<label class="form-check-label" for="BK_HIDE_ALL_SETS">
BK_HIDE_ALL_SETS {{ config_badges('BK_HIDE_ALL_SETS') }}
<div class="text-muted small">Hide the "Sets" menu entry</div>
</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_ALL_STORAGES" data-var="BK_HIDE_ALL_STORAGES">
<label class="form-check-label" for="BK_HIDE_ALL_STORAGES">
BK_HIDE_ALL_STORAGES {{ config_badges('BK_HIDE_ALL_STORAGES') }}
<div class="text-muted small">Hide the "Storages" menu entry</div>
</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_STATISTICS" data-var="BK_HIDE_STATISTICS">
<label class="form-check-label" for="BK_HIDE_STATISTICS">
BK_HIDE_STATISTICS {{ config_badges('BK_HIDE_STATISTICS') }}
<div class="text-muted small">Hide the "Statistics" menu entry</div>
</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_WISHES" data-var="BK_HIDE_WISHES">
<label class="form-check-label" for="BK_HIDE_WISHES">
BK_HIDE_WISHES {{ config_badges('BK_HIDE_WISHES') }}
<div class="text-muted small">Hide the "Wishes" menu entry</div>
</label>
</div>
</div>
</div>
<!-- Table Display -->
<h6 class="fw-bold text-primary border-bottom pb-1 mb-3 mt-4">Table Display</h6>
<div class="row g-3">
<div class="col-md-6">
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_SET_INSTRUCTIONS" data-var="BK_HIDE_SET_INSTRUCTIONS">
<label class="form-check-label" for="BK_HIDE_SET_INSTRUCTIONS">
BK_HIDE_SET_INSTRUCTIONS {{ config_badges('BK_HIDE_SET_INSTRUCTIONS') }}
<div class="text-muted small">Hide instructions section in set details</div>
</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_TABLE_DAMAGED_PARTS" data-var="BK_HIDE_TABLE_DAMAGED_PARTS">
<label class="form-check-label" for="BK_HIDE_TABLE_DAMAGED_PARTS">
BK_HIDE_TABLE_DAMAGED_PARTS {{ config_badges('BK_HIDE_TABLE_DAMAGED_PARTS') }}
<div class="text-muted small">Hide the "Damaged" column in parts tables</div>
</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_TABLE_MISSING_PARTS" data-var="BK_HIDE_TABLE_MISSING_PARTS">
<label class="form-check-label" for="BK_HIDE_TABLE_MISSING_PARTS">
BK_HIDE_TABLE_MISSING_PARTS {{ config_badges('BK_HIDE_TABLE_MISSING_PARTS') }}
<div class="text-muted small">Hide the "Missing" column in parts tables</div>
</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_HIDE_TABLE_CHECKED_PARTS" data-var="BK_HIDE_TABLE_CHECKED_PARTS">
<label class="form-check-label" for="BK_HIDE_TABLE_CHECKED_PARTS">
BK_HIDE_TABLE_CHECKED_PARTS {{ config_badges('BK_HIDE_TABLE_CHECKED_PARTS') }}
<div class="text-muted small">Hide the "Checked" column in parts tables</div>
</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_SHOW_GRID_FILTERS" data-var="BK_SHOW_GRID_FILTERS">
<label class="form-check-label" for="BK_SHOW_GRID_FILTERS">
BK_SHOW_GRID_FILTERS {{ config_badges('BK_SHOW_GRID_FILTERS') }}
<div class="text-muted small">Show filter controls on grid views by default</div>
</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_SHOW_GRID_SORT" data-var="BK_SHOW_GRID_SORT">
<label class="form-check-label" for="BK_SHOW_GRID_SORT">
BK_SHOW_GRID_SORT {{ config_badges('BK_SHOW_GRID_SORT') }}
<div class="text-muted small">Show sort options on grids by default</div>
</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_SHOW_SETS_DUPLICATE_FILTER" data-var="BK_SHOW_SETS_DUPLICATE_FILTER">
<label class="form-check-label" for="BK_SHOW_SETS_DUPLICATE_FILTER">
BK_SHOW_SETS_DUPLICATE_FILTER {{ config_badges('BK_SHOW_SETS_DUPLICATE_FILTER') }}
<div class="text-muted small">Show duplicate/consolidated filter button on sets page</div>
</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_INDEPENDENT_ACCORDIONS" data-var="BK_INDEPENDENT_ACCORDIONS">
<label class="form-check-label" for="BK_INDEPENDENT_ACCORDIONS">
BK_INDEPENDENT_ACCORDIONS {{ config_badges('BK_INDEPENDENT_ACCORDIONS') }}
<div class="text-muted small">Make accordion sections independent (can open multiple)</div>
</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_SETS_CONSOLIDATION" data-var="BK_SETS_CONSOLIDATION">
<label class="form-check-label" for="BK_SETS_CONSOLIDATION">
BK_SETS_CONSOLIDATION {{ config_badges('BK_SETS_CONSOLIDATION') }}
<div class="text-muted small">Enable set consolidation/grouping functionality</div>
</label>
</div>
</div>
</div>
<!-- Pagination Settings -->
<h6 class="fw-bold text-primary border-bottom pb-1 mb-3 mt-4">Pagination Settings</h6>
<!-- Sets and Parts (Top Row) -->
<div class="row g-4 mb-4">
<!-- Sets Column -->
<div class="col-md-6">
<h6 class="fw-bold text-secondary mb-3">Sets</h6>
<div class="row g-3">
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_SETS_SERVER_SIDE_PAGINATION" data-var="BK_SETS_SERVER_SIDE_PAGINATION">
<label class="form-check-label" for="BK_SETS_SERVER_SIDE_PAGINATION">
BK_SETS_SERVER_SIDE_PAGINATION {{ config_badges('BK_SETS_SERVER_SIDE_PAGINATION') }}
<div class="text-muted small">Enable/disable pagination for sets</div>
</label>
</div>
</div>
<div class="col-12">
<label for="BK_SETS_PAGINATION_SIZE_DESKTOP" class="form-label">
BK_SETS_PAGINATION_SIZE_DESKTOP {{ config_badges('BK_SETS_PAGINATION_SIZE_DESKTOP') }}
<div class="text-muted small">Sets per page on desktop</div>
</label>
<input type="number" class="form-control config-number" id="BK_SETS_PAGINATION_SIZE_DESKTOP" data-var="BK_SETS_PAGINATION_SIZE_DESKTOP" min="1" max="100">
</div>
<div class="col-12">
<label for="BK_SETS_PAGINATION_SIZE_MOBILE" class="form-label">
BK_SETS_PAGINATION_SIZE_MOBILE {{ config_badges('BK_SETS_PAGINATION_SIZE_MOBILE') }}
<div class="text-muted small">Sets per page on mobile</div>
</label>
<input type="number" class="form-control config-number" id="BK_SETS_PAGINATION_SIZE_MOBILE" data-var="BK_SETS_PAGINATION_SIZE_MOBILE" min="1" max="50">
</div>
</div>
</div>
<!-- Parts Column -->
<div class="col-md-6">
<h6 class="fw-bold text-secondary mb-3">Parts</h6>
<div class="row g-3">
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_PARTS_SERVER_SIDE_PAGINATION" data-var="BK_PARTS_SERVER_SIDE_PAGINATION">
<label class="form-check-label" for="BK_PARTS_SERVER_SIDE_PAGINATION">
BK_PARTS_SERVER_SIDE_PAGINATION {{ config_badges('BK_PARTS_SERVER_SIDE_PAGINATION') }}
<div class="text-muted small">Enable/disable pagination for parts</div>
</label>
</div>
</div>
<div class="col-12">
<label for="BK_PARTS_PAGINATION_SIZE_DESKTOP" class="form-label">
BK_PARTS_PAGINATION_SIZE_DESKTOP {{ config_badges('BK_PARTS_PAGINATION_SIZE_DESKTOP') }}
<div class="text-muted small">Parts per page on desktop</div>
</label>
<input type="number" class="form-control config-number" id="BK_PARTS_PAGINATION_SIZE_DESKTOP" data-var="BK_PARTS_PAGINATION_SIZE_DESKTOP" min="1" max="100">
</div>
<div class="col-12">
<label for="BK_PARTS_PAGINATION_SIZE_MOBILE" class="form-label">
BK_PARTS_PAGINATION_SIZE_MOBILE {{ config_badges('BK_PARTS_PAGINATION_SIZE_MOBILE') }}
<div class="text-muted small">Parts per page on mobile</div>
</label>
<input type="number" class="form-control config-number" id="BK_PARTS_PAGINATION_SIZE_MOBILE" data-var="BK_PARTS_PAGINATION_SIZE_MOBILE" min="1" max="50">
</div>
</div>
</div>
</div>
<!-- Minifigures and Problems (Bottom Row) -->
<div class="row g-4 mb-4">
<!-- Minifigures Column -->
<div class="col-md-6">
<h6 class="fw-bold text-secondary mb-3">Minifigures</h6>
<div class="row g-3">
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_MINIFIGURES_SERVER_SIDE_PAGINATION" data-var="BK_MINIFIGURES_SERVER_SIDE_PAGINATION">
<label class="form-check-label" for="BK_MINIFIGURES_SERVER_SIDE_PAGINATION">
BK_MINIFIGURES_SERVER_SIDE_PAGINATION {{ config_badges('BK_MINIFIGURES_SERVER_SIDE_PAGINATION') }}
<div class="text-muted small">Enable/disable pagination for minifigures</div>
</label>
</div>
</div>
<div class="col-12">
<label for="BK_MINIFIGURES_PAGINATION_SIZE_DESKTOP" class="form-label">
BK_MINIFIGURES_PAGINATION_SIZE_DESKTOP {{ config_badges('BK_MINIFIGURES_PAGINATION_SIZE_DESKTOP') }}
<div class="text-muted small">Minifigures per page on desktop</div>
</label>
<input type="number" class="form-control config-number" id="BK_MINIFIGURES_PAGINATION_SIZE_DESKTOP" data-var="BK_MINIFIGURES_PAGINATION_SIZE_DESKTOP" min="1" max="100">
</div>
<div class="col-12">
<label for="BK_MINIFIGURES_PAGINATION_SIZE_MOBILE" class="form-label">
BK_MINIFIGURES_PAGINATION_SIZE_MOBILE {{ config_badges('BK_MINIFIGURES_PAGINATION_SIZE_MOBILE') }}
<div class="text-muted small">Minifigures per page on mobile</div>
</label>
<input type="number" class="form-control config-number" id="BK_MINIFIGURES_PAGINATION_SIZE_MOBILE" data-var="BK_MINIFIGURES_PAGINATION_SIZE_MOBILE" min="1" max="50">
</div>
</div>
</div>
<!-- Problems Column -->
<div class="col-md-6">
<h6 class="fw-bold text-secondary mb-3">Problems</h6>
<div class="row g-3">
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_PROBLEMS_SERVER_SIDE_PAGINATION" data-var="BK_PROBLEMS_SERVER_SIDE_PAGINATION">
<label class="form-check-label" for="BK_PROBLEMS_SERVER_SIDE_PAGINATION">
BK_PROBLEMS_SERVER_SIDE_PAGINATION {{ config_badges('BK_PROBLEMS_SERVER_SIDE_PAGINATION') }}
<div class="text-muted small">Enable/disable pagination for problems</div>
</label>
</div>
</div>
<div class="col-12">
<label for="BK_PROBLEMS_PAGINATION_SIZE_DESKTOP" class="form-label">
BK_PROBLEMS_PAGINATION_SIZE_DESKTOP {{ config_badges('BK_PROBLEMS_PAGINATION_SIZE_DESKTOP') }}
<div class="text-muted small">Problem parts per page on desktop</div>
</label>
<input type="number" class="form-control config-number" id="BK_PROBLEMS_PAGINATION_SIZE_DESKTOP" data-var="BK_PROBLEMS_PAGINATION_SIZE_DESKTOP" min="1" max="100">
</div>
<div class="col-12">
<label for="BK_PROBLEMS_PAGINATION_SIZE_MOBILE" class="form-label">
BK_PROBLEMS_PAGINATION_SIZE_MOBILE {{ config_badges('BK_PROBLEMS_PAGINATION_SIZE_MOBILE') }}
<div class="text-muted small">Problem parts per page on mobile</div>
</label>
<input type="number" class="form-control config-number" id="BK_PROBLEMS_PAGINATION_SIZE_MOBILE" data-var="BK_PROBLEMS_PAGINATION_SIZE_MOBILE" min="1" max="50">
</div>
</div>
</div>
</div>
<!-- Client side pagination -->
<h6 class="fw-bold text-secondary mb-3">Client side pagination</h6>
<div class="row g-3">
<div class="col-md-6">
<label for="BK_DEFAULT_TABLE_PER_PAGE" class="form-label">
BK_DEFAULT_TABLE_PER_PAGE {{ config_badges('BK_DEFAULT_TABLE_PER_PAGE') }}
<div class="text-muted small">Default number of items per page in tables</div>
</label>
<input type="number" class="form-control config-number" id="BK_DEFAULT_TABLE_PER_PAGE" data-var="BK_DEFAULT_TABLE_PER_PAGE" min="1" max="500">
</div>
</div>
<!-- Features & External Services -->
<h6 class="fw-bold text-primary border-bottom pb-1 mb-3 mt-4">Features & External Services</h6>
<div class="row g-3">
<div class="col-md-6">
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_RANDOM" data-var="BK_RANDOM">
<label class="form-check-label" for="BK_RANDOM">
BK_RANDOM {{ config_badges('BK_RANDOM') }}
<div class="text-muted small">Shuffle the lists on the front page</div>
</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_BRICKLINK_LINKS" data-var="BK_BRICKLINK_LINKS">
<label class="form-check-label" for="BK_BRICKLINK_LINKS">
BK_BRICKLINK_LINKS {{ config_badges('BK_BRICKLINK_LINKS') }}
<div class="text-muted small">Display BrickLink links wherever applicable</div>
</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_REBRICKABLE_LINKS" data-var="BK_REBRICKABLE_LINKS">
<label class="form-check-label" for="BK_REBRICKABLE_LINKS">
BK_REBRICKABLE_LINKS {{ config_badges('BK_REBRICKABLE_LINKS') }}
<div class="text-muted small">Display Rebrickable links wherever applicable</div>
</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_SKIP_SPARE_PARTS" data-var="BK_SKIP_SPARE_PARTS">
<label class="form-check-label" for="BK_SKIP_SPARE_PARTS">
BK_SKIP_SPARE_PARTS {{ config_badges('BK_SKIP_SPARE_PARTS') }}
<div class="text-muted small">Skip spare parts when importing sets</div>
</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_USE_REMOTE_IMAGES" data-var="BK_USE_REMOTE_IMAGES">
<label class="form-check-label" for="BK_USE_REMOTE_IMAGES">
BK_USE_REMOTE_IMAGES {{ config_badges('BK_USE_REMOTE_IMAGES') }}
<div class="text-muted small">Use remote images from Rebrickable CDN instead of local storage</div>
</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_STATISTICS_SHOW_CHARTS" data-var="BK_STATISTICS_SHOW_CHARTS">
<label class="form-check-label" for="BK_STATISTICS_SHOW_CHARTS">
BK_STATISTICS_SHOW_CHARTS {{ config_badges('BK_STATISTICS_SHOW_CHARTS') }}
<div class="text-muted small">Show collection growth charts on statistics page</div>
</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_STATISTICS_DEFAULT_EXPANDED" data-var="BK_STATISTICS_DEFAULT_EXPANDED">
<label class="form-check-label" for="BK_STATISTICS_DEFAULT_EXPANDED">
BK_STATISTICS_DEFAULT_EXPANDED {{ config_badges('BK_STATISTICS_DEFAULT_EXPANDED') }}
<div class="text-muted small">Expand all statistics sections by default</div>
</label>
</div>
</div>
</div>
<!-- Advanced Settings -->
<h6 class="fw-bold text-primary border-bottom pb-1 mb-3 mt-4">Advanced Settings</h6>
<div class="row g-3">
<div class="col-md-6">
<label for="BK_DESCRIPTION_BADGE_MAX_LENGTH" class="form-label">
BK_DESCRIPTION_BADGE_MAX_LENGTH {{ config_badges('BK_DESCRIPTION_BADGE_MAX_LENGTH') }}
<div class="text-muted small">Maximum length for description text in badges before truncating with ellipsis</div>
</label>
<input type="number" class="form-control config-number" id="BK_DESCRIPTION_BADGE_MAX_LENGTH" data-var="BK_DESCRIPTION_BADGE_MAX_LENGTH" min="5" max="200">
</div>
<div class="col-md-6">
<label for="BK_PEERON_DOWNLOAD_DELAY" class="form-label">
BK_PEERON_DOWNLOAD_DELAY {{ config_badges('BK_PEERON_DOWNLOAD_DELAY') }}
<div class="text-muted small">Delay between Peeron downloads in milliseconds</div>
</label>
<input type="number" class="form-control config-number" id="BK_PEERON_DOWNLOAD_DELAY" data-var="BK_PEERON_DOWNLOAD_DELAY" min="0" max="10000">
</div>
<div class="col-md-6">
<label for="BK_PEERON_MIN_IMAGE_SIZE" class="form-label">
BK_PEERON_MIN_IMAGE_SIZE {{ config_badges('BK_PEERON_MIN_IMAGE_SIZE') }}
<div class="text-muted small">Minimum valid image size in bytes</div>
</label>
<input type="number" class="form-control config-number" id="BK_PEERON_MIN_IMAGE_SIZE" data-var="BK_PEERON_MIN_IMAGE_SIZE" min="1000" max="1000000">
</div>
<div class="col-md-6">
<label for="BK_REBRICKABLE_PAGE_SIZE" class="form-label">
BK_REBRICKABLE_PAGE_SIZE {{ config_badges('BK_REBRICKABLE_PAGE_SIZE') }}
<div class="text-muted small">Number of items per page for Rebrickable API requests</div>
</label>
<input type="number" class="form-control config-number" id="BK_REBRICKABLE_PAGE_SIZE" data-var="BK_REBRICKABLE_PAGE_SIZE" min="100" max="5000">
</div>
<div class="col-md-6">
<label for="BK_ADMIN_DEFAULT_EXPANDED_SECTIONS" class="form-label">
BK_ADMIN_DEFAULT_EXPANDED_SECTIONS {{ config_badges('BK_ADMIN_DEFAULT_EXPANDED_SECTIONS') }}
<div class="text-muted small">Admin sections to expand by default (comma-separated)</div>
</label>
<input type="text" class="form-control config-text" id="BK_ADMIN_DEFAULT_EXPANDED_SECTIONS" data-var="BK_ADMIN_DEFAULT_EXPANDED_SECTIONS">
</div>
</div>
<!-- Default Ordering & Formatting -->
<h6 class="fw-bold text-primary border-bottom pb-1 mb-3 mt-4">Default Ordering & Formatting</h6>
<div class="row g-3">
<div class="col-12">
<label for="BK_INSTRUCTIONS_ALLOWED_EXTENSIONS" class="form-label">
BK_INSTRUCTIONS_ALLOWED_EXTENSIONS {{ config_badges('BK_INSTRUCTIONS_ALLOWED_EXTENSIONS') }}
<div class="text-muted small">Allowed file extensions for instructions (comma-separated)</div>
</label>
<input type="text" class="form-control config-text" id="BK_INSTRUCTIONS_ALLOWED_EXTENSIONS" data-var="BK_INSTRUCTIONS_ALLOWED_EXTENSIONS">
</div>
<div class="col-12">
<label for="BK_MINIFIGURES_DEFAULT_ORDER" class="form-label">
BK_MINIFIGURES_DEFAULT_ORDER {{ config_badges('BK_MINIFIGURES_DEFAULT_ORDER') }}
<div class="text-muted small">SQL ORDER BY clause for minifigures listing</div>
</label>
<input type="text" class="form-control config-text" id="BK_MINIFIGURES_DEFAULT_ORDER" data-var="BK_MINIFIGURES_DEFAULT_ORDER">
</div>
<div class="col-12">
<label for="BK_PARTS_DEFAULT_ORDER" class="form-label">
BK_PARTS_DEFAULT_ORDER {{ config_badges('BK_PARTS_DEFAULT_ORDER') }}
<div class="text-muted small">SQL ORDER BY clause for parts listing</div>
</label>
<input type="text" class="form-control config-text" id="BK_PARTS_DEFAULT_ORDER" data-var="BK_PARTS_DEFAULT_ORDER">
</div>
<div class="col-12">
<label for="BK_SETS_DEFAULT_ORDER" class="form-label">
BK_SETS_DEFAULT_ORDER {{ config_badges('BK_SETS_DEFAULT_ORDER') }}
<div class="text-muted small">SQL ORDER BY clause for sets listing</div>
</label>
<input type="text" class="form-control config-text" id="BK_SETS_DEFAULT_ORDER" data-var="BK_SETS_DEFAULT_ORDER">
</div>
<div class="col-12">
<label for="BK_PURCHASE_LOCATION_DEFAULT_ORDER" class="form-label">
BK_PURCHASE_LOCATION_DEFAULT_ORDER {{ config_badges('BK_PURCHASE_LOCATION_DEFAULT_ORDER') }}
<div class="text-muted small">SQL ORDER BY clause for purchase locations</div>
</label>
<input type="text" class="form-control config-text" id="BK_PURCHASE_LOCATION_DEFAULT_ORDER" data-var="BK_PURCHASE_LOCATION_DEFAULT_ORDER">
</div>
<div class="col-12">
<label for="BK_STORAGE_DEFAULT_ORDER" class="form-label">
BK_STORAGE_DEFAULT_ORDER {{ config_badges('BK_STORAGE_DEFAULT_ORDER') }}
<div class="text-muted small">SQL ORDER BY clause for storage locations</div>
</label>
<input type="text" class="form-control config-text" id="BK_STORAGE_DEFAULT_ORDER" data-var="BK_STORAGE_DEFAULT_ORDER">
</div>
<div class="col-12">
<label for="BK_WISHES_DEFAULT_ORDER" class="form-label">
BK_WISHES_DEFAULT_ORDER {{ config_badges('BK_WISHES_DEFAULT_ORDER') }}
<div class="text-muted small">SQL ORDER BY clause for wishes listing</div>
</label>
<input type="text" class="form-control config-text" id="BK_WISHES_DEFAULT_ORDER" data-var="BK_WISHES_DEFAULT_ORDER">
</div>
</div>
<!-- URL Patterns & Links -->
<h6 class="fw-bold text-primary border-bottom pb-1 mb-3 mt-4">URL Patterns & Links</h6>
<div class="row g-3">
<div class="col-12">
<label for="BK_BRICKLINK_LINK_PART_PATTERN" class="form-label">
BK_BRICKLINK_LINK_PART_PATTERN {{ config_badges('BK_BRICKLINK_LINK_PART_PATTERN') }}
<div class="text-muted small">Pattern for BrickLink part links (supports {part} and {color})</div>
</label>
<input type="text" class="form-control config-text" id="BK_BRICKLINK_LINK_PART_PATTERN" data-var="BK_BRICKLINK_LINK_PART_PATTERN">
</div>
<div class="col-12">
<label for="BK_REBRICKABLE_LINK_MINIFIGURE_PATTERN" class="form-label">
BK_REBRICKABLE_LINK_MINIFIGURE_PATTERN {{ config_badges('BK_REBRICKABLE_LINK_MINIFIGURE_PATTERN') }}
<div class="text-muted small">Pattern for Rebrickable minifigure links (supports {figure})</div>
</label>
<input type="text" class="form-control config-text" id="BK_REBRICKABLE_LINK_MINIFIGURE_PATTERN" data-var="BK_REBRICKABLE_LINK_MINIFIGURE_PATTERN">
</div>
<div class="col-12">
<label for="BK_REBRICKABLE_LINK_PART_PATTERN" class="form-label">
BK_REBRICKABLE_LINK_PART_PATTERN {{ config_badges('BK_REBRICKABLE_LINK_PART_PATTERN') }}
<div class="text-muted small">Pattern for Rebrickable part links (supports {part} and {color})</div>
</label>
<input type="text" class="form-control config-text" id="BK_REBRICKABLE_LINK_PART_PATTERN" data-var="BK_REBRICKABLE_LINK_PART_PATTERN">
</div>
<div class="col-12">
<label for="BK_REBRICKABLE_LINK_INSTRUCTIONS_PATTERN" class="form-label">
BK_REBRICKABLE_LINK_INSTRUCTIONS_PATTERN {{ config_badges('BK_REBRICKABLE_LINK_INSTRUCTIONS_PATTERN') }}
<div class="text-muted small">Pattern for Rebrickable instruction links (supports {path})</div>
</label>
<input type="text" class="form-control config-text" id="BK_REBRICKABLE_LINK_INSTRUCTIONS_PATTERN" data-var="BK_REBRICKABLE_LINK_INSTRUCTIONS_PATTERN">
</div>
<div class="col-12">
<label for="BK_PEERON_INSTRUCTION_PATTERN" class="form-label">
BK_PEERON_INSTRUCTION_PATTERN {{ config_badges('BK_PEERON_INSTRUCTION_PATTERN') }}
<div class="text-muted small">Pattern for Peeron instruction URLs (supports {set_number} and {version_number})</div>
</label>
<input type="text" class="form-control config-text" id="BK_PEERON_INSTRUCTION_PATTERN" data-var="BK_PEERON_INSTRUCTION_PATTERN">
</div>
<div class="col-12">
<label for="BK_PEERON_SCAN_PATTERN" class="form-label">
BK_PEERON_SCAN_PATTERN {{ config_badges('BK_PEERON_SCAN_PATTERN') }}
<div class="text-muted small">Pattern for Peeron scan URLs (supports {set_number} and {version_number})</div>
</label>
<input type="text" class="form-control config-text" id="BK_PEERON_SCAN_PATTERN" data-var="BK_PEERON_SCAN_PATTERN">
</div>
<div class="col-12">
<label for="BK_PEERON_THUMBNAIL_PATTERN" class="form-label">
BK_PEERON_THUMBNAIL_PATTERN {{ config_badges('BK_PEERON_THUMBNAIL_PATTERN') }}
<div class="text-muted small">Pattern for Peeron thumbnail URLs (supports {set_number} and {version_number})</div>
</label>
<input type="text" class="form-control config-text" id="BK_PEERON_THUMBNAIL_PATTERN" data-var="BK_PEERON_THUMBNAIL_PATTERN">
</div>
</div>
<!-- Images & Resources -->
<h6 class="fw-bold text-primary border-bottom pb-1 mb-3 mt-4">Images & Resources</h6>
<div class="row g-3">
<div class="col-12">
<label for="BK_REBRICKABLE_IMAGE_NIL" class="form-label">
BK_REBRICKABLE_IMAGE_NIL {{ config_badges('BK_REBRICKABLE_IMAGE_NIL') }}
<div class="text-muted small">URL for missing image placeholder</div>
</label>
<input type="text" class="form-control config-text" id="BK_REBRICKABLE_IMAGE_NIL" data-var="BK_REBRICKABLE_IMAGE_NIL">
</div>
<div class="col-12">
<label for="BK_REBRICKABLE_IMAGE_NIL_MINIFIGURE" class="form-label">
BK_REBRICKABLE_IMAGE_NIL_MINIFIGURE {{ config_badges('BK_REBRICKABLE_IMAGE_NIL_MINIFIGURE') }}
<div class="text-muted small">URL for missing minifigure image placeholder</div>
</label>
<input type="text" class="form-control config-text" id="BK_REBRICKABLE_IMAGE_NIL_MINIFIGURE" data-var="BK_REBRICKABLE_IMAGE_NIL_MINIFIGURE">
</div>
<div class="col-12">
<label for="BK_RETIRED_SETS_FILE_URL" class="form-label">
BK_RETIRED_SETS_FILE_URL {{ config_badges('BK_RETIRED_SETS_FILE_URL') }}
<div class="text-muted small">URL to the retired sets CSV file</div>
</label>
<input type="text" class="form-control config-text" id="BK_RETIRED_SETS_FILE_URL" data-var="BK_RETIRED_SETS_FILE_URL">
</div>
<div class="col-12">
<label for="BK_RETIRED_SETS_PATH" class="form-label">
BK_RETIRED_SETS_PATH {{ config_badges('BK_RETIRED_SETS_PATH') }}
<div class="text-muted small">Local path to store retired sets CSV</div>
</label>
<input type="text" class="form-control config-text" id="BK_RETIRED_SETS_PATH" data-var="BK_RETIRED_SETS_PATH">
</div>
<div class="col-12">
<label for="BK_THEMES_FILE_URL" class="form-label">
BK_THEMES_FILE_URL {{ config_badges('BK_THEMES_FILE_URL') }}
<div class="text-muted small">URL to the Rebrickable themes CSV file</div>
</label>
<input type="text" class="form-control config-text" id="BK_THEMES_FILE_URL" data-var="BK_THEMES_FILE_URL">
</div>
<div class="col-12">
<label for="BK_THEMES_PATH" class="form-label">
BK_THEMES_PATH {{ config_badges('BK_THEMES_PATH') }}
<div class="text-muted small">Local path to store themes CSV</div>
</label>
<input type="text" class="form-control config-text" id="BK_THEMES_PATH" data-var="BK_THEMES_PATH">
</div>
</div>
</div>
</div>
</div>
<!-- Static Settings Sub-Drawer -->
<div class="accordion-item">
<h2 class="accordion-header" id="static-settings-heading">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#static-settings-collapse" aria-expanded="false" aria-controls="static-settings-collapse">
<i class="ri-database-2-line me-2"></i> Static Settings
<span class="badge text-bg-warning ms-2">Requires Restart</span>
</button>
</h2>
<div id="static-settings-collapse" class="accordion-collapse collapse" aria-labelledby="static-settings-heading" data-bs-parent="#configuration-accordion">
<div class="accordion-body">
<!-- Static configuration with editable fields -->
<div class="alert alert-warning">
<h6><i class="ri-warning-line"></i> Restart Required</h6>
These settings require an application restart to take effect. Values can be edited here and will be saved to the .env file.
</div>
<!-- Authentication -->
<h6 class="fw-bold text-secondary border-bottom pb-1 mb-3">Authentication & Security</h6>
<div class="row g-3">
<div class="col-md-6">
<label for="static-BK_AUTHENTICATION_PASSWORD" class="form-label">
BK_AUTHENTICATION_PASSWORD {{ config_badges('BK_AUTHENTICATION_PASSWORD') }}
<div class="text-muted small">Password for authentication system</div>
</label>
<input type="password" class="form-control config-static" id="static-BK_AUTHENTICATION_PASSWORD" data-var="BK_AUTHENTICATION_PASSWORD">
</div>
<div class="col-md-6">
<label for="static-BK_AUTHENTICATION_KEY" class="form-label">
BK_AUTHENTICATION_KEY {{ config_badges('BK_AUTHENTICATION_KEY') }}
<div class="text-muted small">Secret key for session signing</div>
</label>
<input type="password" class="form-control config-static" id="static-BK_AUTHENTICATION_KEY" data-var="BK_AUTHENTICATION_KEY">
</div>
</div>
<!-- Server Configuration -->
<h6 class="fw-bold text-secondary border-bottom pb-1 mb-3 mt-4">Server Configuration</h6>
<div class="row g-3">
<div class="col-md-6">
<label for="static-BK_HOST" class="form-label">
BK_HOST {{ config_badges('BK_HOST') }}
<div class="text-muted small">Server host address to bind to</div>
</label>
<input type="text" class="form-control config-static" id="static-BK_HOST" data-var="BK_HOST">
</div>
<div class="col-md-6">
<label for="static-BK_PORT" class="form-label">
BK_PORT {{ config_badges('BK_PORT') }}
<div class="text-muted small">Port number for the web server</div>
</label>
<input type="number" class="form-control config-static" id="static-BK_PORT" data-var="BK_PORT" min="1" max="65535">
</div>
<div class="col-md-6">
<div class="form-check form-switch">
<input class="form-check-input config-static-toggle" type="checkbox" id="static-BK_DEBUG" data-var="BK_DEBUG">
<label class="form-check-label" for="static-BK_DEBUG">
BK_DEBUG {{ config_badges('BK_DEBUG') }}
<div class="text-muted small">Enable debug mode</div>
</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check form-switch">
<input class="form-check-input config-static-toggle" type="checkbox" id="static-BK_DISABLE_INDIVIDUAL_MINIFIGURES" data-var="BK_DISABLE_INDIVIDUAL_MINIFIGURES">
<label class="form-check-label" for="static-BK_DISABLE_INDIVIDUAL_MINIFIGURES">
BK_DISABLE_INDIVIDUAL_MINIFIGURES {{ config_badges('BK_DISABLE_INDIVIDUAL_MINIFIGURES') }}
<div class="text-muted small">Completely disable individual/loose minifigures system</div>
</label>
</div>
</div>
<div class="col-md-6">
<label for="static-BK_DOMAIN_NAME" class="form-label">
BK_DOMAIN_NAME {{ config_badges('BK_DOMAIN_NAME') }}
<div class="text-muted small">Domain name for CORS configuration</div>
</label>
<input type="text" class="form-control config-static" id="static-BK_DOMAIN_NAME" data-var="BK_DOMAIN_NAME">
</div>
<div class="col-md-6">
<label for="static-BK_TIMEZONE" class="form-label">
BK_TIMEZONE {{ config_badges('BK_TIMEZONE') }}
<div class="text-muted small">Application timezone</div>
</label>
<input type="text" class="form-control config-static" id="static-BK_TIMEZONE" data-var="BK_TIMEZONE">
</div>
</div>
<!-- Database & Storage -->
<h6 class="fw-bold text-secondary border-bottom pb-1 mb-3 mt-4">Database & Storage</h6>
<div class="row g-3">
<div class="col-md-6">
<label for="static-BK_DATABASE_PATH" class="form-label">
BK_DATABASE_PATH {{ config_badges('BK_DATABASE_PATH') }}
<div class="text-muted small">Path to the SQLite database file</div>
</label>
<input type="text" class="form-control config-static" id="static-BK_DATABASE_PATH" data-var="BK_DATABASE_PATH">
</div>
<div class="col-md-6">
<label for="static-BK_INSTRUCTIONS_FOLDER" class="form-label">
BK_INSTRUCTIONS_FOLDER {{ config_badges('BK_INSTRUCTIONS_FOLDER') }}
<div class="text-muted small">Folder for instruction files</div>
</label>
<input type="text" class="form-control config-static" id="static-BK_INSTRUCTIONS_FOLDER" data-var="BK_INSTRUCTIONS_FOLDER">
</div>
<div class="col-md-6">
<label for="static-BK_PARTS_FOLDER" class="form-label">
BK_PARTS_FOLDER {{ config_badges('BK_PARTS_FOLDER') }}
<div class="text-muted small">Folder for part images</div>
</label>
<input type="text" class="form-control config-static" id="static-BK_PARTS_FOLDER" data-var="BK_PARTS_FOLDER">
</div>
<div class="col-md-6">
<label for="static-BK_SETS_FOLDER" class="form-label">
BK_SETS_FOLDER {{ config_badges('BK_SETS_FOLDER') }}
<div class="text-muted small">Folder for set images</div>
</label>
<input type="text" class="form-control config-static" id="static-BK_SETS_FOLDER" data-var="BK_SETS_FOLDER">
</div>
<div class="col-md-6">
<label for="static-BK_MINIFIGURES_FOLDER" class="form-label">
BK_MINIFIGURES_FOLDER {{ config_badges('BK_MINIFIGURES_FOLDER') }}
<div class="text-muted small">Folder for minifigure images</div>
</label>
<input type="text" class="form-control config-static" id="static-BK_MINIFIGURES_FOLDER" data-var="BK_MINIFIGURES_FOLDER">
</div>
</div>
<!-- API Configuration -->
<h6 class="fw-bold text-secondary border-bottom pb-1 mb-3 mt-4">API Configuration</h6>
<div class="row g-3">
<div class="col-md-6">
<label for="static-BK_REBRICKABLE_API_KEY" class="form-label">
BK_REBRICKABLE_API_KEY {{ config_badges('BK_REBRICKABLE_API_KEY') }}
<div class="text-muted small">API key for Rebrickable integration</div>
</label>
<input type="password" class="form-control config-static" id="static-BK_REBRICKABLE_API_KEY" data-var="BK_REBRICKABLE_API_KEY">
</div>
<div class="col-md-6">
<label for="static-BK_USER_AGENT" class="form-label">
BK_USER_AGENT {{ config_badges('BK_USER_AGENT') }}
<div class="text-muted small">User agent string for HTTP requests</div>
</label>
<input type="text" class="form-control config-static" id="static-BK_USER_AGENT" data-var="BK_USER_AGENT">
</div>
<div class="col-md-6">
<label for="static-BK_REBRICKABLE_USER_AGENT" class="form-label">
BK_REBRICKABLE_USER_AGENT {{ config_badges('BK_REBRICKABLE_USER_AGENT') }}
<div class="text-muted small">User agent for Rebrickable API requests</div>
</label>
<input type="text" class="form-control config-static" id="static-BK_REBRICKABLE_USER_AGENT" data-var="BK_REBRICKABLE_USER_AGENT">
</div>
</div>
<!-- Date & Currency Formats -->
<h6 class="fw-bold text-secondary border-bottom pb-1 mb-3 mt-4">Date & Currency Formats</h6>
<div class="row g-3">
<div class="col-md-6">
<label for="static-BK_DATABASE_TIMESTAMP_FORMAT" class="form-label">
BK_DATABASE_TIMESTAMP_FORMAT {{ config_badges('BK_DATABASE_TIMESTAMP_FORMAT') }}
<div class="text-muted small">Database timestamp format</div>
</label>
<input type="text" class="form-control config-static" id="static-BK_DATABASE_TIMESTAMP_FORMAT" data-var="BK_DATABASE_TIMESTAMP_FORMAT">
</div>
<div class="col-md-6">
<label for="static-BK_FILE_DATETIME_FORMAT" class="form-label">
BK_FILE_DATETIME_FORMAT {{ config_badges('BK_FILE_DATETIME_FORMAT') }}
<div class="text-muted small">File datetime format</div>
</label>
<input type="text" class="form-control config-static" id="static-BK_FILE_DATETIME_FORMAT" data-var="BK_FILE_DATETIME_FORMAT">
</div>
<div class="col-md-6">
<label for="static-BK_PURCHASE_DATE_FORMAT" class="form-label">
BK_PURCHASE_DATE_FORMAT {{ config_badges('BK_PURCHASE_DATE_FORMAT') }}
<div class="text-muted small">Purchase date format</div>
</label>
<input type="text" class="form-control config-static" id="static-BK_PURCHASE_DATE_FORMAT" data-var="BK_PURCHASE_DATE_FORMAT">
</div>
<div class="col-md-6">
<label for="static-BK_PURCHASE_CURRENCY" class="form-label">
BK_PURCHASE_CURRENCY {{ config_badges('BK_PURCHASE_CURRENCY') }}
<div class="text-muted small">Purchase currency</div>
</label>
<input type="text" class="form-control config-static" id="static-BK_PURCHASE_CURRENCY" data-var="BK_PURCHASE_CURRENCY">
</div>
</div>
<!-- Socket Configuration -->
<h6 class="fw-bold text-secondary border-bottom pb-1 mb-3 mt-4">Socket Configuration</h6>
<div class="row g-3">
<div class="col-md-6">
<label for="static-BK_SOCKET_NAMESPACE" class="form-label">
BK_SOCKET_NAMESPACE {{ config_badges('BK_SOCKET_NAMESPACE') }}
<div class="text-muted small">Socket.IO namespace</div>
</label>
<input type="text" class="form-control config-static" id="static-BK_SOCKET_NAMESPACE" data-var="BK_SOCKET_NAMESPACE">
</div>
<div class="col-md-6">
<label for="static-BK_SOCKET_PATH" class="form-label">
BK_SOCKET_PATH {{ config_badges('BK_SOCKET_PATH') }}
<div class="text-muted small">Socket.IO path</div>
</label>
<input type="text" class="form-control config-static" id="static-BK_SOCKET_PATH" data-var="BK_SOCKET_PATH">
</div>
<div class="col-md-6">
<div class="form-check form-switch">
<input class="form-check-input config-static-toggle" type="checkbox" id="static-BK_NO_THREADED_SOCKET" data-var="BK_NO_THREADED_SOCKET">
<label class="form-check-label" for="static-BK_NO_THREADED_SOCKET">
BK_NO_THREADED_SOCKET {{ config_badges('BK_NO_THREADED_SOCKET') }}
<div class="text-muted small">Disable threaded socket mode</div>
</label>
</div>
</div>
</div>
<!-- Save Button for Static Settings -->
<div class="d-flex gap-2 justify-content-end mt-4">
<button id="config-save-static" class="btn btn-warning">
<i class="ri-save-line"></i> Save Static Settings to .env
</button>
</div>
</div>
</div>
</div>
</div>
</div>
{{ accordion.footer() }}
<!-- Initialize Configuration Data -->
<script type="text/javascript">
window.CURRENT_CONFIG = {
{% for env_name, value in env_values.items() %}
'{{ env_name }}': {{ value|tojson }},
{% endfor %}
};
window.DEFAULT_CONFIG = {
{% for env_name, value in config_defaults.items() %}
'{{ env_name }}': {{ value|tojson }},
{% endfor %}
};
window.EXPLICIT_VALUES = {
{% for env_name, is_explicit in env_explicit_values.items() %}
'{{ env_name }}': {{ is_explicit|tojson }},
{% endfor %}
};
</script>
<!-- Configuration Management JavaScript -->
<script src="{{ url_for('static', filename='scripts/admin_config.js') }}"></script>
+4
View File
@@ -92,7 +92,11 @@
<script src="{{ url_for('static', filename='scripts/socket/socket.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/socket/instructions.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/socket/set.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/socket/minifigure.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/table.js') }}"></script>
{% if request.endpoint == 'add.add' %}
<script src="{{ url_for('static', filename='scripts/add.js') }}"></script>
{% endif %}
{% if request.endpoint == 'minifigure.list' %}
<script src="{{ url_for('static', filename='scripts/minifigures.js') }}"></script>
{% endif %}
+1 -1
View File
@@ -10,7 +10,7 @@
<p>An error has occured: {{error}}.</p>
<hr>
<div class="text-end">
<a class="btn btn-danger me-1" href="{{ url_for('admin.admin') }}" role="button"><i class="ri-settings-4-line"></i> Administration</a>
<a class="btn btn-danger me-1" href="{{ url_for('admin.admin', open_database=true) }}" role="button"><i class="ri-settings-4-line"></i> Administration</a>
<a class="btn btn-primary" href="{{ url_for('index.index') }}" role="button"><i class="ri-home-line"></i> Home</a>
</div>
</div>
+1 -1
View File
@@ -11,7 +11,7 @@
<pre>{{name}} {% if line %}at line {{line}} of{% else %}in{% endif %} {{file}}: {{error}}</pre>
<hr>
<div class="text-end">
<a class="btn btn-danger me-1" href="{{ url_for('admin.admin') }}" role="button"><i class="ri-settings-4-line"></i> Administration</a>
<a class="btn btn-danger me-1" href="{{ url_for('admin.admin', open_database=true) }}" role="button"><i class="ri-settings-4-line"></i> Administration</a>
<a class="btn btn-primary" href="{{ url_for('index.index') }}" role="button"><i class="ri-home-line"></i> Home</a>
</div>
</div>
+40
View File
@@ -0,0 +1,40 @@
{% import 'macro/badge.html' as badge %}
{% import 'macro/card.html' as card %}
{% import 'macro/form.html' as form %}
<div class="card mb-3 flex-fill">
{{ card.header(item, item.fields.name, solo=false, identifier=item.fields.figure, icon='user-line') }}
{{ card.image(item, solo=false, last=false, caption=item.fields.name, alt=item.fields.figure, medium=false) }}
<div class="card-body border-bottom p-1">
{{ badge.quantity(item.fields.quantity, solo=false, last=false) }}
{{ badge.parts(item.fields.number_of_parts, solo=false, last=false) }}
{{ badge.total_missing(item.fields.total_missing, solo=false, last=false) }}
{{ badge.total_damaged(item.fields.total_damaged, solo=false, last=false) }}
{% for owner in brickset_owners %}
{{ badge.owner(item, owner, solo=false, last=false) }}
{% endfor %}
{% for tag in brickset_tags %}
{{ badge.tag(item, tag, solo=false, last=false) }}
{% endfor %}
{% if item.fields.storage %}
{{ badge.storage(item, brickset_storages, solo=false, last=false) }}
{% endif %}
{% if item.fields.purchase_location %}
{{ badge.purchase_location(item, brickset_purchase_locations, solo=false, last=false) }}
{% endif %}
{% if item.fields.description %}
<span class="badge text-bg-light text-dark text-wrap" data-bs-toggle="tooltip" title="{{ item.fields.description }}">
<i class="ri-file-text-line"></i> {{ item.fields.description[:config['DESCRIPTION_BADGE_MAX_LENGTH']] }}{% if item.fields.description | length > config['DESCRIPTION_BADGE_MAX_LENGTH'] %}...{% endif %}
</span>
{% endif %}
</div>
{% if brickset_statuses | length %}
<ul class="list-group list-group-flush card-check border-bottom-0">
{% for status in brickset_statuses %}
<li class="d-flex list-group-item p-1 text-nowrap">
{{ form.checkbox(status.fields.name, item.fields.id, status.as_dataset(), status.url_for_individual_minifigure_state(item.fields.id), item.fields[status.as_column()]) }}
</li>
{% endfor %}
</ul>
{% endif %}
</div>
@@ -0,0 +1,69 @@
{% extends 'base.html' %}
{% import 'macro/accordion.html' as accordion %}
{% import 'macro/form.html' as form %}
{% block title %} - Individual Minifigure {{ item.fields.name }}{% endblock %}
{% block main %}
<div class="container">
<div class="row">
<div class="col-12">
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="ri-user-line"></i> {{ item.fields.name }}
<span class="badge text-bg-secondary fw-normal"><i class="ri-hashtag"></i> {{ item.fields.figure }}</span>
</h5>
<div>
<a href="{{ url_for('minifigure.details', figure=item.fields.figure) }}" class="btn btn-sm btn-secondary">
<i class="ri-arrow-left-line"></i> Back to {{ item.fields.figure }}
</a>
</div>
</div>
<div class="card-img border-bottom" style="background-image: url({{ item.url_for_image() }})">
<a data-lightbox data-caption="{{ item.fields.name }}" href="{{ item.url_for_image() }}" target="_blank">
<img class="card-medium-img" src="{{ item.url_for_image() }}" alt="{{ item.fields.figure }}" loading="lazy">
</a>
</div>
<div class="accordion accordion-flush border-top" id="individual-minifigure-details-{{ item.fields.id }}">
{{ accordion.header('Quantity', 'accordion-quantity-' ~ item.fields.id, 'individual-minifigure-details-' ~ item.fields.id, icon='functions') }}
{{ form.input('Quantity', item.fields.id, 'quantity', item.url_for_quantity(), item.fields.quantity, icon='functions', type='number') }}
{{ accordion.footer() }}
{{ accordion.header('Description', 'accordion-description-' ~ item.fields.id, 'individual-minifigure-details-' ~ item.fields.id, icon='file-text-line') }}
{{ form.input('Description', item.fields.id, 'description', item.url_for_description(), item.fields.description or '', icon='file-text-line', textarea=true) }}
{{ accordion.footer() }}
{{ accordion.table(item.generic_parts(), 'Parts', 'accordion-parts-' ~ item.fields.id, 'individual-minifigure-details-' ~ item.fields.id, 'part/table.html', icon='shapes-line', alt=item.fields.figure) }}
{% include 'individual_minifigure/management.html' %}
{% if g.login.is_authenticated() %}
{{ accordion.header('Danger zone', 'accordion-danger-zone-' ~ item.fields.id, 'individual-minifigure-details-' ~ item.fields.id, danger=true, class='text-end') }}
<a href="{{ url_for('individual_minifigure.delete', id=item.fields.id) }}" class="btn btn-danger" role="button" data-bs-toggle="modal" data-bs-target="#deleteModal"><i class="ri-close-line"></i> Delete this individual minifigure instance</a>
{{ accordion.footer() }}
{% endif %}
</div>
<div class="card-footer"></div>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteModalLabel">Confirm Delete</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
Are you sure you want to delete this individual minifigure instance? This action cannot be undone.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<form method="POST" action="{{ url_for('individual_minifigure.delete', id=item.fields.id) }}">
<button type="submit" class="btn btn-danger">Delete</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

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