Compare commits

...

21 Commits

Author SHA1 Message Date
c40da16d9e fix(socket): testing change to socket for reverse proxy 2026-01-01 10:49:39 -05:00
d885f3aa11 fix(sets): show note in the grid or details view with new env var 2025-12-30 11:25:42 -05:00
a72cb67c8c fix(add): fixed #129, so bulk add can have trailing comma and not fail 2025-12-29 09:42:48 -05:00
423540bba4 Update version to 1.4.0 2025-12-26 18:05:25 -05:00
a915a0001f fix(sets): fixed an issue with refresh, where parts didn't get updated correctly 2025-12-26 12:08:50 -05:00
2c961f9a78 Updated changelog 2025-12-25 20:49:40 -05:00
3e1e846a99 feat(admin): added bulk refresh 2025-12-25 20:46:54 -05:00
5725872060 feat(sets): added notes field to sets. Displayed both at top of page, if not empty and in the metadata section, where it can be changed 2025-12-25 20:44:40 -05:00
dcf9496db9 Updated changelog 2025-12-25 15:20:14 -05:00
19e3d8afe6 fix(filter): changed equal and not equal icon to text character to avoid weird resizing 2025-12-25 15:19:47 -05:00
f54dd3ec73 feat(filter): added not equal to filter on sets page, so it is possible to filter for not-tags, not-status, not-year etc 2025-12-25 14:35:59 -05:00
4336ad4de3 fix(sets): cleaup of code for set refresh 2025-12-24 08:28:36 -05:00
5418aca8f0 fix(sets): refreshing sets should work now 2025-12-23 23:08:30 -05:00
9518b0261c feat(frontpage): added parts row 2025-12-21 21:45:22 -05:00
9bd80c1352 feat(frontpage): added parts row 2025-12-21 21:45:11 -05:00
2f1bba475d feat(admin): added options to order badges on sets and details page. 2025-12-21 20:52:02 -05:00
b30deef529 fix(add): fix FK constraint errors when importing sets with metadata 2025-12-21 18:05:09 -05:00
c20231f654 Merge branch 'release/1.3.2' into release/1.4 2025-12-20 18:08:04 -05:00
d783b8fbc9 feat(db): added integrity check and cleanup of database to admin page 2025-12-20 17:55:37 -05:00
6044841329 Updated changelog 2025-12-19 22:43:38 -05:00
136f7d03f5 feat(admin): first version of export feature. 2025-12-19 22:41:28 -05:00
62 changed files with 2102 additions and 121 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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},
]

View File

@@ -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):

View File

@@ -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,

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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])

View File

@@ -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

View File

@@ -0,0 +1,2 @@
-- Clear temporary refresh tracking table
DELETE FROM temp_refresh_parts

View File

@@ -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)
)

View 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
)

View 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 %}

View 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)

View 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

View 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;

View File

@@ -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"
)
);

View 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
)
);

View 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"
)
);

View File

@@ -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"
)
);

View File

@@ -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"
)
);

View File

@@ -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
)
);

View File

@@ -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"
)
);

View File

@@ -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"
)
);

View 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;

View 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";

View 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;

View 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;

View File

@@ -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",

View File

@@ -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"

View File

@@ -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' %}

View File

@@ -0,0 +1,3 @@
UPDATE "bricktracker_sets"
SET "description" = :description
WHERE "bricktracker_sets"."id" IS NOT DISTINCT FROM :id

View 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')

View File

@@ -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

View File

@@ -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))

View 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'}
)

View File

@@ -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)
)

View File

@@ -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)
)

View File

@@ -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
View 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

View File

@@ -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 |

View File

@@ -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;

View File

@@ -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;
}
}
}
}
}

View 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();
});

View File

@@ -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'));
}
});

View File

@@ -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 !== "")
}
}

View File

@@ -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' %}

View File

@@ -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') }}

View File

@@ -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>

View 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>

View 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() }}

View File

@@ -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>

View 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 %}

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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">

View File

@@ -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) }}