Compare commits
21 Commits
master
...
release/1.
| Author | SHA1 | Date | |
|---|---|---|---|
| c40da16d9e | |||
| d885f3aa11 | |||
| a72cb67c8c | |||
| 423540bba4 | |||
| a915a0001f | |||
| 2c961f9a78 | |||
| 3e1e846a99 | |||
| 5725872060 | |||
| dcf9496db9 | |||
| 19e3d8afe6 | |||
| f54dd3ec73 | |||
| 4336ad4de3 | |||
| 5418aca8f0 | |||
| 9518b0261c | |||
| 9bd80c1352 | |||
| 2f1bba475d | |||
| b30deef529 | |||
| c20231f654 | |||
| d783b8fbc9 | |||
| 6044841329 | |||
| 136f7d03f5 |
16
.env.sample
16
.env.sample
@@ -481,6 +481,20 @@
|
||||
# BK_STATISTICS_DEFAULT_EXPANDED=false
|
||||
|
||||
# Optional: Enable dark mode by default
|
||||
# When true, the application starts in dark mode.
|
||||
# When true, the application starts in dark mode.
|
||||
# Default: false
|
||||
# BK_DARK_MODE=true
|
||||
|
||||
# Optional: Customize badge order for Grid view (set cards on /sets/)
|
||||
# Comma-separated list of badge keys in the order they should appear
|
||||
# Available badges: theme, tag, year, parts, instance_count, total_minifigures,
|
||||
# total_missing, total_damaged, owner, storage, purchase_date, purchase_location,
|
||||
# purchase_price, instructions, rebrickable, bricklink
|
||||
# Default: theme,year,parts,total_minifigures,owner
|
||||
# BK_BADGE_ORDER_GRID=theme,year,parts,total_minifigures,owner,storage
|
||||
|
||||
# Optional: Customize badge order for Detail view (individual set details page)
|
||||
# Comma-separated list of badge keys in the order they should appear
|
||||
# Use the same badge keys as BK_BADGE_ORDER_GRID
|
||||
# Default: theme,tag,year,parts,instance_count,total_minifigures,total_missing,total_damaged,owner,storage,purchase_date,purchase_location,purchase_price,instructions,rebrickable,bricklink
|
||||
# BK_BADGE_ORDER_DETAIL=theme,tag,year,parts,owner,storage,purchase_date,rebrickable,bricklink
|
||||
|
||||
99
CHANGELOG.md
99
CHANGELOG.md
@@ -1,13 +1,112 @@
|
||||
# Changelog
|
||||
|
||||
## 1.4
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Fixed set refresh functionality**: Resolved issues with refreshing sets from Rebrickable
|
||||
- Fixed foreign key constraint errors during refresh by reusing existing set IDs instead of generating new UUIDs
|
||||
- Implemented UPDATE-then-INSERT pattern to properly update existing parts while preserving user tracking data
|
||||
- Part quantities now correctly sync with Rebrickable during refresh
|
||||
- User tracking data (`checked`, `missing`, `damaged`) is now preserved across refreshes
|
||||
- New parts from Rebrickable are added to local inventory during refresh
|
||||
- Orphaned parts (parts no longer in Rebrickable's inventory) are now properly removed during refresh
|
||||
- Refresh now works correctly for both set parts and minifigure parts
|
||||
- Uses temporary tracking table to identify which parts are still valid before cleanup
|
||||
- **Fixed Socket.IO connections behind reverse proxies**: Resolved WebSocket disconnection issues when using Traefik, Nginx, or other reverse proxies
|
||||
- Root cause: Setting `BK_DOMAIN_NAME` enables strict CORS checking that fails with reverse proxies
|
||||
- Solution: Leave `BK_DOMAIN_NAME` empty for reverse proxy deployments (allows all origins by default)
|
||||
- Added debug logging for Socket.IO connections to help troubleshoot proxy issues
|
||||
- **Fixed bulk import hanging on empty set numbers**: Resolved issue where trailing commas in bulk import input would cause infinite loops
|
||||
- Empty strings from trailing commas (e.g., `"10312, 21348, "`) are now filtered out before processing
|
||||
- Prevents "Set number cannot be empty" errors from blocking the bulk import queue
|
||||
- **Added notes display toggles**: Added configuration options to show/hide notes on grid and detail views
|
||||
- New `BK_SHOW_NOTES_GRID` setting (default: `false`) - controls whether notes appear on grid view cards
|
||||
- New `BK_SHOW_NOTES_DETAIL` setting (default: `true`) - controls whether notes appear on set detail pages
|
||||
- Notes display as an info alert box below badges when enabled
|
||||
- Both settings can be toggled in Admin → Live Settings panel without container restart
|
||||
- Fixed consolidated SQL query to include description field for proper notes display in server-side pagination
|
||||
|
||||
### New Features
|
||||
|
||||
- **Export Functionality**
|
||||
- Added comprehensive export system in admin panel for sets, parts, and problem parts
|
||||
- Export accordion in `/admin/` with three main categories:
|
||||
- **Export Sets**: Rebrickable CSV format for collection tracking
|
||||
- **Export All Parts**: Three formats available:
|
||||
- Rebrickable CSV (Part, Color, Quantity)
|
||||
- LEGO Pick-a-Brick CSV (elementId, quantity)
|
||||
- BrickLink XML (wanted list format)
|
||||
- **Export Missing/Damaged Parts**: Same three formats as parts exports
|
||||
- All exports aggregate quantities automatically (parts by part+color, LEGO by element ID)
|
||||
- BrickLink exports use proper BrickLink part numbers and color IDs when available
|
||||
- Filter support: All part exports accept owner, color, theme, and year query parameters
|
||||
- Format information displayed in UI for user guidance
|
||||
- **Badge Order Customization**
|
||||
- Added customizable badge ordering for set cards and detail pages
|
||||
- Separate configurations for Grid view (`/sets/` cards) and Detail view (individual set pages)
|
||||
- Configure via environment variables in `.env` file:
|
||||
- `BK_BADGE_ORDER_GRID`: Comma-separated badge keys for grid view (default: theme,year,parts,total_minifigures,owner)
|
||||
- `BK_BADGE_ORDER_DETAIL`: Comma-separated badge keys for detail view (default: all 16 badges)
|
||||
- Can also be configured via Live Settings page in admin panel under "Default Ordering & Formatting"
|
||||
- Changes apply immediately without restart when edited via admin panel
|
||||
- 16 available badge types: theme, tag, year, parts, instance_count, total_minifigures, total_missing, total_damaged, owner, storage, purchase_date, purchase_location, purchase_price, instructions, rebrickable, bricklink
|
||||
- **Front Page Parts Display**
|
||||
- Added latest/random parts section to the front page alongside sets and minifigures
|
||||
- Shows 6 parts with quantity badges and other relevant information
|
||||
- Respects `BK_RANDOM` configuration (random selection when enabled, latest when disabled)
|
||||
- Respects `BK_HIDE_SPARE_PARTS` configuration
|
||||
- Respects `BK_HIDE_ALL_PARTS` configuration for "All parts" button visibility
|
||||
- **NOT Filter Toggle Buttons**
|
||||
- Added toggle buttons next to all filter dropdowns to switch between "equals" and "not equals" modes
|
||||
- Visual feedback: Button displays red with "not equals" icon (≠) when in NOT mode
|
||||
- Works with all filter types: Status, Theme, Owner, Storage, Purchase Location, Tag, and Year
|
||||
- Supports both client-side and server-side pagination modes
|
||||
- Filter chains persist NOT states across page reloads via URL parameters (e.g., `?theme=-frozen&status=-has-missing`)
|
||||
- Clear filters button resets all toggle states to equals mode
|
||||
- Enables complex filter combinations like "Show me 2025 sets that are NOT Frozen theme AND have missing pieces"
|
||||
- **Notes/Comments Field**
|
||||
- Added general notes field to set details for storing custom notes and comments
|
||||
- Accessible via Management -> Notes accordion section on set detail pages
|
||||
- Auto-save functionality with visual feedback (save icon updates on change)
|
||||
- Notes display prominently below badges on set cards when populated
|
||||
- Supports multi-line text input with configurable row height
|
||||
- Clear button to quickly remove notes
|
||||
- **Bulk Set Refresh**
|
||||
- Added batch refresh functionality for updating multiple sets at once
|
||||
- New "Bulk Refresh" button appears on Admin -> Sets needing refresh page
|
||||
- Pre-populates text-area with comma-separated list of all sets needing refresh
|
||||
- Follows same pattern as bulk add with progress tracking and set card preview
|
||||
- Shows real-time progress with current set being processed
|
||||
- Failed sets remain in input field for easy retry
|
||||
|
||||
|
||||
## 1.3.1
|
||||
|
||||
### New Functionality
|
||||
|
||||
- **Database Integrity Check and Cleanup**
|
||||
- Added database integrity scanner to detect orphaned records and foreign key violations
|
||||
- New "Check Database Integrity" button in admin panel scans for issues
|
||||
- Detects orphaned sets, parts, and parts with missing set references
|
||||
- Warning prompts users to backup database before cleanup
|
||||
- Cleanup removes all orphaned records in one operation
|
||||
- Detailed scan results show affected records with counts and descriptions
|
||||
- **Database Optimization**
|
||||
- Added "Optimize Database" button to re-create performance indexes
|
||||
- Safe to run after database imports or restores
|
||||
- Re-creates all indexes from migration #19 using `CREATE INDEX IF NOT EXISTS`
|
||||
- Runs `ANALYZE` to rebuild query statistics
|
||||
- Runs `PRAGMA optimize` for additional query plan optimization
|
||||
- Helpful after importing backup databases that may lack performance optimizations
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Fixed foreign key constraint errors during set imports**: Resolved `FOREIGN KEY constraint failed` errors when importing sets with parts and minifigures
|
||||
- Fixed insertion order in `bricktracker/part.py`: Parent records (`rebrickable_parts`) now inserted before child records (`bricktracker_parts`)
|
||||
- Fixed insertion order in `bricktracker/minifigure.py`: Parent records (`rebrickable_minifigures`) now inserted before child records (`bricktracker_minifigures`)
|
||||
- Ensures foreign key references are valid when SQLite checks constraints
|
||||
|
||||
- **Fixed set metadata updates**: Owner, status, and tag checkboxes now properly persist changes on set details page
|
||||
- Fixed `update_set_state()` method to commit database transactions (was using deferred execution without commit)
|
||||
- All metadata updates (owner, status, tags, storage, purchase info) now work consistently
|
||||
|
||||
@@ -17,6 +17,7 @@ from bricktracker.version import __version__
|
||||
from bricktracker.views.add import add_page
|
||||
from bricktracker.views.admin.admin import admin_page
|
||||
from bricktracker.views.admin.database import admin_database_page
|
||||
from bricktracker.views.admin.export import admin_export_page
|
||||
from bricktracker.views.admin.image import admin_image_page
|
||||
from bricktracker.views.admin.instructions import admin_instructions_page
|
||||
from bricktracker.views.admin.owner import admin_owner_page
|
||||
@@ -149,6 +150,7 @@ def setup_app(app: Flask) -> None:
|
||||
# Register admin routes
|
||||
app.register_blueprint(admin_page)
|
||||
app.register_blueprint(admin_database_page)
|
||||
app.register_blueprint(admin_export_page)
|
||||
app.register_blueprint(admin_image_page)
|
||||
app.register_blueprint(admin_instructions_page)
|
||||
app.register_blueprint(admin_retired_page)
|
||||
|
||||
@@ -97,4 +97,8 @@ CONFIG: Final[list[dict[str, Any]]] = [
|
||||
{'n': 'STATISTICS_SHOW_CHARTS', 'd': True, 'c': bool},
|
||||
{'n': 'STATISTICS_DEFAULT_EXPANDED', 'd': True, 'c': bool},
|
||||
{'n': 'DARK_MODE', 'c': bool},
|
||||
{'n': 'BADGE_ORDER_GRID', 'd': ['theme', 'year', 'parts', 'total_minifigures', 'owner'], 'c': list},
|
||||
{'n': 'BADGE_ORDER_DETAIL', 'd': ['theme', 'tag', 'year', 'parts', 'instance_count', 'total_minifigures', 'total_missing', 'total_damaged', 'owner', 'storage', 'purchase_date', 'purchase_location', 'purchase_price', 'instructions', 'rebrickable', 'bricklink'], 'c': list},
|
||||
{'n': 'SHOW_NOTES_GRID', 'd': False, 'c': bool},
|
||||
{'n': 'SHOW_NOTES_DETAIL', 'd': True, 'c': bool},
|
||||
]
|
||||
|
||||
@@ -54,6 +54,11 @@ LIVE_CHANGEABLE_VARS: Final[List[str]] = [
|
||||
'BK_STATISTICS_SHOW_CHARTS',
|
||||
'BK_STATISTICS_DEFAULT_EXPANDED',
|
||||
'BK_DARK_MODE',
|
||||
# Badge order preferences
|
||||
'BK_BADGE_ORDER_GRID',
|
||||
'BK_BADGE_ORDER_DETAIL',
|
||||
'BK_SHOW_NOTES_GRID',
|
||||
'BK_SHOW_NOTES_DETAIL',
|
||||
# Default ordering and formatting
|
||||
'BK_INSTRUCTIONS_ALLOWED_EXTENSIONS',
|
||||
'BK_MINIFIGURES_DEFAULT_ORDER',
|
||||
@@ -179,8 +184,8 @@ class ConfigManager:
|
||||
|
||||
def _cast_value(self, var_name: str, value: Any) -> Any:
|
||||
"""Cast value to appropriate type based on variable name"""
|
||||
# List variables (admin sections) - Check this FIRST before boolean check
|
||||
if 'sections' in var_name.lower():
|
||||
# List variables (admin sections, badge order) - Check this FIRST before boolean check
|
||||
if any(keyword in var_name.lower() for keyword in ['sections', 'badge_order']):
|
||||
if isinstance(value, str):
|
||||
return [section.strip() for section in value.split(',') if section.strip()]
|
||||
elif isinstance(value, list):
|
||||
|
||||
@@ -182,7 +182,8 @@ class BrickMetadata(BrickRecord):
|
||||
/,
|
||||
*,
|
||||
json: Any | None = None,
|
||||
state: Any | None = None
|
||||
state: Any | None = None,
|
||||
commit: bool = True
|
||||
) -> Any:
|
||||
if state is None and json is not None:
|
||||
state = json.get('value', False)
|
||||
@@ -191,13 +192,22 @@ class BrickMetadata(BrickRecord):
|
||||
parameters['set_id'] = brickset.fields.id
|
||||
parameters['state'] = state
|
||||
|
||||
rows, _ = BrickSQL().execute_and_commit(
|
||||
self.update_set_state_query,
|
||||
parameters=parameters,
|
||||
name=self.as_column(),
|
||||
)
|
||||
if commit:
|
||||
rows, _ = BrickSQL().execute_and_commit(
|
||||
self.update_set_state_query,
|
||||
parameters=parameters,
|
||||
name=self.as_column(),
|
||||
)
|
||||
else:
|
||||
rows, _ = BrickSQL().execute(
|
||||
self.update_set_state_query,
|
||||
parameters=parameters,
|
||||
defer=True,
|
||||
name=self.as_column(),
|
||||
)
|
||||
|
||||
if rows != 1:
|
||||
# When deferred, rows will be -1, so skip the check
|
||||
if commit and rows != 1:
|
||||
raise DatabaseException('Could not update the {kind} state for set {set} ({id})'.format(
|
||||
kind=self.kind,
|
||||
set=brickset.fields.set,
|
||||
|
||||
@@ -23,6 +23,7 @@ class BrickPart(RebrickablePart):
|
||||
|
||||
# Queries
|
||||
insert_query: str = 'part/insert'
|
||||
update_on_refresh_query: str = 'part/update_on_refresh'
|
||||
generic_query: str = 'part/select/generic'
|
||||
select_query: str = 'part/select/specific'
|
||||
|
||||
@@ -66,7 +67,28 @@ class BrickPart(RebrickablePart):
|
||||
# This must happen before inserting into bricktracker_parts due to FK constraint
|
||||
self.insert_rebrickable()
|
||||
|
||||
if not refresh:
|
||||
if refresh:
|
||||
params = self.sql_parameters()
|
||||
|
||||
# Track this part in the refresh temp table (for orphan cleanup later)
|
||||
BrickSQL().execute(
|
||||
'part/track_refresh_part',
|
||||
parameters=params,
|
||||
defer=False
|
||||
)
|
||||
|
||||
# Try to update existing part first (preserves checked, missing, and damaged states)
|
||||
# Note: Cannot defer this because we need to check if rows were affected
|
||||
rows, _ = BrickSQL().execute(
|
||||
self.update_on_refresh_query,
|
||||
parameters=params,
|
||||
defer=False
|
||||
)
|
||||
|
||||
# If no rows were updated, the part doesn't exist yet, so insert it
|
||||
if rows == 0:
|
||||
self.insert(commit=False)
|
||||
else:
|
||||
# Insert into bricktracker_parts database (child record)
|
||||
self.insert(commit=False)
|
||||
|
||||
|
||||
@@ -254,6 +254,21 @@ class BrickPartList(BrickRecordList[BrickPart]):
|
||||
|
||||
return self
|
||||
|
||||
# Last added parts
|
||||
def last(self, /, *, limit: int = 6) -> Self:
|
||||
if current_app.config['RANDOM']:
|
||||
order = 'RANDOM()'
|
||||
else:
|
||||
order = '"bricktracker_parts"."rowid" DESC'
|
||||
|
||||
context = {}
|
||||
if current_app.config.get('HIDE_SPARE_PARTS', False):
|
||||
context['skip_spare_parts'] = True
|
||||
|
||||
self.list(override_query=self.last_query, order=order, limit=limit, **context)
|
||||
|
||||
return self
|
||||
|
||||
# Load problematic parts
|
||||
def problem(self, /) -> Self:
|
||||
self.list(override_query=self.problem_query)
|
||||
|
||||
@@ -30,6 +30,7 @@ class BrickSet(RebrickableSet):
|
||||
insert_query: str = 'set/insert'
|
||||
update_purchase_date_query: str = 'set/update/purchase_date'
|
||||
update_purchase_price_query: str = 'set/update/purchase_price'
|
||||
update_description_query: str = 'set/update/description'
|
||||
|
||||
# Delete a set
|
||||
def delete(self, /) -> None:
|
||||
@@ -56,8 +57,23 @@ class BrickSet(RebrickableSet):
|
||||
# Grabbing the refresh flag
|
||||
refresh: bool = bool(data.get('refresh', False))
|
||||
|
||||
# Generate an UUID for self
|
||||
self.fields.id = str(uuid4())
|
||||
# Generate an UUID for self (or use existing ID if refreshing)
|
||||
if refresh:
|
||||
# Find the existing set by set number to get its ID
|
||||
result = BrickSQL().raw_execute(
|
||||
'SELECT "id" FROM "bricktracker_sets" WHERE "set" = :set',
|
||||
{'set': self.fields.set}
|
||||
).fetchone()
|
||||
|
||||
if result:
|
||||
# Use existing set ID
|
||||
self.fields.id = result['id']
|
||||
else:
|
||||
# If set doesn't exist in database, treat as new import
|
||||
refresh = False
|
||||
self.fields.id = str(uuid4())
|
||||
else:
|
||||
self.fields.id = str(uuid4())
|
||||
|
||||
# Insert the rebrickable set into database FIRST
|
||||
# This must happen before inserting bricktracker_sets due to FK constraint
|
||||
@@ -82,19 +98,25 @@ class BrickSet(RebrickableSet):
|
||||
# All operations are atomic - if anything fails, nothing is committed
|
||||
self.insert(commit=False)
|
||||
|
||||
# Save the owners
|
||||
# Save the owners (deferred - will execute at final commit)
|
||||
owners: list[str] = list(data.get('owners', []))
|
||||
|
||||
for id in owners:
|
||||
owner = BrickSetOwnerList.get(id)
|
||||
owner.update_set_state(self, state=True)
|
||||
owner.update_set_state(self, state=True, commit=False)
|
||||
|
||||
# Save the tags
|
||||
# Save the tags (deferred - will execute at final commit)
|
||||
tags: list[str] = list(data.get('tags', []))
|
||||
|
||||
for id in tags:
|
||||
tag = BrickSetTagList.get(id)
|
||||
tag.update_set_state(self, state=True)
|
||||
tag.update_set_state(self, state=True, commit=False)
|
||||
|
||||
# If refreshing, prepare temp table for tracking parts across both set and minifigs
|
||||
if refresh:
|
||||
sql = BrickSQL()
|
||||
sql.execute('part/create_temp_refresh_tracking_table', defer=False)
|
||||
sql.execute('part/clear_temp_refresh_tracking_table', defer=False)
|
||||
|
||||
# Load the inventory
|
||||
if not BrickPartList.download(socket, self, refresh=refresh):
|
||||
@@ -104,6 +126,15 @@ class BrickSet(RebrickableSet):
|
||||
if not BrickMinifigureList.download(socket, self, refresh=refresh):
|
||||
return False
|
||||
|
||||
# If refreshing, clean up orphaned parts after all parts have been processed
|
||||
if refresh:
|
||||
# Delete orphaned parts (parts that weren't in the API response)
|
||||
BrickSQL().execute(
|
||||
'part/delete_untracked_parts',
|
||||
parameters={'id': self.fields.id},
|
||||
defer=False
|
||||
)
|
||||
|
||||
# Commit the transaction to the database
|
||||
socket.auto_progress(
|
||||
message='Set {set}: writing to the database'.format(
|
||||
@@ -355,3 +386,36 @@ class BrickSet(RebrickableSet):
|
||||
# Update purchase price url
|
||||
def url_for_purchase_price(self, /) -> str:
|
||||
return url_for('set.update_purchase_price', id=self.fields.id)
|
||||
|
||||
# Update description
|
||||
def update_description(self, json: Any | None, /) -> Any:
|
||||
value = json.get('value', None) # type: ignore
|
||||
|
||||
if value == '':
|
||||
value = None
|
||||
|
||||
self.fields.description = value
|
||||
|
||||
rows, _ = BrickSQL().execute_and_commit(
|
||||
self.update_description_query,
|
||||
parameters=self.sql_parameters()
|
||||
)
|
||||
|
||||
if rows != 1:
|
||||
raise DatabaseException('Could not update the description for set {set} ({id})'.format( # noqa: E501
|
||||
set=self.fields.set,
|
||||
id=self.fields.id,
|
||||
))
|
||||
|
||||
# Info
|
||||
logger.info('Description changed to "{value}" for set {set} ({id})'.format( # noqa: E501
|
||||
value=value,
|
||||
set=self.fields.set,
|
||||
id=self.fields.id,
|
||||
))
|
||||
|
||||
return value
|
||||
|
||||
# Update description url
|
||||
def url_for_description(self, /) -> str:
|
||||
return url_for('set.update_description', id=self.fields.id)
|
||||
|
||||
@@ -92,7 +92,15 @@ class BrickSetList(BrickRecordList[BrickSet]):
|
||||
# Convert theme name to theme ID for filtering
|
||||
theme_id_filter = None
|
||||
if theme_filter:
|
||||
theme_id_filter = self._theme_name_to_id(theme_filter)
|
||||
# Check if this is a NOT filter
|
||||
if theme_filter.startswith('-'):
|
||||
# Extract the actual theme value without the "-" prefix
|
||||
actual_theme = theme_filter[1:]
|
||||
theme_id = self._theme_name_to_id(actual_theme)
|
||||
# Re-add the "-" prefix to the theme ID
|
||||
theme_id_filter = f'-{theme_id}' if theme_id else None
|
||||
else:
|
||||
theme_id_filter = self._theme_name_to_id(theme_filter)
|
||||
|
||||
# Check if any filters are applied
|
||||
has_filters = any([status_filter, theme_id_filter, owner_filter, purchase_location_filter, storage_filter, tag_filter, year_filter, duplicate_filter])
|
||||
|
||||
@@ -65,6 +65,8 @@ class BrickSocket(object):
|
||||
)
|
||||
|
||||
# Inject CORS if a domain is defined
|
||||
# Note: For reverse proxy deployments, leave BK_DOMAIN_NAME empty to allow all origins
|
||||
# When empty, Socket.IO defaults to permissive CORS which works with reverse proxies
|
||||
if app.config['DOMAIN_NAME'] != '':
|
||||
kwargs['cors_allowed_origins'] = app.config['DOMAIN_NAME']
|
||||
|
||||
@@ -75,6 +77,8 @@ class BrickSocket(object):
|
||||
**kwargs,
|
||||
path=app.config['SOCKET_PATH'],
|
||||
async_mode='gevent',
|
||||
# Enable detailed logging in debug mode for troubleshooting
|
||||
logger=app.config['DEBUG'],
|
||||
# Ping/pong settings for mobile network resilience
|
||||
ping_timeout=30, # Wait 30s for pong response before disconnecting
|
||||
ping_interval=25, # Send ping every 25s to keep connection alive
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Clear temporary refresh tracking table
|
||||
DELETE FROM temp_refresh_parts
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Create temporary table to track which parts are being refreshed
|
||||
CREATE TEMPORARY TABLE IF NOT EXISTS temp_refresh_parts (
|
||||
id TEXT NOT NULL,
|
||||
part TEXT NOT NULL,
|
||||
color INTEGER NOT NULL,
|
||||
figure TEXT,
|
||||
spare BOOLEAN NOT NULL,
|
||||
PRIMARY KEY (id, part, color, figure, spare)
|
||||
)
|
||||
14
bricktracker/sql/part/delete_untracked_parts.sql
Normal file
14
bricktracker/sql/part/delete_untracked_parts.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- Delete parts that weren't in the refresh (orphaned parts)
|
||||
-- Only delete if we actually tracked some parts (safety check)
|
||||
DELETE FROM bricktracker_parts
|
||||
WHERE id = :id
|
||||
AND EXISTS (SELECT 1 FROM temp_refresh_parts WHERE id = :id)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM temp_refresh_parts
|
||||
WHERE temp_refresh_parts.id = bricktracker_parts.id
|
||||
AND temp_refresh_parts.part = bricktracker_parts.part
|
||||
AND temp_refresh_parts.color = bricktracker_parts.color
|
||||
AND (temp_refresh_parts.figure IS NULL AND bricktracker_parts.figure IS NULL
|
||||
OR temp_refresh_parts.figure = bricktracker_parts.figure)
|
||||
AND temp_refresh_parts.spare = bricktracker_parts.spare
|
||||
)
|
||||
35
bricktracker/sql/part/list/last.sql
Normal file
35
bricktracker/sql/part/list/last.sql
Normal file
@@ -0,0 +1,35 @@
|
||||
{% extends 'part/base/base.sql' %}
|
||||
|
||||
{% block total_missing %}
|
||||
"bricktracker_parts"."missing" AS "total_missing",
|
||||
{% endblock %}
|
||||
|
||||
{% block total_damaged %}
|
||||
"bricktracker_parts"."damaged" AS "total_damaged",
|
||||
{% endblock %}
|
||||
|
||||
{% block total_quantity %}
|
||||
"bricktracker_parts"."quantity" AS "total_quantity",
|
||||
{% endblock %}
|
||||
|
||||
{% block total_spare %}
|
||||
"bricktracker_parts"."spare" AS "total_spare",
|
||||
{% endblock %}
|
||||
|
||||
{% block total_sets %}
|
||||
1 AS "total_sets",
|
||||
{% endblock %}
|
||||
|
||||
{% block total_minifigures %}
|
||||
CASE WHEN "bricktracker_parts"."figure" IS NOT NULL THEN 1 ELSE 0 END AS "total_minifigures"
|
||||
{% endblock %}
|
||||
|
||||
{% block where %}
|
||||
{% set conditions = [] %}
|
||||
{% if skip_spare_parts %}
|
||||
{% set _ = conditions.append('"bricktracker_parts"."spare" = 0') %}
|
||||
{% endif %}
|
||||
{% if conditions %}
|
||||
WHERE {{ conditions | join(' AND ') }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
3
bricktracker/sql/part/track_refresh_part.sql
Normal file
3
bricktracker/sql/part/track_refresh_part.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- Track that we've seen/updated this part during refresh
|
||||
INSERT OR IGNORE INTO temp_refresh_parts (id, part, color, figure, spare)
|
||||
VALUES (:id, :part, :color, :figure, :spare)
|
||||
11
bricktracker/sql/part/update_on_refresh.sql
Normal file
11
bricktracker/sql/part/update_on_refresh.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- Update existing part quantities during refresh while preserving tracking data
|
||||
UPDATE "bricktracker_parts"
|
||||
SET
|
||||
"quantity" = :quantity,
|
||||
"element" = :element,
|
||||
"rebrickable_inventory" = :rebrickable_inventory
|
||||
WHERE "id" = :id
|
||||
AND "figure" IS NOT DISTINCT FROM :figure
|
||||
AND "part" = :part
|
||||
AND "color" = :color
|
||||
AND "spare" = :spare
|
||||
24
bricktracker/sql/schema/integrity_check_summary.sql
Normal file
24
bricktracker/sql/schema/integrity_check_summary.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
-- Database integrity check summary
|
||||
-- Returns count of each type of integrity issue
|
||||
|
||||
SELECT 'orphaned_sets' as issue_type, COUNT(*) as count,
|
||||
'Sets in bricktracker_sets without matching rebrickable_sets record' as description
|
||||
FROM bricktracker_sets bs
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM rebrickable_sets rs WHERE rs."set" = bs."set"
|
||||
)
|
||||
UNION ALL
|
||||
SELECT 'orphaned_parts' as issue_type, COUNT(*) as count,
|
||||
'Parts in bricktracker_parts without matching rebrickable_parts record' as description
|
||||
FROM bricktracker_parts bp
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM rebrickable_parts rp WHERE rp.part = bp.part AND rp.color_id = bp.color
|
||||
)
|
||||
UNION ALL
|
||||
SELECT 'parts_missing_set' as issue_type, COUNT(DISTINCT bp.id) as count,
|
||||
'Parts referencing non-existent sets in bricktracker_sets' as description
|
||||
FROM bricktracker_parts bp
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM bricktracker_sets bs WHERE bs.id = bp.id
|
||||
)
|
||||
ORDER BY count DESC;
|
||||
@@ -0,0 +1,8 @@
|
||||
DELETE FROM bricktracker_minifigures
|
||||
WHERE id IN (
|
||||
SELECT bs.id
|
||||
FROM bricktracker_sets bs
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM rebrickable_sets rs WHERE rs."set" = bs."set"
|
||||
)
|
||||
);
|
||||
10
bricktracker/sql/schema/integrity_delete_orphaned_parts.sql
Normal file
10
bricktracker/sql/schema/integrity_delete_orphaned_parts.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- Delete orphaned parts (bricktracker_parts records without parent rebrickable_parts)
|
||||
|
||||
DELETE FROM bricktracker_parts
|
||||
WHERE rowid IN (
|
||||
SELECT bp.rowid
|
||||
FROM bricktracker_parts bp
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM rebrickable_parts rp WHERE rp.part = bp.part AND rp.color_id = bp.color
|
||||
)
|
||||
);
|
||||
10
bricktracker/sql/schema/integrity_delete_orphaned_sets.sql
Normal file
10
bricktracker/sql/schema/integrity_delete_orphaned_sets.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- Delete orphaned sets (bricktracker_sets records without parent rebrickable_sets)
|
||||
|
||||
DELETE FROM bricktracker_sets
|
||||
WHERE "set" IN (
|
||||
SELECT bs."set"
|
||||
FROM bricktracker_sets bs
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM rebrickable_sets rs WHERE rs."set" = bs."set"
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
DELETE FROM bricktracker_set_owners
|
||||
WHERE id IN (
|
||||
SELECT bs.id
|
||||
FROM bricktracker_sets bs
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM rebrickable_sets rs WHERE rs."set" = bs."set"
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
DELETE FROM bricktracker_parts
|
||||
WHERE id IN (
|
||||
SELECT bs.id
|
||||
FROM bricktracker_sets bs
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM rebrickable_sets rs WHERE rs."set" = bs."set"
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Delete parts that reference non-existent sets
|
||||
|
||||
DELETE FROM bricktracker_parts
|
||||
WHERE rowid IN (
|
||||
SELECT bp.rowid
|
||||
FROM bricktracker_parts bp
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM bricktracker_sets bs WHERE bs.id = bp.id
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
DELETE FROM bricktracker_set_statuses
|
||||
WHERE id IN (
|
||||
SELECT bs.id
|
||||
FROM bricktracker_sets bs
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM rebrickable_sets rs WHERE rs."set" = bs."set"
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
DELETE FROM bricktracker_set_tags
|
||||
WHERE id IN (
|
||||
SELECT bs.id
|
||||
FROM bricktracker_sets bs
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM rebrickable_sets rs WHERE rs."set" = bs."set"
|
||||
)
|
||||
);
|
||||
17
bricktracker/sql/schema/integrity_orphaned_parts.sql
Normal file
17
bricktracker/sql/schema/integrity_orphaned_parts.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- Find orphaned parts (bricktracker_parts records without parent rebrickable_parts)
|
||||
|
||||
SELECT
|
||||
bp.id,
|
||||
bp.part,
|
||||
bp.color,
|
||||
bp.quantity,
|
||||
bp.spare,
|
||||
bp.missing,
|
||||
bp.damaged,
|
||||
bs."set" as set_number
|
||||
FROM bricktracker_parts bp
|
||||
LEFT JOIN bricktracker_sets bs ON bs.id = bp.id
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM rebrickable_parts rp WHERE rp.part = bp.part AND rp.color_id = bp.color
|
||||
)
|
||||
ORDER BY bp.id, bp.part, bp.color;
|
||||
15
bricktracker/sql/schema/integrity_orphaned_sets.sql
Normal file
15
bricktracker/sql/schema/integrity_orphaned_sets.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- Find orphaned sets (bricktracker_sets records without parent rebrickable_sets)
|
||||
|
||||
SELECT
|
||||
bs."set",
|
||||
bs.id,
|
||||
bs.description,
|
||||
bs.storage,
|
||||
bs.purchase_date,
|
||||
bs.purchase_location,
|
||||
bs.purchase_price
|
||||
FROM bricktracker_sets bs
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM rebrickable_sets rs WHERE rs."set" = bs."set"
|
||||
)
|
||||
ORDER BY bs."set";
|
||||
15
bricktracker/sql/schema/integrity_parts_missing_set.sql
Normal file
15
bricktracker/sql/schema/integrity_parts_missing_set.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- Find parts referencing non-existent sets
|
||||
|
||||
SELECT
|
||||
bp.id,
|
||||
bp.part,
|
||||
bp.color,
|
||||
bp.quantity,
|
||||
bp.spare,
|
||||
bp.missing,
|
||||
bp.damaged
|
||||
FROM bricktracker_parts bp
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM bricktracker_sets bs WHERE bs.id = bp.id
|
||||
)
|
||||
ORDER BY bp.id, bp.part, bp.color;
|
||||
39
bricktracker/sql/schema/optimize.sql
Normal file
39
bricktracker/sql/schema/optimize.sql
Normal file
@@ -0,0 +1,39 @@
|
||||
-- Optimize database performance
|
||||
-- Re-applies performance indexes and runs database maintenance
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bricktracker_parts_id_missing_damaged
|
||||
ON bricktracker_parts(id, missing, damaged);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bricktracker_parts_part_color_spare
|
||||
ON bricktracker_parts(part, color, spare);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bricktracker_sets_set_storage
|
||||
ON bricktracker_sets("set", storage);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rebrickable_sets_name_lower
|
||||
ON rebrickable_sets(LOWER(name));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rebrickable_parts_name_lower
|
||||
ON rebrickable_parts(LOWER(name));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bricktracker_sets_purchase_location
|
||||
ON bricktracker_sets(purchase_location);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bricktracker_parts_quantity
|
||||
ON bricktracker_parts(quantity);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rebrickable_sets_year
|
||||
ON rebrickable_sets(year);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rebrickable_sets_theme_id
|
||||
ON rebrickable_sets(theme_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rebrickable_sets_number_version
|
||||
ON rebrickable_sets(number, version);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bricktracker_sets_purchase_date
|
||||
ON bricktracker_sets(purchase_date);
|
||||
|
||||
ANALYZE;
|
||||
|
||||
PRAGMA optimize;
|
||||
@@ -4,6 +4,7 @@ SELECT
|
||||
"bricktracker_sets"."purchase_date",
|
||||
"bricktracker_sets"."purchase_location",
|
||||
"bricktracker_sets"."purchase_price",
|
||||
"bricktracker_sets"."description",
|
||||
"rebrickable_sets"."set",
|
||||
"rebrickable_sets"."number",
|
||||
"rebrickable_sets"."version",
|
||||
|
||||
@@ -8,20 +8,36 @@ AND (LOWER("rebrickable_sets"."name") LIKE LOWER('%{{ search_query }}%')
|
||||
{% endif %}
|
||||
|
||||
{% if theme_filter %}
|
||||
{% if theme_filter is string and theme_filter.startswith('-') %}
|
||||
AND "rebrickable_sets"."theme_id" != {{ theme_filter[1:] }}
|
||||
{% else %}
|
||||
AND "rebrickable_sets"."theme_id" = {{ theme_filter }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if year_filter %}
|
||||
{% if year_filter is string and year_filter.startswith('-') %}
|
||||
AND "rebrickable_sets"."year" != {{ year_filter[1:] }}
|
||||
{% else %}
|
||||
AND "rebrickable_sets"."year" = {{ year_filter }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if storage_filter %}
|
||||
{% if storage_filter.startswith('-') %}
|
||||
AND ("bricktracker_sets"."storage" IS NULL OR "bricktracker_sets"."storage" != '{{ storage_filter[1:] }}')
|
||||
{% else %}
|
||||
AND "bricktracker_sets"."storage" = '{{ storage_filter }}'
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if purchase_location_filter %}
|
||||
{% if purchase_location_filter.startswith('-') %}
|
||||
AND ("bricktracker_sets"."purchase_location" IS NULL OR "bricktracker_sets"."purchase_location" != '{{ purchase_location_filter[1:] }}')
|
||||
{% else %}
|
||||
AND "bricktracker_sets"."purchase_location" = '{{ purchase_location_filter }}'
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if status_filter %}
|
||||
{% if status_filter == 'has-missing' %}
|
||||
@@ -52,7 +68,13 @@ AND NOT EXISTS (
|
||||
{% endif %}
|
||||
|
||||
{% if owner_filter %}
|
||||
{% if owner_filter.startswith('owner-') %}
|
||||
{% if owner_filter.startswith('-owner-') %}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "bricktracker_set_owners"
|
||||
WHERE "bricktracker_set_owners"."id" = "bricktracker_sets"."id"
|
||||
AND "bricktracker_set_owners"."{{ owner_filter[1:].replace('-', '_') }}" = 1
|
||||
)
|
||||
{% elif owner_filter.startswith('owner-') %}
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM "bricktracker_set_owners"
|
||||
WHERE "bricktracker_set_owners"."id" = "bricktracker_sets"."id"
|
||||
@@ -62,7 +84,13 @@ AND EXISTS (
|
||||
{% endif %}
|
||||
|
||||
{% if tag_filter %}
|
||||
{% if tag_filter.startswith('tag-') %}
|
||||
{% if tag_filter.startswith('-tag-') %}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "bricktracker_set_tags"
|
||||
WHERE "bricktracker_set_tags"."id" = "bricktracker_sets"."id"
|
||||
AND "bricktracker_set_tags"."{{ tag_filter[1:].replace('-', '_') }}" = 1
|
||||
)
|
||||
{% elif tag_filter.startswith('tag-') %}
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM "bricktracker_set_tags"
|
||||
WHERE "bricktracker_set_tags"."id" = "bricktracker_sets"."id"
|
||||
|
||||
@@ -19,7 +19,8 @@ SELECT
|
||||
MIN("bricktracker_sets"."purchase_date") AS "purchase_date",
|
||||
MAX("bricktracker_sets"."purchase_date") AS "purchase_date_max",
|
||||
REPLACE(GROUP_CONCAT(DISTINCT "bricktracker_sets"."purchase_location"), ',', '|') AS "purchase_location",
|
||||
ROUND(AVG("bricktracker_sets"."purchase_price"), 1) AS "purchase_price"
|
||||
ROUND(AVG("bricktracker_sets"."purchase_price"), 1) AS "purchase_price",
|
||||
(SELECT "description" FROM "bricktracker_sets" WHERE "set" = "rebrickable_sets"."set" LIMIT 1) AS "description"
|
||||
{% block owners %}
|
||||
{% if owners_dict %}
|
||||
{% for column, uuid in owners_dict.items() %}
|
||||
@@ -91,28 +92,52 @@ AND (LOWER("rebrickable_sets"."name") LIKE LOWER('%{{ search_query }}%')
|
||||
{% endif %}
|
||||
|
||||
{% if theme_filter %}
|
||||
{% if theme_filter is string and theme_filter.startswith('-') %}
|
||||
AND "rebrickable_sets"."theme_id" != {{ theme_filter[1:] }}
|
||||
{% else %}
|
||||
AND "rebrickable_sets"."theme_id" = {{ theme_filter }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if year_filter %}
|
||||
{% if year_filter is string and year_filter.startswith('-') %}
|
||||
AND "rebrickable_sets"."year" != {{ year_filter[1:] }}
|
||||
{% else %}
|
||||
AND "rebrickable_sets"."year" = {{ year_filter }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if storage_filter %}
|
||||
{% if storage_filter.startswith('-') %}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "bricktracker_sets" bs_filter
|
||||
WHERE bs_filter."set" = "rebrickable_sets"."set"
|
||||
AND bs_filter."storage" = '{{ storage_filter[1:] }}'
|
||||
)
|
||||
{% else %}
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM "bricktracker_sets" bs_filter
|
||||
WHERE bs_filter."set" = "rebrickable_sets"."set"
|
||||
AND bs_filter."storage" = '{{ storage_filter }}'
|
||||
)
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if purchase_location_filter %}
|
||||
{% if purchase_location_filter.startswith('-') %}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "bricktracker_sets" bs_filter
|
||||
WHERE bs_filter."set" = "rebrickable_sets"."set"
|
||||
AND bs_filter."purchase_location" = '{{ purchase_location_filter[1:] }}'
|
||||
)
|
||||
{% else %}
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM "bricktracker_sets" bs_filter
|
||||
WHERE bs_filter."set" = "rebrickable_sets"."set"
|
||||
AND bs_filter."purchase_location" = '{{ purchase_location_filter }}'
|
||||
)
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if status_filter %}
|
||||
{% if status_filter == 'has-storage' %}
|
||||
|
||||
3
bricktracker/sql/set/update/description.sql
Normal file
3
bricktracker/sql/set/update/description.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
UPDATE "bricktracker_sets"
|
||||
SET "description" = :description
|
||||
WHERE "bricktracker_sets"."id" IS NOT DISTINCT FROM :id
|
||||
242
bricktracker/sql_integrity.py
Normal file
242
bricktracker/sql_integrity.py
Normal file
@@ -0,0 +1,242 @@
|
||||
import logging
|
||||
|
||||
from .sql import BrickSQL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BrickIntegrityIssue(object):
|
||||
issue_type: str
|
||||
count: int
|
||||
description: str
|
||||
|
||||
def __init__(self, issue_type: str, count: int, description: str, /):
|
||||
self.issue_type = issue_type
|
||||
self.count = count
|
||||
self.description = description
|
||||
|
||||
|
||||
class BrickOrphanedSet(object):
|
||||
set: str
|
||||
id: str
|
||||
description: str | None
|
||||
storage: str | None
|
||||
purchase_date: float | None
|
||||
purchase_location: str | None
|
||||
purchase_price: float | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
set: str,
|
||||
id: str,
|
||||
description: str | None,
|
||||
storage: str | None,
|
||||
purchase_date: float | None,
|
||||
purchase_location: str | None,
|
||||
purchase_price: float | None,
|
||||
/
|
||||
):
|
||||
self.set = set
|
||||
self.id = id
|
||||
self.description = description
|
||||
self.storage = storage
|
||||
self.purchase_date = purchase_date
|
||||
self.purchase_location = purchase_location
|
||||
self.purchase_price = purchase_price
|
||||
|
||||
|
||||
class BrickOrphanedPart(object):
|
||||
id: str
|
||||
part: str
|
||||
color: int
|
||||
quantity: int
|
||||
spare: bool
|
||||
missing: int
|
||||
damaged: int
|
||||
set_number: str | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
id: str,
|
||||
part: str,
|
||||
color: int,
|
||||
quantity: int,
|
||||
spare: bool,
|
||||
missing: int,
|
||||
damaged: int,
|
||||
set_number: str | None,
|
||||
/
|
||||
):
|
||||
self.id = id
|
||||
self.part = part
|
||||
self.color = color
|
||||
self.quantity = quantity
|
||||
self.spare = spare
|
||||
self.missing = missing
|
||||
self.damaged = damaged
|
||||
self.set_number = set_number
|
||||
|
||||
|
||||
class BrickPartMissingSet(object):
|
||||
id: str
|
||||
part: str
|
||||
color: int
|
||||
quantity: int
|
||||
spare: bool
|
||||
missing: int
|
||||
damaged: int
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
id: str,
|
||||
part: str,
|
||||
color: int,
|
||||
quantity: int,
|
||||
spare: bool,
|
||||
missing: int,
|
||||
damaged: int,
|
||||
/
|
||||
):
|
||||
self.id = id
|
||||
self.part = part
|
||||
self.color = color
|
||||
self.quantity = quantity
|
||||
self.spare = spare
|
||||
self.missing = missing
|
||||
self.damaged = damaged
|
||||
|
||||
|
||||
class BrickIntegrityCheck(object):
|
||||
def check_summary(self, /) -> list[BrickIntegrityIssue]:
|
||||
sql = BrickSQL()
|
||||
results = sql.fetchall('schema/integrity_check_summary')
|
||||
|
||||
issues: list[BrickIntegrityIssue] = []
|
||||
for row in results:
|
||||
issues.append(BrickIntegrityIssue(
|
||||
row['issue_type'],
|
||||
row['count'],
|
||||
row['description']
|
||||
))
|
||||
|
||||
return issues
|
||||
|
||||
def get_orphaned_sets(self, /) -> list[BrickOrphanedSet]:
|
||||
sql = BrickSQL()
|
||||
results = sql.fetchall('schema/integrity_orphaned_sets')
|
||||
|
||||
sets: list[BrickOrphanedSet] = []
|
||||
for row in results:
|
||||
sets.append(BrickOrphanedSet(
|
||||
row['set'],
|
||||
row['id'],
|
||||
row['description'],
|
||||
row['storage'],
|
||||
row['purchase_date'],
|
||||
row['purchase_location'],
|
||||
row['purchase_price']
|
||||
))
|
||||
|
||||
return sets
|
||||
|
||||
def get_orphaned_parts(self, /) -> list[BrickOrphanedPart]:
|
||||
sql = BrickSQL()
|
||||
results = sql.fetchall('schema/integrity_orphaned_parts')
|
||||
|
||||
parts: list[BrickOrphanedPart] = []
|
||||
for row in results:
|
||||
parts.append(BrickOrphanedPart(
|
||||
row['id'],
|
||||
row['part'],
|
||||
row['color'],
|
||||
row['quantity'],
|
||||
row['spare'],
|
||||
row['missing'],
|
||||
row['damaged'],
|
||||
row['set_number']
|
||||
))
|
||||
|
||||
return parts
|
||||
|
||||
def get_parts_missing_set(self, /) -> list[BrickPartMissingSet]:
|
||||
sql = BrickSQL()
|
||||
results = sql.fetchall('schema/integrity_parts_missing_set')
|
||||
|
||||
parts: list[BrickPartMissingSet] = []
|
||||
for row in results:
|
||||
parts.append(BrickPartMissingSet(
|
||||
row['id'],
|
||||
row['part'],
|
||||
row['color'],
|
||||
row['quantity'],
|
||||
row['spare'],
|
||||
row['missing'],
|
||||
row['damaged']
|
||||
))
|
||||
|
||||
return parts
|
||||
|
||||
def cleanup_orphaned_sets(self, /) -> int:
|
||||
sql = BrickSQL()
|
||||
orphaned = self.get_orphaned_sets()
|
||||
count = len(orphaned)
|
||||
|
||||
if count > 0:
|
||||
sql.executescript('schema/integrity_delete_parts_for_orphaned_sets')
|
||||
sql.executescript('schema/integrity_delete_minifigures_for_orphaned_sets')
|
||||
sql.executescript('schema/integrity_delete_tags_for_orphaned_sets')
|
||||
sql.executescript('schema/integrity_delete_owners_for_orphaned_sets')
|
||||
sql.executescript('schema/integrity_delete_statuses_for_orphaned_sets')
|
||||
sql.executescript('schema/integrity_delete_orphaned_sets')
|
||||
sql.commit()
|
||||
logger.info(f'Deleted {count} orphaned set(s)')
|
||||
|
||||
return count
|
||||
|
||||
def cleanup_orphaned_parts(self, /) -> int:
|
||||
sql = BrickSQL()
|
||||
orphaned = self.get_orphaned_parts()
|
||||
count = len(orphaned)
|
||||
|
||||
if count > 0:
|
||||
sql.executescript('schema/integrity_delete_orphaned_parts')
|
||||
sql.commit()
|
||||
logger.info(f'Deleted {count} orphaned part(s)')
|
||||
|
||||
return count
|
||||
|
||||
def cleanup_parts_missing_set(self, /) -> int:
|
||||
sql = BrickSQL()
|
||||
orphaned = self.get_parts_missing_set()
|
||||
count = len(orphaned)
|
||||
|
||||
if count > 0:
|
||||
sql.executescript('schema/integrity_delete_parts_missing_set')
|
||||
sql.commit()
|
||||
logger.info(f'Deleted {count} part(s) with missing set references')
|
||||
|
||||
return count
|
||||
|
||||
def cleanup_all(self, /) -> dict[str, int]:
|
||||
orphaned_parts = self.cleanup_orphaned_parts()
|
||||
parts_missing_set = self.cleanup_parts_missing_set()
|
||||
orphaned_sets = self.cleanup_orphaned_sets()
|
||||
|
||||
counts = {
|
||||
'orphaned_parts': orphaned_parts,
|
||||
'parts_missing_set': parts_missing_set,
|
||||
'orphaned_sets': orphaned_sets
|
||||
}
|
||||
|
||||
total = sum(counts.values())
|
||||
logger.info(f'Integrity cleanup complete: {total} total records removed')
|
||||
|
||||
return counts
|
||||
|
||||
def optimize_database(self, /) -> None:
|
||||
sql = BrickSQL()
|
||||
sql.executescript('schema/optimize')
|
||||
sql.commit()
|
||||
|
||||
sql.connection.execute('VACUUM')
|
||||
logger.info('Database optimization complete')
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Final
|
||||
|
||||
__version__: Final[str] = '1.3.1'
|
||||
__version__: Final[str] = '1.4.0'
|
||||
__database_version__: Final[int] = 20
|
||||
|
||||
@@ -17,6 +17,7 @@ from werkzeug.wrappers.response import Response
|
||||
|
||||
from ..exceptions import exception_handler
|
||||
from ...reload import reload
|
||||
from ...sql_integrity import BrickIntegrityCheck
|
||||
from ...sql_migration_list import BrickSQLMigrationList
|
||||
from ...sql import BrickSQL
|
||||
from ..upload import upload_helper
|
||||
@@ -184,3 +185,57 @@ def upgrade() -> str | Response:
|
||||
),
|
||||
database_error=request.args.get('database_error')
|
||||
)
|
||||
|
||||
|
||||
@admin_database_page.route('/integrity/check', methods=['GET'])
|
||||
@login_required
|
||||
@exception_handler(__file__)
|
||||
def integrity_check() -> str:
|
||||
integrity = BrickIntegrityCheck()
|
||||
issues = integrity.check_summary()
|
||||
orphaned_sets = integrity.get_orphaned_sets()
|
||||
orphaned_parts = integrity.get_orphaned_parts()
|
||||
parts_missing_set = integrity.get_parts_missing_set()
|
||||
total_issues = sum(issue.count for issue in issues)
|
||||
|
||||
return render_template(
|
||||
'admin.html',
|
||||
integrity_check=True,
|
||||
integrity_issues=issues,
|
||||
orphaned_sets=orphaned_sets,
|
||||
orphaned_parts=orphaned_parts,
|
||||
parts_missing_set=parts_missing_set,
|
||||
total_issues=total_issues,
|
||||
database_error=request.args.get('database_error')
|
||||
)
|
||||
|
||||
|
||||
@admin_database_page.route('/integrity/cleanup', methods=['POST'])
|
||||
@login_required
|
||||
@exception_handler(
|
||||
__file__,
|
||||
post_redirect='admin_database.integrity_check',
|
||||
error_name='database_error'
|
||||
)
|
||||
def integrity_cleanup() -> Response:
|
||||
integrity = BrickIntegrityCheck()
|
||||
counts = integrity.cleanup_all()
|
||||
total = sum(counts.values())
|
||||
logger.info(f'Database integrity cleanup: removed {total} orphaned records')
|
||||
|
||||
return redirect(url_for('admin.admin', cleanup_success=total))
|
||||
|
||||
|
||||
@admin_database_page.route('/optimize', methods=['POST'])
|
||||
@login_required
|
||||
@exception_handler(
|
||||
__file__,
|
||||
post_redirect='admin.admin',
|
||||
error_name='database_error'
|
||||
)
|
||||
def optimize() -> Response:
|
||||
integrity = BrickIntegrityCheck()
|
||||
integrity.optimize_database()
|
||||
logger.info('Database optimization complete')
|
||||
|
||||
return redirect(url_for('admin.admin', optimize_success=1))
|
||||
|
||||
295
bricktracker/views/admin/export.py
Normal file
295
bricktracker/views/admin/export.py
Normal file
@@ -0,0 +1,295 @@
|
||||
import csv
|
||||
import io
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
request,
|
||||
)
|
||||
from flask_login import login_required
|
||||
from werkzeug.wrappers.response import Response
|
||||
|
||||
from ..exceptions import exception_handler
|
||||
from ...part_list import BrickPartList
|
||||
from ...set_list import BrickSetList
|
||||
|
||||
admin_export_page = Blueprint(
|
||||
'admin_export',
|
||||
__name__,
|
||||
url_prefix='/admin/export'
|
||||
)
|
||||
|
||||
|
||||
# Export all sets to Rebrickable CSV format
|
||||
@admin_export_page.route('/sets/rebrickable-csv', methods=['GET'])
|
||||
@login_required
|
||||
@exception_handler(__file__)
|
||||
def export_sets_rebrickable() -> Response:
|
||||
|
||||
set_list = BrickSetList()
|
||||
all_sets = set_list.all()
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
writer.writerow(['Set Number', 'Quantity', 'Includes Spares', 'Inventory Ver'])
|
||||
|
||||
for set_item in all_sets.records:
|
||||
writer.writerow([
|
||||
set_item.fields.set,
|
||||
1, # Each set instance counts as 1
|
||||
'True', # BrickTracker tracks spares separately
|
||||
1 # Inventory version (always 1 for now)
|
||||
])
|
||||
|
||||
# Prepare response
|
||||
output.seek(0)
|
||||
return Response(
|
||||
output.getvalue(),
|
||||
mimetype='text/csv',
|
||||
headers={'Content-Disposition': 'attachment;filename=bricktracker_sets_rebrickable.csv'}
|
||||
)
|
||||
|
||||
|
||||
# Export all parts to Rebrickable CSV format
|
||||
@admin_export_page.route('/parts/rebrickable-csv', methods=['GET'])
|
||||
@login_required
|
||||
@exception_handler(__file__)
|
||||
def export_parts_rebrickable() -> Response:
|
||||
|
||||
owner_id = request.args.get('owner')
|
||||
color_id = request.args.get('color')
|
||||
theme_id = request.args.get('theme')
|
||||
year = request.args.get('year')
|
||||
|
||||
part_list = BrickPartList()
|
||||
part_list.all_filtered(owner_id, color_id, theme_id, year)
|
||||
|
||||
part_quantities = {}
|
||||
for part in part_list.records:
|
||||
key = (part.fields.part, part.fields.color)
|
||||
if key in part_quantities:
|
||||
part_quantities[key] += part.fields.quantity
|
||||
else:
|
||||
part_quantities[key] = part.fields.quantity
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
writer.writerow(['Part', 'Color', 'Quantity'])
|
||||
|
||||
for (part_num, color_id), quantity in sorted(part_quantities.items()):
|
||||
writer.writerow([part_num, color_id, quantity])
|
||||
|
||||
output.seek(0)
|
||||
return Response(
|
||||
output.getvalue(),
|
||||
mimetype='text/csv',
|
||||
headers={'Content-Disposition': 'attachment;filename=bricktracker_parts_rebrickable.csv'}
|
||||
)
|
||||
|
||||
|
||||
# Export all parts to LEGO Pick-a-Brick CSV format
|
||||
@admin_export_page.route('/parts/lego-csv', methods=['GET'])
|
||||
@login_required
|
||||
@exception_handler(__file__)
|
||||
def export_parts_lego() -> Response:
|
||||
|
||||
owner_id = request.args.get('owner')
|
||||
color_id = request.args.get('color')
|
||||
theme_id = request.args.get('theme')
|
||||
year = request.args.get('year')
|
||||
|
||||
part_list = BrickPartList()
|
||||
part_list.all_filtered(owner_id, color_id, theme_id, year)
|
||||
|
||||
element_quantities = {}
|
||||
for part in part_list.records:
|
||||
if part.fields.element:
|
||||
element_id = part.fields.element
|
||||
if element_id in element_quantities:
|
||||
element_quantities[element_id] += part.fields.quantity
|
||||
else:
|
||||
element_quantities[element_id] = part.fields.quantity
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
writer.writerow(['elementId', 'quantity'])
|
||||
|
||||
for element_id, quantity in sorted(element_quantities.items()):
|
||||
writer.writerow([element_id, quantity])
|
||||
|
||||
output.seek(0)
|
||||
return Response(
|
||||
output.getvalue(),
|
||||
mimetype='text/csv',
|
||||
headers={'Content-Disposition': 'attachment;filename=bricktracker_parts_lego.csv'}
|
||||
)
|
||||
|
||||
|
||||
# Export all parts to BrickLink XML format
|
||||
@admin_export_page.route('/parts/bricklink-xml', methods=['GET'])
|
||||
@login_required
|
||||
@exception_handler(__file__)
|
||||
def export_parts_bricklink() -> Response:
|
||||
|
||||
owner_id = request.args.get('owner')
|
||||
color_id = request.args.get('color')
|
||||
theme_id = request.args.get('theme')
|
||||
year = request.args.get('year')
|
||||
|
||||
part_list = BrickPartList()
|
||||
part_list.all_filtered(owner_id, color_id, theme_id, year)
|
||||
|
||||
part_quantities = {}
|
||||
for part in part_list.records:
|
||||
part_num = part.fields.bricklink_part_num or part.fields.part
|
||||
color_id = part.fields.bricklink_color_id or part.fields.color
|
||||
|
||||
key = (part_num, color_id)
|
||||
if key in part_quantities:
|
||||
part_quantities[key] += part.fields.quantity
|
||||
else:
|
||||
part_quantities[key] = part.fields.quantity
|
||||
|
||||
xml_lines = ['<INVENTORY>']
|
||||
|
||||
for (part_num, color_id), quantity in sorted(part_quantities.items()):
|
||||
xml_lines.append(
|
||||
f'<ITEM><ITEMTYPE>P</ITEMTYPE><ITEMID>{part_num}</ITEMID>'
|
||||
f'<COLOR>{color_id}</COLOR><MINQTY>{quantity}</MINQTY></ITEM>'
|
||||
)
|
||||
|
||||
xml_lines.append('</INVENTORY>')
|
||||
xml_content = ''.join(xml_lines)
|
||||
|
||||
return Response(
|
||||
xml_content,
|
||||
mimetype='application/xml',
|
||||
headers={'Content-Disposition': 'attachment;filename=bricktracker_parts_bricklink.xml'}
|
||||
)
|
||||
|
||||
|
||||
# Export missing/damaged parts to Rebrickable CSV format
|
||||
@admin_export_page.route('/problems/rebrickable-csv', methods=['GET'])
|
||||
@login_required
|
||||
@exception_handler(__file__)
|
||||
def export_problems_rebrickable() -> Response:
|
||||
|
||||
owner_id = request.args.get('owner')
|
||||
color_id = request.args.get('color')
|
||||
theme_id = request.args.get('theme')
|
||||
year = request.args.get('year')
|
||||
|
||||
part_list = BrickPartList()
|
||||
part_list.problem_filtered(owner_id, color_id, theme_id, year)
|
||||
|
||||
part_quantities = {}
|
||||
for part in part_list.records:
|
||||
qty = (part.fields.missing or 0) + (part.fields.damaged or 0)
|
||||
if qty > 0:
|
||||
key = (part.fields.part, part.fields.color)
|
||||
if key in part_quantities:
|
||||
part_quantities[key] += qty
|
||||
else:
|
||||
part_quantities[key] = qty
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
writer.writerow(['Part', 'Color', 'Quantity'])
|
||||
|
||||
for (part_num, color_id), quantity in sorted(part_quantities.items()):
|
||||
writer.writerow([part_num, color_id, quantity])
|
||||
|
||||
output.seek(0)
|
||||
return Response(
|
||||
output.getvalue(),
|
||||
mimetype='text/csv',
|
||||
headers={'Content-Disposition': 'attachment;filename=bricktracker_problems_rebrickable.csv'}
|
||||
)
|
||||
|
||||
|
||||
# Export missing/damaged parts to LEGO Pick-a-Brick CSV format
|
||||
@admin_export_page.route('/problems/lego-csv', methods=['GET'])
|
||||
@login_required
|
||||
@exception_handler(__file__)
|
||||
def export_problems_lego() -> Response:
|
||||
|
||||
owner_id = request.args.get('owner')
|
||||
color_id = request.args.get('color')
|
||||
theme_id = request.args.get('theme')
|
||||
year = request.args.get('year')
|
||||
|
||||
part_list = BrickPartList()
|
||||
part_list.problem_filtered(owner_id, color_id, theme_id, year)
|
||||
|
||||
element_quantities = {}
|
||||
for part in part_list.records:
|
||||
qty = (part.fields.missing or 0) + (part.fields.damaged or 0)
|
||||
if qty > 0 and part.fields.element:
|
||||
element_id = part.fields.element
|
||||
if element_id in element_quantities:
|
||||
element_quantities[element_id] += qty
|
||||
else:
|
||||
element_quantities[element_id] = qty
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
writer.writerow(['elementId', 'quantity'])
|
||||
|
||||
for element_id, quantity in sorted(element_quantities.items()):
|
||||
writer.writerow([element_id, quantity])
|
||||
|
||||
output.seek(0)
|
||||
return Response(
|
||||
output.getvalue(),
|
||||
mimetype='text/csv',
|
||||
headers={'Content-Disposition': 'attachment;filename=bricktracker_problems_lego.csv'}
|
||||
)
|
||||
|
||||
|
||||
# Export missing/damaged parts to BrickLink XML format
|
||||
@admin_export_page.route('/problems/bricklink-xml', methods=['GET'])
|
||||
@login_required
|
||||
@exception_handler(__file__)
|
||||
def export_problems_bricklink() -> Response:
|
||||
|
||||
owner_id = request.args.get('owner')
|
||||
color_id = request.args.get('color')
|
||||
theme_id = request.args.get('theme')
|
||||
year = request.args.get('year')
|
||||
|
||||
part_list = BrickPartList()
|
||||
part_list.problem_filtered(owner_id, color_id, theme_id, year)
|
||||
|
||||
part_quantities = {}
|
||||
for part in part_list.records:
|
||||
qty = (part.fields.missing or 0) + (part.fields.damaged or 0)
|
||||
if qty > 0:
|
||||
part_num = part.fields.bricklink_part_num or part.fields.part
|
||||
color_id = part.fields.bricklink_color_id or part.fields.color
|
||||
|
||||
key = (part_num, color_id)
|
||||
if key in part_quantities:
|
||||
part_quantities[key] += qty
|
||||
else:
|
||||
part_quantities[key] = qty
|
||||
|
||||
xml_lines = ['<INVENTORY>']
|
||||
|
||||
for (part_num, color_id), quantity in sorted(part_quantities.items()):
|
||||
xml_lines.append(
|
||||
f'<ITEM><ITEMTYPE>P</ITEMTYPE><ITEMID>{part_num}</ITEMID>'
|
||||
f'<COLOR>{color_id}</COLOR><MINQTY>{quantity}</MINQTY></ITEM>'
|
||||
)
|
||||
|
||||
xml_lines.append('</INVENTORY>')
|
||||
xml_content = ''.join(xml_lines)
|
||||
|
||||
return Response(
|
||||
xml_content,
|
||||
mimetype='application/xml',
|
||||
headers={'Content-Disposition': 'attachment;filename=bricktracker_problems_bricklink.xml'}
|
||||
)
|
||||
@@ -1,8 +1,10 @@
|
||||
from flask import Blueprint, render_template, request
|
||||
from flask import Blueprint, current_app, render_template, request
|
||||
from flask_login import login_required
|
||||
|
||||
from ..exceptions import exception_handler
|
||||
from ...configuration_list import BrickConfigurationList
|
||||
from ...rebrickable_set_list import RebrickableSetList
|
||||
from ...socket import MESSAGES
|
||||
|
||||
admin_set_page = Blueprint('admin_set', __name__, url_prefix='/admin/set')
|
||||
|
||||
@@ -18,3 +20,28 @@ def refresh() -> str:
|
||||
table_collection=RebrickableSetList().need_refresh(),
|
||||
set_error=request.args.get('set_error')
|
||||
)
|
||||
|
||||
|
||||
# Bulk refresh sets
|
||||
@admin_set_page.route('/refresh/bulk', methods=['GET'])
|
||||
@login_required
|
||||
@exception_handler(__file__)
|
||||
def refresh_bulk() -> str:
|
||||
BrickConfigurationList.error_unless_is_set('REBRICKABLE_API_KEY')
|
||||
|
||||
# Get list of sets needing refresh
|
||||
refresh_needed = RebrickableSetList().need_refresh()
|
||||
|
||||
# Build comma-separated list of set numbers
|
||||
set_list = ', '.join([s.fields.set for s in refresh_needed.records])
|
||||
|
||||
return render_template(
|
||||
'admin/set/refresh_bulk.html',
|
||||
path=current_app.config['SOCKET_PATH'],
|
||||
namespace=current_app.config['SOCKET_NAMESPACE'],
|
||||
messages=MESSAGES,
|
||||
bulk=True,
|
||||
refresh=True,
|
||||
set_list=set_list,
|
||||
refresh_count=len(refresh_needed.records)
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ from flask import Blueprint, render_template
|
||||
|
||||
from .exceptions import exception_handler
|
||||
from ..minifigure_list import BrickMinifigureList
|
||||
from ..part_list import BrickPartList
|
||||
from ..set_status_list import BrickSetStatusList
|
||||
from ..set_list import BrickSetList, set_metadata_lists
|
||||
|
||||
@@ -17,5 +18,6 @@ def index() -> str:
|
||||
brickset_collection=BrickSetList().last(),
|
||||
brickset_statuses=BrickSetStatusList.list(),
|
||||
minifigure_collection=BrickMinifigureList().last(),
|
||||
part_collection=BrickPartList().last(),
|
||||
**set_metadata_lists(as_class=True)
|
||||
)
|
||||
|
||||
@@ -158,6 +158,18 @@ def update_purchase_price(*, id: str) -> Response:
|
||||
return jsonify({'value': value})
|
||||
|
||||
|
||||
# Change the value of description
|
||||
@set_page.route('/<id>/description', methods=['POST'])
|
||||
@login_required
|
||||
@exception_handler(__file__, json=True)
|
||||
def update_description(*, id: str) -> Response:
|
||||
brickset = BrickSet().select_light(id)
|
||||
|
||||
value = brickset.update_description(request.json)
|
||||
|
||||
return jsonify({'value': value})
|
||||
|
||||
|
||||
# Change the state of a owner
|
||||
@set_page.route('/<id>/owner/<metadata_id>', methods=['POST'])
|
||||
@login_required
|
||||
|
||||
24
compose.dev.yaml
Normal file
24
compose.dev.yaml
Normal file
@@ -0,0 +1,24 @@
|
||||
services:
|
||||
bricktracker:
|
||||
container_name: BrickTracker
|
||||
restart: unless-stopped
|
||||
build: .
|
||||
ports:
|
||||
- "3334:3333"
|
||||
volumes:
|
||||
- ./local:/app/data # Data directory for database and files
|
||||
- ./bricktracker:/app/bricktracker # Mount code for live reload
|
||||
- ./templates:/app/templates # Mount templates for live reload
|
||||
- ./static:/app/static # Mount static files for live reload
|
||||
environment:
|
||||
- BK_DEBUG=true
|
||||
- FLASK_ENV=development
|
||||
- FLASK_DEBUG=1
|
||||
# For local development, place .env in data/ folder
|
||||
# The app automatically detects and uses data/.env (no env_file needed)
|
||||
# Uncomment below only if you keep .env in root for backward compatibility
|
||||
# env_file: .env
|
||||
develop:
|
||||
watch:
|
||||
- action: rebuild
|
||||
path: requirements.txt
|
||||
@@ -74,7 +74,7 @@
|
||||
## API and Network Configuration
|
||||
| Variable | Purpose | Default | Required |
|
||||
|----------|---------|----------|-----------|
|
||||
| `BK_DOMAIN_NAME` | CORS origin restriction | None | No |
|
||||
| `BK_DOMAIN_NAME` | Socket.IO CORS origin restriction (leave empty for reverse proxy) | None | No |
|
||||
| `BK_REBRICKABLE_PAGE_SIZE` | Items per API call | `100` | No |
|
||||
| `BK_SOCKET_NAMESPACE` | Socket.IO namespace | `bricksocket` | No |
|
||||
| `BK_SOCKET_PATH` | Socket.IO path | `/bricksocket/` | No |
|
||||
|
||||
@@ -38,6 +38,11 @@ class BrickChanger {
|
||||
listener = "change";
|
||||
break;
|
||||
|
||||
case "TEXTAREA":
|
||||
this.html_type = "textarea";
|
||||
listener = "change";
|
||||
break;
|
||||
|
||||
default:
|
||||
throw Error(`Unsupported HTML tag type for BrickChanger: ${this.html_element.tagName}`);
|
||||
}
|
||||
@@ -131,6 +136,7 @@ class BrickChanger {
|
||||
|
||||
case "text":
|
||||
case "select":
|
||||
case "textarea":
|
||||
value = this.html_element.value;
|
||||
break;
|
||||
|
||||
|
||||
@@ -67,28 +67,33 @@ class BrickGridFilter {
|
||||
|
||||
// Build filters
|
||||
for (const select of this.selects) {
|
||||
if (select.value != "") {
|
||||
// Get the actual filter value (includes "-" prefix if toggle is in NOT mode)
|
||||
const filterValue = typeof BrickFilterToggle !== 'undefined'
|
||||
? BrickFilterToggle.getFilterValue(select)
|
||||
: select.value;
|
||||
|
||||
if (filterValue != "") {
|
||||
// Multi-attribute filter
|
||||
switch (select.dataset.filter) {
|
||||
// List contains values
|
||||
case "value":
|
||||
options.filters.push({
|
||||
attribute: select.dataset.filterAttribute,
|
||||
value: select.value,
|
||||
value: filterValue,
|
||||
})
|
||||
break;
|
||||
|
||||
// List contains metadata attribute name, looking for true/false
|
||||
case "metadata":
|
||||
if (select.value.startsWith("-")) {
|
||||
if (filterValue.startsWith("-")) {
|
||||
options.filters.push({
|
||||
attribute: select.value.substring(1),
|
||||
attribute: filterValue.substring(1),
|
||||
bool: true,
|
||||
value: "0"
|
||||
})
|
||||
} else {
|
||||
options.filters.push({
|
||||
attribute: select.value,
|
||||
attribute: filterValue,
|
||||
bool: true,
|
||||
value: "1"
|
||||
});
|
||||
@@ -130,23 +135,58 @@ class BrickGridFilter {
|
||||
|
||||
// Value check
|
||||
// For consolidated cards, attributes may be comma or pipe-separated (e.g., "storage1,storage2" or "storage1|storage2")
|
||||
else if (attribute == null) {
|
||||
// Hide if attribute is missing
|
||||
current.parentElement.classList.add("d-none");
|
||||
return;
|
||||
} else if (attribute.includes(',') || attribute.includes('|')) {
|
||||
// Handle comma or pipe-separated values (consolidated cards)
|
||||
const separator = attribute.includes('|') ? '|' : ',';
|
||||
const values = attribute.split(separator).map(v => v.trim());
|
||||
if (!values.includes(filter.value)) {
|
||||
current.parentElement.classList.add("d-none");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Handle single values (regular cards)
|
||||
if (attribute != filter.value) {
|
||||
current.parentElement.classList.add("d-none");
|
||||
return;
|
||||
else {
|
||||
// Check if this is a NOT filter (value starts with "-")
|
||||
const isNot = filter.value.startsWith('-');
|
||||
const actualValue = isNot ? filter.value.substring(1) : filter.value;
|
||||
|
||||
if (attribute == null) {
|
||||
// If attribute is missing
|
||||
if (isNot) {
|
||||
// NOT filter: missing attribute means it doesn't match, so SHOW it
|
||||
// (e.g., NOT "Basement" and has no storage = show)
|
||||
// Continue to next filter
|
||||
} else {
|
||||
// Regular filter: missing attribute means hide
|
||||
current.parentElement.classList.add("d-none");
|
||||
return;
|
||||
}
|
||||
} else if (attribute.includes(',') || attribute.includes('|')) {
|
||||
// Handle comma or pipe-separated values (consolidated cards)
|
||||
const separator = attribute.includes('|') ? '|' : ',';
|
||||
const values = attribute.split(separator).map(v => v.trim());
|
||||
const hasValue = values.includes(actualValue);
|
||||
|
||||
if (isNot) {
|
||||
// NOT filter: hide if ANY of the values match
|
||||
if (hasValue) {
|
||||
current.parentElement.classList.add("d-none");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Regular filter: hide if NONE of the values match
|
||||
if (!hasValue) {
|
||||
current.parentElement.classList.add("d-none");
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Handle single values (regular cards)
|
||||
const matches = (attribute == actualValue);
|
||||
|
||||
if (isNot) {
|
||||
// NOT filter: hide if it matches
|
||||
if (matches) {
|
||||
current.parentElement.classList.add("d-none");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Regular filter: hide if it doesn't match
|
||||
if (!matches) {
|
||||
current.parentElement.classList.add("d-none");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
121
static/scripts/grid/filter_toggle.js
Normal file
121
static/scripts/grid/filter_toggle.js
Normal file
@@ -0,0 +1,121 @@
|
||||
// Filter toggle for NOT filtering
|
||||
class BrickFilterToggle {
|
||||
constructor() {
|
||||
// Find all filter toggle buttons
|
||||
this.toggles = document.querySelectorAll('.filter-toggle');
|
||||
|
||||
// Initialize each toggle
|
||||
this.toggles.forEach(toggle => {
|
||||
this.initializeToggle(toggle);
|
||||
});
|
||||
}
|
||||
|
||||
initializeToggle(toggle) {
|
||||
const targetId = toggle.dataset.filterTarget;
|
||||
const targetSelect = document.getElementById(targetId);
|
||||
|
||||
if (!targetSelect) {
|
||||
console.error(`Filter toggle: Target select #${targetId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we need to initialize in NOT mode based on URL parameters
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const filterParam = this.getFilterParamName(targetId);
|
||||
const filterValue = urlParams.get(filterParam);
|
||||
|
||||
// Initialize the NOT mode flag
|
||||
if (filterValue && filterValue.startsWith('-')) {
|
||||
targetSelect.dataset.notMode = 'true';
|
||||
this.setToggleState(toggle, 'not-equals');
|
||||
} else {
|
||||
targetSelect.dataset.notMode = 'false';
|
||||
this.setToggleState(toggle, 'equals');
|
||||
}
|
||||
|
||||
// Add click event listener to toggle button
|
||||
toggle.addEventListener('click', () => {
|
||||
this.handleToggleClick(toggle, targetSelect);
|
||||
});
|
||||
|
||||
// Add change event listener to the select
|
||||
targetSelect.addEventListener('change', () => {
|
||||
// If select is cleared (empty value), reset toggle to equals mode
|
||||
const selectValue = targetSelect.options[targetSelect.selectedIndex]?.value || '';
|
||||
if (!selectValue) {
|
||||
targetSelect.dataset.notMode = 'false';
|
||||
this.setToggleState(toggle, 'equals');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getFilterParamName(selectId) {
|
||||
// Map select IDs to URL parameter names
|
||||
const mapping = {
|
||||
'grid-status': 'status',
|
||||
'grid-theme': 'theme',
|
||||
'grid-owner': 'owner',
|
||||
'grid-storage': 'storage',
|
||||
'grid-purchase-location': 'purchase_location',
|
||||
'grid-tag': 'tag',
|
||||
'grid-year': 'year'
|
||||
};
|
||||
return mapping[selectId] || selectId.replace('grid-', '');
|
||||
}
|
||||
|
||||
handleToggleClick(toggle, targetSelect) {
|
||||
const selectValue = targetSelect.options[targetSelect.selectedIndex]?.value || '';
|
||||
|
||||
// Don't toggle if no value is selected
|
||||
if (!selectValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle the NOT mode
|
||||
const isNotMode = targetSelect.dataset.notMode === 'true';
|
||||
targetSelect.dataset.notMode = isNotMode ? 'false' : 'true';
|
||||
|
||||
// Update toggle button visual state
|
||||
this.setToggleState(toggle, isNotMode ? 'equals' : 'not-equals');
|
||||
|
||||
// Trigger change event on the select to update the grid filter
|
||||
targetSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
|
||||
setToggleState(toggle, mode) {
|
||||
toggle.dataset.filterMode = mode;
|
||||
const icon = toggle.querySelector('i');
|
||||
|
||||
if (mode === 'not-equals') {
|
||||
// Use ≠ symbol (text character)
|
||||
icon.className = '';
|
||||
icon.textContent = '≠';
|
||||
toggle.classList.remove('btn-outline-secondary');
|
||||
toggle.classList.add('btn-outline-danger');
|
||||
toggle.title = 'NOT equals (click to toggle)';
|
||||
} else {
|
||||
// Use = symbol (text character) instead of icon
|
||||
icon.className = '';
|
||||
icon.textContent = '=';
|
||||
toggle.classList.remove('btn-outline-danger');
|
||||
toggle.classList.add('btn-outline-secondary');
|
||||
toggle.title = 'Equals (click to toggle)';
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to get the actual filter value (with "-" prefix if in NOT mode)
|
||||
static getFilterValue(select) {
|
||||
const selectValue = select.options[select.selectedIndex]?.value || '';
|
||||
const isNotMode = select.dataset.notMode === 'true';
|
||||
|
||||
if (selectValue && isNotMode && !selectValue.startsWith('-')) {
|
||||
return '-' + selectValue;
|
||||
}
|
||||
return selectValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
new BrickFilterToggle();
|
||||
});
|
||||
@@ -145,12 +145,15 @@ function initializeFilterDropdowns() {
|
||||
// Set filter dropdown values from URL parameters
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
|
||||
// Helper function to strip "-" prefix from filter values
|
||||
const stripNotPrefix = (value) => value && value.startsWith('-') ? value.substring(1) : value;
|
||||
|
||||
// Set each filter dropdown value if the parameter exists
|
||||
const yearParam = urlParams.get('year');
|
||||
if (yearParam) {
|
||||
const yearDropdown = document.getElementById('grid-year');
|
||||
if (yearDropdown) {
|
||||
yearDropdown.value = yearParam;
|
||||
yearDropdown.value = stripNotPrefix(yearParam);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,14 +161,15 @@ function initializeFilterDropdowns() {
|
||||
if (themeParam) {
|
||||
const themeDropdown = document.getElementById('grid-theme');
|
||||
if (themeDropdown) {
|
||||
const cleanTheme = stripNotPrefix(themeParam);
|
||||
// Try to set the theme value directly first (for theme names)
|
||||
themeDropdown.value = themeParam;
|
||||
themeDropdown.value = cleanTheme;
|
||||
|
||||
// If that didn't work and the param is numeric (theme ID),
|
||||
// try to find the corresponding theme name by looking at cards
|
||||
if (themeDropdown.value !== themeParam && /^\d+$/.test(themeParam)) {
|
||||
if (themeDropdown.value !== cleanTheme && /^\d+$/.test(cleanTheme)) {
|
||||
// Look for a card with this theme ID and get its theme name
|
||||
const cardWithTheme = document.querySelector(`[data-theme-id="${themeParam}"]`);
|
||||
const cardWithTheme = document.querySelector(`[data-theme-id="${cleanTheme}"]`);
|
||||
if (cardWithTheme) {
|
||||
const themeName = cardWithTheme.getAttribute('data-theme');
|
||||
if (themeName) {
|
||||
@@ -180,7 +184,7 @@ function initializeFilterDropdowns() {
|
||||
if (statusParam) {
|
||||
const statusDropdown = document.getElementById('grid-status');
|
||||
if (statusDropdown) {
|
||||
statusDropdown.value = statusParam;
|
||||
statusDropdown.value = stripNotPrefix(statusParam);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +192,7 @@ function initializeFilterDropdowns() {
|
||||
if (ownerParam) {
|
||||
const ownerDropdown = document.getElementById('grid-owner');
|
||||
if (ownerDropdown) {
|
||||
ownerDropdown.value = ownerParam;
|
||||
ownerDropdown.value = stripNotPrefix(ownerParam);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,7 +200,7 @@ function initializeFilterDropdowns() {
|
||||
if (purchaseLocationParam) {
|
||||
const purchaseLocationDropdown = document.getElementById('grid-purchase-location');
|
||||
if (purchaseLocationDropdown) {
|
||||
purchaseLocationDropdown.value = purchaseLocationParam;
|
||||
purchaseLocationDropdown.value = stripNotPrefix(purchaseLocationParam);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,7 +208,7 @@ function initializeFilterDropdowns() {
|
||||
if (storageParam) {
|
||||
const storageDropdown = document.getElementById('grid-storage');
|
||||
if (storageDropdown) {
|
||||
storageDropdown.value = storageParam;
|
||||
storageDropdown.value = stripNotPrefix(storageParam);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,7 +216,7 @@ function initializeFilterDropdowns() {
|
||||
if (tagParam) {
|
||||
const tagDropdown = document.getElementById('grid-tag');
|
||||
if (tagDropdown) {
|
||||
tagDropdown.value = tagParam;
|
||||
tagDropdown.value = stripNotPrefix(tagParam);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,6 +226,9 @@ function initializeClientSideFilterDropdowns() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
let needsFiltering = false;
|
||||
|
||||
// Helper function to strip "-" prefix from filter values
|
||||
const stripNotPrefix = (value) => value && value.startsWith('-') ? value.substring(1) : value;
|
||||
|
||||
// Check if we have any filter parameters to avoid flash of all content
|
||||
const hasFilterParams = urlParams.has('year') || urlParams.has('theme') || urlParams.has('storage') || urlParams.has('purchase_location');
|
||||
|
||||
@@ -238,7 +245,7 @@ function initializeClientSideFilterDropdowns() {
|
||||
if (yearParam) {
|
||||
const yearDropdown = document.getElementById('grid-year');
|
||||
if (yearDropdown) {
|
||||
yearDropdown.value = yearParam;
|
||||
yearDropdown.value = stripNotPrefix(yearParam);
|
||||
needsFiltering = true;
|
||||
}
|
||||
}
|
||||
@@ -248,16 +255,17 @@ function initializeClientSideFilterDropdowns() {
|
||||
if (themeParam) {
|
||||
const themeDropdown = document.getElementById('grid-theme');
|
||||
if (themeDropdown) {
|
||||
if (/^\d+$/.test(themeParam)) {
|
||||
const cleanTheme = stripNotPrefix(themeParam);
|
||||
if (/^\d+$/.test(cleanTheme)) {
|
||||
// Theme parameter is an ID, need to convert to theme name by looking at cards
|
||||
const themeNameFromId = findThemeNameById(themeParam);
|
||||
const themeNameFromId = findThemeNameById(cleanTheme);
|
||||
if (themeNameFromId) {
|
||||
themeDropdown.value = themeNameFromId;
|
||||
needsFiltering = true;
|
||||
}
|
||||
} else {
|
||||
// Theme parameter is already a name
|
||||
themeDropdown.value = themeParam.toLowerCase();
|
||||
themeDropdown.value = cleanTheme.toLowerCase();
|
||||
needsFiltering = true;
|
||||
}
|
||||
}
|
||||
@@ -268,7 +276,7 @@ function initializeClientSideFilterDropdowns() {
|
||||
if (storageParam) {
|
||||
const storageDropdown = document.getElementById('grid-storage');
|
||||
if (storageDropdown) {
|
||||
storageDropdown.value = storageParam;
|
||||
storageDropdown.value = stripNotPrefix(storageParam);
|
||||
needsFiltering = true;
|
||||
}
|
||||
}
|
||||
@@ -278,7 +286,7 @@ function initializeClientSideFilterDropdowns() {
|
||||
if (purchaseLocationParam) {
|
||||
const purchaseLocationDropdown = document.getElementById('grid-purchase-location');
|
||||
if (purchaseLocationDropdown) {
|
||||
purchaseLocationDropdown.value = purchaseLocationParam;
|
||||
purchaseLocationDropdown.value = stripNotPrefix(purchaseLocationParam);
|
||||
needsFiltering = true;
|
||||
}
|
||||
}
|
||||
@@ -343,14 +351,30 @@ function setupPaginationFilterDropdowns() {
|
||||
function performServerFilter() {
|
||||
const currentUrl = new URL(window.location);
|
||||
|
||||
// Get all filter values
|
||||
const statusFilter = document.getElementById('grid-status')?.value || '';
|
||||
const themeFilter = document.getElementById('grid-theme')?.value || '';
|
||||
const yearFilter = document.getElementById('grid-year')?.value || '';
|
||||
const ownerFilter = document.getElementById('grid-owner')?.value || '';
|
||||
const purchaseLocationFilter = document.getElementById('grid-purchase-location')?.value || '';
|
||||
const storageFilter = document.getElementById('grid-storage')?.value || '';
|
||||
const tagFilter = document.getElementById('grid-tag')?.value || '';
|
||||
// Get all filter values (using BrickFilterToggle helper to include "-" prefix if in NOT mode)
|
||||
const statusSelect = document.getElementById('grid-status');
|
||||
const themeSelect = document.getElementById('grid-theme');
|
||||
const yearSelect = document.getElementById('grid-year');
|
||||
const ownerSelect = document.getElementById('grid-owner');
|
||||
const purchaseLocationSelect = document.getElementById('grid-purchase-location');
|
||||
const storageSelect = document.getElementById('grid-storage');
|
||||
const tagSelect = document.getElementById('grid-tag');
|
||||
|
||||
// Helper to safely get filter value with NOT mode support
|
||||
const getFilterValue = (select) => {
|
||||
if (!select) return '';
|
||||
return typeof BrickFilterToggle !== 'undefined'
|
||||
? BrickFilterToggle.getFilterValue(select)
|
||||
: select.value;
|
||||
};
|
||||
|
||||
const statusFilter = getFilterValue(statusSelect);
|
||||
const themeFilter = getFilterValue(themeSelect);
|
||||
const yearFilter = getFilterValue(yearSelect);
|
||||
const ownerFilter = getFilterValue(ownerSelect);
|
||||
const purchaseLocationFilter = getFilterValue(purchaseLocationSelect);
|
||||
const storageFilter = getFilterValue(storageSelect);
|
||||
const tagFilter = getFilterValue(tagSelect);
|
||||
|
||||
// Update URL parameters
|
||||
if (statusFilter) {
|
||||
@@ -746,6 +770,8 @@ function initializeClearFiltersButton() {
|
||||
const dropdown = document.getElementById(dropdownId);
|
||||
if (dropdown) {
|
||||
dropdown.value = '';
|
||||
// Trigger change event to reset toggle button state
|
||||
dropdown.dispatchEvent(new Event('change'));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -215,7 +215,10 @@ class BrickSetSocket extends BrickSocket {
|
||||
|
||||
if (this.html_input) {
|
||||
const value = this.html_input.value;
|
||||
this.set_list = value.split(",").map((el) => el.trim())
|
||||
// Split by comma, trim whitespace, and filter out empty strings
|
||||
this.set_list = value.split(",")
|
||||
.map((el) => el.trim())
|
||||
.filter((el) => el !== "")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,12 +30,15 @@
|
||||
{% include 'admin/database/drop.html' %}
|
||||
{% elif import_database %}
|
||||
{% include 'admin/database/import.html' %}
|
||||
{% elif integrity_check %}
|
||||
{% include 'admin/database/integrity_check.html' %}
|
||||
{% elif upgrade_database %}
|
||||
{% include 'admin/database/upgrade.html' %}
|
||||
{% elif refresh_set %}
|
||||
{% include 'admin/set/refresh.html' %}
|
||||
{% else %}
|
||||
{% include 'admin/logout.html' %}
|
||||
{% include 'admin/export.html' %}
|
||||
{% include 'admin/instructions.html' %}
|
||||
{% if not config['USE_REMOTE_IMAGES'] %}
|
||||
{% include 'admin/image.html' %}
|
||||
|
||||
@@ -560,6 +560,42 @@
|
||||
<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_BADGE_ORDER_GRID" class="form-label">
|
||||
BK_BADGE_ORDER_GRID {{ config_badges('BK_BADGE_ORDER_GRID') }}
|
||||
<div class="text-muted small">Badge order for grid view (comma-separated badge keys)</div>
|
||||
</label>
|
||||
<input type="text" class="form-control config-text" id="BK_BADGE_ORDER_GRID" data-var="BK_BADGE_ORDER_GRID" {{ is_locked('BK_BADGE_ORDER_GRID') }}>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label for="BK_BADGE_ORDER_DETAIL" class="form-label">
|
||||
BK_BADGE_ORDER_DETAIL {{ config_badges('BK_BADGE_ORDER_DETAIL') }}
|
||||
<div class="text-muted small">Badge order for detail view (comma-separated badge keys)</div>
|
||||
</label>
|
||||
<input type="text" class="form-control config-text" id="BK_BADGE_ORDER_DETAIL" data-var="BK_BADGE_ORDER_DETAIL" {{ is_locked('BK_BADGE_ORDER_DETAIL') }}>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input config-toggle" type="checkbox" id="BK_SHOW_NOTES_GRID" data-var="BK_SHOW_NOTES_GRID" {{ is_locked('BK_SHOW_NOTES_GRID') }}>
|
||||
<label class="form-check-label" for="BK_SHOW_NOTES_GRID">
|
||||
BK_SHOW_NOTES_GRID {{ config_badges('BK_SHOW_NOTES_GRID') }}
|
||||
<div class="text-muted small">Show notes on grid view cards</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_NOTES_DETAIL" data-var="BK_SHOW_NOTES_DETAIL" {{ is_locked('BK_SHOW_NOTES_DETAIL') }}>
|
||||
<label class="form-check-label" for="BK_SHOW_NOTES_DETAIL">
|
||||
BK_SHOW_NOTES_DETAIL {{ config_badges('BK_SHOW_NOTES_DETAIL') }}
|
||||
<div class="text-muted small">Show notes on set detail page</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label for="BK_INSTRUCTIONS_ALLOWED_EXTENSIONS" class="form-label">
|
||||
BK_INSTRUCTIONS_ALLOWED_EXTENSIONS {{ config_badges('BK_INSTRUCTIONS_ALLOWED_EXTENSIONS') }}
|
||||
|
||||
@@ -33,11 +33,72 @@
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h5 class="border-bottom mt-4">Maintenance</h5>
|
||||
{% if request.args.get('optimize_success') %}
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<i class="ri-checkbox-circle-line"></i> <strong>Success!</strong> Database optimization complete. Indexes rebuilt and statistics updated.
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if request.args.get('cleanup_success') %}
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<i class="ri-checkbox-circle-line"></i> <strong>Success!</strong> Removed {{ request.args.get('cleanup_success') }} orphaned record(s) from database.
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<p class="text-muted small">Maintain and optimize your database for best performance.</p>
|
||||
<div class="mb-3">
|
||||
<a href="{{ url_for('admin_database.integrity_check') }}" class="btn btn-info" role="button">
|
||||
<i class="ri-shield-check-line"></i> Check Database Integrity
|
||||
</a>
|
||||
<span class="text-muted small">Scan for orphaned records and foreign key violations</span>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<button type="button" class="btn btn-success" data-bs-toggle="modal" data-bs-target="#optimizeConfirmModal">
|
||||
<i class="ri-speed-up-line"></i> Optimize Database
|
||||
</button>
|
||||
<span class="text-muted small">Re-create indexes and rebuild statistics (safe to run anytime)</span>
|
||||
</div>
|
||||
|
||||
<h5 class="border-bottom mt-4">Danger zone</h5>
|
||||
{% if database_error %}<div class="alert alert-danger text-start" role="alert"><strong>Error:</strong> {{ database_error }}.</div>{% endif %}
|
||||
<div class="text-end">
|
||||
<a href="{{ url_for('admin_database.upload') }}" class="btn btn-warning" role="button"><i class="ri-upload-line"></i> Import a database file</a>
|
||||
<a href="{{ url_for('admin_database.drop') }}" class="btn btn-danger" role="button"><i class="ri-close-line"></i> Drop the database</a>
|
||||
<a href="{{ url_for('admin_database.delete') }}" class="btn btn-danger" role="button"><i class="ri-delete-bin-2-line"></i> Delete the database file</a>
|
||||
</div>
|
||||
{{ accordion.footer() }}
|
||||
|
||||
{{ accordion.header('Database danger zone', 'database-danger', 'admin', danger=true, class='text-end') }}
|
||||
{% if database_error %}<div class="alert alert-danger text-start" role="alert"><strong>Error:</strong> {{ database_error }}.</div>{% endif %}
|
||||
<a href="{{ url_for('admin_database.upload') }}" class="btn btn-warning" role="button"><i class="ri-upload-line"></i> Import a database file</a>
|
||||
<a href="{{ url_for('admin_database.drop') }}" class="btn btn-danger" role="button"><i class="ri-close-line"></i> Drop the database</a>
|
||||
<a href="{{ url_for('admin_database.delete') }}" class="btn btn-danger" role="button"><i class="ri-delete-bin-2-line"></i> Delete the database file</a>
|
||||
{{ accordion.footer() }}
|
||||
<div class="modal fade" id="optimizeConfirmModal" tabindex="-1" aria-labelledby="optimizeConfirmModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-success text-white">
|
||||
<h5 class="modal-title" id="optimizeConfirmModalLabel">
|
||||
<i class="ri-speed-up-line"></i> Confirm Database Optimization
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Run this to improve speed and reclaim storage space after making changes to your collection.</p>
|
||||
<p><strong>What happens:</strong></p>
|
||||
<ul>
|
||||
<li>Speeds up searches and filters</li>
|
||||
<li>Improves page load times</li>
|
||||
<li>Reduces file size by removing unused space</li>
|
||||
</ul>
|
||||
<p class="text-muted small"><i class="ri-information-line"></i> Safe to run anytime. Takes a few seconds to complete.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="ri-close-line"></i> Cancel
|
||||
</button>
|
||||
<form action="{{ url_for('admin_database.optimize') }}" method="post" style="display: inline;">
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="ri-speed-up-line"></i> Yes, Optimize Now
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
180
templates/admin/database/integrity_check.html
Normal file
180
templates/admin/database/integrity_check.html
Normal file
@@ -0,0 +1,180 @@
|
||||
{% import 'macro/accordion.html' as accordion %}
|
||||
|
||||
{{ accordion.header('Database Integrity Check', 'database-integrity', 'admin', expanded=true, icon='shield-check-line') }}
|
||||
|
||||
{% if database_error %}
|
||||
<div class="alert alert-danger text-start" role="alert"><strong>Error:</strong> {{ database_error }}.</div>
|
||||
{% endif %}
|
||||
|
||||
<h5 class="border-bottom">Scan Results</h5>
|
||||
|
||||
{% if total_issues == 0 %}
|
||||
<div class="alert alert-success" role="alert">
|
||||
<i class="ri-checkbox-circle-line"></i> <strong>No integrity issues found!</strong> Your database is healthy.
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<a class="btn btn-primary" href="{{ url_for('admin.admin') }}" role="button"><i class="ri-arrow-left-long-line"></i> Back to admin</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<i class="ri-alert-line"></i> <strong>Found {{ total_issues }} integrity issue(s)</strong> that need attention.
|
||||
</div>
|
||||
|
||||
<h6 class="mt-3">Summary</h6>
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Issue Type</th>
|
||||
<th>Count</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for issue in integrity_issues %}
|
||||
<tr class="{% if issue.count > 0 %}table-warning{% endif %}">
|
||||
<td><code>{{ issue.issue_type }}</code></td>
|
||||
<td><span class="badge {% if issue.count > 0 %}text-bg-warning{% else %}text-bg-success{% endif %}">{{ issue.count }}</span></td>
|
||||
<td>{{ issue.description }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if orphaned_sets|length > 0 %}
|
||||
<h6 class="mt-4">Orphaned Sets ({{ orphaned_sets|length }})</h6>
|
||||
<p class="text-muted small">These sets exist in bricktracker_sets but are missing from rebrickable_sets:</p>
|
||||
<table class="table table-sm table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Set Number</th>
|
||||
<th>ID</th>
|
||||
<th>Storage</th>
|
||||
<th>Purchase Price</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for set in orphaned_sets %}
|
||||
<tr>
|
||||
<td><code>{{ set.set }}</code></td>
|
||||
<td><code class="small">{{ set.id }}</code></td>
|
||||
<td>{{ set.storage or '-' }}</td>
|
||||
<td>{{ set.purchase_price or '-' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% if orphaned_parts|length > 0 %}
|
||||
<h6 class="mt-4">Orphaned Parts ({{ orphaned_parts|length }})</h6>
|
||||
<p class="text-muted small">These parts exist in bricktracker_parts but are missing from rebrickable_parts:</p>
|
||||
<table class="table table-sm table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Part</th>
|
||||
<th>Color</th>
|
||||
<th>Set Number</th>
|
||||
<th>Quantity</th>
|
||||
<th>Spare</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for part in orphaned_parts[:20] %}
|
||||
<tr>
|
||||
<td><code>{{ part.part }}</code></td>
|
||||
<td>{{ part.color }}</td>
|
||||
<td>{{ part.set_number or '-' }}</td>
|
||||
<td>{{ part.quantity }}</td>
|
||||
<td>{{ 'Yes' if part.spare else 'No' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if orphaned_parts|length > 20 %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-muted text-center"><em>... and {{ orphaned_parts|length - 20 }} more</em></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% if parts_missing_set|length > 0 %}
|
||||
<h6 class="mt-4">Parts with Missing Set References ({{ parts_missing_set|length }})</h6>
|
||||
<p class="text-muted small">These parts reference sets that don't exist in bricktracker_sets:</p>
|
||||
<table class="table table-sm table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Part</th>
|
||||
<th>Color</th>
|
||||
<th>Set ID</th>
|
||||
<th>Quantity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for part in parts_missing_set[:20] %}
|
||||
<tr>
|
||||
<td><code>{{ part.part }}</code></td>
|
||||
<td>{{ part.color }}</td>
|
||||
<td><code class="small">{{ part.id }}</code></td>
|
||||
<td>{{ part.quantity }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if parts_missing_set|length > 20 %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-muted text-center"><em>... and {{ parts_missing_set|length - 20 }} more</em></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-4 text-end">
|
||||
<a class="btn btn-secondary" href="{{ url_for('admin.admin') }}" role="button"><i class="ri-arrow-left-long-line"></i> Back to admin</a>
|
||||
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#cleanupConfirmModal">
|
||||
<i class="ri-delete-bin-line"></i> Clean Up Orphaned Records
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{{ accordion.footer() }}
|
||||
|
||||
<!-- Cleanup Confirmation Modal -->
|
||||
<div class="modal fade" id="cleanupConfirmModal" tabindex="-1" aria-labelledby="cleanupConfirmModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-danger text-white">
|
||||
<h5 class="modal-title" id="cleanupConfirmModalLabel">
|
||||
<i class="ri-alert-line"></i> Confirm Database Cleanup
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<i class="ri-error-warning-line"></i> <strong>Warning!</strong> This action cannot be undone.
|
||||
</div>
|
||||
<p><strong>This will permanently delete:</strong></p>
|
||||
<ul>
|
||||
{% if orphaned_sets|length > 0 %}
|
||||
<li><strong>{{ orphaned_sets|length }}</strong> orphaned set record(s)</li>
|
||||
{% endif %}
|
||||
{% if orphaned_parts|length > 0 %}
|
||||
<li><strong>{{ orphaned_parts|length }}</strong> orphaned part record(s)</li>
|
||||
{% endif %}
|
||||
{% if parts_missing_set|length > 0 %}
|
||||
<li><strong>{{ parts_missing_set|length }}</strong> part(s) with missing set references</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<p class="text-muted small"><i class="ri-information-line"></i> <strong>Recommendation:</strong> Download a backup of your database before proceeding.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="ri-close-line"></i> Cancel
|
||||
</button>
|
||||
<form action="{{ url_for('admin_database.integrity_cleanup') }}" method="post" style="display: inline;">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="ri-delete-bin-line"></i> Yes, Clean Up Now
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
70
templates/admin/export.html
Normal file
70
templates/admin/export.html
Normal file
@@ -0,0 +1,70 @@
|
||||
{{ accordion.header('Export', 'export', 'admin', expanded=open_export, icon='download-line') }}
|
||||
<div class="p-3">
|
||||
<p class="text-muted">Export your sets, parts, or missing/damaged parts to various formats for use with Rebrickable, BrickLink, or LEGO Pick-a-Brick.</p>
|
||||
|
||||
<!-- Export Sets Section -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0"><i class="ri-stack-line"></i> Export Sets</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="small text-muted">Export all your sets to Rebrickable format for tracking your collection.</p>
|
||||
<a href="{{ url_for('admin_export.export_sets_rebrickable') }}" class="btn btn-sm btn-primary">
|
||||
<i class="ri-file-text-line"></i> Download Sets (Rebrickable CSV)
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export All Parts Section -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0"><i class="ri-shapes-line"></i> Export All Parts</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="small text-muted">Export all parts from your collection in different formats.</p>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<a href="{{ url_for('admin_export.export_parts_rebrickable') }}" class="btn btn-sm btn-success">
|
||||
<i class="ri-file-text-line"></i> Rebrickable CSV
|
||||
</a>
|
||||
<a href="{{ url_for('admin_export.export_parts_lego') }}" class="btn btn-sm btn-warning">
|
||||
<i class="ri-file-text-line"></i> LEGO Pick-a-Brick CSV
|
||||
</a>
|
||||
<a href="{{ url_for('admin_export.export_parts_bricklink') }}" class="btn btn-sm btn-info">
|
||||
<i class="ri-file-code-line"></i> BrickLink XML
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export Problems Section -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h6 class="mb-0"><i class="ri-error-warning-line"></i> Export Missing/Damaged Parts</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="small text-muted">Export only missing or damaged parts to create wanted lists or shopping lists.</p>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<a href="{{ url_for('admin_export.export_problems_rebrickable') }}" class="btn btn-sm btn-success">
|
||||
<i class="ri-file-text-line"></i> Rebrickable CSV
|
||||
</a>
|
||||
<a href="{{ url_for('admin_export.export_problems_lego') }}" class="btn btn-sm btn-warning">
|
||||
<i class="ri-file-text-line"></i> LEGO Pick-a-Brick CSV
|
||||
</a>
|
||||
<a href="{{ url_for('admin_export.export_problems_bricklink') }}" class="btn btn-sm btn-info">
|
||||
<i class="ri-file-code-line"></i> BrickLink XML
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Format Info -->
|
||||
<div class="alert alert-info mt-3 mb-0">
|
||||
<h6 class="alert-heading"><i class="ri-information-line"></i> Format Information</h6>
|
||||
<ul class="mb-0 small">
|
||||
<li><strong>Rebrickable CSV:</strong> Part,Color,Quantity format for direct import to Rebrickable</li>
|
||||
<li><strong>LEGO Pick-a-Brick CSV:</strong> Element ID and quantity for LEGO's Pick-a-Brick service</li>
|
||||
<li><strong>BrickLink XML:</strong> Wanted list format for importing to BrickLink</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{{ accordion.footer() }}
|
||||
@@ -3,6 +3,17 @@
|
||||
{% import 'macro/form.html' as form %}
|
||||
|
||||
<div class="alert alert-info m-2" role="alert">This page lists the sets that may need a refresh because they have some of their newer fields containing empty values.</div>
|
||||
{% if table_collection | length > 0 %}
|
||||
<div class="alert alert-primary m-2" role="alert">
|
||||
<h4 class="alert-heading">Too many sets to refresh individually?</h4>
|
||||
<p class="mb-0">
|
||||
You can refresh multiple sets at once with
|
||||
<a href="{{ url_for('admin_set.refresh_bulk') }}" class="btn btn-primary">
|
||||
<i class="ri-refresh-line"></i> Bulk Refresh ({{ table_collection | length }} sets)
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="table-responsive-sm">
|
||||
<table data-table="true" class="table table-striped align-middle" id="wish">
|
||||
<thead>
|
||||
|
||||
100
templates/admin/set/refresh_bulk.html
Normal file
100
templates/admin/set/refresh_bulk.html
Normal file
@@ -0,0 +1,100 @@
|
||||
{% import 'macro/accordion.html' as accordion %}
|
||||
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %} - Bulk refresh sets{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
<div class="container">
|
||||
<div class="alert alert-info" role="alert">
|
||||
<h4 class="alert-heading">Bulk Refresh Sets</h4>
|
||||
<p>This page allows you to refresh multiple sets at once. Sets needing refresh: <strong>{{ refresh_count }}</strong></p>
|
||||
<p class="mb-0">
|
||||
<a href="{{ url_for('admin_set.refresh') }}" class="btn btn-sm btn-outline-info">
|
||||
<i class="ri-arrow-left-line"></i> Back to Refresh List
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="ri-refresh-line"></i> Bulk Refresh Sets
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="add-fail" class="alert alert-danger d-none" role="alert"></div>
|
||||
<div id="add-complete"></div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="add-set" class="form-label">
|
||||
List of sets to refresh (separated by comma)
|
||||
</label>
|
||||
<textarea
|
||||
class="form-control"
|
||||
id="add-set"
|
||||
rows="4"
|
||||
placeholder="10255-1, 21005-1, 71043-1, ...">{{ set_list }}</textarea>
|
||||
<small class="form-text text-muted">
|
||||
The list above contains all {{ refresh_count }} sets that need refreshing. You can edit it before starting.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input type="checkbox" class="form-check-input" id="add-no-confirm" checked disabled>
|
||||
<label class="form-check-label" for="add-no-confirm">
|
||||
Refresh without confirmation
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="mb-3">
|
||||
<p>
|
||||
Progress <span id="add-count"></span>
|
||||
<span id="add-spinner" class="d-none">
|
||||
<span class="spinner-border spinner-border-sm" aria-hidden="true"></span>
|
||||
<span class="visually-hidden" role="status">Loading...</span>
|
||||
</span>
|
||||
</p>
|
||||
<div id="add-progress" class="progress" role="progressbar">
|
||||
<div id="add-progress-bar" class="progress-bar" style="width: 0%"></div>
|
||||
</div>
|
||||
<p id="add-progress-message" class="text-center d-none"></p>
|
||||
</div>
|
||||
|
||||
<div id="add-card" class="d-flex d-none justify-content-center">
|
||||
<div class="card mb-3 col-6">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<span class="badge text-bg-secondary fw-normal">
|
||||
<i class="ri-hashtag"></i> <span id="add-card-set"></span>
|
||||
</span>
|
||||
<span id="add-card-name"></span>
|
||||
</h5>
|
||||
</div>
|
||||
<div id="add-card-image-container" class="card-img">
|
||||
<img id="add-card-image" loading="lazy">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer text-end">
|
||||
<span id="add-status-icon" class="me-1"></span>
|
||||
<span id="add-status" class="me-1"></span>
|
||||
<button id="add" type="button" class="btn btn-primary">
|
||||
<i class="ri-refresh-line"></i> Start Bulk Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% with id='add', bulk=true, refresh=true %}
|
||||
{% include 'set/socket.html' %}
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
@@ -84,6 +84,7 @@
|
||||
<!-- BrickTracker scripts -->
|
||||
<script src="{{ url_for('static', filename='scripts/collapsible-state.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='scripts/changer.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='scripts/grid/filter_toggle.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='scripts/grid/filter.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='scripts/grid/grid.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='scripts/grid/sort.js') }}"></script>
|
||||
|
||||
@@ -38,5 +38,22 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if part_collection | length %}
|
||||
<h2 class="border-bottom lh-base pb-1">
|
||||
<i class="ri-shapes-line"></i> {% if config['RANDOM'] %}Random selection of{% else %}Latest added{% endif %} parts
|
||||
{% if not config['HIDE_ALL_PARTS'] %}
|
||||
<a href="{{ url_for('part.list') }}" class="btn btn-sm btn-primary ms-1">All parts</a>
|
||||
{% endif %}
|
||||
</h2>
|
||||
<div class="row" id="grid">
|
||||
{% for item in part_collection %}
|
||||
<div class="col-md-4 col-xl-2 d-flex align-items-stretch">
|
||||
{% with solo=false, tiny=true, last=true, sets_using=[], minifigures_using=[] %}
|
||||
{% include 'part/card.html' %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -218,3 +218,69 @@
|
||||
{% macro year(year, solo=false, last=false) %}
|
||||
{{ badge(check=year, solo=solo, last=last, color='secondary', icon='calendar-line', collapsible='Year:', text=year, alt='Year') }}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro render_ordered_badges(item, brickset_tags, brickset_owners, brickset_storages, brickset_purchase_locations, solo=false, last=false, context='grid') %}
|
||||
{# Get badge order from config based on context (grid or detail) #}
|
||||
{% if context == 'detail' %}
|
||||
{% set badge_order = config.get('BADGE_ORDER_DETAIL', ['theme', 'tag', 'year', 'parts', 'instance_count', 'total_minifigures', 'total_missing', 'total_damaged', 'owner', 'storage', 'purchase_date', 'purchase_location', 'purchase_price', 'instructions', 'rebrickable', 'bricklink']) %}
|
||||
{% else %}
|
||||
{% set badge_order = config.get('BADGE_ORDER_GRID', ['theme', 'year', 'parts', 'total_minifigures', 'owner']) %}
|
||||
{% endif %}
|
||||
|
||||
{# Render each badge in the configured order #}
|
||||
{% for badge_key in badge_order %}
|
||||
{% if badge_key == 'theme' %}
|
||||
{{ theme(item.theme.name, solo=solo, last=last) }}
|
||||
{% elif badge_key == 'tag' %}
|
||||
{% for tag_item in brickset_tags %}
|
||||
{{ tag(item, tag_item, solo=solo, last=last) }}
|
||||
{% endfor %}
|
||||
{% elif badge_key == 'year' %}
|
||||
{% if not last %}
|
||||
{{ year(item.fields.year, solo=solo, last=last) }}
|
||||
{% endif %}
|
||||
{% elif badge_key == 'parts' %}
|
||||
{{ parts(item.fields.number_of_parts, solo=solo, last=last) }}
|
||||
{% elif badge_key == 'instance_count' %}
|
||||
{% if item.fields.instance_count is defined and item.fields.instance_count > 1 %}
|
||||
<span class="badge bg-primary"><i class="ri-stack-line"></i> {{ item.fields.instance_count }} copies</span>
|
||||
{% endif %}
|
||||
{% elif badge_key == 'total_minifigures' %}
|
||||
{{ total_minifigures(item.fields.total_minifigures, solo=solo, last=last) }}
|
||||
{% elif badge_key == 'total_missing' %}
|
||||
{{ total_missing(item.fields.total_missing, solo=solo, last=last) }}
|
||||
{% elif badge_key == 'total_damaged' %}
|
||||
{{ total_damaged(item.fields.total_damaged, solo=solo, last=last) }}
|
||||
{% elif badge_key == 'owner' %}
|
||||
{% for owner_item in brickset_owners %}
|
||||
{{ owner(item, owner_item, solo=solo, last=last) }}
|
||||
{% endfor %}
|
||||
{% elif badge_key == 'storage' %}
|
||||
{{ storage(item, brickset_storages, solo=solo, last=last) }}
|
||||
{% elif badge_key == 'purchase_date' %}
|
||||
{% if not last %}
|
||||
{{ purchase_date(item.purchase_date(), solo=solo, last=last, date_max_formatted=item.purchase_date_max_formatted()) }}
|
||||
{% endif %}
|
||||
{% elif badge_key == 'purchase_location' %}
|
||||
{% if not last %}
|
||||
{{ purchase_location(item, brickset_purchase_locations, solo=solo, last=last) }}
|
||||
{% endif %}
|
||||
{% elif badge_key == 'purchase_price' %}
|
||||
{% if not last %}
|
||||
{{ purchase_price(item.purchase_price(), solo=solo, last=last) }}
|
||||
{% endif %}
|
||||
{% elif badge_key == 'instructions' %}
|
||||
{% if not last and not solo %}
|
||||
{{ instructions(item, solo=solo, last=last) }}
|
||||
{% endif %}
|
||||
{% elif badge_key == 'rebrickable' %}
|
||||
{% if not last %}
|
||||
{{ rebrickable(item, solo=solo, last=last) }}
|
||||
{% endif %}
|
||||
{% elif badge_key == 'bricklink' %}
|
||||
{% if not last %}
|
||||
{{ bricklink(item, solo=solo, last=last) }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endmacro %}
|
||||
|
||||
@@ -64,3 +64,38 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro textarea(name, id, prefix, url, value, all=none, read_only=none, icon=none, rows=3, delete=false) %}
|
||||
{% if all or read_only %}
|
||||
{{ value }}
|
||||
{% else %}
|
||||
<label class="visually-hidden" for="{{ prefix }}-{{ id }}">{{ name }}</label>
|
||||
<div class="input-group">
|
||||
{% if icon %}<span class="input-group-text px-1"><i class="ri-{{ icon }} me-1"></i><span class="ms-1 d-none d-md-inline"> {{ name }}</span></span>{% endif %}
|
||||
<textarea class="form-control form-control-sm flex-shrink-1 px-1" id="{{ prefix }}-{{ id }}" rows="{{ rows }}"
|
||||
{% if g.login.is_authenticated() and not delete %}
|
||||
data-changer-id="{{ id }}" data-changer-prefix="{{ prefix }}" data-changer-url="{{ url }}"
|
||||
{% else %}
|
||||
disabled
|
||||
{% endif %}
|
||||
autocomplete="off">{% if value %}{{ value }}{% endif %}</textarea>
|
||||
{% if g.login.is_authenticated() and not delete %}
|
||||
<span id="status-{{ prefix }}-{{ id }}" class="input-group-text ri-save-line px-1"></span>
|
||||
<button id="clear-{{ prefix }}-{{ id }}" type="button" class="btn btn-sm btn-light btn-outline-danger border px-1"><i class="ri-eraser-line"></i></button>
|
||||
{% else %}
|
||||
<span class="input-group-text ri-prohibited-line px-1"></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro filter_toggle(filter_id) %}
|
||||
<button type="button"
|
||||
class="btn btn-outline-secondary filter-toggle"
|
||||
id="{{ filter_id }}-toggle"
|
||||
data-filter-target="{{ filter_id }}"
|
||||
data-filter-mode="equals"
|
||||
title="Toggle between equals and not equals">
|
||||
<i>=</i>
|
||||
</button>
|
||||
{% endmacro %}
|
||||
|
||||
@@ -57,35 +57,19 @@
|
||||
{{ card.header(item, item.fields.name, solo=solo, identifier=item.fields.set) }}
|
||||
{{ card.image(item, solo=solo, last=last, caption=item.fields.name, alt=item.fields.set) }}
|
||||
<div class="card-body border-bottom-0 {% if not solo %}p-1{% endif %}"{% if current_viewing %} style="border-color: var(--bs-border-color) !important; border-width: 1px !important;"{% endif %}>
|
||||
{{ badge.theme(item.theme.name, solo=solo, last=last) }}
|
||||
{% for tag in brickset_tags %}
|
||||
{{ badge.tag(item, tag, solo=solo, last=last) }}
|
||||
{% endfor %}
|
||||
{% if not last %}
|
||||
{{ badge.year(item.fields.year, solo=solo, last=last) }}
|
||||
{% endif %}
|
||||
{{ badge.parts(item.fields.number_of_parts, solo=solo, last=last) }}
|
||||
{% if item.fields.instance_count is defined and item.fields.instance_count > 1 %}
|
||||
<span class="badge bg-primary"><i class="ri-stack-line"></i> {{ item.fields.instance_count }} copies</span>
|
||||
{% endif %}
|
||||
{{ badge.total_minifigures(item.fields.total_minifigures, solo=solo, last=last) }}
|
||||
{{ badge.total_missing(item.fields.total_missing, solo=solo, last=last) }}
|
||||
{{ badge.total_damaged(item.fields.total_damaged, solo=solo, last=last) }}
|
||||
{% for owner in brickset_owners %}
|
||||
{{ badge.owner(item, owner, solo=solo, last=last) }}
|
||||
{% endfor %}
|
||||
{{ badge.storage(item, brickset_storages, solo=solo, last=last) }}
|
||||
{% if not last %}
|
||||
{{ badge.purchase_date(item.purchase_date(), solo=solo, last=last, date_max_formatted=item.purchase_date_max_formatted()) }}
|
||||
{{ badge.purchase_location(item, brickset_purchase_locations, solo=solo, last=last) }}
|
||||
{{ badge.purchase_price(item.purchase_price(), solo=solo, last=last) }}
|
||||
{% if not solo %}
|
||||
{{ badge.instructions(item, solo=solo, last=last) }}
|
||||
{% endif %}
|
||||
{{ badge.rebrickable(item, solo=solo, last=last) }}
|
||||
{{ badge.bricklink(item, solo=solo, last=last) }}
|
||||
{% endif %}
|
||||
{# Render badges in configured order based on context (grid view vs detail view) #}
|
||||
{{ badge.render_ordered_badges(item, brickset_tags, brickset_owners, brickset_storages, brickset_purchase_locations, solo=solo, last=last, context='detail' if solo else 'grid') }}
|
||||
</div>
|
||||
{# Show notes based on context and config #}
|
||||
{% if item.fields.description and item.fields.description | trim %}
|
||||
{% if (solo and config['SHOW_NOTES_DETAIL']) or (not solo and config['SHOW_NOTES_GRID']) %}
|
||||
<div class="card-body border-bottom-0 {% if not solo %}p-1{% endif %} pt-0"{% if current_viewing %} style="border-color: var(--bs-border-color) !important; border-width: 1px !important;"{% endif %}>
|
||||
<div class="alert alert-info mb-0 {% if not solo %}p-1 small{% endif %}" role="alert">
|
||||
<i class="ri-sticky-note-line me-1"></i>{{ item.fields.description }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if not tiny and brickset_statuses | length %}
|
||||
<ul class="list-group list-group-flush card-check border-bottom-0"{% if current_viewing %} style="border-color: var(--bs-border-color) !important; border-width: 1px !important;"{% endif %}>
|
||||
{% for status in brickset_statuses %}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{% import 'macro/form.html' as form %}
|
||||
<div id="grid-filter" class="collapse {% if config['SHOW_GRID_FILTERS'] %}show{% endif %} row row-cols-lg-auto g-1 justify-content-center align-items-center pb-2">
|
||||
<div class="col-12 flex-grow-1">
|
||||
<label class="visually-hidden" for="grid-status">Status</label>
|
||||
@@ -8,26 +9,22 @@
|
||||
autocomplete="off">
|
||||
<option value="" {% if not current_status_filter %}selected{% endif %}>All</option>
|
||||
{% if not config['HIDE_TABLE_MISSING_PARTS'] %}
|
||||
<option value="has-missing" {% if current_status_filter == 'has-missing' %}selected{% endif %}>Has missing pieces</option>
|
||||
<option value="-has-missing" {% if current_status_filter == '-has-missing' %}selected{% endif %}>Has NO missing pieces</option>
|
||||
<option value="has-missing" {% if current_status_filter == 'has-missing' or current_status_filter == '-has-missing' %}selected{% endif %}>Missing pieces</option>
|
||||
{% endif %}
|
||||
{% if not config['HIDE_TABLE_DAMAGED_PARTS'] %}
|
||||
<option value="has-damaged" {% if current_status_filter == 'has-damaged' %}selected{% endif %}>Has damaged pieces</option>
|
||||
<option value="-has-damaged" {% if current_status_filter == '-has-damaged' %}selected{% endif %}>Has NO damaged pieces</option>
|
||||
<option value="has-damaged" {% if current_status_filter == 'has-damaged' or current_status_filter == '-has-damaged' %}selected{% endif %}>Damaged pieces</option>
|
||||
{% endif %}
|
||||
{% if not config['HIDE_SET_INSTRUCTIONS'] %}
|
||||
<option value="-has-missing-instructions" {% if current_status_filter == '-has-missing-instructions' %}selected{% endif %}>Has instructions</option>
|
||||
<option value="has-missing-instructions" {% if current_status_filter == 'has-missing-instructions' %}selected{% endif %}>Is MISSING instructions</option>
|
||||
<option value="has-missing-instructions" {% if current_status_filter == 'has-missing-instructions' or current_status_filter == '-has-missing-instructions' %}selected{% endif %}>Missing instructions</option>
|
||||
{% endif %}
|
||||
{% if brickset_storages | length %}
|
||||
<option value="has-storage" {% if current_status_filter == 'has-storage' %}selected{% endif %}>Is in storage</option>
|
||||
<option value="-has-storage" {% if current_status_filter == '-has-storage' %}selected{% endif %}>Is NOT in storage</option>
|
||||
<option value="has-storage" {% if current_status_filter == 'has-storage' or current_status_filter == '-has-storage' %}selected{% endif %}>In storage</option>
|
||||
{% endif %}
|
||||
{% for status in brickset_statuses %}
|
||||
<option value="{{ status.as_dataset() }}" {% if current_status_filter == status.as_dataset() %}selected{% endif %}>{{ status.fields.name }}</option>
|
||||
<option value="-{{ status.as_dataset() }}" {% if current_status_filter == ('-' + status.as_dataset()) %}selected{% endif %}>NOT: {{ status.fields.name }}</option>
|
||||
<option value="{{ status.as_dataset() }}" {% if current_status_filter == status.as_dataset() or current_status_filter == ('-' + status.as_dataset()) %}selected{% endif %}>{{ status.fields.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{{ form.filter_toggle('grid-status') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 flex-grow-1">
|
||||
@@ -39,9 +36,10 @@
|
||||
autocomplete="off">
|
||||
<option value="" {% if not current_theme_filter %}selected{% endif %}>All</option>
|
||||
{% for theme in collection.themes %}
|
||||
<option value="{{ theme | lower }}" {% if current_theme_filter == (theme | lower) %}selected{% endif %}>{{ theme }}</option>
|
||||
<option value="{{ theme | lower }}" {% if current_theme_filter == (theme | lower) or current_theme_filter == ('-' + (theme | lower)) %}selected{% endif %}>{{ theme }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{{ form.filter_toggle('grid-theme') }}
|
||||
</div>
|
||||
</div>
|
||||
{% if brickset_owners | length %}
|
||||
@@ -57,12 +55,13 @@
|
||||
<option value="{{ owner.as_dataset() }}" {% if current_owner_filter == owner.as_dataset() %}selected{% endif %}>{{ owner.fields.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{{ form.filter_toggle('grid-owner') }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if brickset_purchase_locations | length %}
|
||||
<div class="col-12 flex-grow-1">
|
||||
<label class="visually-hidden" for="grid-owner">Purchase location</label>
|
||||
<label class="visually-hidden" for="grid-purchase-location">Purchase location</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="ri-building-line"></i><span class="ms-1 d-none d-md-inline"> Purchase location</span></span>
|
||||
<select id="grid-purchase-location" class="form-select"
|
||||
@@ -70,9 +69,10 @@
|
||||
autocomplete="off">
|
||||
<option value="" {% if not current_purchase_location_filter %}selected{% endif %}>All</option>
|
||||
{% for purchase_location in brickset_purchase_locations %}
|
||||
<option value="{{ purchase_location.fields.id }}" {% if current_purchase_location_filter == purchase_location.fields.id %}selected{% endif %}>{{ purchase_location.fields.name }}</option>
|
||||
<option value="{{ purchase_location.fields.id }}" {% if current_purchase_location_filter == purchase_location.fields.id or current_purchase_location_filter == ('-' + purchase_location.fields.id) %}selected{% endif %}>{{ purchase_location.fields.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{{ form.filter_toggle('grid-purchase-location') }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -80,7 +80,7 @@
|
||||
<div class="w-100"></div>
|
||||
{% if brickset_storages | length %}
|
||||
<div class="col-12 flex-grow-1">
|
||||
<label class="visually-hidden" for="grid-owner">Storage</label>
|
||||
<label class="visually-hidden" for="grid-storage">Storage</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="ri-archive-2-line"></i><span class="ms-1 d-none d-md-inline"> Storage</span></span>
|
||||
<select id="grid-storage" class="form-select"
|
||||
@@ -88,9 +88,10 @@
|
||||
autocomplete="off">
|
||||
<option value="" {% if not current_storage_filter %}selected{% endif %}>All</option>
|
||||
{% for storage in brickset_storages %}
|
||||
<option value="{{ storage.fields.id }}" {% if current_storage_filter == storage.fields.id %}selected{% endif %}>{{ storage.fields.name }}</option>
|
||||
<option value="{{ storage.fields.id }}" {% if current_storage_filter == storage.fields.id or current_storage_filter == ('-' + storage.fields.id) %}selected{% endif %}>{{ storage.fields.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{{ form.filter_toggle('grid-storage') }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -104,9 +105,10 @@
|
||||
autocomplete="off">
|
||||
<option value="" {% if not current_tag_filter %}selected{% endif %}>All</option>
|
||||
{% for tag in brickset_tags %}
|
||||
<option value="{{ tag.as_dataset() }}" {% if current_tag_filter == tag.as_dataset() %}selected{% endif %}>{{ tag.fields.name }}</option>
|
||||
<option value="{{ tag.as_dataset() }}" {% if current_tag_filter == tag.as_dataset() or current_tag_filter == ('-' + tag.as_dataset()) %}selected{% endif %}>{{ tag.fields.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{{ form.filter_toggle('grid-tag') }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -119,9 +121,10 @@
|
||||
autocomplete="off">
|
||||
<option value="" {% if not current_year_filter %}selected{% endif %}>All</option>
|
||||
{% for year in collection.years %}
|
||||
<option value="{{ year }}" {% if current_year_filter == year %}selected{% endif %}>{{ year }}</option>
|
||||
<option value="{{ year }}" {% if current_year_filter == year or current_year_filter == ('-' + year|string) %}selected{% endif %}>{{ year }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{{ form.filter_toggle('grid-year') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-auto">
|
||||
|
||||
@@ -34,6 +34,9 @@
|
||||
<hr>
|
||||
<a href="{{ url_for('admin.admin', open_purchase_location=true) }}" class="btn btn-primary" role="button"><i class="ri-settings-4-line"></i> Manage the set purchase locations</a>
|
||||
{{ accordion.footer() }}
|
||||
{{ accordion.header('Notes', 'notes', 'set-management', icon='sticky-note-line') }}
|
||||
{{ form.textarea('Notes', item.fields.id, 'description', item.url_for_description(), item.fields.description, rows=4, delete=delete) }}
|
||||
{{ accordion.footer() }}
|
||||
{{ accordion.header('Storage', 'storage', 'set-management', icon='archive-2-line') }}
|
||||
{% if brickset_storages | length %}
|
||||
{{ form.select('Storage', item.fields.id, brickset_storages.as_prefix(), brickset_storages.url_for_set_value(item.fields.id), item.fields.storage, brickset_storages, icon='building-line', delete=delete) }}
|
||||
|
||||
Reference in New Issue
Block a user