Compare commits

...

29 Commits

Author SHA1 Message Date
FrederikBaerentsen d56e25ce88 fix(sidecar): make additional-images flag live-toggleable from admin 2026-07-08 20:09:12 +02:00
FrederikBaerentsen 7b77888812 feat(sidecar): show market value min/max on hover (#160) 2026-07-08 02:24:37 +02:00
FrederikBaerentsen 23564953da fix(parts): polish audit mode for mobile and small screens 2026-07-08 01:43:18 +02:00
FrederikBaerentsen 63aedfa2eb feat(parts): add keyboard-driven audit mode for parts tables 2026-07-07 23:36:51 +02:00
FrederikBaerentsen f9a68235c7 fix(parts): stop sortable crash on accordion parts tables 2026-07-06 22:17:33 +02:00
FrederikBaerentsen 655ddfd609 feat(sidecar): per-element BrickData set-detail toggles + price basis 2026-07-06 21:23:47 +02:00
FrederikBaerentsen 67c9a73ebf docs(changelog): document 1.5 enhancements and fixes 2026-07-05 20:34:12 +02:00
FrederikBaerentsen 7350205c7a fix(sidecar): proxy companion-service images through BrickTracker 2026-07-04 21:47:38 +02:00
FrederikBaerentsen fced5374f5 feat(wish): show retirement dates for already-retired sets (#37) 2026-07-03 21:23:51 +02:00
FrederikBaerentsen 75c1906e63 fix(refresh): stop unmappable bricklink parts from re-flagging sets (#163) 2026-07-02 22:36:09 +02:00
FrederikBaerentsen 0f73567db6 fix(part): match grid card title size to sets and minifigures (#167) 2026-07-02 21:52:17 +02:00
FrederikBaerentsen 621d673785 fix(instructions): link sets with alphanumeric ids (#166) 2026-07-02 21:08:43 +02:00
FrederikBaerentsen e9768be79f docs(changelog): add 1.5 section, name BrickData service 2026-07-01 23:14:26 +02:00
FrederikBaerentsen 9a956c54be fix(part): honor HIDE_SPARE_PARTS on set detail and minifigure parts 2026-07-01 22:43:17 +02:00
FrederikBaerentsen 3efae530d6 refactor(sidecar): pull live from brickdata, drop local cache 2026-07-01 22:20:00 +02:00
FrederikBaerentsen 3beca33789 feat(sidecar): render scraped Brickset fields on set detail 2026-06-30 21:50:00 +02:00
FrederikBaerentsen 14c49d3950 feat(set): mass-edit metadata across selected sets (#146) 2026-06-28 21:43:43 +02:00
FrederikBaerentsen c93a14f70b feat(set): admin-defined typed custom fields per set (#146) 2026-06-26 22:18:09 +02:00
FrederikBaerentsen 32a70c7358 feat(set): browse Brickset additional images and use any as cover (#146) 2026-06-24 00:11:43 +02:00
FrederikBaerentsen 52211a7900 fix(add): Fix #162, show purchase date picker on bulk add page. 2026-06-22 21:53:10 +02:00
FrederikBaerentsen 4d09be06ae fix(sets): fixed formatting 2026-06-22 21:39:47 +02:00
FrederikBaerentsen 3a7772db0b feat(set): add parts-range, year-range and None grid filters (#141) 2026-06-22 21:11:05 +02:00
FrederikBaerentsen a43ec39d8c feat(statistics): paid/retail/market comparison and instructions stats 2026-06-21 16:22:38 +02:00
FrederikBaerentsen a3122c52d2 feat(set): box art cover override, sidecar details and badges 2026-06-19 20:48:23 +02:00
FrederikBaerentsen c64541ba43 feat(sidecar): add brickset-sidecar client, cache table and migration 2026-06-18 22:07:51 +02:00
FrederikBaerentsen e7a83817d3 add(config): add sidecar settings, admin UI and live/restart split 2026-06-16 21:34:09 +02:00
FrederikBaerentsen 811a348058 fix(part): compute per-color set/minifig counts on sub-cards (#159) 2026-06-15 19:12:46 +02:00
FrederikBaerentsen 4daab79c3b fix(statistics): divide price coverage by all priced item types and clamp to 100% (#156) 2026-06-15 17:41:01 +02:00
FrederikBaerentsen 2cb3e96e21 Merge pull request 'release/1.4.1' (#161) from release/1.4.1 into master
Reviewed-on: #161
2026-06-14 10:04:00 +02:00
74 changed files with 3915 additions and 48 deletions
+23
View File
@@ -511,3 +511,26 @@
# 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
# Optional: Show/hide individual BrickData (sidecar) elements on the set detail page
# All default to true (shown). Badges are controlled separately via BK_BADGE_ORDER_DETAIL.
# These take effect live (no restart). Set to false to hide.
# BK_SIDECAR_SHOW_DESIGNER: the set designer line
# BK_SIDECAR_SHOW_DESCRIPTION: the "About this set" description
# BK_SIDECAR_SHOW_NOTES: the Brickset notes
# BK_SIDECAR_SHOW_PRICE_PAID: price comparison "Paid" row (and "Change vs paid")
# BK_SIDECAR_SHOW_PRICE_MSRP: price comparison retail/MSRP rows (and "Saved vs retail")
# BK_SIDECAR_SHOW_PRICE_MARKET: price comparison "Worth now" market value rows
# Default: true
# BK_SIDECAR_SHOW_DESIGNER=false
# BK_SIDECAR_SHOW_DESCRIPTION=false
# BK_SIDECAR_SHOW_NOTES=false
# BK_SIDECAR_SHOW_PRICE_PAID=false
# BK_SIDECAR_SHOW_PRICE_MSRP=false
# BK_SIDECAR_SHOW_PRICE_MARKET=false
# Optional: which BrickLink market value the set-detail "Change vs paid" compares
# against: used or new (falls back to the other when one is missing). The statistics
# page shows both regardless of this setting. Live-changeable.
# Default: used
# BK_SIDECAR_PRICE_BASIS=new
+27
View File
@@ -1,5 +1,32 @@
# Changelog
## 1.5
### Enhancements
- **BrickData integration**: New BrickData companion service that enriches set detail pages live with data from Brickset and BrickLink (description, extra metadata fields, pricing and badges). Data is pulled on demand rather than cached locally, so it always reflects the current source
- Added BrickData settings with an admin UI and a live/restart split so most options apply without a container restart
- **Box art cover override**: Browse a set's additional images from BrickData and pick any of them (including official box art) as the cover image
- **Admin-defined custom fields per set** (#146): Admins can define typed custom fields that then appear on each set, with a mass-edit screen to apply metadata across multiple selected sets at once
- **Retail (MSRP) price and savings** (#144): Set detail shows the lego.com retail price for a chosen region (US/UK/CA/DE) as a badge and in the price comparison, plus a "saved vs retail" figure (MSRP minus what you paid) per set and summed on the Statistics page
- **BrickLink market value** (#160): Set detail shows the current BrickLink market value (new/used average, min/max on hover) pulled on demand from BrickData, with a collection-value estimate on the Statistics page
- **Instructions badge and stats** (#154): Sets with downloaded instruction files now show an "instructions" badge (respects `BK_HIDE_SET_INSTRUCTIONS` and the badge order), and the Statistics page counts how many sets have instructions and the total number of instruction files
- **Statistics: price and instructions stats**: Added a paid / retail / market price comparison and instruction-related statistics
- **Retirement dates for already-retired sets** (#37): The wishlist now falls back to BrickData for sets that the upcoming-retirements CSV does not cover, so long-retired sets show a retirement date instead of "Not found". Dates are colour-coded (yellow for upcoming, red for already retired), and the set detail "Retired" badge shows the full exit date when available
- **New grid filters** (#141): Added parts-range, year-range and "None" filters to the set grid
- **Audit mode for parts tables**: A new "Start audit" entry in the parts table header menu opens a focused, keyboard-driven popup that walks the visible parts one at a time (respecting the active filter and sort), showing a large image, the part name, color and needed quantity. Arrow keys move between parts, Enter/Space save and advance, and a Missing/Found toggle records the missing count (Found mode works out the missing count from what you found). Optimized for phones with a numeric keypad and the image kept in view
### Bug Fixes
- **Fixed instructions never linking to sets with alphanumeric ids** (#166): Instructions for "Pick a Brick" / promotional builds whose set id begins with letters (e.g. `EG00029-1`) were orphaned and never associated with the set; the matching now handles alphanumeric set ids
- **Fixed font size on the landing page** (#167): Item titles in the "Latest added parts" section on the homepage now match the smaller size used by the sets and minifigures sections
- **Fixed sets with unmappable BrickLink parts never clearing the "need refresh" list** (#163): Sets containing parts that Rebrickable has no BrickLink mapping for (e.g. length-specific pneumatic hoses) kept reappearing on the Refresh admin page right after being refreshed; these unmappable parts no longer flag a set as needing a refresh
- **Fixed `BK_HIDE_SPARE_PARTS` being ignored on set detail pages**: Spare parts were still shown in the parts inventory on a set's detail page (and in per-minifigure part tables) even with the setting enabled. The set-specific and minifigure part queries now honor the flag like the other part lists do
- **Fixed price coverage exceeding sensible bounds** (#156): Price coverage is now divided by all priced item types and clamped to 100%
- **Fixed per-color counts on parts sub-cards** (#159): Set and minifigure counts are now computed per color
- **Fixed missing purchase date picker on the bulk add page** (#162)
## 1.4.1
### Enhancements
+15
View File
@@ -11,11 +11,13 @@ from werkzeug.middleware.proxy_fix import ProxyFix
from bricktracker.configuration_list import BrickConfigurationList
from bricktracker.login import LoginManager
from bricktracker.navbar import Navbar
from bricktracker.sidecar import BrickSidecar
from bricktracker.sql import close
from bricktracker.template_filters import replace_query_filter
from bricktracker.version import __version__
from bricktracker.views.add import add_page
from bricktracker.views.admin.admin import admin_page
from bricktracker.views.admin.custom_field import admin_custom_field_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
@@ -39,6 +41,7 @@ from bricktracker.views.minifigure import minifigure_page
from bricktracker.views.part import part_page
from bricktracker.views.purchase_location import purchase_location_page
from bricktracker.views.set import set_page
from bricktracker.views.sidecar import sidecar_page
from bricktracker.views.statistics import statistics_page
from bricktracker.views.storage import storage_page
from bricktracker.views.wish import wish_page
@@ -149,12 +152,14 @@ def setup_app(app: Flask) -> None:
app.register_blueprint(part_page)
app.register_blueprint(purchase_location_page)
app.register_blueprint(set_page)
app.register_blueprint(sidecar_page)
app.register_blueprint(statistics_page)
app.register_blueprint(storage_page)
app.register_blueprint(wish_page)
# Register admin routes
app.register_blueprint(admin_page)
app.register_blueprint(admin_custom_field_page)
app.register_blueprint(admin_database_page)
app.register_blueprint(admin_export_page)
app.register_blueprint(admin_image_page)
@@ -195,6 +200,16 @@ def setup_app(app: Flask) -> None:
# Register custom Jinja2 filters
app.jinja_env.filters['replace_query'] = replace_query_filter
# Expose the sidecar feature flag and helper to every template so they can
# do `{% if sidecar_enabled %}`. Health is best-effort and cached, so this
# is cheap to evaluate on render and never blocks on a dead sidecar.
@app.context_processor
def inject_sidecar() -> dict[str, object]:
return {
'sidecar_enabled': BrickSidecar.enabled(),
'sidecar': BrickSidecar,
}
# Make sure all connections are closed at the end
@app.teardown_request
def teardown_request(_: BaseException | None) -> None:
+29 -2
View File
@@ -105,8 +105,35 @@ 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': 'BADGE_ORDER_GRID', 'd': ['theme', 'year', 'parts', 'total_minifigures', 'instructions', 'owner'], 'c': list},
{'n': 'BADGE_ORDER_DETAIL', 'd': ['theme', 'tag', 'year', 'retired', 'parts', 'instance_count', 'total_minifigures', 'total_missing', 'total_damaged', 'owner', 'storage', 'purchase_date', 'purchase_location', 'purchase_price', 'msrp', 'value', 'dimensions', 'weight', 'instructions', 'rebrickable', 'bricklink'], 'c': list},
{'n': 'SHOW_NOTES_GRID', 'd': False, 'c': bool},
{'n': 'SHOW_NOTES_DETAIL', 'd': True, 'c': bool},
# Sidecar (brickset-sidecar / BrickData) integration. Opt-in: an empty
# SIDECAR_URL means the whole feature is off and everything degrades.
{'n': 'SIDECAR_URL', 'd': ''},
{'n': 'SIDECAR_TIMEOUT', 'd': 5, 'c': int},
{'n': 'SIDECAR_DEFAULT_COVER', 'd': 'rebrickable'},
# Opt-in browsing of every BrickLink image the sidecar exposes for a set
# (a carousel on the detail page + extra cover sources). Off keeps the
# plain single cover behaviour.
{'n': 'SIDECAR_ADDITIONAL_IMAGES', 'd': False, 'c': bool},
{'n': 'SIDECAR_RETAIL_REGION', 'd': 'US'},
{'n': 'SIDECAR_AUTO_FETCH_PRICE', 'c': bool},
# Currency to request BrickLink market values in (e.g. EUR). Empty lets the
# sidecar use its default. Sent as a query parameter only when set; sidecars
# that do not support it simply ignore it.
{'n': 'SIDECAR_CURRENCY', 'd': ''},
# Per-element show/hide toggles for the BrickData enrichment on the set
# detail page. All default on so behaviour is unchanged; badges are handled
# separately via BADGE_ORDER_DETAIL.
{'n': 'SIDECAR_SHOW_DESIGNER', 'd': True, 'c': bool},
{'n': 'SIDECAR_SHOW_DESCRIPTION', 'd': True, 'c': bool},
{'n': 'SIDECAR_SHOW_NOTES', 'd': True, 'c': bool},
{'n': 'SIDECAR_SHOW_PRICE_PAID', 'd': True, 'c': bool},
{'n': 'SIDECAR_SHOW_PRICE_MSRP', 'd': True, 'c': bool},
{'n': 'SIDECAR_SHOW_PRICE_MARKET', 'd': True, 'c': bool},
# Which BrickLink market value the set-detail "Change vs paid" compares
# against: 'used' or 'new'. Statistics shows both regardless.
{'n': 'SIDECAR_PRICE_BASIS', 'd': 'used'},
]
+38 -5
View File
@@ -86,7 +86,23 @@ LIVE_CHANGEABLE_VARS: Final[List[str]] = [
'BK_RETIRED_SETS_FILE_URL',
'BK_RETIRED_SETS_PATH',
'BK_THEMES_FILE_URL',
'BK_THEMES_PATH'
'BK_THEMES_PATH',
# Sidecar integration (everything but the URL is live-changeable; the URL
# itself requires a restart, see RESTART_REQUIRED_VARS)
'BK_SIDECAR_TIMEOUT',
'BK_SIDECAR_DEFAULT_COVER',
'BK_SIDECAR_RETAIL_REGION',
'BK_SIDECAR_AUTO_FETCH_PRICE',
'BK_SIDECAR_ADDITIONAL_IMAGES',
'BK_SIDECAR_CURRENCY',
# Per-element show/hide toggles for the BrickData set-detail enrichment
'BK_SIDECAR_SHOW_DESIGNER',
'BK_SIDECAR_SHOW_DESCRIPTION',
'BK_SIDECAR_SHOW_NOTES',
'BK_SIDECAR_SHOW_PRICE_PAID',
'BK_SIDECAR_SHOW_PRICE_MSRP',
'BK_SIDECAR_SHOW_PRICE_MARKET',
'BK_SIDECAR_PRICE_BASIS'
]
# Environment variables that require restart
@@ -114,7 +130,10 @@ RESTART_REQUIRED_VARS: Final[List[str]] = [
'BK_PURCHASE_DATE_FORMAT',
'BK_PURCHASE_CURRENCY',
'BK_REBRICKABLE_USER_AGENT',
'BK_USER_AGENT'
'BK_USER_AGENT',
# Sidecar base URL only: changing it rebinds the whole feature, so it needs
# a restart. The other sidecar settings are live-changeable.
'BK_SIDECAR_URL'
]
class ConfigManager:
@@ -200,13 +219,13 @@ class ConfigManager:
else:
return []
# Integer variables (pagination sizes, delays, etc.) - Check BEFORE boolean check
if any(keyword in var_name.lower() for keyword in ['_size', '_page', 'delay', 'min_', 'per_page', 'page_size']):
if any(keyword in var_name.lower() for keyword in ['_size', '_page', 'delay', 'min_', 'per_page', 'page_size', '_timeout', '_hours']):
try:
return int(value)
except (ValueError, TypeError):
return 0
# Boolean variables - More specific patterns to avoid conflicts
if any(keyword in var_name.lower() for keyword in ['hide_', 'disable_', 'server_side_pagination', '_links', 'random', 'skip_', 'show_', 'use_', '_consolidation', '_charts', '_expanded']):
if any(keyword in var_name.lower() for keyword in ['hide_', 'disable_', 'server_side_pagination', '_links', 'random', 'skip_', 'show_', 'use_', '_consolidation', '_charts', '_expanded', 'auto_fetch']):
if isinstance(value, str):
return value.lower() in ('true', '1', 'yes', 'on')
return bool(value)
@@ -338,6 +357,20 @@ class ConfigManager:
'BK_USE_REMOTE_IMAGES': 'Use remote images from Rebrickable CDN instead of local',
'BK_STATISTICS_SHOW_CHARTS': 'Show collection growth charts on statistics page',
'BK_STATISTICS_DEFAULT_EXPANDED': 'Expand all statistics sections by default',
'BK_DARK_MODE': 'Enable dark mode theme'
'BK_DARK_MODE': 'Enable dark mode theme',
'BK_SIDECAR_URL': 'Base URL of the brickset-sidecar container (e.g. http://localhost:3335). Leave empty to disable all sidecar features.',
'BK_SIDECAR_TIMEOUT': 'Request timeout in seconds for sidecar calls',
'BK_SIDECAR_DEFAULT_COVER': 'Default cover image source for bulk add: rebrickable, box, or set',
'BK_SIDECAR_RETAIL_REGION': 'LEGO.com retail price region for MSRP: US, UK, CA, or DE',
'BK_SIDECAR_AUTO_FETCH_PRICE': 'Automatically fetch BrickLink market value when viewing a set (respects the cache TTL), so you do not have to press "Fetch value". Off by default as it is the slower live path.',
'BK_SIDECAR_ADDITIONAL_IMAGES': 'Browse every BrickLink image the sidecar exposes for a set: adds a carousel on the set detail page and extra cover sources. Off keeps the plain single cover.',
'BK_SIDECAR_CURRENCY': 'Currency to request BrickLink market values in, e.g. EUR. Empty uses the sidecar default. (The sidecar must support the currency query parameter.)',
'BK_SIDECAR_SHOW_DESIGNER': 'Show the BrickData set designer on the set detail page',
'BK_SIDECAR_SHOW_DESCRIPTION': 'Show the BrickData "About this set" description on the set detail page',
'BK_SIDECAR_SHOW_NOTES': 'Show the BrickData Brickset notes on the set detail page',
'BK_SIDECAR_SHOW_PRICE_PAID': 'Show the "Paid" row (and "Change vs paid") in the BrickData price comparison',
'BK_SIDECAR_SHOW_PRICE_MSRP': 'Show the retail/MSRP rows (and "Saved vs retail") in the BrickData price comparison',
'BK_SIDECAR_SHOW_PRICE_MARKET': 'Show the "Worth now" market value rows in the BrickData price comparison',
'BK_SIDECAR_PRICE_BASIS': 'Which BrickLink market value the set-detail "Change vs paid" compares against: used or new (falls back to the other when one is missing). Statistics shows both regardless.'
}
return help_text.get(var_name, 'No help available for this variable')
+4 -3
View File
@@ -75,10 +75,11 @@ class BrickInstructions(object):
splits = normalized.split('-', 2)
if len(splits) >= 2:
if len(splits) >= 2 and splits[0]:
try:
# Trying to make sense of each part as integers
int(splits[0])
# The set number can be alphanumeric (e.g. Pick-a-Brick
# builds like "EG00029-1"); only the version suffix has
# to be an integer
int(splits[1])
self.set = '-'.join(splits[:2])
+2
View File
@@ -6,6 +6,7 @@ from flask import url_for
from .exceptions import ErrorException, NotFoundException
from .fields import BrickRecordFields
from .record_list import BrickRecordList
from .set_custom_field import BrickSetCustomField
from .set_owner import BrickSetOwner
from .set_purchase_location import BrickSetPurchaseLocation
from .set_status import BrickSetStatus
@@ -17,6 +18,7 @@ logger = logging.getLogger(__name__)
T = TypeVar(
'T',
BrickSetCustomField,
BrickSetOwner,
BrickSetPurchaseLocation,
BrickSetStatus,
+8 -2
View File
@@ -181,8 +181,14 @@ class RebrickablePart(BrickRecord):
'color_rgb': data['color']['rgb'],
'color_transparent': data['color']['is_trans'],
'bricklink_color_id': None,
'bricklink_color_name': None,
'bricklink_part_num': None,
# Empty-string sentinel (not NULL) means "fetched from Rebrickable,
# but no BrickLink mapping exists upstream". need_refresh.sql flags
# only NULL (never fetched), so confirmed-missing parts/colors stop
# reappearing on the refresh list forever (#163). color_id stays
# NULL since it is an INTEGER column and is excluded from the
# refresh trigger instead.
'bricklink_color_name': '',
'bricklink_part_num': '',
'name': data['part']['name'],
'category': data['part']['part_cat_id'],
'image': data['part']['part_img_url'],
+4
View File
@@ -1,5 +1,6 @@
from .instructions_list import BrickInstructionsList
from .retired_list import BrickRetiredList
from .set_custom_field_list import BrickSetCustomFieldList
from .set_owner_list import BrickSetOwnerList
from .set_purchase_location_list import BrickSetPurchaseLocationList
from .set_status_list import BrickSetStatusList
@@ -31,6 +32,9 @@ def reload() -> None:
# Reload the set tags
BrickSetTagList.new(force=True)
# Reload the set custom fields
BrickSetCustomFieldList.new(force=True)
# Reload retired sets
BrickRetiredList(force=True)
+67
View File
@@ -10,6 +10,7 @@ from .exceptions import NotFoundException, DatabaseException, ErrorException
from .minifigure_list import BrickMinifigureList
from .part_list import BrickPartList
from .rebrickable_set import RebrickableSet
from .set_custom_field_list import BrickSetCustomFieldList
from .set_owner_list import BrickSetOwnerList
from .set_purchase_location_list import BrickSetPurchaseLocationList
from .set_status_list import BrickSetStatusList
@@ -79,6 +80,11 @@ class BrickSet(RebrickableSet):
# This must happen before inserting bricktracker_sets due to FK constraint
self.insert_rebrickable()
# Optionally override the freshly downloaded Rebrickable cover with
# the sidecar box art (best-effort; falls back silently to the
# Rebrickable image when unavailable).
self.apply_sidecar_box_art(data)
if not refresh:
# Save the storage
storage = BrickSetStorageList.get(
@@ -225,6 +231,42 @@ class BrickSet(RebrickableSet):
return True
# Override the cover with sidecar box art when requested during add.
# Never raises: a sidecar miss or failure leaves the Rebrickable cover.
def apply_sidecar_box_art(self, data: dict[str, Any], /) -> bool:
if not data.get('box_art'):
return False
try:
from .sidecar import BrickSidecar
if not BrickSidecar.enabled():
return False
ref = self.fields.set
# Honour SIDECAR_DEFAULT_COVER ('box' or 'set'); anything else
# (e.g. 'rebrickable') falls back to the reliable box image. The
# *_large variants almost never exist, so use the normal resolution.
cover = str(current_app.config.get('SIDECAR_DEFAULT_COVER', 'box'))
image_type = cover if cover in ('box', 'set') else 'box'
saved = BrickSidecar.save_cover_override(ref, image_type)
if saved:
logger.info('Set {ref}: cover set from sidecar {type} art'.format(
ref=ref,
type=image_type,
))
else:
logger.info('Set {ref}: no sidecar {type} art, kept Rebrickable cover'.format( # noqa: E501
ref=ref,
type=image_type,
))
return saved
except Exception:
logger.debug('sidecar box art override failed', exc_info=True)
return False
# Purchase date
def purchase_date(self, /, *, standard: bool = False) -> str:
if self.fields.purchase_date is not None:
@@ -293,6 +335,7 @@ class BrickSet(RebrickableSet):
# Load from database
if not self.select(
custom_fields=BrickSetCustomFieldList.as_columns(),
owners=BrickSetOwnerList.as_columns(),
statuses=BrickSetStatusList.as_columns(all=True),
tags=BrickSetTagList.as_columns(),
@@ -456,3 +499,27 @@ class BrickSet(RebrickableSet):
# Update description url
def url_for_description(self, /) -> str:
return url_for('set.update_description', id=self.fields.id)
# Cover override url (image_type: 'box' or 'set')
def url_for_cover(self, image_type: str, /) -> str:
return url_for(
'set.cover_override',
id=self.fields.id,
image_type=image_type,
)
# Cover override url for a Brickset additional image (0-indexed)
def url_for_cover_additional(self, index: int, /) -> str:
return url_for(
'set.cover_override_additional',
id=self.fields.id,
index=index,
)
# Restore the Rebrickable cover url
def url_for_cover_restore(self, /) -> str:
return url_for('set.cover_restore', id=self.fields.id)
# Refresh the BrickLink market value url
def url_for_value_refresh(self, /) -> str:
return url_for('set.value_refresh', id=self.fields.id)
+99
View File
@@ -0,0 +1,99 @@
import logging
from typing import Any, Self
from .exceptions import DatabaseException, ErrorException
from .metadata import BrickMetadata
from .sql import BrickSQL
logger = logging.getLogger(__name__)
# Allowed custom field types. The type only drives the input widget and light
# validation; the value is always stored as TEXT.
CUSTOM_FIELD_TYPES = ('text', 'number', 'date')
# Lego set custom field metadata (a typed, admin-defined per-set value)
class BrickSetCustomField(BrickMetadata):
kind: str = 'custom field'
# Set value endpoint
set_state_endpoint: str = 'set.update_custom_field'
# Queries
delete_query: str = 'set/metadata/custom_field/delete'
insert_query: str = 'set/metadata/custom_field/insert'
select_query: str = 'set/metadata/custom_field/select'
update_field_query: str = 'set/metadata/custom_field/update/field'
update_set_value_query: str = 'set/metadata/custom_field/update/value'
# SQL column name uses an underscore (kind has a space), so override the
# default which would produce "custom-field_{id}".
def as_column(self, /) -> str:
return 'custom_field_{id}'.format(id=self.fields.id)
# Grab data from a form (name + type)
def from_form(self, form: dict[str, str], /) -> Self:
super().from_form(form)
field_type = form.get('type', 'text')
if field_type not in CUSTOM_FIELD_TYPES:
raise ErrorException(
'Unknown custom field type: {type}'.format(type=field_type)
)
self.fields.type = field_type
return self
# Insert into database (carry the type along)
def insert(self, /, **_) -> None:
super().insert(
type=self.fields.type
)
# Update the per-set value of this custom field. Unlike the base
# update_set_value (which targets a fixed column), the column name is
# dynamic, so it is injected into the query like update_set_state does.
def update_value(
self,
brickset: Any,
/,
*,
json: Any | None = None,
value: Any | None = None,
) -> Any:
if value is None and json is not None:
value = json.get('value', '')
if value == '':
value = None
parameters = self.sql_parameters()
parameters['set_id'] = brickset.fields.id
parameters['value'] = value
rows, _ = BrickSQL().execute_and_commit(
self.update_set_value_query,
parameters=parameters,
name=self.as_column(),
)
if rows != 1:
raise DatabaseException(
'Could not update the custom field value for set {set} ({id})'.format( # noqa: E501
set=brickset.fields.set,
id=brickset.fields.id,
)
)
logger.info(
'Custom field "{name}" value changed to "{value}" for set {set} ({id})'.format( # noqa: E501
name=self.fields.name,
value=value,
set=brickset.fields.set,
id=brickset.fields.id,
)
)
return value
+24
View File
@@ -0,0 +1,24 @@
from typing import Self
from .metadata_list import BrickMetadataList
from .set_custom_field import BrickSetCustomField
# Lego sets custom field list
class BrickSetCustomFieldList(BrickMetadataList[BrickSetCustomField]):
kind: str = 'set custom fields'
# Database
table: str = 'bricktracker_set_custom_fields'
order: str = '"bricktracker_metadata_custom_fields"."name"'
# Queries
select_query: str = 'set/metadata/custom_field/list'
# Set value endpoint
set_value_endpoint: str = 'set.update_custom_field'
# Instantiate the list with the proper class
@classmethod
def new(cls, /, *, force: bool = False) -> Self:
return cls(BrickSetCustomField, force=force)
+6
View File
@@ -3,6 +3,7 @@ from typing import Any, Self, Union
from flask import current_app
from .record_list import BrickRecordList
from .set_custom_field_list import BrickSetCustomFieldList
from .set_owner import BrickSetOwner
from .set_owner_list import BrickSetOwnerList
from .set_purchase_location import BrickSetPurchaseLocation
@@ -52,6 +53,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
def all(self, /) -> Self:
# Load the sets from the database with metadata context for filtering
filter_context = {
'custom_fields': BrickSetCustomFieldList.as_columns(),
'owners': BrickSetOwnerList.as_columns(),
'statuses': BrickSetStatusList.as_columns(),
'tags': BrickSetTagList.as_columns(),
@@ -64,6 +66,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
def all_consolidated(self, /) -> Self:
# Load the sets from the database using consolidated query with metadata context
filter_context = {
'custom_fields_dict': BrickSetCustomFieldList.as_column_mapping(),
'owners_dict': BrickSetOwnerList.as_column_mapping(),
'statuses_dict': BrickSetStatusList.as_column_mapping(),
'tags_dict': BrickSetTagList.as_column_mapping(),
@@ -117,9 +120,11 @@ class BrickSetList(BrickRecordList[BrickSet]):
'tag_filter': tag_filter,
'year_filter': year_filter,
'duplicate_filter': duplicate_filter,
'custom_fields': BrickSetCustomFieldList.as_columns(),
'owners': BrickSetOwnerList.as_columns(),
'statuses': BrickSetStatusList.as_columns(),
'tags': BrickSetTagList.as_columns(),
'custom_fields_dict': BrickSetCustomFieldList.as_column_mapping(),
'owners_dict': BrickSetOwnerList.as_column_mapping(),
'statuses_dict': BrickSetStatusList.as_column_mapping(),
'tags_dict': BrickSetTagList.as_column_mapping(),
@@ -705,6 +710,7 @@ def set_metadata_lists(
]
]:
return {
'brickset_custom_fields': BrickSetCustomFieldList.list(),
'brickset_owners': BrickSetOwnerList.list(),
'brickset_purchase_locations': BrickSetPurchaseLocationList.list(as_class=as_class), # noqa: E501
'brickset_storages': BrickSetStorageList.list(as_class=as_class),
+580
View File
@@ -0,0 +1,580 @@
import json
import logging
import os
import time
from typing import Any, Final
from flask import current_app, url_for
import requests
logger = logging.getLogger(__name__)
# The four LEGO.com retail price regions the sidecar exposes. The sidecar
# returns each of these as a JSON *string* (not a nested object), so we parse
# them eagerly in get_set().
RETAIL_REGIONS: Final[list[str]] = ['US', 'UK', 'CA', 'DE']
# Currency code per retail region, used to label MSRP.
REGION_CURRENCY: Final[dict[str, str]] = {
'US': 'USD',
'UK': 'GBP',
'CA': 'CAD',
'DE': 'EUR',
}
# Maps a currency *symbol* (as a user might set BK_PURCHASE_CURRENCY) to the ISO
# code(s) it can stand for, so '$' is treated as the same currency as 'USD' and
# 'kr' as 'DKK'. Symbols are inherently ambiguous (several currencies share one),
# so we map to a set and treat two values as the same currency when their code
# sets overlap. Keys are normalized (lowercased, trailing dots stripped).
CURRENCY_SYMBOLS: Final[dict[str, set[str]]] = {
'$': {'USD', 'CAD', 'AUD', 'NZD', 'HKD', 'SGD', 'MXN', 'BRL'},
'us$': {'USD'},
'ca$': {'CAD'},
'c$': {'CAD'},
'a$': {'AUD'},
'nz$': {'NZD'},
'': {'EUR'},
'£': {'GBP'},
'¥': {'JPY', 'CNY'},
'': {'JPY', 'CNY'},
'kr': {'DKK', 'SEK', 'NOK', 'ISK'},
'': {'PLN'},
'': {'RUB'},
'': {'INR'},
'r$': {'BRL'},
'': {'KRW'},
'฿': {'THB'},
'': {'ILS'},
'chf': {'CHF'},
'fr': {'CHF'},
}
# Valid BrickLink image types served by the sidecar image proxy.
IMAGE_TYPES: Final[list[str]] = [
'box', 'box_large',
'set', 'set_large',
'instruction', 'instruction_large',
]
# How long (seconds) to trust a cached /health result so the UI never blocks
# on a dead sidecar.
HEALTH_CACHE_TTL: Final[int] = 30
# Best-effort client for the brickset-sidecar (BrickData) container.
#
# Design rule: a missing or broken sidecar must NEVER break an existing page.
# Every public method returns None / False on any failure and logs at debug.
# Nothing in here is allowed to raise into a request handler. Guard call sites
# with BrickSidecar.enabled().
class BrickSidecar(object):
# Cache for the health probe: (checked_at, healthy)
_health_cache: tuple[float, bool] | None = None
# --- Configuration helpers ------------------------------------------
# Normalized base URL (no trailing slash), or '' when unset.
@staticmethod
def base_url() -> str:
return str(current_app.config.get('SIDECAR_URL', '') or '').rstrip('/')
# True only when a sidecar URL is configured (the feature toggle).
@staticmethod
def enabled() -> bool:
return BrickSidecar.base_url() != ''
@staticmethod
def timeout() -> int:
return int(current_app.config.get('SIDECAR_TIMEOUT', 5))
# --- Low level ------------------------------------------------------
# Perform a GET and return the parsed JSON, or None on any failure.
@staticmethod
def _get_json(path: str, /, *, params: dict[str, Any] | None = None) -> Any:
if not BrickSidecar.enabled():
return None
url = '{base}{path}'.format(base=BrickSidecar.base_url(), path=path)
try:
response = requests.get(
url,
params=params,
timeout=BrickSidecar.timeout(),
)
if not response.ok:
logger.debug(
'sidecar GET %s -> HTTP %s', url, response.status_code,
)
return None
return response.json()
except (requests.RequestException, ValueError) as exception:
# ValueError covers JSON decode errors. Never propagate.
logger.debug('sidecar GET %s failed: %s', url, exception)
return None
# --- Health ---------------------------------------------------------
# Cached short-TTL health probe. Safe to call on every render.
@staticmethod
def healthy() -> bool:
if not BrickSidecar.enabled():
return False
now = time.monotonic()
cache = BrickSidecar._health_cache
if cache is not None and (now - cache[0]) < HEALTH_CACHE_TTL:
return cache[1]
payload = BrickSidecar._get_json('/health')
healthy = bool(payload) and payload.get('status') == 'ok'
BrickSidecar._health_cache = (now, healthy)
return healthy
# --- Sets -----------------------------------------------------------
# GET /sets/{ref}. Returns the single set dict (the sidecar wraps it in a
# {"sets": [...]} list) with the legoCom* JSON-string fields parsed into
# dicts, or None if missing. When price=True, the BrickLink price block is
# merged in under 'bricklink_price'.
#
# Metadata is read from the persistent cache first (effectively permanent);
# pass refresh=True to force a network fetch and rewrite the cache.
@staticmethod
def get_set(
ref: str,
/,
*,
price: bool = False,
refresh: bool = False,
use_cache: bool = True,
) -> dict[str, Any] | None:
# The sidecar (brickdata) is the single source of truth and cache: it
# serves from its own DB and only hits Brickset on its own staleness
# rules. We always read live from it — no second cache here. (use_cache
# is kept for signature compatibility but no longer caches locally.)
params: dict[str, Any] = {}
if refresh:
params['refresh'] = 'true'
payload = BrickSidecar._get_json(
'/sets/{ref}'.format(ref=ref),
params=params or None,
)
if not payload:
return None
sets = payload.get('sets') or []
if not sets:
return None
data = sets[0]
# The sidecar serializes legoCom* as JSON strings; parse them so call
# sites get a dict (or None) instead of a raw string.
for region in RETAIL_REGIONS:
key = 'legoCom{region}'.format(region=region)
data[key] = BrickSidecar._parse_json_field(data.get(key))
if price:
data = dict(data)
data['bricklink_price'] = BrickSidecar.get_price(ref, refresh=refresh)
return data
# BrickLink market value for a set, or None. The sidecar owns price caching
# (and its own TTL): the normal path returns its cached price (fetching once
# if its cache expired), while refresh=True forces a live re-fetch via the
# dedicated /price endpoint. No local caching here.
@staticmethod
def get_price(
ref: str,
/,
*,
refresh: bool = False,
use_cache: bool = True,
) -> dict[str, Any] | None:
currency = str(current_app.config.get('SIDECAR_CURRENCY', '') or '').strip()
# Forced refresh (explicit user action): the dedicated endpoint
# re-fetches from BrickLink and rewrites the sidecar's price cache.
if refresh:
params: dict[str, Any] = {'refresh': 'true'}
if currency:
params['currency'] = currency
return BrickSidecar._get_json(
'/sets/{ref}/price'.format(ref=ref),
params=params,
)
# Normal path: the sidecar returns its cached price, or fetches once if
# its own TTL expired. The price rides along on the set payload under
# bricklink_price. No local caching — the sidecar is the cache.
params = {'price': 'true'}
if currency:
params['currency'] = currency
payload = BrickSidecar._get_json(
'/sets/{ref}'.format(ref=ref),
params=params,
)
if payload:
sets = payload.get('sets') or []
if sets:
return sets[0].get('bricklink_price') or None
return None
# GET /sets/bulk -> cached metadata (+ cached price) for many sets at once,
# keyed by ref. The sidecar serves this from its own DB with no live
# Brickset/BrickLink calls, so it is cheap enough for collection-wide
# aggregation (stats) without a local cache. Requests are chunked to keep
# the query string within sane limits.
@staticmethod
def get_sets_bulk(
refs: list[str],
/,
*,
price: bool = True,
chunk_size: int = 200,
) -> dict[str, dict[str, Any]]:
wanted = [r for r in refs if r]
if not wanted:
return {}
currency = str(current_app.config.get('SIDECAR_CURRENCY', '') or '').strip()
out: dict[str, dict[str, Any]] = {}
for start in range(0, len(wanted), chunk_size):
chunk = wanted[start:start + chunk_size]
params: dict[str, Any] = {'refs': ','.join(chunk)}
if price:
params['price'] = 'true'
if currency:
params['currency'] = currency
payload = BrickSidecar._get_json('/sets/bulk', params=params)
if not payload:
continue
for s in payload.get('sets') or []:
ref = '{n}-{v}'.format(
n=s.get('number'), v=s.get('numberVariant'),
)
for region in RETAIL_REGIONS:
key = 'legoCom{region}'.format(region=region)
s[key] = BrickSidecar._parse_json_field(s.get(key))
out[ref] = s
return out
# Convenience: MSRP for the configured retail region (SIDECAR_RETAIL_REGION),
# read from an already-fetched get_set() payload. None when unavailable.
@staticmethod
def retail_price(set_data: dict[str, Any], /) -> float | None:
region = BrickSidecar.retail_region()
block = set_data.get('legoCom{region}'.format(region=region))
if not isinstance(block, dict):
return None
value = block.get('retailPrice')
try:
return float(value) if value is not None else None
except (TypeError, ValueError):
return None
# Configured retail region (US/UK/CA/DE), defaulting to US.
@staticmethod
def retail_region() -> str:
region = str(current_app.config.get('SIDECAR_RETAIL_REGION', 'US')).upper()
return region if region in REGION_CURRENCY else 'US'
# Currency code for the configured retail region.
@staticmethod
def retail_currency() -> str:
return REGION_CURRENCY[BrickSidecar.retail_region()]
# The ISO code(s) a currency value can represent. An ISO code maps to itself;
# a known symbol maps to its candidate set (see CURRENCY_SYMBOLS); anything
# else falls back to its own uppercased form.
@staticmethod
def currency_codes(value: Any, /) -> set[str]:
text = str(value or '').strip()
if not text:
return set()
key = text.rstrip('.').lower()
if key in CURRENCY_SYMBOLS:
return set(CURRENCY_SYMBOLS[key])
return {text.upper()}
# Whether two currency values (symbol or ISO code) denote the same currency.
# Empty values are treated as "unknown" -> compatible, so we never warn just
# because one side is unset.
@staticmethod
def same_currency(a: Any, b: Any, /) -> bool:
codes_a = BrickSidecar.currency_codes(a)
codes_b = BrickSidecar.currency_codes(b)
if not codes_a or not codes_b:
return True
return bool(codes_a & codes_b)
# Read a price WITHOUT triggering a live BrickLink fetch: ask the sidecar
# for a cache-only price (cached_only=true), so a render never blocks.
# Returns (price_dict, fetched_at) or (None, None).
@staticmethod
def cached_price(ref: str, /) -> tuple[dict[str, Any] | None, Any]:
params: dict[str, Any] = {'price': 'true', 'cached_only': 'true'}
currency = str(current_app.config.get('SIDECAR_CURRENCY', '') or '').strip()
if currency:
params['currency'] = currency
payload = BrickSidecar._get_json(
'/sets/{ref}'.format(ref=ref),
params=params,
)
price: dict[str, Any] | None = None
if payload:
sets = payload.get('sets') or []
if sets:
price = sets[0].get('bricklink_price') or None
if price is None:
return None, None
return price, price.get('fetched_at')
# --- Images ---------------------------------------------------------
# GET /sets/{ref}/images -> {'brickset': {...}, 'bricklink': {...}}, or None.
@staticmethod
def get_images(ref: str, /) -> dict[str, Any] | None:
return BrickSidecar._get_json('/sets/{ref}/images'.format(ref=ref))
# Build a BrickTracker-served proxy URL for an <img src>. The browser hits
# BrickTracker (same origin), which fetches the image from the sidecar
# server-side. This works even when the sidecar is only reachable on the
# internal Docker network (e.g. http://brickdata:3335), which the browser
# cannot resolve. Returns None when disabled or the type is unknown.
@staticmethod
def image_proxy_url(image_type: str, ref: str, /) -> str | None:
if image_type not in IMAGE_TYPES or not BrickSidecar.enabled():
return None
return url_for('sidecar.image', image_type=image_type, ref=ref)
# The real sidecar image URL, used server-side to fetch the bytes.
@staticmethod
def _remote_image_url(image_type: str, ref: str, /) -> str | None:
if image_type not in IMAGE_TYPES or not BrickSidecar.enabled():
return None
return '{base}/images/bricklink/{type}/{ref}.png'.format(
base=BrickSidecar.base_url(),
type=image_type,
ref=ref,
)
# Build a BrickTracker-served proxy URL for one Brickset additional image
# (0-indexed) for use in an <img src>. Like image_proxy_url, the browser
# talks only to BrickTracker, which fetches from the sidecar server-side.
# Pass thumbnail=True for the smaller variant. Returns None when disabled.
@staticmethod
def additional_image_url(
ref: str,
index: int,
/,
*,
thumbnail: bool = False,
) -> str | None:
if not BrickSidecar.enabled():
return None
return url_for(
'sidecar.additional_image',
ref=ref,
index=index,
thumbnail=1 if thumbnail else None,
)
# The real sidecar additional-image URL, used server-side to fetch bytes.
# Unlike box/instruction art these come from Brickset, not the BrickLink
# image proxy.
@staticmethod
def _remote_additional_image_url(
ref: str,
index: int,
/,
*,
thumbnail: bool = False,
) -> str | None:
if not BrickSidecar.enabled():
return None
url = '{base}/sets/{ref}/additional-images/{index}'.format(
base=BrickSidecar.base_url(),
ref=ref,
index=index,
)
if thumbnail:
url = '{url}?size=thumbnail'.format(url=url)
return url
# Fetch the raw bytes of one Brickset additional image (used when saving it
# as the cover). Returns None on any failure or a 404.
@staticmethod
def fetch_additional_image_bytes(
ref: str,
index: int,
/,
*,
thumbnail: bool = False,
) -> bytes | None:
url = BrickSidecar._remote_additional_image_url(
ref, index, thumbnail=thumbnail,
)
if url is None:
return None
return BrickSidecar._fetch_bytes(url)
# Fetch the raw bytes of a proxied image (used when saving box art locally).
# Returns None on any failure or a 404 (BrickLink has no such image).
@staticmethod
def fetch_image_bytes(image_type: str, ref: str, /) -> bytes | None:
url = BrickSidecar._remote_image_url(image_type, ref)
if url is None:
return None
return BrickSidecar._fetch_bytes(url)
# Download the raw bytes at a sidecar image URL, returning None on any
# failure or a non-OK status (e.g. a 404 when the image does not exist).
@staticmethod
def _fetch_bytes(url: str, /) -> bytes | None:
try:
response = requests.get(url, timeout=BrickSidecar.timeout())
if not response.ok:
logger.debug(
'sidecar image %s -> HTTP %s', url, response.status_code,
)
return None
return response.content
except requests.RequestException as exception:
logger.debug('sidecar image %s failed: %s', url, exception)
return None
# --- Cover override -------------------------------------------------
# Absolute path of the locally stored cover for a set ref, mirroring the
# logic in RebrickableImage.path() (everything is stored as .jpg).
@staticmethod
def cover_path(set_ref: str, /) -> str:
folder: str = current_app.config['SETS_FOLDER']
if folder.startswith('/'):
base_path = folder
else:
base_path = os.path.join(current_app.root_path, folder)
return os.path.join(base_path, '{ref}.jpg'.format(ref=set_ref))
# Backup path used so a box-art override is reversible.
@staticmethod
def cover_backup_path(set_ref: str, /) -> str:
return BrickSidecar.cover_path(set_ref).replace('.jpg', '.bak.jpg')
# Override the local cover image with a sidecar image (box / set). Backs up
# the existing cover the first time so it can be restored. Returns True on
# success, False if the sidecar has no such image or anything fails.
@staticmethod
def save_cover_override(set_ref: str, image_type: str, /) -> bool:
data = BrickSidecar.fetch_image_bytes(image_type, set_ref)
return BrickSidecar._write_cover(set_ref, data)
# Override the local cover with one Brickset additional image (0-indexed).
# Same backup/restore semantics as save_cover_override.
@staticmethod
def save_cover_override_from_additional(set_ref: str, index: int, /) -> bool:
data = BrickSidecar.fetch_additional_image_bytes(set_ref, index)
return BrickSidecar._write_cover(set_ref, data)
# Write image bytes as the local cover, backing up the original Rebrickable
# cover once so it stays restorable. Returns False when data is None (the
# sidecar had no such image) or on any filesystem error.
@staticmethod
def _write_cover(set_ref: str, data: bytes | None, /) -> bool:
if data is None:
return False
path = BrickSidecar.cover_path(set_ref)
backup = BrickSidecar.cover_backup_path(set_ref)
try:
# Preserve the original Rebrickable cover once.
if os.path.exists(path) and not os.path.exists(backup):
os.replace(path, backup)
# os.replace moved the file; re-create it below from bytes.
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, 'wb') as handle:
handle.write(data)
return True
except OSError as exception:
logger.debug('cover override for %s failed: %s', set_ref, exception)
return False
# Restore a previously backed-up Rebrickable cover. Returns True if a backup
# existed and was restored, False otherwise (caller may re-download instead).
@staticmethod
def restore_cover(set_ref: str, /) -> bool:
path = BrickSidecar.cover_path(set_ref)
backup = BrickSidecar.cover_backup_path(set_ref)
if not os.path.exists(backup):
return False
try:
os.replace(backup, path)
return True
except OSError as exception:
logger.debug('cover restore for %s failed: %s', set_ref, exception)
return False
# --- Internal -------------------------------------------------------
# Parse a field the sidecar returns as a JSON string into a dict, tolerating
# None / empty / malformed values.
@staticmethod
def _parse_json_field(value: Any) -> dict[str, Any] | None:
if not value:
return None
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else None
except ValueError:
return None
return None
+294
View File
@@ -0,0 +1,294 @@
import logging
import re
from datetime import datetime, timezone
from html.parser import HTMLParser
from typing import Any
from flask import current_app
from markupsafe import Markup, escape
from .sidecar import BrickSidecar
logger = logging.getLogger(__name__)
# Brickset descriptions ship as HTML (<p>, <ul>, <li>, <i>, ...). Keep only a
# small allowlist of formatting tags and drop every attribute so the markup can
# be rendered as-is without opening an XSS hole.
_ALLOWED_TAGS = frozenset({
'p', 'br', 'ul', 'ol', 'li', 'b', 'strong', 'i', 'em', 'u',
'h3', 'h4', 'h5', 'h6', 'small', 'sub', 'sup',
})
_VOID_TAGS = frozenset({'br'})
# Build a normalized, template-friendly summary of the sidecar data for a set:
# enrichment (description, dimensions, retired, ...) plus the three-way price
# comparison (paid / retail MSRP / BrickLink market value).
#
# Metadata comes from the persistent cache (a local read, effectively
# permanent). The BrickLink
# market value is read from cache only here (no network) so a page render never
# blocks; refreshing it is an explicit user action. Returns None when the
# sidecar is disabled or has no data for the set.
def summarize(
set_ref: str,
/,
*,
purchase_price: float | None = None,
fetch_price: bool = False,
) -> dict[str, Any] | None:
if not BrickSidecar.enabled():
return None
data = BrickSidecar.get_set(set_ref)
if data is None:
return None
summary: dict[str, Any] = {
'description': _description(data.get('description')),
'pieces': data.get('pieces'),
'minifigs': data.get('minifigs'),
'year': data.get('year'),
'theme': _clean_str(data.get('theme')),
'subtheme': _clean_str(data.get('subtheme')),
# Set designer(s); web-scraped, the API does not expose it.
'designer': _clean_str(data.get('designer')),
'dimensions': _dimensions(data),
'weight': _weight(data),
'instructions_count': data.get('instructionsCount'),
'additional_image_count': _to_int(data.get('additionalImageCount')),
# Carousel + extra cover sources are opt-in; off keeps the plain cover.
'additional_images_enabled': bool(
current_app.config['SIDECAR_ADDITIONAL_IMAGES']
),
'tags': _tags(data.get('tags')),
# Brickset "Notes" blurb (web-scraped; contains light HTML). Sanitised
# like the description so it renders safely.
'notes': _description(data.get('notes')),
}
# Retired status from the exit date. exit_date is the full formatted date
# (BrickData/Brickset has it even for sets retired long ago, which the
# upcoming-retirements CSV does not cover, see #37).
retired, exit_year = _retired(data.get('exitDate'))
summary['retired'] = retired
summary['exit_year'] = exit_year
summary['exit_date'] = _format_date(data.get('exitDate'))
summary['launch_year'] = _year(data.get('launchDate'))
# --- Prices: paid / retail / market --------------------------------
msrp = BrickSidecar.retail_price(data)
# When auto-fetch is on, hit the TTL-aware path (network only on a cache
# miss/expiry); otherwise stay cache-only so the render never blocks.
if fetch_price:
price_payload = BrickSidecar.get_price(set_ref)
price_fetched_at = None
else:
price_payload, price_fetched_at = BrickSidecar.cached_price(set_ref)
market_new = _to_float(price_payload.get('new_avg')) if price_payload else None
market_used = _to_float(price_payload.get('used_avg')) if price_payload else None
paid = _to_float(purchase_price)
prices: dict[str, Any] = {
'paid': paid,
'msrp': msrp,
'msrp_currency': BrickSidecar.retail_currency(),
# Inflation-adjusted RRP, web-scraped by the sidecar (single value in
# its own currency, independent of the configured retail region).
'msrp_inflated': _to_float(data.get('rrpInflated')),
'msrp_inflated_currency': _clean_str(data.get('rrpInflatedCurrency')),
'market_new': market_new,
'market_used': market_used,
'market_min': _to_float(price_payload.get('new_min')) if price_payload else None, # noqa: E501
'market_max': _to_float(price_payload.get('new_max')) if price_payload else None, # noqa: E501
'market_used_min': _to_float(price_payload.get('used_min')) if price_payload else None, # noqa: E501
'market_used_max': _to_float(price_payload.get('used_max')) if price_payload else None, # noqa: E501
'market_currency': (price_payload or {}).get('currency_code'),
'market_fetched_at': (price_payload or {}).get('fetched_at') or price_fetched_at, # noqa: E501
'has_market': price_payload is not None,
}
# Savings vs MSRP (positive = paid less than retail).
if msrp is not None and paid is not None:
prices['savings_vs_msrp'] = round(msrp - paid, 2)
# Value movement vs what was paid. SIDECAR_PRICE_BASIS picks which market
# average to compare against ('used' or 'new'); fall back to the other when
# the preferred one is missing.
basis = str(current_app.config.get('SIDECAR_PRICE_BASIS', 'used')).lower()
if basis == 'new':
market_ref = market_new if market_new is not None else market_used
else:
market_ref = market_used if market_used is not None else market_new
prices['market_basis'] = basis
if market_ref is not None and paid is not None:
prices['gain_vs_paid'] = round(market_ref - paid, 2)
summary['prices'] = prices
return summary
# --- Helpers ------------------------------------------------------------
def _clean_str(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
# Rebuild an HTML fragment keeping only the allowlisted formatting tags, with
# all attributes stripped and all text escaped. convert_charrefs=True turns
# entities (&reg;, &#39;, ...) into real characters before they reach the data
# handler, so they render correctly.
class _DescriptionSanitizer(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.parts: list[str] = []
def handle_starttag(self, tag: str, attrs: Any) -> None:
if tag in _ALLOWED_TAGS:
self.parts.append(f'<{tag}>')
def handle_startendtag(self, tag: str, attrs: Any) -> None:
if tag in _ALLOWED_TAGS:
self.parts.append(f'<{tag}>')
def handle_endtag(self, tag: str) -> None:
if tag in _ALLOWED_TAGS and tag not in _VOID_TAGS:
self.parts.append(f'</{tag}>')
def handle_data(self, data: str) -> None:
self.parts.append(str(escape(data)))
def _description(value: Any) -> Markup | None:
text = _clean_str(value)
if text is None:
return None
parser = _DescriptionSanitizer()
parser.feed(text)
parser.close()
cleaned = ''.join(parser.parts)
# Brickset pads descriptions with empty spacer paragraphs (<p>&nbsp;</p>);
# drop those so the blocks sit flush instead of leaving big gaps. \s also
# matches the &nbsp; (\xa0) the parser leaves behind.
cleaned = re.sub(r'<p>\s*</p>', '', cleaned).strip()
return Markup(cleaned) if cleaned else None
def _to_float(value: Any) -> float | None:
if value is None or value == '':
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _to_int(value: Any) -> int | None:
if value is None or value == '':
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _dimensions(data: dict[str, Any], /) -> str | None:
height = _to_float(data.get('height'))
width = _to_float(data.get('width'))
depth = _to_float(data.get('depth'))
if height is None and width is None and depth is None:
return None
parts = [
'{0:g}'.format(round(value, 1)) if value is not None else '?'
for value in (height, width, depth)
]
return '{0} cm'.format(' × '.join(parts))
def _weight(data: dict[str, Any], /) -> str | None:
weight = _to_float(data.get('weight'))
if weight is None:
return None
return '{0:g} kg'.format(round(weight, 2))
def _tags(value: Any) -> list[str]:
if not value:
return []
return [tag.strip() for tag in str(value).split(',') if tag.strip()]
def _parse_date(value: Any) -> datetime | None:
if not value:
return None
text = str(value).replace('Z', '+00:00')
try:
parsed = datetime.fromisoformat(text)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
def _year(value: Any) -> int | None:
parsed = _parse_date(value)
return parsed.year if parsed is not None else None
def _format_date(value: Any) -> str | None:
parsed = _parse_date(value)
if parsed is None:
return None
return parsed.strftime(current_app.config['PURCHASE_DATE_FORMAT'])
def _retired(exit_date: Any) -> tuple[bool, int | None]:
parsed = _parse_date(exit_date)
if parsed is None:
return False, None
return parsed < datetime.now(timezone.utc), parsed.year
# Bulk retirement lookup for a list of set refs (e.g. the whole wishlist) in a
# single cached BrickData call. Returns {ref: {'date': str|None,
# 'retired': bool}} keyed by both "number-variant" and the bare number so the
# caller can match whichever form it stores. Empty dict when the sidecar is
# disabled or unreachable, so callers degrade to the CSV (see #37).
def retirement_dates(
set_refs: Any,
/,
) -> dict[str, dict[str, Any]]:
if not BrickSidecar.enabled():
return {}
refs = list({str(ref) for ref in set_refs if ref})
if not refs:
return {}
try:
bulk = BrickSidecar.get_sets_bulk(refs, price=False)
except Exception as exception:
logger.debug('sidecar retirement bulk fetch failed: %s', exception)
return {}
out: dict[str, dict[str, Any]] = {}
for ref, data in bulk.items():
retired, _ = _retired(data.get('exitDate'))
info = {
'date': _format_date(data.get('exitDate')),
'retired': retired,
}
out[ref] = info
number, _, _ = ref.partition('-')
out.setdefault(number, info)
return out
+64
View File
@@ -36,6 +36,7 @@ MESSAGES: Final[dict[str, str]] = {
'PART_LOADED': 'part_loaded',
'PROGRESS': 'progress',
'SET_LOADED': 'set_loaded',
'VALUE_ALL_SETS': 'value_all_sets',
}
@@ -298,6 +299,69 @@ class BrickSocket(object):
from .individual_part import IndividualPart
IndividualPart().create_bulk(self, data)
@self.socket.on(MESSAGES['VALUE_ALL_SETS'], namespace=self.namespace)
@authenticated_socket(self)
def value_all_sets(data: dict[str, Any], /) -> None:
logger.debug('Socket: VALUE_ALL_SETS (from: {fr})'.format(
fr=request.sid, # type: ignore
))
from .sidecar import BrickSidecar
from .sql import BrickSQL
if not BrickSidecar.enabled():
self.fail(message='The sidecar is not configured')
return
# Distinct set numbers in the collection (reuses the instructions
# statistics query).
try:
rows = BrickSQL().fetchall('statistics/set_numbers')
except Exception:
rows = []
refs = [row['set'] for row in rows]
self.progress_count = 0
self.update_total(len(refs))
priced = 0 # sets that ended up with a value (cached or fetched)
fetched = 0 # sets that required a live fetch this run
missing = 0 # sets with no value available at all
for ref in refs:
self.auto_progress(
message='Valuing set {ref}'.format(ref=ref),
)
# Does the sidecar already hold a cached price for this set? If
# so, get_price() returns it without a live BrickLink fetch.
cached, fetched_at = BrickSidecar.cached_price(ref)
was_fresh = cached is not None
try:
price = BrickSidecar.get_price(ref)
except Exception:
price = None
if price is not None:
priced += 1
if not was_fresh:
fetched += 1
else:
missing += 1
# Only pause when we actually hit the network, so re-runs over an
# already-cached collection stay fast.
if not was_fresh:
self.socket.sleep(0.2)
self.complete(
message=(
'{priced} set(s) now have a value ({fetched} newly fetched); '
'{missing} had none available.'
).format(priced=priced, fetched=fetched, missing=missing),
)
# Update the progress auto-incrementing
def auto_progress(
self,
+17
View File
@@ -0,0 +1,17 @@
-- description: Add sidecar_set_cache table for brickset-sidecar metadata and price caching
BEGIN TRANSACTION;
-- Caches responses from the optional brickset-sidecar so we never hammer it.
-- payload : JSON of GET /sets/{ref} (effectively permanent, refresh on demand)
-- price_payload : JSON of GET /sets/{ref}/price (honours SIDECAR_PRICE_CACHE_HOURS TTL)
-- *_fetched_at : epoch seconds (REAL) of when each payload was stored
CREATE TABLE IF NOT EXISTS "sidecar_set_cache" (
"set_ref" TEXT NOT NULL PRIMARY KEY,
"payload" TEXT,
"price_payload" TEXT,
"fetched_at" REAL,
"price_fetched_at" REAL
);
COMMIT;
+23
View File
@@ -0,0 +1,23 @@
-- description: Add typed custom fields (admin-defined per-set fields)
BEGIN TRANSACTION;
-- Defines each custom field: an id, a name and a type (text / number / date).
-- The type only drives the input widget + light validation; the per-set value
-- is always stored as TEXT in a dynamic column on bricktracker_set_custom_fields.
CREATE TABLE "bricktracker_metadata_custom_fields" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT NOT NULL DEFAULT 'text',
PRIMARY KEY("id")
);
-- Holds the per-set value of each custom field. A "custom_field_{id}" TEXT
-- column is added for every field created (mirroring the tag/status pattern).
CREATE TABLE "bricktracker_set_custom_fields" (
"id" TEXT NOT NULL,
PRIMARY KEY("id"),
FOREIGN KEY("id") REFERENCES "bricktracker_sets"("id")
);
COMMIT;
@@ -65,6 +65,10 @@ INNER JOIN "rebrickable_parts"
ON "parts_combined"."part" = "rebrickable_parts"."part"
AND "parts_combined"."color" = "rebrickable_parts"."color_id"
{% if skip_spare_parts %}
WHERE "parts_combined"."spare" = 0
{% endif %}
GROUP BY
"parts_combined"."part",
"parts_combined"."color",
+31
View File
@@ -5,6 +5,37 @@
{% block total_damaged %}{% endblock %}
{# Per part+color set/minifigure counts so each sub-card shows its own totals
(same #159 inherited-count bug as with_different_color). Mirrors all.sql. #}
{% block total_sets %}
IFNULL(COUNT(DISTINCT CASE WHEN "combined"."source_type" = 'set' THEN "combined"."id" ELSE NULL END), 0) AS "total_sets",
{% endblock %}
{% block total_minifigures %}
SUM(IFNULL("minifigure_quantities"."quantity", 0)) AS "total_minifigures"
{% endblock %}
{% block join %}
-- Join to get minifigure quantities from both set-based and individual minifigures
LEFT JOIN (
SELECT
"bricktracker_minifigures"."id",
"bricktracker_minifigures"."figure",
"bricktracker_minifigures"."quantity"
FROM "bricktracker_minifigures"
UNION ALL
SELECT
"bricktracker_individual_minifigures"."id",
"bricktracker_individual_minifigures"."figure",
"bricktracker_individual_minifigures"."quantity"
FROM "bricktracker_individual_minifigures"
) AS "minifigure_quantities"
ON "combined"."id" IS NOT DISTINCT FROM "minifigure_quantities"."id"
AND "combined"."figure" IS NOT DISTINCT FROM "minifigure_quantities"."figure"
{% endblock %}
{% block where %}
WHERE "rebrickable_parts"."print" IS NOT DISTINCT FROM :print
AND "combined"."color" IS NOT DISTINCT FROM :color
+3
View File
@@ -12,4 +12,7 @@ IFNULL("combined"."damaged", 0) AS "total_damaged",
{% block where %}
WHERE "combined"."id" IS NOT DISTINCT FROM :id
AND "combined"."figure" IS NOT DISTINCT FROM :figure
{% if skip_spare_parts %}
AND "combined"."spare" = 0
{% endif %}
{% endblock %}
@@ -5,6 +5,38 @@
{% block total_damaged %}{% endblock %}
{# Compute per part+color set/minifigure counts so each sub-card shows its own
totals (fixes #159: they used to inherit the parent part's counts). Mirrors
part/list/all.sql. #}
{% block total_sets %}
IFNULL(COUNT(DISTINCT CASE WHEN "combined"."source_type" = 'set' THEN "combined"."id" ELSE NULL END), 0) AS "total_sets",
{% endblock %}
{% block total_minifigures %}
SUM(IFNULL("minifigure_quantities"."quantity", 0)) AS "total_minifigures"
{% endblock %}
{% block join %}
-- Join to get minifigure quantities from both set-based and individual minifigures
LEFT JOIN (
SELECT
"bricktracker_minifigures"."id",
"bricktracker_minifigures"."figure",
"bricktracker_minifigures"."quantity"
FROM "bricktracker_minifigures"
UNION ALL
SELECT
"bricktracker_individual_minifigures"."id",
"bricktracker_individual_minifigures"."figure",
"bricktracker_individual_minifigures"."quantity"
FROM "bricktracker_individual_minifigures"
) AS "minifigure_quantities"
ON "combined"."id" IS NOT DISTINCT FROM "minifigure_quantities"."id"
AND "combined"."figure" IS NOT DISTINCT FROM "minifigure_quantities"."figure"
{% endblock %}
{% block where %}
WHERE "combined"."color" IS DISTINCT FROM :color
AND "combined"."part" IS NOT DISTINCT FROM :part
@@ -58,10 +58,14 @@ INNER JOIN (
) "null_sums"
-- bricklink_color_id is intentionally NOT a trigger: it is an INTEGER
-- column so it cannot carry the empty-string "confirmed missing" sentinel,
-- and BrickLink mappings can be absent upstream forever. color_name and
-- part_num still trigger because NULL there means "never fetched"; once a
-- refresh confirms no mapping they are written as '' and stop matching (#163).
WHERE "null_rgb" > 0
OR "null_transparent" > 0
OR "null_url" > 0
OR "null_bricklink_color_id" > 0
OR "null_bricklink_color_name" > 0
OR "null_bricklink_part_num" > 0
) "null_join"
+3
View File
@@ -23,6 +23,9 @@ SELECT
{% block statuses %}
{% if statuses %}{{ statuses }},{% endif %}
{% endblock %}
{% block custom_fields %}
{% if custom_fields %}{{ custom_fields }},{% endif %}
{% endblock %}
{% block total_missing %}
NULL AS "total_missing", -- dummy for order: total_missing
{% endblock %}
+5
View File
@@ -32,6 +32,11 @@ LEFT JOIN "bricktracker_set_tags"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_tags"."id"
{% endif %}
{% if custom_fields %}
LEFT JOIN "bricktracker_set_custom_fields"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_custom_fields"."id"
{% endif %}
-- LEFT JOIN + SELECT to avoid messing the total
LEFT JOIN (
SELECT
@@ -43,6 +43,15 @@ SELECT
{% endfor %}
{% endif %}
{% endblock %}
{% block custom_fields %}
{% if custom_fields_dict %}
{% for column, uuid in custom_fields_dict.items() %}
-- Representative value for the group (shared value when all
-- instances agree; used for bulk-edit read across instances).
, MAX("bricktracker_set_custom_fields"."{{ column }}") AS "{{ column }}"
{% endfor %}
{% endif %}
{% endblock %}
FROM "bricktracker_sets"
INNER JOIN "rebrickable_sets"
@@ -84,6 +93,11 @@ LEFT JOIN "bricktracker_set_tags"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_tags"."id"
{% endif %}
{% if custom_fields_dict %}
LEFT JOIN "bricktracker_set_custom_fields"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_custom_fields"."id"
{% endif %}
{% block where %}
WHERE 1=1
{% if search_query %}
@@ -0,0 +1,11 @@
SELECT
"bricktracker_metadata_custom_fields"."id",
"bricktracker_metadata_custom_fields"."name",
"bricktracker_metadata_custom_fields"."type"
FROM "bricktracker_metadata_custom_fields"
{% block where %}{% endblock %}
{% if order %}
ORDER BY {{ order }}
{% endif %}
@@ -0,0 +1,9 @@
BEGIN TRANSACTION;
ALTER TABLE "bricktracker_set_custom_fields"
DROP COLUMN "custom_field_{{ id }}";
DELETE FROM "bricktracker_metadata_custom_fields"
WHERE "bricktracker_metadata_custom_fields"."id" IS NOT DISTINCT FROM '{{ id }}';
COMMIT;
@@ -0,0 +1,17 @@
BEGIN TRANSACTION;
-- Add the value column for this custom field (per-set TEXT value).
ALTER TABLE "bricktracker_set_custom_fields"
ADD COLUMN "custom_field_{{ id }}" TEXT;
INSERT INTO "bricktracker_metadata_custom_fields" (
"id",
"name",
"type"
) VALUES (
'{{ id }}',
'{{ name }}',
'{{ type }}'
);
COMMIT;
@@ -0,0 +1 @@
{% extends 'set/metadata/custom_field/base.sql' %}
@@ -0,0 +1,5 @@
{% extends 'set/metadata/custom_field/base.sql' %}
{% block where %}
WHERE "bricktracker_metadata_custom_fields"."id" IS NOT DISTINCT FROM :id
{% endblock %}
@@ -0,0 +1,3 @@
UPDATE "bricktracker_metadata_custom_fields"
SET "{{field}}" = :value
WHERE "bricktracker_metadata_custom_fields"."id" IS NOT DISTINCT FROM :id
@@ -0,0 +1,10 @@
INSERT INTO "bricktracker_set_custom_fields" (
"id",
"{{name}}"
) VALUES (
:set_id,
:value
)
ON CONFLICT("id")
DO UPDATE SET "{{name}}" = :value
WHERE "bricktracker_set_custom_fields"."id" IS NOT DISTINCT FROM :set_id
+9
View File
@@ -39,6 +39,7 @@ individual_part_stats AS (
COALESCE(SUM("missing"), 0) AS total_missing_individual_parts,
COALESCE(SUM("damaged"), 0) AS total_damaged_individual_parts,
COUNT(CASE WHEN "purchase_price" IS NOT NULL AND "lot_id" IS NULL THEN 1 END) AS individual_parts_with_price,
COUNT(CASE WHEN "lot_id" IS NULL THEN 1 END) AS individual_parts_standalone,
COALESCE(ROUND(SUM(CASE WHEN "lot_id" IS NULL THEN "purchase_price" END), 2), 0) AS individual_parts_total_cost
FROM "bricktracker_individual_parts"
),
@@ -133,6 +134,13 @@ financial_stats AS (
COALESCE(individual_minifig_stats.individual_minifigs_with_price, 0) +
COALESCE(part_lot_stats.part_lots_with_price, 0) AS total_items_with_price,
-- Total priceable items, mirroring total_items_with_price's item types
-- (fixes #156: dividing by total_sets let coverage exceed 100%)
set_stats.total_sets +
COALESCE(individual_part_stats.individual_parts_standalone, 0) +
COALESCE(individual_minifig_stats.total_individual_minifigures, 0) +
COALESCE(part_lot_stats.total_part_lots, 0) AS total_items,
-- Total cost across all item types
ROUND(COALESCE(set_stats.total_cost, 0) +
COALESCE(individual_part_stats.individual_parts_total_cost, 0) +
@@ -190,6 +198,7 @@ SELECT
-- Combined financial statistics (all item types)
financial_stats.total_items_with_price,
financial_stats.total_items,
financial_stats.combined_total_cost,
financial_stats.combined_average_cost,
financial_stats.combined_minimum_cost,
@@ -0,0 +1,5 @@
-- Distinct set numbers in the collection. Used to intersect with the
-- filesystem instructions list (which is not in the database) for the
-- instructions statistics (#154).
SELECT DISTINCT "bricktracker_sets"."set" AS "set"
FROM "bricktracker_sets"
@@ -0,0 +1,8 @@
-- description: Set refs and purchase prices for the sidecar pricing aggregation.
-- One row per set instance (matches the old per-instance JOIN), so the totals
-- are computed in Python against the sidecar bulk response instead of a local
-- cache table.
SELECT
"bricktracker_sets"."set" AS "set_ref",
"bricktracker_sets"."purchase_price" AS "purchase_price"
FROM "bricktracker_sets"
+160 -3
View File
@@ -63,9 +63,12 @@ class BrickStatistics:
'items_with_price': overview.get('total_items_with_price') or 0,
'sets_with_price': overview.get('sets_with_price') or 0,
'total_sets': overview.get('total_sets') or 0,
'percentage_with_price': round(
((overview.get('total_items_with_price') or 0) / max((overview.get('total_sets') or 0), 1)) * 100, 1
)
'total_items': overview.get('total_items') or 0,
# #156: divide by the matching total of all priceable item types
# (not total_sets), and clamp to 100% as a safety net.
'percentage_with_price': min(round(
((overview.get('total_items_with_price') or 0) / max((overview.get('total_items') or 0), 1)) * 100, 1
), 100.0)
}
def get_collection_summary(self) -> dict[str, Any]:
@@ -84,6 +87,160 @@ class BrickStatistics:
'purchase_locations_used': overview.get('purchase_locations_used') or 0
}
def get_instructions_summary(self) -> dict[str, Any] | None:
"""Instruction coverage across the collection (#154).
Instructions live on the filesystem (INSTRUCTIONS_FOLDER), not in the
database, so this intersects the distinct collection set numbers with the
cached instructions file list. Returns None when instructions are hidden.
"""
from flask import current_app
if current_app.config.get('HIDE_SET_INSTRUCTIONS', False):
return None
from .instructions_list import BrickInstructionsList
instructions = BrickInstructionsList()
rows = self.sql.fetchall('statistics/set_numbers')
set_numbers = {row['set'] for row in rows}
with_instructions = sum(
1 for number in set_numbers if number in instructions.sets
)
unique_sets = len(set_numbers)
return {
'instruction_files': instructions.sets_total,
'sets_with_instructions': with_instructions,
'unique_sets': unique_sets,
'percentage_with_instructions': min(round(
(with_instructions / max(unique_sets, 1)) * 100, 1
), 100.0),
}
def get_sidecar_pricing_summary(self) -> dict[str, Any] | None:
"""Collection-wide paid / retail (MSRP) / BrickLink market comparison.
Pulls cached metadata + price for the whole collection from the sidecar
in one bulk call and aggregates in Python (the sidecar is the single
cache). Returns None when the sidecar is disabled or unreachable.
"""
from .sidecar import BrickSidecar
if not BrickSidecar.enabled():
return None
try:
instances = self.sql.fetchall('statistics/sidecar_sets')
except Exception as exception:
logger.debug('sidecar pricing summary failed: %s', exception)
return None
instances = [dict(row) for row in instances]
if not instances:
return None
refs = list({row['set_ref'] for row in instances if row.get('set_ref')})
try:
bulk = BrickSidecar.get_sets_bulk(refs)
except Exception as exception:
logger.debug('sidecar bulk fetch failed: %s', exception)
return None
def to_number(value: Any) -> float | None:
try:
return float(value) if value is not None else None
except (TypeError, ValueError):
return None
data: dict[str, Any] = {
'total_sets': 0,
'total_paid': 0.0, 'sets_with_paid': 0,
'total_msrp': 0.0, 'sets_with_msrp': 0,
'total_market_new': 0.0, 'sets_with_market': 0,
'total_market_used': 0.0, 'sets_with_market_used': 0,
'paid_where_msrp': 0.0, 'msrp_where_paid': 0.0,
'paid_where_market': 0.0, 'market_where_paid': 0.0,
'paid_where_market_used': 0.0, 'market_used_where_paid': 0.0,
'market_currency': None,
}
# One row per set instance, mirroring the old per-instance aggregation.
for row in instances:
set_data = bulk.get(row.get('set_ref')) or {}
price_block = set_data.get('bricklink_price') or {}
paid = to_number(row.get('purchase_price'))
msrp = BrickSidecar.retail_price(set_data) if set_data else None
market_new = to_number(price_block.get('new_avg'))
market_used = to_number(price_block.get('used_avg'))
data['total_sets'] += 1
if paid is not None:
data['total_paid'] += paid
data['sets_with_paid'] += 1
if msrp is not None:
data['total_msrp'] += msrp
data['sets_with_msrp'] += 1
if market_new is not None:
data['total_market_new'] += market_new
data['sets_with_market'] += 1
if market_used is not None:
data['total_market_used'] += market_used
data['sets_with_market_used'] += 1
if msrp is not None and paid is not None:
data['paid_where_msrp'] += paid
data['msrp_where_paid'] += msrp
if market_new is not None and paid is not None:
data['paid_where_market'] += paid
data['market_where_paid'] += market_new
if market_used is not None and paid is not None:
data['paid_where_market_used'] += paid
data['market_used_where_paid'] += market_used
if data['market_currency'] is None and price_block.get('currency_code'):
data['market_currency'] = price_block.get('currency_code')
def number(key: str) -> float:
value = data.get(key)
try:
return float(value) if value is not None else 0.0
except (TypeError, ValueError):
return 0.0
# Savings vs retail and value change vs paid, computed only across the
# sets where both sides of the comparison are known.
data['total_saved_vs_msrp'] = round(
number('msrp_where_paid') - number('paid_where_msrp'), 2
)
data['total_gain_vs_paid'] = round(
number('market_where_paid') - number('paid_where_market'), 2
)
data['total_gain_vs_paid_used'] = round(
number('market_used_where_paid') - number('paid_where_market_used'), 2
)
data['retail_currency'] = BrickSidecar.retail_currency()
# Currency the user records purchase prices in (may be a symbol such as
# '$' or 'kr'). Compared against the retail/market ISO codes through the
# symbol map so '$' vs 'USD' and 'kr' vs 'DKK' are NOT flagged.
from flask import current_app
paid_currency = str(
current_app.config.get('PURCHASE_CURRENCY', '') or ''
).strip()
data['paid_currency'] = paid_currency
mismatch = False
if data.get('sets_with_paid'):
if not BrickSidecar.same_currency(paid_currency, data.get('market_currency')):
mismatch = True
if not BrickSidecar.same_currency(paid_currency, data['retail_currency']):
mismatch = True
data['currency_mismatch'] = mismatch
return data
def get_sets_by_year_statistics(self) -> list[dict[str, Any]]:
"""Get statistics grouped by LEGO set release year"""
results = self.sql.fetchall('statistics/sets_by_year')
+2 -2
View File
@@ -1,4 +1,4 @@
from typing import Final
__version__: Final[str] = '1.4.0'
__database_version__: Final[int] = 27
__version__: Final[str] = '1.5.0'
__database_version__: Final[int] = 29
+16
View File
@@ -11,6 +11,8 @@ from ..exceptions import exception_handler
from ...instructions_list import BrickInstructionsList
from ...rebrickable_image import RebrickableImage
from ...retired_list import BrickRetiredList
from ...set_custom_field import BrickSetCustomField
from ...set_custom_field_list import BrickSetCustomFieldList
from ...set_owner import BrickSetOwner
from ...set_owner_list import BrickSetOwnerList
from ...set_purchase_location import BrickSetPurchaseLocation
@@ -23,6 +25,7 @@ from ...set_tag import BrickSetTag
from ...set_tag_list import BrickSetTagList
from ...sql_counter import BrickCounter
from ...sql import BrickSQL
from ...socket import MESSAGES
from ...theme_list import BrickThemeList
logger = logging.getLogger(__name__)
@@ -128,6 +131,7 @@ def admin() -> str:
database_upgrade_needed: bool = False
database_version: int = -1
instructions: BrickInstructionsList | None = None
metadata_custom_fields: list[BrickSetCustomField] = []
metadata_owners: list[BrickSetOwner] = []
metadata_purchase_locations: list[BrickSetPurchaseLocation] = []
metadata_statuses: list[BrickSetStatus] = []
@@ -147,6 +151,7 @@ def admin() -> str:
instructions = BrickInstructionsList()
metadata_custom_fields = BrickSetCustomFieldList.list()
metadata_owners = BrickSetOwnerList.list()
metadata_purchase_locations = BrickSetPurchaseLocationList.list()
metadata_statuses = BrickSetStatusList.list(all=True)
@@ -175,6 +180,7 @@ def admin() -> str:
open_image = request.args.get('open_image', None)
open_instructions = request.args.get('open_instructions', None)
open_logout = request.args.get('open_logout', None)
open_custom_field = request.args.get('open_custom_field', None)
open_metadata = request.args.get('open_metadata', None)
open_owner = request.args.get('open_owner', None)
open_purchase_location = request.args.get('open_purchase_location', None)
@@ -186,6 +192,7 @@ def admin() -> str:
open_metadata = (
open_metadata or
open_custom_field or
open_owner or
open_purchase_location or
open_status or
@@ -211,8 +218,10 @@ def admin() -> str:
open_logout = should_expand('authentication', open_logout)
open_retired = should_expand('retired', open_retired)
open_theme = should_expand('theme', open_theme)
open_value = should_expand('value', request.args.get('open_value', None))
# Metadata sub-sections
open_custom_field = should_expand('custom_field', open_custom_field)
open_owner = should_expand('owner', open_owner)
open_purchase_location = should_expand('purchase_location', open_purchase_location)
open_status = should_expand('status', open_status)
@@ -222,6 +231,7 @@ def admin() -> str:
# Recalculate metadata section based on sub-sections or direct config
open_metadata = (
should_expand('metadata', open_metadata) or
open_custom_field or
open_owner or
open_purchase_location or
open_status or
@@ -265,6 +275,7 @@ def admin() -> str:
database_upgrade_needed=database_upgrade_needed,
database_version=database_version,
instructions=instructions,
metadata_custom_fields=metadata_custom_fields,
metadata_owners=metadata_owners,
metadata_purchase_locations=metadata_purchase_locations,
metadata_statuses=metadata_statuses,
@@ -278,6 +289,7 @@ def admin() -> str:
open_image=open_image,
open_instructions=open_instructions,
open_logout=open_logout,
open_custom_field=open_custom_field,
open_metadata=open_metadata,
open_owner=open_owner,
open_purchase_location=open_purchase_location,
@@ -286,6 +298,10 @@ def admin() -> str:
open_storage=open_storage,
open_tag=open_tag,
open_theme=open_theme,
open_value=open_value,
messages=MESSAGES,
path=current_app.config['SOCKET_PATH'],
namespace=current_app.config['SOCKET_NAMESPACE'],
owner_error=request.args.get('owner_error'),
purchase_location_error=request.args.get('purchase_location_error'),
retired=BrickRetiredList(),
+98
View File
@@ -0,0 +1,98 @@
from flask import (
Blueprint,
jsonify,
redirect,
request,
render_template,
url_for,
)
from flask_login import login_required
from werkzeug.wrappers.response import Response
from ..exceptions import exception_handler
from ...reload import reload
from ...set_custom_field import BrickSetCustomField
admin_custom_field_page = Blueprint(
'admin_custom_field',
__name__,
url_prefix='/admin/custom_field'
)
# Add a metadata custom field
@admin_custom_field_page.route('/add', methods=['POST'])
@login_required
@exception_handler(
__file__,
post_redirect='admin.admin',
error_name='custom_field_error',
open_custom_field=True
)
def add() -> Response:
BrickSetCustomField().from_form(request.form).insert()
reload()
return redirect(url_for('admin.admin', open_custom_field=True))
# Delete the metadata custom field
@admin_custom_field_page.route('<id>/delete', methods=['GET'])
@login_required
@exception_handler(__file__)
def delete(*, id: str) -> str:
return render_template(
'admin.html',
delete_custom_field=True,
custom_field=BrickSetCustomField().select_specific(id),
custom_field_error=request.args.get('custom_field_error')
)
# Actually delete the metadata custom field
@admin_custom_field_page.route('<id>/delete', methods=['POST'])
@login_required
@exception_handler(
__file__,
post_redirect='admin_custom_field.delete',
error_name='custom_field_error'
)
def do_delete(*, id: str) -> Response:
custom_field = BrickSetCustomField().select_specific(id)
custom_field.delete()
reload()
return redirect(url_for('admin.admin', open_custom_field=True))
# Change a field of a metadata custom field (name or type)
@admin_custom_field_page.route('/<id>/field/<name>', methods=['POST'])
@login_required
@exception_handler(__file__, json=True)
def update_field(*, id: str, name: str) -> Response:
custom_field = BrickSetCustomField().select_specific(id)
value = custom_field.update_field(name, json=request.json)
reload()
return jsonify({'value': value})
# Rename the metadata custom field
@admin_custom_field_page.route('<id>/rename', methods=['POST'])
@login_required
@exception_handler(
__file__,
post_redirect='admin.admin',
error_name='custom_field_error',
open_custom_field=True
)
def rename(*, id: str) -> Response:
custom_field = BrickSetCustomField().select_specific(id)
custom_field.from_form(request.form).rename()
reload()
return redirect(url_for('admin.admin', open_custom_field=True))
+239
View File
@@ -1,4 +1,5 @@
import logging
import os
from flask import (
Blueprint,
@@ -17,8 +18,12 @@ from ..exceptions import ErrorException
from ..minifigure import BrickMinifigure
from ..pagination_helper import get_pagination_config, build_pagination_context, get_request_params
from ..part import BrickPart
from ..rebrickable_image import RebrickableImage
from ..rebrickable_set import RebrickableSet
from ..set import BrickSet
from ..sidecar import BrickSidecar
from ..sidecar_set import summarize as sidecar_summarize
from ..set_custom_field_list import BrickSetCustomFieldList
from ..set_list import BrickSetList, set_metadata_lists
from ..set_owner_list import BrickSetOwnerList
from ..set_purchase_location_list import BrickSetPurchaseLocationList
@@ -26,6 +31,7 @@ from ..set_status_list import BrickSetStatusList
from ..set_storage_list import BrickSetStorageList
from ..set_tag_list import BrickSetTagList
from ..socket import MESSAGES
from ..sql import BrickSQL
logger = logging.getLogger(__name__)
@@ -225,6 +231,101 @@ def update_tag(*, id: str, metadata_id: str) -> Response:
return jsonify({'value': state})
# Change the value of a custom field
@set_page.route('/<id>/custom_field/<metadata_id>', methods=['POST'])
@login_required
@exception_handler(__file__, json=True)
def update_custom_field(*, id: str, metadata_id: str) -> Response:
brickset = BrickSet().select_light(id)
custom_field = BrickSetCustomFieldList.get(metadata_id)
value = custom_field.update_value(brickset, json=request.json)
return jsonify({'value': value})
# Mass-edit metadata across several selected sets in one request. Only the
# fields the user actually touched are present in "changes"; tri-state booleans
# arrive as explicit true/false, value fields as a string ('' clears).
@set_page.route('/bulk/edit', methods=['POST'])
@login_required
@exception_handler(__file__, json=True)
def bulk_edit() -> Response:
payload = request.json or {}
set_ids = payload.get('set_ids', [])
changes = payload.get('changes', {})
if not set_ids:
raise ErrorException('No sets were selected')
tags = changes.get('tags', {})
statuses = changes.get('statuses', {})
owners = changes.get('owners', {})
custom_fields = changes.get('custom_fields', {})
has_storage = 'storage' in changes
has_purchase_location = 'purchase_location' in changes
has_purchase_date = 'purchase_date' in changes
has_purchase_price = 'purchase_price' in changes
for set_id in set_ids:
brickset = BrickSet().select_light(set_id)
# Boolean metadata: defer so the batch is grouped (committed below).
for metadata_id, state in tags.items():
BrickSetTagList.get(metadata_id).update_set_state(
brickset, state=state, commit=False
)
for metadata_id, state in statuses.items():
BrickSetStatusList.get(metadata_id).update_set_state(
brickset, state=state, commit=False
)
for metadata_id, state in owners.items():
BrickSetOwnerList.get(metadata_id).update_set_state(
brickset, state=state, commit=False
)
# Value metadata (each commits on its own).
if has_storage:
storage = BrickSetStorageList.get(
changes.get('storage', ''), allow_none=True
)
storage.update_set_value(brickset, value=storage.fields.id)
if has_purchase_location:
purchase_location = BrickSetPurchaseLocationList.get(
changes.get('purchase_location', ''), allow_none=True
)
purchase_location.update_set_value(
brickset, value=purchase_location.fields.id
)
if has_purchase_date:
brickset.update_purchase_date(
{'value': changes.get('purchase_date', '')}
)
if has_purchase_price:
brickset.update_purchase_price(
{'value': changes.get('purchase_price', '')}
)
for metadata_id, value in custom_fields.items():
BrickSetCustomFieldList.get(metadata_id).update_value(
brickset, value=value
)
# Flush any deferred boolean updates that did not ride along with a
# value update's commit.
BrickSQL().commit()
logger.info('Bulk edit applied to {count} set(s)'.format(
count=len(set_ids),
))
return jsonify({'updated': len(set_ids)})
# Ask for deletion of a set
@set_page.route('/<id>/delete', methods=['GET'])
@login_required
@@ -274,6 +375,15 @@ def details(*, id: str) -> str:
# Load the specific set
item = BrickSet().select_specific(id)
# Sidecar enrichment + price comparison. Cache-only by default; when
# SIDECAR_AUTO_FETCH_PRICE is on, the market value is fetched on view
# (TTL-aware) so the user does not have to press "Fetch value".
sidecar_summary = sidecar_summarize(
item.fields.set,
purchase_price=item.fields.purchase_price,
fetch_price=current_app.config.get('SIDECAR_AUTO_FETCH_PRICE', False),
)
# Check if there are multiple instances of this set
all_instances = BrickSetList()
# Load all sets with metadata context for tags, owners, etc.
@@ -298,6 +408,7 @@ def details(*, id: str) -> str:
all_instances=same_set_instances,
open_instructions=request.args.get('open_instructions'),
brickset_statuses=BrickSetStatusList.list(all=True),
sidecar_summary=sidecar_summary,
**set_metadata_lists(as_class=True)
)
else:
@@ -307,6 +418,7 @@ def details(*, id: str) -> str:
item=item,
open_instructions=request.args.get('open_instructions'),
brickset_statuses=BrickSetStatusList.list(all=True),
sidecar_summary=sidecar_summary,
**set_metadata_lists(as_class=True)
)
@@ -422,3 +534,130 @@ def refresh(*, id: str | None = None, set: str | None = None) -> str:
namespace=current_app.config['SOCKET_NAMESPACE'],
messages=MESSAGES
)
# Override the cover image with a sidecar image (box / set art). Best quality
# first: tries the *_large variant, falls back to the normal resolution (which
# BrickLink almost always has where _large is missing).
@set_page.route('/<id>/cover/<image_type>', methods=['POST'])
@login_required
@exception_handler(__file__, post_redirect='set.details')
def cover_override(*, id: str, image_type: str) -> Response:
if image_type not in ('box', 'set'):
raise ErrorException('Unknown cover image type: {type}'.format(
type=image_type,
))
if not BrickSidecar.enabled():
raise ErrorException('The sidecar is not configured')
brickset = BrickSet().select_light(id)
ref = brickset.fields.set
# BrickLink box/set covers only reliably exist at normal resolution
# (the *_large variants are almost always 404), so use the normal image.
saved = BrickSidecar.save_cover_override(ref, image_type)
if not saved:
raise ErrorException(
'The sidecar has no {type} image for set {ref}'.format(
type=image_type,
ref=ref,
)
)
logger.info('Set {ref} ({id}): cover overridden with sidecar {type} art'.format( # noqa: E501
ref=ref,
id=id,
type=image_type,
))
return redirect(brickset.url())
# Override the cover with one of the Brickset additional images (0-indexed).
@set_page.route('/<id>/cover/additional/<int:index>', methods=['POST'])
@login_required
@exception_handler(__file__, post_redirect='set.details')
def cover_override_additional(*, id: str, index: int) -> Response:
if not current_app.config['SIDECAR_ADDITIONAL_IMAGES']:
raise ErrorException('Additional images are not enabled')
if not BrickSidecar.enabled():
raise ErrorException('The sidecar is not configured')
brickset = BrickSet().select_light(id)
ref = brickset.fields.set
saved = BrickSidecar.save_cover_override_from_additional(ref, index)
if not saved:
raise ErrorException(
'The sidecar has no additional image {index} for set {ref}'.format(
index=index,
ref=ref,
)
)
logger.info('Set {ref} ({id}): cover overridden with additional image {index}'.format( # noqa: E501
ref=ref,
id=id,
index=index,
))
return redirect(brickset.url())
# Restore the original Rebrickable cover: use the backup if we made one,
# otherwise delete the local file and re-download from Rebrickable.
@set_page.route('/<id>/cover/restore', methods=['POST'])
@login_required
@exception_handler(__file__, post_redirect='set.details')
def cover_restore(*, id: str) -> Response:
brickset = BrickSet().select_specific(id)
ref = brickset.fields.set
if not BrickSidecar.restore_cover(ref):
# No backup to restore: drop the local file and pull from Rebrickable.
path = BrickSidecar.cover_path(ref)
try:
if os.path.exists(path):
os.remove(path)
except OSError:
pass
RebrickableImage(brickset).download()
logger.info('Set {ref} ({id}): cover restored to Rebrickable image'.format(
ref=ref,
id=id,
))
return redirect(brickset.url())
# Lazily (re)fetch the BrickLink market value for a set and cache it. This is
# the expensive live path, so it only runs on an explicit user action.
@set_page.route('/<id>/value/refresh', methods=['POST'])
@login_required
@exception_handler(__file__, post_redirect='set.details')
def value_refresh(*, id: str) -> Response:
if not BrickSidecar.enabled():
raise ErrorException('The sidecar is not configured')
brickset = BrickSet().select_light(id)
ref = brickset.fields.set
price = BrickSidecar.get_price(ref, refresh=True)
if price is None:
raise ErrorException(
'Could not fetch a market value for set {ref}'.format(ref=ref),
)
logger.info('Set {ref} ({id}): market value refreshed'.format(
ref=ref,
id=id,
))
return redirect(brickset.url())
+53
View File
@@ -0,0 +1,53 @@
from flask import Blueprint, Response, abort, request
from ..sidecar import BrickSidecar
# Proxies sidecar (BrickData) images through BrickTracker so the browser only
# ever talks to BrickTracker. This is what makes box art, the additional-images
# carousel and the cover previews work when the sidecar is only reachable on
# the internal Docker network (e.g. http://brickdata:3335), which the browser
# itself cannot resolve. Public (no login) because these images appear on pages
# anonymous users can already see.
sidecar_page = Blueprint('sidecar', __name__, url_prefix='/sidecar')
def _image_response(data: bytes | None, mimetype: str, /) -> Response:
if data is None:
abort(404)
response = Response(data, mimetype=mimetype)
# Cache in the browser so viewing a set does not re-proxy every image.
response.headers['Cache-Control'] = 'public, max-age=86400'
return response
# Box / set art (BrickLink image proxy), e.g. /sidecar/image/box/8250-1.png
@sidecar_page.route('/image/<image_type>/<ref>.png', methods=['GET'])
def image(*, image_type: str, ref: str) -> Response:
if not BrickSidecar.enabled():
abort(404)
return _image_response(
BrickSidecar.fetch_image_bytes(image_type, ref), 'image/png',
)
# One Brickset additional image, e.g. /sidecar/additional-images/8250-1/0.
# Path kept plural ("additional-images") so it matches the BaguetteBox gallery
# filter in base.html, which keys on that string for extensionless image URLs.
@sidecar_page.route(
'/additional-images/<ref>/<int:index>', methods=['GET'],
)
def additional_image(*, ref: str, index: int) -> Response:
if not BrickSidecar.enabled():
abort(404)
thumbnail = request.args.get('thumbnail') is not None
return _image_response(
BrickSidecar.fetch_additional_image_bytes(
ref, index, thumbnail=thumbnail,
),
'image/jpeg',
)
+4
View File
@@ -32,6 +32,8 @@ def overview() -> str:
purchase_location_stats = stats.get_purchase_location_statistics()
financial_summary = stats.get_financial_summary()
collection_summary = stats.get_collection_summary()
instructions_summary = stats.get_instructions_summary()
sidecar_pricing = stats.get_sidecar_pricing_summary()
sets_by_year_stats = stats.get_sets_by_year_statistics()
purchases_by_year_stats = stats.get_purchases_by_year_statistics()
year_summary = stats.get_year_summary()
@@ -57,6 +59,8 @@ def overview() -> str:
purchase_location_statistics=purchase_location_stats,
financial_summary=financial_summary,
collection_summary=collection_summary,
instructions_summary=instructions_summary,
sidecar_pricing=sidecar_pricing,
sets_by_year_statistics=sets_by_year_stats,
purchases_by_year_statistics=purchases_by_year_stats,
year_summary=year_summary,
+8 -1
View File
@@ -11,6 +11,7 @@ from werkzeug.wrappers.response import Response
from .exceptions import exception_handler
from ..retired_list import BrickRetiredList
from ..sidecar_set import retirement_dates
from ..wish import BrickWish
from ..wish_list import BrickWishList
from ..wish_owner_list import BrickWishOwnerList
@@ -23,10 +24,16 @@ wish_page = Blueprint('wish', __name__, url_prefix='/wishes')
@wish_page.route('/', methods=['GET'])
@exception_handler(__file__)
def list() -> str:
table_collection = BrickWishList().all()
return render_template(
'wishes.html',
table_collection=BrickWishList().all(),
table_collection=table_collection,
retired=BrickRetiredList(),
# BrickData fallback for already-retired sets the CSV does not cover.
sidecar_retired=retirement_dates(
[item.fields.set for item in table_collection]
),
error=request.args.get('error'),
owners=BrickWishOwnerList.list(),
)
+7
View File
@@ -18,7 +18,14 @@ services:
# 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
networks:
- default
- brickshare
develop:
watch:
- action: rebuild
path: requirements.txt
networks:
brickshare:
external: true
+4
View File
@@ -11,6 +11,10 @@ document.addEventListener("DOMContentLoaded", () => {
const addContainer = document.getElementById('add-set');
if (!addContainer) return;
// On the bulk page the socket is initialized by set/socket.html include,
// so only wire it up here for the single add page to avoid a double init.
if (addContainer.dataset.bulk === 'true') return;
new BrickSetSocket(
'add',
addContainer.dataset.path,
+60
View File
@@ -46,6 +46,18 @@ class BrickGridFilter {
});
}
// Numeric range filters (e.g. parts 100-500, year 2015-2020). Each input
// carries data-filter-range="<attribute>" and data-filter-bound="min"|"max".
this.ranges = [];
if (this.html_filter) {
this.html_filter.querySelectorAll("input[data-filter-range]").forEach(input => {
input.addEventListener("input", ((gridfilter) => () => {
gridfilter.filter();
})(this));
this.ranges.push(input);
});
}
if (this.html_theme) {
this.html_theme.addEventListener("change", ((grid) => () => {
grid.filter();
@@ -65,6 +77,28 @@ class BrickGridFilter {
options.search = this.html_search.value.toLowerCase();
}
// Build numeric range filters
options.ranges = {};
for (const input of this.ranges) {
const value = input.value.trim();
if (value === "") {
continue;
}
const number = parseFloat(value);
if (isNaN(number)) {
continue;
}
const attribute = input.dataset.filterRange;
const bound = input.dataset.filterBound;
if (!options.ranges[attribute]) {
options.ranges[attribute] = {};
}
options.ranges[attribute][bound] = number;
}
// Build filters
for (const select of this.selects) {
// Get the actual filter value (includes "-" prefix if toggle is in NOT mode)
@@ -140,6 +174,17 @@ class BrickGridFilter {
const isNot = filter.value.startsWith('-');
const actualValue = isNot ? filter.value.substring(1) : filter.value;
// "None" option: match cards that have no value for this
// attribute (inverted = cards that DO have a value).
if (actualValue === "__none__") {
const isEmpty = (attribute == null || attribute === "");
if (isNot ? isEmpty : !isEmpty) {
current.parentElement.classList.add("d-none");
return;
}
continue;
}
if (attribute == null) {
// If attribute is missing
if (isNot) {
@@ -191,6 +236,21 @@ class BrickGridFilter {
}
}
// Process numeric range filters (parts, year, ...)
for (const attribute in options.ranges) {
const bounds = options.ranges[attribute];
const raw = current.getAttribute(`data-${attribute}`);
const number = (raw == null || raw === "") ? NaN : parseFloat(raw);
// No value, or outside the configured bounds: hide.
if (isNaN(number)
|| (bounds.min != null && number < bounds.min)
|| (bounds.max != null && number > bounds.max)) {
current.parentElement.classList.add("d-none");
return;
}
}
// Check all searchable fields for a match
if (options.search) {
// Browse the whole dataset
+369
View File
@@ -0,0 +1,369 @@
// Audit mode: a focused, keyboard-driven modal that walks the visible parts of
// one accordion parts table, letting you check each part off and record missing
// counts without hunting for tiny checkboxes.
//
// Saving reuses the existing BrickChanger auto-save: set the value on the row's
// missing input / checked checkbox and dispatch a "change" event, exactly like
// parts-bulk-operations.js does.
class PartsAuditMode {
constructor(accordionId) {
this.accordionId = accordionId;
this.trigger = document.getElementById(`audit-mode-${accordionId}`);
if (!this.trigger) {
return;
}
this.rows = [];
this.index = 0;
this.mode = 'missing'; // 'missing' or 'found'
this.setupModal();
this.cacheElements();
this.setupEventListeners();
}
// Build the shared modal once and reuse it across every accordion.
setupModal() {
if (document.getElementById('partsAuditModal')) {
return;
}
const modalHTML = `
<div class="modal fade" id="partsAuditModal" tabindex="-1" aria-labelledby="partsAuditModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header d-block">
<div class="d-flex align-items-center">
<h5 class="modal-title mb-0" id="partsAuditModalLabel"><i class="ri-eye-line me-2"></i>Audit parts</h5>
<span class="ms-auto me-3 text-secondary"><span id="audit-position">0 / 0</span></span>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="progress mt-2" role="progressbar" style="height: 6px;">
<div id="audit-progress-bar" class="progress-bar" style="width: 0%;"></div>
</div>
<small class="text-secondary"><span id="audit-checked-count">0</span> checked</small>
</div>
<div class="modal-body text-center">
<div class="mb-2">
<img id="audit-image" class="img-fluid border rounded audit-image" alt="">
</div>
<div class="mb-1">Quantity <span id="audit-qty">0</span></div>
<div class="mb-3"><span id="audit-name" class="fs-6"></span> <span id="audit-color" class="text-secondary ms-1"></span></div>
<div id="audit-input-area">
<div class="btn-group mb-2" role="group" aria-label="Audit mode">
<button type="button" class="btn btn-sm" id="audit-mode-missing">Missing</button>
<button type="button" class="btn btn-sm" id="audit-mode-found">Found</button>
</div>
<div class="row justify-content-center mb-1">
<div class="col-auto">
<div class="input-group">
<span class="input-group-text" id="audit-input-label">Missing</span>
<input type="number" inputmode="numeric" pattern="[0-9]*" min="0" class="form-control text-center" id="audit-number" style="max-width: 6rem;" autocomplete="off">
</div>
</div>
</div>
<small class="text-secondary">type a number</small>
</div>
<div id="audit-no-input" class="text-secondary d-none">This section only tracks checked status.</div>
</div>
<div class="modal-footer d-flex">
<button type="button" class="btn btn-outline-secondary" id="audit-prev"><i class="ri-arrow-left-line"></i> Prev</button>
<button type="button" class="btn btn-primary ms-auto" id="audit-next">Check &amp; Next <i class="ri-arrow-right-line"></i></button>
</div>
<div class="modal-footer py-1">
<small class="text-secondary w-100 text-center">
/ move · type number · Enter / Space save+next
</small>
</div>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHTML);
}
cacheElements() {
this.modalEl = document.getElementById('partsAuditModal');
this.modal = bootstrap.Modal.getOrCreateInstance(this.modalEl);
this.el = {
position: document.getElementById('audit-position'),
progress: document.getElementById('audit-progress-bar'),
checkedCount: document.getElementById('audit-checked-count'),
image: document.getElementById('audit-image'),
name: document.getElementById('audit-name'),
color: document.getElementById('audit-color'),
qty: document.getElementById('audit-qty'),
inputArea: document.getElementById('audit-input-area'),
noInput: document.getElementById('audit-no-input'),
modeMissing: document.getElementById('audit-mode-missing'),
modeFound: document.getElementById('audit-mode-found'),
inputLabel: document.getElementById('audit-input-label'),
number: document.getElementById('audit-number'),
prev: document.getElementById('audit-prev'),
next: document.getElementById('audit-next'),
};
}
setupEventListeners() {
this.trigger.addEventListener('click', (e) => {
e.preventDefault();
this.start();
});
// These shared-modal controls get wired by whichever instance is created
// first; subsequent instances just re-point the handlers at the active one
// via the `activeInstance` lookup performed inside start().
if (this.modalEl.dataset.auditWired === 'true') {
return;
}
this.modalEl.dataset.auditWired = 'true';
this.el.prev.addEventListener('click', () => PartsAuditMode.active && PartsAuditMode.active.go(-1));
this.el.next.addEventListener('click', () => PartsAuditMode.active && PartsAuditMode.active.commitAndNext());
this.el.modeMissing.addEventListener('click', () => PartsAuditMode.active && PartsAuditMode.active.setMode('missing'));
this.el.modeFound.addEventListener('click', () => PartsAuditMode.active && PartsAuditMode.active.setMode('found'));
// Keyboard handling in the capture phase so navigation keys win over the
// focused number input's default cursor movement.
this.modalEl.addEventListener('keydown', (e) => {
const active = PartsAuditMode.active;
if (!active) {
return;
}
switch (e.key) {
case 'ArrowLeft':
e.preventDefault();
active.go(-1);
break;
case 'ArrowRight':
e.preventDefault();
active.go(1);
break;
case 'Enter':
e.preventDefault();
active.commitAndNext();
break;
case ' ':
case 'Spacebar':
e.preventDefault();
active.commitAndNext();
break;
// digits / Backspace fall through to the number input
}
}, true);
// Focus the number box whenever the modal opens (desktop only).
this.modalEl.addEventListener('shown.bs.modal', () => {
const active = PartsAuditMode.active;
if (active) {
active.focusInput();
}
});
}
// On phones we never auto-focus the number box: doing so pops the keyboard
// and scrolls the part image off-screen. The audit flow there is touch
// driven (tap the field only when entering a count, then Check & Next), so
// the image stays visible while you identify the part.
isMobileLayout() {
return window.matchMedia('(max-width: 575.98px)').matches;
}
focusInput() {
if (this.isMobileLayout()) {
return;
}
if (this.el.number && !this.el.inputArea.classList.contains('d-none')) {
this.el.number.focus();
this.el.number.select();
}
}
start() {
const accordion = document.getElementById(this.accordionId);
if (!accordion) {
return;
}
// Snapshot the currently visible rows in DOM (sort) order. Using a
// snapshot means saving (which re-runs the header filter) can't drop the
// row we are sitting on.
this.rows = Array.from(accordion.querySelectorAll('tbody tr')).filter(
row => !row.classList.contains('parts-filtered-out') && row.offsetParent !== null
);
if (!this.rows.length) {
return;
}
PartsAuditMode.active = this;
this.index = 0;
this.render();
this.modal.show();
}
setMode(mode) {
this.mode = mode;
this.render();
this.focusInput();
}
currentRow() {
return this.rows[this.index];
}
neededQty(row) {
const cell = row.querySelector('[data-col="quantity"]');
const value = cell ? parseInt(cell.textContent.trim(), 10) : NaN;
return Number.isNaN(value) ? 1 : value;
}
missingInput(row) {
// ids look like "part-missing-...-{id}" / "individual-part-missing-...",
// so match the same "-missing-" fragment parts-bulk-operations.js uses.
return row.querySelector('input[id*="-missing-"]');
}
checkedBox(row) {
return row.querySelector('input[id*="-checked-"][type="checkbox"]');
}
render() {
const row = this.currentRow();
if (!row) {
return;
}
// Image: prefer the full-size lightbox target, fall back to the thumbnail.
const link = row.querySelector('a[data-lightbox]');
const img = row.querySelector('td img');
this.el.image.src = (link && link.getAttribute('href')) || (img && img.getAttribute('src')) || '';
const nameLink = row.querySelector('[data-col="name"] a');
this.el.name.textContent = nameLink ? nameLink.textContent.trim() : '';
const colorCell = row.querySelector('[data-col="color"]');
this.el.color.innerHTML = colorCell ? colorCell.innerHTML : '';
const needed = this.neededQty(row);
this.el.qty.textContent = needed;
// Toggle the input area depending on whether this table tracks missing.
const missing = this.missingInput(row);
if (missing) {
this.el.inputArea.classList.remove('d-none');
this.el.noInput.classList.add('d-none');
// Mode button styling.
this.el.modeMissing.className = 'btn btn-sm ' + (this.mode === 'missing' ? 'btn-primary' : 'btn-outline-secondary');
this.el.modeFound.className = 'btn btn-sm ' + (this.mode === 'found' ? 'btn-primary' : 'btn-outline-secondary');
this.el.inputLabel.textContent = this.mode === 'found' ? 'Found' : 'Missing';
// Prefill: missing mode shows the current missing value, found starts empty.
this.el.number.value = this.mode === 'missing' ? (missing.value || '') : '';
// Remember what the box started with so a commit that didn't change
// the number leaves the missing field (and the database) untouched.
this.originalNumber = this.el.number.value.trim();
} else {
this.el.inputArea.classList.add('d-none');
this.el.noInput.classList.remove('d-none');
this.originalNumber = '';
}
this.updateProgress();
}
updateProgress() {
const total = this.rows.length;
this.el.position.textContent = `${this.index + 1} / ${total}`;
this.el.progress.style.width = total ? `${Math.round(((this.index + 1) / total) * 100)}%` : '0%';
const checked = this.rows.filter(row => {
const box = this.checkedBox(row);
return box && box.checked;
}).length;
this.el.checkedCount.textContent = checked;
}
// Plain navigation (arrows / Prev) — clamps, never saves or closes.
go(delta) {
const next = this.index + delta;
if (next < 0 || next >= this.rows.length) {
return;
}
this.index = next;
this.render();
this.focusInput();
}
setMissingValue(input, value) {
if (input.value !== value) {
input.value = value;
input.dispatchEvent(new Event('change', { bubbles: true }));
}
}
check(row) {
const box = this.checkedBox(row);
if (box && !box.checked) {
box.checked = true;
box.dispatchEvent(new Event('change', { bubbles: true }));
}
}
// Enter / Space: record the typed number per mode, tick checked, advance.
// If the number box is unchanged from what the part loaded with, we leave the
// missing field (and the database) completely alone and only tick checked, so
// glancing past a part never triggers a redundant POST.
commitAndNext() {
const row = this.currentRow();
if (!row) {
return;
}
const input = this.missingInput(row);
if (input) {
const typed = this.el.number.value.trim();
if (typed !== this.originalNumber) {
const number = parseInt(typed, 10) || 0;
let missing;
if (this.mode === 'found') {
missing = Math.max(0, this.neededQty(row) - number);
} else {
missing = Math.max(0, number);
}
// Empty string clears the field (0 missing), matching the
// "clear all missing" convention in parts-bulk-operations.js.
this.setMissingValue(input, missing > 0 ? String(missing) : '');
}
}
this.check(row);
this.advance();
}
// Move forward after a commit; close the modal once the last part is done.
advance() {
this.updateProgress();
if (this.index + 1 >= this.rows.length) {
this.modal.hide();
return;
}
this.index += 1;
this.render();
this.focusInput();
}
}
PartsAuditMode.active = null;
// Wire up an instance per accordion that exposes an audit menu item.
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('[id^="audit-mode-"]').forEach(item => {
const accordionId = item.id.replace('audit-mode-', '');
new PartsAuditMode(accordionId);
});
});
+11 -7
View File
@@ -150,14 +150,18 @@ class PartsTableFilter {
if (!this.body) {
return;
}
if (!this.emptyRow) {
const columns = this.table.querySelectorAll('thead tr:first-child th').length || 1;
this.emptyRow = document.createElement('tr');
this.emptyRow.className = 'parts-filter-empty no-sort';
this.emptyRow.innerHTML = `<td colspan="${columns}" class="text-center text-body-secondary py-3">No matching parts</td>`;
this.body.appendChild(this.emptyRow);
// Render the empty-state message as a sibling of the table rather than a
// tbody row. The sortable library iterates every <tr> in the tbody and
// reads row.cells[columnIndex], so a single colspan cell would make it
// crash with "can't access property dataset" when sorting any column
// past the first.
if (!this.emptyMessage) {
this.emptyMessage = document.createElement('div');
this.emptyMessage.className = 'parts-filter-empty text-center text-body-secondary py-3';
this.emptyMessage.textContent = 'No matching parts';
this.table.insertAdjacentElement('afterend', this.emptyMessage);
}
this.emptyRow.style.display = visibleCount === 0 ? '' : 'none';
this.emptyMessage.style.display = visibleCount === 0 ? '' : 'none';
}
clear() {
+309
View File
@@ -0,0 +1,309 @@
// Mass-edit of sets from the grid (Feature 4).
//
// Lets an authenticated user select several cards on the sets grid and edit
// their metadata in one pass. Each editable control is pre-filled from the
// selected cards: a shared value is shown and editable, differing values show
// "varies" and only overwrite when actually touched. Boolean metadata
// (tags/statuses/owners) is tri-state: all-on / all-off / mixed.
(function () {
// Convert a stored purchase-date timestamp (epoch seconds) to the value
// expected by <input type="date"> (yyyy-mm-dd), using local time since the
// stored timestamp was built from a local midnight date.
function tsToDateInput(ts) {
if (!ts) return "";
const d = new Date(parseFloat(ts) * 1000);
if (isNaN(d.getTime())) return "";
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${d.getFullYear()}-${m}-${day}`;
}
// Custom date fields are stored as yyyy/mm/dd text (matching the single-set
// editor), so just swap the separators for the date input.
function slashToDateInput(v) {
return v ? v.replaceAll("/", "-") : "";
}
// Reverse of the above: the backend (purchase date + custom date fields)
// expects yyyy/mm/dd.
function dateInputToSlash(v) {
return v ? v.replaceAll("-", "/") : "";
}
function removeVariesOption(sel) {
if (sel.tagName !== "SELECT") return;
const opt = sel.querySelector('option[data-varies="true"]');
if (opt) opt.remove();
}
function addVariesOption(sel) {
removeVariesOption(sel);
const opt = document.createElement("option");
opt.value = "__varies__";
opt.textContent = "— varies —";
opt.dataset.varies = "true";
opt.disabled = true;
opt.selected = true;
sel.insertBefore(opt, sel.firstChild);
sel.value = "__varies__";
}
function initBulkEdit() {
const toggle = document.getElementById("bulk-select-toggle");
const grid = document.getElementById("grid");
const panel = document.getElementById("bulk-edit-panel");
const bar = document.getElementById("bulk-select-bar");
// Only on the authenticated sets grid
if (!toggle || !grid || !panel || !bar) return;
const countEl = document.getElementById("bulk-select-count");
const editCountEl = document.getElementById("bulk-edit-count");
const openBtn = document.getElementById("bulk-edit-open");
const applyBtn = document.getElementById("bulk-edit-apply");
const bulkUrl = bar.dataset.bulkEditUrl;
const selected = new Set();
const cards = () => Array.from(grid.querySelectorAll(".card[data-set-id]"));
// Inject a checkbox overlay into every card (revealed by CSS in select
// mode).
cards().forEach((card) => {
if (card.querySelector(".bulk-select-check")) return;
const overlay = document.createElement("div");
overlay.className = "bulk-select-check";
overlay.innerHTML =
'<input type="checkbox" class="form-check-input" tabindex="-1">';
card.appendChild(overlay);
});
const isSelected = (card) => card.classList.contains("bulk-selected");
function setSelected(card, on) {
card.classList.toggle("bulk-selected", on);
const cb = card.querySelector(".bulk-select-check input");
if (cb) cb.checked = on;
if (on) selected.add(card);
else selected.delete(card);
}
function refreshCount() {
const n = selected.size;
countEl.textContent = n;
editCountEl.textContent = n;
openBtn.disabled = n === 0;
}
function enterMode() {
document.body.classList.add("bulk-select-mode");
bar.classList.add("show");
toggle.classList.add("active");
}
function exitMode() {
document.body.classList.remove("bulk-select-mode");
bar.classList.remove("show");
toggle.classList.remove("active");
cards().forEach((c) => setSelected(c, false));
refreshCount();
}
toggle.addEventListener("click", () => {
if (document.body.classList.contains("bulk-select-mode")) exitMode();
else enterMode();
});
document
.getElementById("bulk-select-exit")
.addEventListener("click", exitMode);
document
.getElementById("bulk-select-clear")
.addEventListener("click", () => {
cards().forEach((c) => setSelected(c, false));
refreshCount();
});
document.getElementById("bulk-select-all").addEventListener("click", () => {
cards().forEach((c) => {
// Skip cards hidden by client-side search/filter
if (c.offsetParent === null) return;
setSelected(c, true);
});
refreshCount();
});
// Clicking a card while in select mode toggles it (capture phase so it
// wins over the card's own links).
grid.addEventListener(
"click",
(e) => {
if (!document.body.classList.contains("bulk-select-mode")) return;
const card = e.target.closest(".card[data-set-id]");
if (!card || !grid.contains(card)) return;
e.preventDefault();
e.stopPropagation();
setSelected(card, !isSelected(card));
refreshCount();
},
true,
);
// --- Field computation ----------------------------------------------
function computeTristate(ctrl, cardEls) {
const attr = "data-" + ctrl.dataset.cardAttr;
let on = 0;
cardEls.forEach((c) => {
if (c.getAttribute(attr) !== null) on++;
});
if (on === 0) {
ctrl.checked = false;
ctrl.indeterminate = false;
} else if (on === cardEls.length) {
ctrl.checked = true;
ctrl.indeterminate = false;
} else {
ctrl.checked = false;
ctrl.indeterminate = true;
}
}
function computeValue(ctrl, cardEls) {
const attr = "data-" + ctrl.dataset.cardAttr;
const raw = cardEls.map((c) => {
const v = c.getAttribute(attr);
return v === null ? "" : v;
});
const allEqual = raw.every((v) => v === raw[0]);
removeVariesOption(ctrl);
if (allEqual) {
let v = raw[0];
if (ctrl.type === "date") {
v =
ctrl.dataset.bulkField === "purchase_date"
? tsToDateInput(v)
: slashToDateInput(v);
}
if (ctrl.tagName === "SELECT") ctrl.value = v == null ? "" : v;
else ctrl.value = v;
ctrl.placeholder = "";
} else if (ctrl.tagName === "SELECT") {
addVariesOption(ctrl);
} else {
ctrl.value = "";
ctrl.placeholder = "— varies —";
}
}
function computePanel(cardEls) {
panel.querySelectorAll(".bulk-control").forEach((ctrl) => {
if (ctrl.classList.contains("bulk-tristate")) {
computeTristate(ctrl, cardEls);
} else {
computeValue(ctrl, cardEls);
}
ctrl.dataset.pristine = "true";
ctrl.classList.remove("bulk-touched");
});
}
// Touched tracking: only fields the user actually changes are applied.
panel.querySelectorAll(".bulk-control").forEach((ctrl) => {
const onTouch = () => {
ctrl.dataset.pristine = "false";
ctrl.classList.add("bulk-touched");
removeVariesOption(ctrl);
if (ctrl.classList.contains("bulk-tristate"))
ctrl.indeterminate = false;
};
ctrl.addEventListener("change", onTouch);
ctrl.addEventListener("input", onTouch);
});
openBtn.addEventListener("click", () => {
if (selected.size === 0) return;
computePanel(Array.from(selected));
bootstrap.Offcanvas.getOrCreateInstance(panel).show();
});
// --- Apply ----------------------------------------------------------
applyBtn.addEventListener("click", async () => {
const cardEls = Array.from(selected);
if (cardEls.length === 0) return;
// Expand consolidated cards into their real instance ids.
const setIds = [];
const seen = new Set();
cardEls.forEach((c) => {
const inst = c.getAttribute("data-instance-ids");
const ids = inst
? inst.split(/[\s,|]+/).filter(Boolean)
: [c.getAttribute("data-set-id")].filter(Boolean);
ids.forEach((id) => {
if (!seen.has(id)) {
seen.add(id);
setIds.push(id);
}
});
});
const changes = { tags: {}, statuses: {}, owners: {}, custom_fields: {} };
panel.querySelectorAll(".bulk-control").forEach((ctrl) => {
if (ctrl.dataset.pristine !== "false") return; // untouched
const field = ctrl.dataset.bulkField;
if (ctrl.classList.contains("bulk-tristate")) {
const id = ctrl.dataset.metaId;
const map =
field === "tag"
? changes.tags
: field === "status"
? changes.statuses
: changes.owners;
map[id] = ctrl.checked;
} else if (field === "custom_field") {
let v = ctrl.value;
if (ctrl.type === "date") v = dateInputToSlash(v);
changes.custom_fields[ctrl.dataset.cfId] = v;
} else if (field === "storage") {
changes.storage = ctrl.value;
} else if (field === "purchase_location") {
changes.purchase_location = ctrl.value;
} else if (field === "purchase_price") {
changes.purchase_price = ctrl.value;
} else if (field === "purchase_date") {
changes.purchase_date = dateInputToSlash(ctrl.value);
}
});
// Drop empty boolean/custom maps so the backend skips them.
["tags", "statuses", "owners", "custom_fields"].forEach((k) => {
if (Object.keys(changes[k]).length === 0) delete changes[k];
});
applyBtn.disabled = true;
try {
const resp = await fetch(bulkUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ set_ids: setIds, changes: changes }),
});
if (!resp.ok)
throw new Error(`Response status: ${resp.status} (${resp.statusText})`);
const json = await resp.json();
if ("error" in json) throw new Error(json.error);
window.location.reload();
} catch (err) {
applyBtn.disabled = false;
alert("Bulk edit failed: " + err.message);
}
});
}
document.addEventListener("DOMContentLoaded", initBulkEdit);
})();
+5
View File
@@ -775,6 +775,11 @@ function initializeClearFiltersButton() {
}
});
// Clear numeric range filters (parts, year)
document.querySelectorAll('#grid-filter input[data-filter-range]').forEach(input => {
input.value = '';
});
// Clear duplicate filter if active
const duplicateButton = document.getElementById('duplicate-filter-toggle');
if (duplicateButton && duplicateButton.classList.contains('btn-secondary')) {
+8
View File
@@ -23,6 +23,7 @@ class BrickSetSocket extends BrickSocket {
this.html_storage = document.getElementById(`${id}-storage`);
this.html_tags = document.getElementById(`${id}-tags`);
this.html_description = document.getElementById(`${id}-description`);
this.html_box_art = document.getElementById(`${id}-box-art`);
// Card elements
this.html_card = document.getElementById(`${id}-card`);
@@ -213,6 +214,12 @@ class BrickSetSocket extends BrickSocket {
description = this.html_description.value;
}
// Grab the box art checkbox (sidecar cover override)
let box_art = false;
if (this.html_box_art) {
box_art = this.html_box_art.checked;
}
this.socket.emit(this.messages.IMPORT_SET, {
set: (set !== undefined) ? set : this.html_input.value,
owners: owners,
@@ -223,6 +230,7 @@ class BrickSetSocket extends BrickSocket {
storage: storage,
tags: tags,
description: description,
box_art: box_art,
refresh: this.refresh
});
} else {
+35
View File
@@ -0,0 +1,35 @@
// Socket client for the bulk "value all sets" admin action. Connects, emits a
// single VALUE_ALL_SETS message and lets the generic BrickSocket base render
// the progress bar / completion message while the server prices each set.
class BrickValueSocket extends BrickSocket {
constructor(id, path, namespace, messages) {
// Single-shot operation (not bulk): the base complete() then stops the
// spinner, shows the success message and re-enables the button.
super(id, path, namespace, messages, false);
this.html_button = document.getElementById(`${id}-button`);
if (this.html_button) {
this.html_button.addEventListener("click", ((bricksocket) => () => {
bricksocket.start();
})(this));
}
}
// Kick off the server-side valuation
start() {
if (this.disabled) {
return;
}
// Connect lazily on first use so the host page (e.g. the admin page)
// does not open a socket until the action is actually triggered.
this.setup();
this.clear();
this.spinner(true);
this.toggle(false);
this.socket.emit(this.messages.VALUE_ALL_SETS, {});
}
}
+82
View File
@@ -12,6 +12,13 @@
backdrop-filter: blur(8px) contrast(60%);
}
/* When the cover is a carousel, each slide carries its own blurred backdrop
so every image keeps the same framed, fixed-height look as a single cover. */
.card-img.carousel .carousel-item {
background-repeat: no-repeat;
background-size: cover;
}
.card-img img.card-last-img {
max-height: 100px;
}
@@ -46,6 +53,18 @@
object-fit:contain;
}
/* Audit mode part image: large on desktop, compact on phones so the part
image, needed quantity and input all stay visible above the keyboard. */
.audit-image {
max-height: 220px;
}
@media (max-width: 575.98px) {
.audit-image {
max-height: 130px;
}
}
.table-td-input {
max-width: 150px;
}
@@ -346,3 +365,66 @@
tr.parts-filtered-out {
display: none;
}
/* --- Mass-edit (Feature 4) --- */
.bulk-select-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 1040;
display: none;
background-color: var(--bs-body-bg);
border-top: 1px solid var(--bs-border-color);
}
.bulk-select-bar.show {
display: block;
}
/* Keep the sticky bar from covering the last row of cards */
body.bulk-select-mode {
padding-bottom: 4.5rem;
}
body.bulk-select-mode #grid .card {
cursor: pointer;
}
.bulk-select-check {
display: none;
position: absolute;
top: 0.5rem;
left: 0.5rem;
z-index: 5;
}
.bulk-select-check .form-check-input {
width: 1.4rem;
height: 1.4rem;
}
body.bulk-select-mode .bulk-select-check {
display: block;
}
#grid .card.bulk-selected {
outline: 3px solid var(--bs-primary);
outline-offset: -3px;
}
.bulk-control.bulk-touched {
border-color: var(--bs-primary);
}
/* Brickset set description: trim the trailing margin so the rendered
<p>/<ul> blocks sit flush inside the alert box. */
.sidecar-description > :last-child {
margin-bottom: 0;
}
.sidecar-description ul,
.sidecar-description ol {
padding-left: 1.2rem;
margin-bottom: 0.5rem;
}
+61
View File
@@ -44,6 +44,7 @@
<input type="text" class="form-control" id="add-set" placeholder="{% if not bulk %}{% if not config.get('HIDE_INDIVIDUAL_MINIFIGURES', False) and not config.get('DISABLE_INDIVIDUAL_MINIFIGURES', False) %}107-1 or fig-001234 or ...{% else %}107-1 or ...{% endif %}{% else %}107-1, 1642-1, ...{% endif %}"
data-path="{{ path }}"
data-namespace="{{ namespace }}"
data-bulk="{% if bulk %}true{% else %}false{% endif %}"
data-msg-complete="{{ messages['COMPLETE'] }}"
data-msg-fail="{{ messages['FAIL'] }}"
data-msg-import-set="{{ messages['IMPORT_SET'] }}"
@@ -61,6 +62,66 @@
Add without confirmation
</label>
</div>
{% if sidecar_enabled %}
<div class="form-check">
<input type="checkbox" class="form-check-input" id="add-box-art" {% if bulk and config['SIDECAR_DEFAULT_COVER'] in ['box', 'set'] %}checked{% endif %}>
<label class="form-check-label" for="add-box-art">
{% if bulk %}Force box art (fallback to normal image){% else %}Use box art as cover (if available){% endif %}
</label>
</div>
{% if not bulk %}
<div class="mt-2">
<button type="button" class="btn btn-sm btn-outline-secondary" id="add-check-box-art"
data-proxy-url="{{ sidecar.image_proxy_url('box', '__REF__') }}"
data-bs-toggle="modal" data-bs-target="#addBoxArtModal">
<i class="ri-eye-line"></i> Check box art
</button>
</div>
<div class="modal fade" id="addBoxArtModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Box art preview</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-center">
<p id="add-box-art-empty" class="text-muted d-none">Enter a set number first (e.g. 42043-1).</p>
<img id="add-box-art-image" src="" alt="Box art preview" class="img-fluid d-none" style="max-height: 60vh;"
onerror="this.classList.add('d-none'); document.getElementById('add-box-art-missing').classList.remove('d-none');">
<p id="add-box-art-missing" class="text-muted d-none"><i class="ri-error-warning-line"></i> The sidecar has no box art for this set.</p>
</div>
</div>
</div>
</div>
<script>
(function () {
var modal = document.getElementById('addBoxArtModal');
if (!modal) { return; }
modal.addEventListener('show.bs.modal', function () {
var proxy = document.getElementById('add-check-box-art').getAttribute('data-proxy-url') || '';
var ref = (document.getElementById('add-set').value || '').trim().split(',')[0].trim();
// BrickTracker appends "-1" to a bare set number; match that so
// the sidecar lookup works (e.g. "10179" -> "10179-1").
if (ref && ref.indexOf('-') === -1) {
ref = ref + '-1';
}
var image = document.getElementById('add-box-art-image');
var empty = document.getElementById('add-box-art-empty');
var missing = document.getElementById('add-box-art-missing');
missing.classList.add('d-none');
if (!ref) {
image.classList.add('d-none');
empty.classList.remove('d-none');
return;
}
empty.classList.add('d-none');
image.src = proxy.replace('__REF__', encodeURIComponent(ref));
image.classList.remove('d-none');
});
})();
</script>
{% endif %}
{% endif %}
<h6 class="border-bottom mt-2">Metadata</h6>
<div class="accordion accordion" id="metadata">
{% if brickset_owners | length %}
+4
View File
@@ -20,6 +20,8 @@
{% include 'admin/owner/delete.html' %}
{% elif delete_purchase_location %}
{% include 'admin/purchase_location/delete.html' %}
{% elif delete_custom_field %}
{% include 'admin/custom_field/delete.html' %}
{% elif delete_status %}
{% include 'admin/status/delete.html' %}
{% elif delete_storage %}
@@ -45,12 +47,14 @@
{% endif %}
{% include 'admin/theme.html' %}
{% include 'admin/retired.html' %}
{% include 'admin/value.html' %}
{{ accordion.header('Set metadata', 'metadata', 'admin', expanded=open_metadata, icon='profile-line', class='p-0') }}
{% include 'admin/owner.html' %}
{% include 'admin/purchase_location.html' %}
{% include 'admin/status.html' %}
{% include 'admin/storage.html' %}
{% include 'admin/tag.html' %}
{% include 'admin/custom_field.html' %}
{{ accordion.footer() }}
{% include 'admin/refresh.html' %}
{% include 'admin/database.html' %}
+131
View File
@@ -543,6 +543,124 @@
</div>
</div>
<!-- Sidecar (BrickData) -->
<h6 class="fw-bold text-primary border-bottom pb-1 mb-3 mt-4">Sidecar (BrickData)</h6>
<p class="text-muted small">These take effect without a restart. The sidecar base URL itself is in the Static Settings section (it needs a restart).</p>
<div class="row g-3">
<div class="col-md-6">
<label for="BK_SIDECAR_TIMEOUT" class="form-label">
BK_SIDECAR_TIMEOUT {{ config_badges('BK_SIDECAR_TIMEOUT') }}
<div class="text-muted small">Request timeout in seconds for sidecar calls</div>
</label>
<input type="number" class="form-control config-number" id="BK_SIDECAR_TIMEOUT" data-var="BK_SIDECAR_TIMEOUT" min="1" max="120">
</div>
<div class="col-md-6">
<label for="BK_SIDECAR_RETAIL_REGION" class="form-label">
BK_SIDECAR_RETAIL_REGION {{ config_badges('BK_SIDECAR_RETAIL_REGION') }}
<div class="text-muted small">LEGO.com MSRP region: US, UK, CA or DE</div>
</label>
<input type="text" class="form-control config-text" id="BK_SIDECAR_RETAIL_REGION" data-var="BK_SIDECAR_RETAIL_REGION" {{ is_locked('BK_SIDECAR_RETAIL_REGION') }}>
</div>
<div class="col-md-6">
<label for="BK_SIDECAR_CURRENCY" class="form-label">
BK_SIDECAR_CURRENCY {{ config_badges('BK_SIDECAR_CURRENCY') }}
<div class="text-muted small">Currency for BrickLink market values, e.g. EUR. Empty = sidecar default.</div>
</label>
<input type="text" class="form-control config-text" id="BK_SIDECAR_CURRENCY" data-var="BK_SIDECAR_CURRENCY" {{ is_locked('BK_SIDECAR_CURRENCY') }}>
</div>
<div class="col-md-6">
<label for="BK_SIDECAR_DEFAULT_COVER" class="form-label">
BK_SIDECAR_DEFAULT_COVER {{ config_badges('BK_SIDECAR_DEFAULT_COVER') }}
<div class="text-muted small">Default bulk-add cover source: rebrickable, box or set</div>
</label>
<input type="text" class="form-control config-text" id="BK_SIDECAR_DEFAULT_COVER" data-var="BK_SIDECAR_DEFAULT_COVER" {{ is_locked('BK_SIDECAR_DEFAULT_COVER') }}>
</div>
<div class="col-md-6">
<label for="BK_SIDECAR_PRICE_BASIS" class="form-label">
BK_SIDECAR_PRICE_BASIS {{ config_badges('BK_SIDECAR_PRICE_BASIS') }}
<div class="text-muted small">Market value the set-detail "Change vs paid" uses: used or new</div>
</label>
<input type="text" class="form-control config-text" id="BK_SIDECAR_PRICE_BASIS" data-var="BK_SIDECAR_PRICE_BASIS" {{ is_locked('BK_SIDECAR_PRICE_BASIS') }}>
</div>
<div class="col-md-6 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_SIDECAR_AUTO_FETCH_PRICE" data-var="BK_SIDECAR_AUTO_FETCH_PRICE" {{ is_locked('BK_SIDECAR_AUTO_FETCH_PRICE') }}>
<label class="form-check-label" for="BK_SIDECAR_AUTO_FETCH_PRICE">
BK_SIDECAR_AUTO_FETCH_PRICE {{ config_badges('BK_SIDECAR_AUTO_FETCH_PRICE') }}
<div class="text-muted small">Auto-fetch BrickLink market value when viewing a set (respects cache TTL)</div>
</label>
</div>
</div>
<div class="col-md-6 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_SIDECAR_ADDITIONAL_IMAGES" data-var="BK_SIDECAR_ADDITIONAL_IMAGES" {{ is_locked('BK_SIDECAR_ADDITIONAL_IMAGES') }}>
<label class="form-check-label" for="BK_SIDECAR_ADDITIONAL_IMAGES">
BK_SIDECAR_ADDITIONAL_IMAGES {{ config_badges('BK_SIDECAR_ADDITIONAL_IMAGES') }}
<div class="text-muted small">Show additional BrickLink images (detail-page carousel + extra cover sources)</div>
</label>
</div>
</div>
</div>
<h6 class="fw-bold text-secondary mb-3 mt-3">Set detail display</h6>
<p class="text-muted small">Show or hide individual BrickData elements on the set detail page. Badges are controlled separately via BK_BADGE_ORDER_DETAIL.</p>
<div class="row g-3">
<div class="col-md-6 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_SIDECAR_SHOW_DESIGNER" data-var="BK_SIDECAR_SHOW_DESIGNER" {{ is_locked('BK_SIDECAR_SHOW_DESIGNER') }}>
<label class="form-check-label" for="BK_SIDECAR_SHOW_DESIGNER">
BK_SIDECAR_SHOW_DESIGNER {{ config_badges('BK_SIDECAR_SHOW_DESIGNER') }}
<div class="text-muted small">Show the set designer</div>
</label>
</div>
</div>
<div class="col-md-6 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_SIDECAR_SHOW_DESCRIPTION" data-var="BK_SIDECAR_SHOW_DESCRIPTION" {{ is_locked('BK_SIDECAR_SHOW_DESCRIPTION') }}>
<label class="form-check-label" for="BK_SIDECAR_SHOW_DESCRIPTION">
BK_SIDECAR_SHOW_DESCRIPTION {{ config_badges('BK_SIDECAR_SHOW_DESCRIPTION') }}
<div class="text-muted small">Show the "About this set" description</div>
</label>
</div>
</div>
<div class="col-md-6 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_SIDECAR_SHOW_NOTES" data-var="BK_SIDECAR_SHOW_NOTES" {{ is_locked('BK_SIDECAR_SHOW_NOTES') }}>
<label class="form-check-label" for="BK_SIDECAR_SHOW_NOTES">
BK_SIDECAR_SHOW_NOTES {{ config_badges('BK_SIDECAR_SHOW_NOTES') }}
<div class="text-muted small">Show the Brickset notes</div>
</label>
</div>
</div>
<div class="col-md-6 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_SIDECAR_SHOW_PRICE_PAID" data-var="BK_SIDECAR_SHOW_PRICE_PAID" {{ is_locked('BK_SIDECAR_SHOW_PRICE_PAID') }}>
<label class="form-check-label" for="BK_SIDECAR_SHOW_PRICE_PAID">
BK_SIDECAR_SHOW_PRICE_PAID {{ config_badges('BK_SIDECAR_SHOW_PRICE_PAID') }}
<div class="text-muted small">Price comparison: "Paid" row (and "Change vs paid")</div>
</label>
</div>
</div>
<div class="col-md-6 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_SIDECAR_SHOW_PRICE_MSRP" data-var="BK_SIDECAR_SHOW_PRICE_MSRP" {{ is_locked('BK_SIDECAR_SHOW_PRICE_MSRP') }}>
<label class="form-check-label" for="BK_SIDECAR_SHOW_PRICE_MSRP">
BK_SIDECAR_SHOW_PRICE_MSRP {{ config_badges('BK_SIDECAR_SHOW_PRICE_MSRP') }}
<div class="text-muted small">Price comparison: retail/MSRP rows (and "Saved vs retail")</div>
</label>
</div>
</div>
<div class="col-md-6 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_SIDECAR_SHOW_PRICE_MARKET" data-var="BK_SIDECAR_SHOW_PRICE_MARKET" {{ is_locked('BK_SIDECAR_SHOW_PRICE_MARKET') }}>
<label class="form-check-label" for="BK_SIDECAR_SHOW_PRICE_MARKET">
BK_SIDECAR_SHOW_PRICE_MARKET {{ config_badges('BK_SIDECAR_SHOW_PRICE_MARKET') }}
<div class="text-muted small">Price comparison: "Worth now" market value rows</div>
</label>
</div>
</div>
</div>
<!-- Advanced Settings -->
<h6 class="fw-bold text-primary border-bottom pb-1 mb-3 mt-4">Advanced Settings</h6>
@@ -1011,6 +1129,19 @@
</div>
</div>
<!-- Sidecar (BrickData). Only the base URL is static (restart required);
the rest are live-changeable and live in the Live Settings section. -->
<h6 class="fw-bold text-secondary border-bottom pb-1 mb-3 mt-4">Sidecar (BrickData)</h6>
<div class="row g-3">
<div class="col-12">
<label for="static-BK_SIDECAR_URL" class="form-label">
BK_SIDECAR_URL {{ config_badges('BK_SIDECAR_URL') }}
<div class="text-muted small">Base URL of the brickset-sidecar (e.g. http://localhost:3335). Empty disables all sidecar features. Changing it requires a restart. The other sidecar settings are in the Live Settings section above.</div>
</label>
<input type="text" class="form-control config-static" id="static-BK_SIDECAR_URL" data-var="BK_SIDECAR_URL" {{ is_locked('BK_SIDECAR_URL') }}>
</div>
</div>
<!-- Save Button for Static Settings -->
<div class="d-flex gap-2 justify-content-end mt-4">
<button id="config-save-static" class="btn btn-warning">
+65
View File
@@ -0,0 +1,65 @@
{% import 'macro/accordion.html' as accordion %}
{{ accordion.header('Set custom fields', 'custom_field', 'metadata', expanded=open_custom_field, icon='edit-line', class='p-0') }}
{% if custom_field_error %}<div class="alert alert-danger m-2" role="alert"><strong>Error:</strong> {{ custom_field_error }}.</div>{% endif %}
<ul class="list-group list-group-flush">
{% if metadata_custom_fields | length %}
{% for custom_field in metadata_custom_fields %}
<li class="list-group-item">
<form action="{{ url_for('admin_custom_field.rename', id=custom_field.fields.id) }}" method="post" class="row row-cols-lg-auto g-3 align-items-center">
<div class="col-12 flex-grow-1">
<label class="visually-hidden" for="name-{{ custom_field.fields.id }}">Name</label>
<div class="input-group">
<div class="input-group-text">Name</div>
<input type="text" class="form-control" id="name-{{ custom_field.fields.id }}" name="name" value="{{ custom_field.fields.name }}">
<button type="submit" class="btn btn-primary"><i class="ri-edit-line"></i> Rename</button>
</div>
</div>
<div class="col-12">
<div class="input-group">
<div class="input-group-text"><i class="ri-shape-line"></i> Type</div>
<select class="form-select" id="type-{{ custom_field.fields.id }}"
data-changer-id="{{ custom_field.fields.id }}" data-changer-prefix="type" data-changer-url="{{ url_for('admin_custom_field.update_field', id=custom_field.fields.id, name='type') }}"
autocomplete="off">
<option value="text" {% if custom_field.fields.type == 'text' %}selected{% endif %}>Text</option>
<option value="number" {% if custom_field.fields.type == 'number' %}selected{% endif %}>Number</option>
<option value="date" {% if custom_field.fields.type == 'date' %}selected{% endif %}>Date</option>
</select>
<span id="status-type-{{ custom_field.fields.id }}" class="input-group-text ri-save-line px-1"></span>
</div>
</div>
<div class="col-12">
<a href="{{ url_for('admin_custom_field.delete', id=custom_field.fields.id) }}" class="btn btn-danger" role="button"><i class="ri-delete-bin-2-line"></i> Delete</a>
</div>
</form>
</li>
{% endfor %}
{% else %}
<li class="list-group-item text-center"><i class="ri-error-warning-line"></i> No custom field found.</li>
{% endif %}
<li class="list-group-item">
<form action="{{ url_for('admin_custom_field.add') }}" method="post" class="row row-cols-lg-auto g-3 align-items-center">
<div class="col-12 flex-grow-1">
<label class="visually-hidden" for="name">Name</label>
<div class="input-group">
<div class="input-group-text">Name</div>
<input type="text" class="form-control" id="name" name="name" value="">
</div>
</div>
<div class="col-12">
<div class="input-group">
<div class="input-group-text"><i class="ri-shape-line"></i> Type</div>
<select class="form-select" id="type" name="type" autocomplete="off">
<option value="text" selected>Text</option>
<option value="number">Number</option>
<option value="date">Date</option>
</select>
</div>
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary"><i class="ri-add-circle-line"></i> Add</button>
</div>
</form>
</li>
</ul>
{{ accordion.footer() }}
+19
View File
@@ -0,0 +1,19 @@
{% import 'macro/accordion.html' as accordion %}
{{ accordion.header('Set custom fields danger zone', 'custom-field-danger', 'admin', expanded=true, danger=true, class='text-end') }}
<form action="{{ url_for('admin_custom_field.do_delete', id=custom_field.fields.id) }}" method="post">
{% if custom_field_error %}<div class="alert alert-danger text-start" role="alert"><strong>Error:</strong> {{ custom_field_error }}.</div>{% endif %}
<div class="alert alert-danger text-center" role="alert">You are about to <strong>delete a custom field</strong> and every value stored in it. This action is irreversible.</div>
<div class="row row-cols-lg-auto g-3 align-items-center">
<div class="col-12 flex-grow-1">
<div class="input-group">
<div class="input-group-text">Name</div>
<input type="text" class="form-control" value="{{ custom_field.fields.name }}" disabled>
</div>
</div>
</div>
<hr class="border-bottom">
<a class="btn btn-danger" href="{{ url_for('admin.admin', open_custom_field=true) }}" role="button"><i class="ri-arrow-left-long-line"></i> Back to the admin</a>
<button type="submit" class="btn btn-danger"><i class="ri-delete-bin-2-line"></i> Delete <strong>the custom field</strong></button>
</form>
{{ accordion.footer() }}
+73
View File
@@ -0,0 +1,73 @@
{% import 'macro/accordion.html' as accordion %}
{% if sidecar_enabled %}
{{ accordion.header('Value', 'value', 'admin', expanded=open_value, icon='funds-line') }}
<p>
Fetch and cache the BrickLink market value for every set in your collection through the
sidecar. Sets whose value is still fresh (within the cache TTL) are skipped, so this is
safe to run again. It is the slow live path, so it runs one set at a time, politely.
</p>
<div id="value-fail" class="alert alert-danger d-none" role="alert"></div>
<div id="value-complete"></div>
<div class="mb-3">
<p>
Progress <span id="value-count"></span>
<span id="value-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="value-progress" class="progress" role="progressbar">
<div id="value-progress-bar" class="progress-bar" style="width: 0%"></div>
</div>
<p id="value-progress-message" class="text-center d-none"></p>
</div>
<div class="d-flex align-items-center justify-content-end">
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#valueConfirmModal">
<i class="ri-funds-line"></i> Update all sets
</button>
</div>
<!-- Confirmation modal -->
<div class="modal fade" id="valueConfirmModal" tabindex="-1" aria-labelledby="valueConfirmModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="valueConfirmModalLabel">Update all set values?</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
This will fetch the BrickLink market value for every set that is not already cached.
Depending on how many sets need pricing this can take a while and makes live calls
through the sidecar. Sets with a fresh cached value are skipped.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" id="value-button" class="btn btn-primary" data-bs-dismiss="modal">
<i class="ri-funds-line"></i> Update all sets
</button>
</div>
</div>
</div>
</div>
<script type="text/javascript">
document.addEventListener("DOMContentLoaded", () => {
new BrickValueSocket(
'value',
'{{ path }}',
'{{ namespace }}',
{
COMPLETE: '{{ messages['COMPLETE'] }}',
FAIL: '{{ messages['FAIL'] }}',
PROGRESS: '{{ messages['PROGRESS'] }}',
VALUE_ALL_SETS: '{{ messages['VALUE_ALL_SETS'] }}',
}
);
});
</script>
{{ accordion.footer() }}
{% endif %}
+14 -1
View File
@@ -170,8 +170,9 @@
<script src="{{ url_for('static', filename='scripts/socket/instructions.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/socket/set.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/socket/minifigure.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/socket/value.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/table.js') }}"></script>
{% if request.endpoint == 'add.add' %}
{% if request.endpoint in ['add.add', 'add.bulk'] %}
<script src="{{ url_for('static', filename='scripts/add.js') }}"></script>
{% endif %}
{% if request.endpoint == 'add.parts' %}
@@ -189,10 +190,14 @@
{% endif %}
{% if request.endpoint == 'set.list' %}
<script src="{{ url_for('static', filename='scripts/sets.js') }}"></script>
{% if g.login.is_authenticated() %}
<script src="{{ url_for('static', filename='scripts/sets-bulk-edit.js') }}"></script>
{% endif %}
{% endif %}
{% if request.endpoint == 'set.details' %}
<script src="{{ url_for('static', filename='scripts/parts-table-filter.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/parts-bulk-operations.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/parts-audit-mode.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/set-details.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/quick-add-individual-part.js') }}"></script>
{% endif %}
@@ -201,9 +206,11 @@
{% endif %}
{% if request.endpoint == 'individual_part.lot_details' %}
<script src="{{ url_for('static', filename='scripts/parts-bulk-operations.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/parts-audit-mode.js') }}"></script>
{% endif %}
{% if request.endpoint == 'individual_minifigure.details' %}
<script src="{{ url_for('static', filename='scripts/parts-bulk-operations.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/parts-audit-mode.js') }}"></script>
{% endif %}
{% if request.endpoint == 'statistics.overview' %}
<script src="{{ url_for('static', filename='scripts/statistics.js') }}"></script>
@@ -217,6 +224,12 @@
setup_changers();
setup_tables({{ config['DEFAULT_TABLE_PER_PAGE'] }});
baguetteBox.run('[data-lightbox]');
// Cover carousel: group all slides into one gallery (arrows + keyboard
// nav). A permissive filter is needed because the Brickset additional
// image URLs have no file extension (/additional-images/0).
baguetteBox.run('.bt-gallery', {
filter: /(\.(gif|jpe?g|png|webp)(\?.*)?$|additional-images)/i
});
});
</script>
+52 -6
View File
@@ -39,7 +39,11 @@
{% endmacro %}
{% macro instructions(item, solo=false, last=false) %}
{{ badge(url=item.url_for_instructions(), solo=solo, last=last, blank=true, color='light border', icon='file-line', collapsible='Instructions:', text=item.instructions | length, alt='Instructions') }}
{# Positive "instructions downloaded" indicator: only render when the set
actually has instruction files (#154). #}
{% if item.instructions | length %}
{{ badge(url=item.url_for_instructions(), solo=solo, last=last, blank=true, color='light border', icon='file-line', collapsible='Instructions:', text=item.instructions | length, alt='Instructions') }}
{% endif %}
{% endmacro %}
{% macro parts(parts, solo=false, last=false) %}
@@ -221,12 +225,44 @@
{{ 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') %}
{# --- Sidecar badges. Each takes the sidecar_summary dict and renders nothing
when the data is absent (so they are safe to put anywhere in BADGE_ORDER_*). #}
{% macro retired(summary, solo=false, last=false) %}
{% if summary and summary.retired %}
{{ badge(check=true, solo=solo, last=last, color='light text-danger-emphasis bg-danger-subtle border border-danger-subtle', icon='time-line', collapsible='Retired:', text=(summary.exit_date or summary.exit_year or 'Yes'), alt='Retired') }}
{% endif %}
{% endmacro %}
{% macro msrp(summary, solo=false, last=false) %}
{% if summary and summary.prices and summary.prices.msrp is not none %}
{{ badge(check=true, solo=solo, last=last, color='light border', icon='price-tag-3-line', collapsible='MSRP:', text='%.2f %s' | format(summary.prices.msrp, summary.prices.msrp_currency), alt='MSRP') }}
{% endif %}
{% endmacro %}
{% macro value(summary, solo=false, last=false) %}
{% if summary and summary.prices and summary.prices.has_market and summary.prices.market_new is not none %}
{{ badge(check=true, solo=solo, last=last, color='light border', icon='funds-line', collapsible='Value:', text='%.2f %s' | format(summary.prices.market_new, summary.prices.market_currency), alt='Value', tooltip='BrickLink new' ~ (' — fetched ' ~ summary.prices.market_fetched_at if summary.prices.market_fetched_at else '')) }}
{% endif %}
{% endmacro %}
{% macro dimensions(summary, solo=false, last=false) %}
{% if summary and summary.dimensions %}
{{ badge(check=true, solo=solo, last=last, color='light border', icon='ruler-line', collapsible='Size:', text=summary.dimensions, alt='Dimensions') }}
{% endif %}
{% endmacro %}
{% macro weight(summary, solo=false, last=false) %}
{% if summary and summary.weight %}
{{ badge(check=true, solo=solo, last=last, color='light border', icon='scales-3-line', collapsible='Weight:', text=summary.weight, alt='Weight') }}
{% endif %}
{% endmacro %}
{% macro render_ordered_badges(item, brickset_tags, brickset_owners, brickset_storages, brickset_purchase_locations, solo=false, last=false, context='grid', sidecar_summary=none) %}
{# 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']) %}
{% set badge_order = config.get('BADGE_ORDER_DETAIL', ['theme', 'tag', 'year', 'retired', 'parts', 'instance_count', 'total_minifigures', 'total_missing', 'total_damaged', 'owner', 'storage', 'purchase_date', 'purchase_location', 'purchase_price', 'msrp', 'value', 'dimensions', 'weight', 'instructions', 'rebrickable', 'bricklink']) %}
{% else %}
{% set badge_order = config.get('BADGE_ORDER_GRID', ['theme', 'year', 'parts', 'total_minifigures', 'owner']) %}
{% set badge_order = config.get('BADGE_ORDER_GRID', ['theme', 'year', 'parts', 'total_minifigures', 'instructions', 'owner']) %}
{% endif %}
{# Render each badge in the configured order #}
@@ -301,8 +337,8 @@
{% endif %}
{% endif %}
{% elif badge_key == 'instructions' %}
{# Only sets have instructions #}
{% if not last and not solo and item.instructions is defined %}
{# Only sets have instructions, and only when not hidden by config (#154) #}
{% if not last and not solo and item.instructions is defined and not config['HIDE_SET_INSTRUCTIONS'] %}
{{ instructions(item, solo=solo, last=last) }}
{% endif %}
{% elif badge_key == 'rebrickable' %}
@@ -315,6 +351,16 @@
{% if not last and item.url_for_bricklink is defined %}
{{ bricklink(item, solo=solo, last=last) }}
{% endif %}
{% elif badge_key == 'retired' %}
{{ retired(sidecar_summary, solo=solo, last=last) }}
{% elif badge_key == 'msrp' %}
{{ msrp(sidecar_summary, solo=solo, last=last) }}
{% elif badge_key == 'value' %}
{{ value(sidecar_summary, solo=solo, last=last) }}
{% elif badge_key == 'dimensions' %}
{{ dimensions(sidecar_summary, solo=solo, last=last) }}
{% elif badge_key == 'weight' %}
{{ weight(sidecar_summary, solo=solo, last=last) }}
{% endif %}
{% endfor %}
{% endmacro %}
+42 -6
View File
@@ -19,11 +19,47 @@
</div>
{% endmacro %}
{% macro image(item, solo=false, last=false, caption=none, alt=none, medium=none) %}
{% macro image(item, solo=false, last=false, caption=none, alt=none, medium=none, sidecar=none, extra_count=0) %}
{% set image_url=item.url_for_image() %}
<div class="card-img border-bottom" style="background-image: url({{ image_url }})">
<a {% if solo %}data-lightbox {% if caption %}data-caption="{{ caption }}"{% endif %} href="{{ image_url }}" target="_blank"{% else %}href="{{ item.url() }}"{% endif %}>
<img {% if solo and medium %}class="card-medium-img"{% endif %}{% if last %}class="card-last-img"{% endif %} src="{{ image_url }}" {% if alt %}alt="{{ alt }}"{% endif %} loading="lazy">
</a>
</div>
{% if solo and sidecar and extra_count and extra_count > 0 %}
{# Cover becomes a carousel: the set cover first, then the Brickset
additional images. Each slide reuses .card-img sizing (object-fit:
contain + max-height) so every image keeps its aspect ratio at a fixed
height, regardless of source resolution. #}
{% set carousel_id = 'setImages-' ~ item.fields.id %}
{# bt-gallery groups every slide anchor into a single BaguetteBox lightbox
gallery (prev/next buttons + arrow-key navigation), instead of each
image opening as its own single-image lightbox. #}
<div id="{{ carousel_id }}" class="card-img border-bottom carousel slide carousel-dark bt-gallery">
<div class="carousel-inner">
<div class="carousel-item active" style="background-image: url({{ image_url }})">
<a {% if caption %}data-caption="{{ caption }} (1/{{ extra_count + 1 }})"{% endif %} href="{{ image_url }}" target="_blank">
<img src="{{ image_url }}" {% if alt %}alt="{{ alt }}"{% endif %} loading="lazy">
</a>
</div>
{% for i in range(extra_count) %}
{% set extra_url = sidecar.additional_image_url(item.fields.set, i) %}
<div class="carousel-item" style="background-image: url({{ extra_url }})">
<a {% if caption %}data-caption="{{ caption }} ({{ loop.index + 1 }}/{{ extra_count + 1 }})"{% endif %} href="{{ extra_url }}" target="_blank">
<img src="{{ extra_url }}" {% if alt %}alt="{{ alt }} image {{ loop.index + 1 }}"{% endif %} loading="lazy">
</a>
</div>
{% endfor %}
</div>
<button class="carousel-control-prev" type="button" data-bs-target="#{{ carousel_id }}" data-bs-slide="prev">
<span class="carousel-control-prev-icon" aria-hidden="true"></span>
<span class="visually-hidden">Previous</span>
</button>
<button class="carousel-control-next" type="button" data-bs-target="#{{ carousel_id }}" data-bs-slide="next">
<span class="carousel-control-next-icon" aria-hidden="true"></span>
<span class="visually-hidden">Next</span>
</button>
</div>
{% else %}
<div class="card-img border-bottom" style="background-image: url({{ image_url }})">
<a {% if solo %}data-lightbox {% if caption %}data-caption="{{ caption }}"{% endif %} href="{{ image_url }}" target="_blank"{% else %}href="{{ item.url() }}"{% endif %}>
<img {% if solo and medium %}class="card-medium-img"{% endif %}{% if last %}class="card-last-img"{% endif %} src="{{ image_url }}" {% if alt %}alt="{{ alt }}"{% endif %} loading="lazy">
</a>
</div>
{% endif %}
{% endmacro %}
+5
View File
@@ -53,6 +53,11 @@
<li><a class="dropdown-item" href="#" id="check-all-{{ accordion_id }}"><i class="ri-checkbox-line me-2"></i>Check all</a></li>
<li><a class="dropdown-item" href="#" id="uncheck-all-{{ accordion_id }}"><i class="ri-checkbox-blank-line me-2"></i>Uncheck all</a></li>
{% endif %}
{% if show_missing_menu or show_checked_menu %}
<li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">Audit</h6></li>
<li><a class="dropdown-item" href="#" id="audit-mode-{{ accordion_id }}"><i class="ri-eye-line me-2"></i>Start audit</a></li>
{% endif %}
</ul>
</div>
</th>
+10 -3
View File
@@ -4,7 +4,7 @@
<div class="card mb-3 flex-fill {% if solo %}card-solo{% endif %}">
<div class="card-header {% if solo %}d-flex justify-content-between align-items-center{% endif %}">
<h5 class="mb-0">
<h5 class="mb-0 {% if not solo %}fs-6 text-nowrap overflow-x-hidden text-truncate{% endif %}">
{{ badge.identifier(item.fields.part, icon='shapes-line', solo=solo, header=true) }}
{% if solo %}
{{ badge.color(item, header=true) }}
@@ -29,8 +29,15 @@
{{ badge.color(item) }}
{{ badge.print(item) }}
{% endif %}
{{ badge.total_sets(sets_using | length, solo=solo, last=last) }}
{{ badge.total_minifigures(minifigures_using | length, solo=solo, last=last) }}
{% if solo %}
{{ badge.total_sets(sets_using | length, solo=solo, last=last) }}
{{ badge.total_minifigures(minifigures_using | length, solo=solo, last=last) }}
{% else %}
{# Sub-cards (different color / same print) carry their own per-color counts
from the query, not the parent's inherited sets_using/minifigures_using (#159). #}
{{ badge.total_sets(item.fields.total_sets, solo=solo, last=last) }}
{{ badge.total_minifigures(item.fields.total_minifigures, solo=solo, last=last) }}
{% endif %}
{{ badge.total_quantity(item.fields.total_quantity, solo=solo, last=last) }}
{{ badge.total_spare(item.fields.total_spare, solo=solo, last=last) }}
{{ badge.total_missing(item.fields.total_missing, solo=solo, last=last) }}
+2 -2
View File
@@ -22,9 +22,9 @@
</td>
{% if not no_quantity %}
{% if all %}
<td>{{ item.fields.total_quantity }}</td>
<td data-col="quantity">{{ item.fields.total_quantity }}</td>
{% else %}
<td>{% if quantity %}{{ item.fields.quantity * quantity }}{% else %}{{ item.fields.quantity }}{% endif %}</td>
<td data-col="quantity">{% if quantity %}{{ item.fields.quantity * quantity }}{% else %}{{ item.fields.quantity }}{% endif %}</td>
{% endif %}
{% endif %}
{% if not config['HIDE_TABLE_MISSING_PARTS'] %}
+114
View File
@@ -0,0 +1,114 @@
{# Mass-edit UI (Feature 4). Rendered server-side with the full metadata lists
so the offcanvas form has every editable field. All logic (selection,
shared-vs-varies detection, tri-state, apply) lives in sets-bulk-edit.js. #}
<!-- Sticky bottom bar shown while in bulk-select mode -->
<div id="bulk-select-bar" class="bulk-select-bar shadow-lg" data-bulk-edit-url="{{ url_for('set.bulk_edit') }}">
<div class="container-fluid d-flex flex-wrap align-items-center gap-2 py-2">
<span class="fw-bold"><span id="bulk-select-count">0</span> selected</span>
<div class="ms-auto d-flex flex-wrap gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary" id="bulk-select-all"><i class="ri-checkbox-multiple-line"></i> Select all visible</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="bulk-select-clear"><i class="ri-close-circle-line"></i> Clear</button>
<button type="button" class="btn btn-sm btn-primary" id="bulk-edit-open" disabled><i class="ri-edit-line"></i> Edit selected</button>
<button type="button" class="btn btn-sm btn-outline-danger" id="bulk-select-exit"><i class="ri-logout-box-line"></i> Done</button>
</div>
</div>
</div>
<!-- Bulk edit panel -->
<div class="offcanvas offcanvas-end" tabindex="-1" id="bulk-edit-panel" aria-labelledby="bulk-edit-panel-label" data-bs-scroll="true" data-bs-backdrop="true">
<div class="offcanvas-header border-bottom">
<h5 class="offcanvas-title" id="bulk-edit-panel-label">Edit <span id="bulk-edit-count">0</span> sets</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
<div class="offcanvas-body">
<p class="text-muted small">
Only fields you actually change are applied. Fields where the selected sets
differ show <em>varies</em> and stay untouched unless you edit them.
</p>
{# --- Value fields --- #}
<div class="mb-3">
<label class="form-label" for="bulk-purchase-price">Purchase price</label>
<input type="number" step="0.01" class="form-control bulk-control" id="bulk-purchase-price" data-bulk-field="purchase_price" data-card-attr="purchase-price">
</div>
<div class="mb-3">
<label class="form-label" for="bulk-purchase-date">Purchase date</label>
<input type="date" class="form-control bulk-control" id="bulk-purchase-date" data-bulk-field="purchase_date" data-card-attr="purchase-date">
</div>
<div class="mb-3">
<label class="form-label" for="bulk-storage">Storage</label>
<select class="form-select bulk-control" id="bulk-storage" data-bulk-field="storage" data-card-attr="storage">
<option value="">-- No storage --</option>
{% for storage in brickset_storages %}
<option value="{{ storage.fields.id }}">{{ storage.fields.name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label" for="bulk-purchase-location">Purchase location</label>
<select class="form-select bulk-control" id="bulk-purchase-location" data-bulk-field="purchase_location" data-card-attr="purchase-location">
<option value="">-- No purchase location --</option>
{% for location in brickset_purchase_locations %}
<option value="{{ location.fields.id }}">{{ location.fields.name }}</option>
{% endfor %}
</select>
</div>
{# --- Custom fields --- #}
{% if brickset_custom_fields | length %}
<hr>
<h6 class="text-muted">Custom fields</h6>
{% for custom_field in brickset_custom_fields %}
<div class="mb-3">
<label class="form-label" for="bulk-cf-{{ custom_field.fields.id }}">{{ custom_field.fields.name }}</label>
{% if custom_field.fields.type == 'number' %}
<input type="number" step="any" class="form-control bulk-control" id="bulk-cf-{{ custom_field.fields.id }}" data-bulk-field="custom_field" data-cf-id="{{ custom_field.fields.id }}" data-card-attr="{{ custom_field.as_dataset() }}">
{% elif custom_field.fields.type == 'date' %}
<input type="date" class="form-control bulk-control" id="bulk-cf-{{ custom_field.fields.id }}" data-bulk-field="custom_field" data-cf-id="{{ custom_field.fields.id }}" data-cf-date="true" data-card-attr="{{ custom_field.as_dataset() }}">
{% else %}
<input type="text" class="form-control bulk-control" id="bulk-cf-{{ custom_field.fields.id }}" data-bulk-field="custom_field" data-cf-id="{{ custom_field.fields.id }}" data-card-attr="{{ custom_field.as_dataset() }}">
{% endif %}
</div>
{% endfor %}
{% endif %}
{# --- Tri-state boolean metadata --- #}
{% if brickset_tags | length %}
<hr>
<h6 class="text-muted">Tags</h6>
{% for tag in brickset_tags %}
<div class="form-check">
<input class="form-check-input bulk-control bulk-tristate" type="checkbox" id="bulk-tag-{{ tag.fields.id }}" data-bulk-field="tag" data-meta-id="{{ tag.fields.id }}" data-card-attr="{{ tag.as_dataset() }}">
<label class="form-check-label" for="bulk-tag-{{ tag.fields.id }}">{{ tag.fields.name }}</label>
</div>
{% endfor %}
{% endif %}
{% if brickset_statuses | length %}
<hr>
<h6 class="text-muted">Statuses</h6>
{% for status in brickset_statuses %}
<div class="form-check">
<input class="form-check-input bulk-control bulk-tristate" type="checkbox" id="bulk-status-{{ status.fields.id }}" data-bulk-field="status" data-meta-id="{{ status.fields.id }}" data-card-attr="{{ status.as_dataset() }}">
<label class="form-check-label" for="bulk-status-{{ status.fields.id }}">{{ status.fields.name }}</label>
</div>
{% endfor %}
{% endif %}
{% if brickset_owners | length %}
<hr>
<h6 class="text-muted">Owners</h6>
{% for owner in brickset_owners %}
<div class="form-check">
<input class="form-check-input bulk-control bulk-tristate" type="checkbox" id="bulk-owner-{{ owner.fields.id }}" data-bulk-field="owner" data-meta-id="{{ owner.fields.id }}" data-card-attr="{{ owner.as_dataset() }}">
<label class="form-check-label" for="bulk-owner-{{ owner.fields.id }}">{{ owner.fields.name }}</label>
</div>
{% endfor %}
{% endif %}
</div>
<div class="offcanvas-footer border-top p-3 d-flex gap-2">
<button type="button" class="btn btn-secondary flex-fill" data-bs-dismiss="offcanvas">Cancel</button>
<button type="button" class="btn btn-primary flex-fill" id="bulk-edit-apply"><i class="ri-save-line"></i> Apply changes</button>
</div>
</div>
+17 -2
View File
@@ -52,14 +52,29 @@
{% endif %}
{% endwith %}
{% endfor %}
{# Custom field values for bulk-edit read (only emitted when set). The attr
absence means "empty/varies" to the bulk editor. #}
{% for custom_field in brickset_custom_fields %}
{% with value=item.fields[custom_field.as_column()] %}
{% if value is not none and value != '' %}
data-{{ custom_field.as_dataset() }}="{{ value }}"
{% endif %}
{% endwith %}
{% endfor %}
{% endif %}
>
{{ 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) }}
{# When opt-in additional images are available, fold them into the cover as a carousel. #}
{% set show_carousel = solo and sidecar_summary is defined and sidecar_summary and sidecar_summary.additional_images_enabled and sidecar_summary.additional_image_count and sidecar_summary.additional_image_count > 0 %}
{{ card.image(item, solo=solo, last=last, caption=item.fields.name, alt=item.fields.set, sidecar=(sidecar if show_carousel else none), extra_count=(sidecar_summary.additional_image_count if show_carousel else 0)) }}
<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 %}>
{# 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') }}
{{ badge.render_ordered_badges(item, brickset_tags, brickset_owners, brickset_storages, brickset_purchase_locations, solo=solo, last=last, context='detail' if solo else 'grid', sidecar_summary=(sidecar_summary if sidecar_summary is defined else none)) }}
</div>
{# Sidecar enrichment + price comparison (detail view only) #}
{% if solo and sidecar_summary is defined %}
{% include 'set/sidecar.html' %}
{% endif %}
{# 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']) %}
+24
View File
@@ -68,6 +68,7 @@
data-filter="value" data-filter-attribute="purchase-location"
autocomplete="off">
<option value="" {% if not current_purchase_location_filter %}selected{% endif %}>All</option>
<option value="__none__" {% if current_purchase_location_filter == '__none__' or current_purchase_location_filter == '-__none__' %}selected{% endif %}>None</option>
{% for purchase_location in brickset_purchase_locations %}
<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 %}
@@ -87,6 +88,7 @@
data-filter="value" data-filter-attribute="storage"
autocomplete="off">
<option value="" {% if not current_storage_filter %}selected{% endif %}>All</option>
<option value="__none__" {% if current_storage_filter == '__none__' or current_storage_filter == '-__none__' %}selected{% endif %}>None</option>
{% for storage in brickset_storages %}
<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 %}
@@ -127,6 +129,28 @@
{{ form.filter_toggle('grid-year') }}
</div>
</div>
<!-- Numeric range filters: parts and year (client-side) -->
<div class="w-100"></div>
<div class="col-12 flex-grow-1">
<label class="visually-hidden" for="grid-parts-min">Parts range</label>
<div class="input-group">
<span class="input-group-text"><i class="ri-shapes-line"></i><span class="ms-1 d-none d-md-inline"> Parts</span></span>
<input type="number" id="grid-parts-min" class="form-control" placeholder="min" min="0" autocomplete="off"
data-filter-range="parts" data-filter-bound="min">
<input type="number" id="grid-parts-max" class="form-control" placeholder="max" min="0" autocomplete="off"
data-filter-range="parts" data-filter-bound="max">
</div>
</div>
<div class="col-12 flex-grow-1">
<label class="visually-hidden" for="grid-year-min">Year range</label>
<div class="input-group">
<span class="input-group-text"><i class="ri-calendar-line"></i><span class="ms-1 d-none d-md-inline"> Year</span></span>
<input type="number" id="grid-year-min" class="form-control" placeholder="from" min="0" autocomplete="off"
data-filter-range="year" data-filter-bound="min">
<input type="number" id="grid-year-max" class="form-control" placeholder="to" min="0" autocomplete="off"
data-filter-range="year" data-filter-bound="max">
</div>
</div>
<div class="col-12 col-lg-auto">
<button id="grid-filter-clear" class="btn btn-outline-danger w-100" type="button" title="Clear all filters">
<i class="ri-filter-off-line"></i> <span class="d-none d-md-inline">Clear</span>
+94
View File
@@ -60,8 +60,102 @@
<a class="list-group-item list-group-item-action" href="{{ url_for('admin.admin', open_tag=true) }}"><i class="ri-settings-4-line"></i> Manage the set tags</a>
</div>
{{ accordion.footer() }}
{{ accordion.header('Custom fields', 'custom-field', 'set-management', icon='edit-line') }}
{% if brickset_custom_fields | length %}
<div class="row row-cols-1 g-2 pb-2">
{% for custom_field in brickset_custom_fields %}
<div class="col-12">
{{ form.input(custom_field.fields.name, item.fields.id, custom_field.as_dataset(), custom_field.url_for_set_state(item.fields.id), item.fields[custom_field.as_column()], icon=('calendar-line' if custom_field.fields.type == 'date' else ('hashtag' if custom_field.fields.type == 'number' else 'edit-line')), date=(custom_field.fields.type == 'date')) }}
</div>
{% endfor %}
</div>
<hr>
{% else %}
<p class="text-center"><i class="ri-error-warning-line"></i> No custom field found.</p>
{% endif %}
<a href="{{ url_for('admin.admin', open_custom_field=true) }}" class="btn btn-primary" role="button"><i class="ri-settings-4-line"></i> Manage the custom fields</a>
{{ accordion.footer() }}
{{ accordion.header('Data', 'data', 'set-management', icon='database-2-line') }}
<a href="{{ item.url_for_refresh() }}" class="btn btn-primary" role="button"><i class="ri-refresh-line"></i> Refresh the set data</a>
{{ accordion.footer() }}
{% if sidecar_enabled %}
{{ accordion.header('Cover image', 'cover', 'set-management', icon='image-line') }}
<p class="text-muted small mb-2">Replace the Rebrickable cover with BrickLink box/set art from the sidecar. A backup of the original is kept so you can restore it.</p>
<div class="d-flex flex-wrap gap-2">
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#coverPreviewModal"
data-cover-type="box" data-cover-label="Box art"
data-cover-preview="{{ sidecar.image_proxy_url('box', item.fields.set) }}"
data-cover-action="{{ item.url_for_cover('box') }}">
<i class="ri-eye-line"></i> Preview box art
</button>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#coverPreviewModal"
data-cover-type="set" data-cover-label="Set image"
data-cover-preview="{{ sidecar.image_proxy_url('set', item.fields.set) }}"
data-cover-action="{{ item.url_for_cover('set') }}">
<i class="ri-eye-line"></i> Preview set image
</button>
<form action="{{ item.url_for_cover_restore() }}" method="post" class="d-inline">
<button type="submit" class="btn btn-outline-secondary"><i class="ri-arrow-go-back-line"></i> Restore Rebrickable image</button>
</form>
</div>
{# Pick any Brickset additional image as the cover (opt-in). Each thumbnail
feeds the same preview modal as the box/set buttons above. #}
{% if sidecar_summary and sidecar_summary.additional_images_enabled and sidecar_summary.additional_image_count %}
<p class="text-muted small mt-3 mb-2">Or use one of the {{ sidecar_summary.additional_image_count }} additional Brickset image(s):</p>
<div class="d-flex flex-wrap gap-2">
{% for i in range(sidecar_summary.additional_image_count) %}
<button type="button" class="btn btn-outline-primary p-1" data-bs-toggle="modal" data-bs-target="#coverPreviewModal"
data-cover-type="additional" data-cover-label="Additional image {{ i + 1 }}"
data-cover-preview="{{ sidecar.additional_image_url(item.fields.set, i) }}"
data-cover-action="{{ item.url_for_cover_additional(i) }}">
<img src="{{ sidecar.additional_image_url(item.fields.set, i, thumbnail=true) }}" alt="Additional image {{ i + 1 }}" style="height: 48px; width: auto;" loading="lazy">
</button>
{% endfor %}
</div>
{% endif %}
<!-- Cover preview modal -->
<div class="modal fade" id="coverPreviewModal" tabindex="-1" aria-labelledby="coverPreviewModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="coverPreviewModalLabel">Cover preview</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-center">
<img id="coverPreviewImage" src="" alt="Cover preview" class="img-fluid" style="max-height: 60vh;"
onerror="this.classList.add('d-none'); document.getElementById('coverPreviewMissing').classList.remove('d-none');">
<p id="coverPreviewMissing" class="text-muted mt-2 d-none"><i class="ri-error-warning-line"></i> The sidecar has no image of this type for this set.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<form id="coverApplyForm" action="" method="post" class="d-inline">
<button type="submit" class="btn btn-primary"><i class="ri-check-line"></i> Use as cover (override)</button>
</form>
</div>
</div>
</div>
</div>
<script>
(function () {
var modal = document.getElementById('coverPreviewModal');
if (!modal) { return; }
modal.addEventListener('show.bs.modal', function (event) {
var button = event.relatedTarget;
if (!button) { return; }
var image = document.getElementById('coverPreviewImage');
// Reset state from any previous open
document.getElementById('coverPreviewMissing').classList.add('d-none');
image.classList.remove('d-none');
image.src = button.getAttribute('data-cover-preview') || '';
document.getElementById('coverPreviewModalLabel').textContent = (button.getAttribute('data-cover-label') || 'Cover') + ' preview';
document.getElementById('coverApplyForm').setAttribute('action', button.getAttribute('data-cover-action') || '');
});
})();
</script>
{{ accordion.footer() }}
{% endif %}
{{ accordion.footer() }}
{% endif %}
+104
View File
@@ -0,0 +1,104 @@
{# Sidecar enrichment + price comparison. Rendered only on the solo (detail)
view and only when the sidecar returned data for this set. #}
{% if solo and sidecar_summary %}
{% set s = sidecar_summary %}
{% set p = s.prices %}
{# Enrichment badges (retired / MSRP / value / dimensions / weight) are rendered
by macro/badge.html via BADGE_ORDER_DETAIL, so they are not repeated here. #}
{# --- Designer (web-scraped; the API does not expose it) --- #}
{% if s.designer and config['SIDECAR_SHOW_DESIGNER'] %}
<div class="card-body border-bottom-0 pt-0">
<div class="small text-muted"><i class="ri-pencil-ruler-2-line me-1"></i>Designer: {{ s.designer }}</div>
</div>
{% endif %}
{# --- Price comparison: paid / retail / worth now. Each row group has its own
live toggle; the whole card hides when all three are off. --- #}
{% set show_paid = config['SIDECAR_SHOW_PRICE_PAID'] %}
{% set show_msrp = config['SIDECAR_SHOW_PRICE_MSRP'] %}
{% set show_market = config['SIDECAR_SHOW_PRICE_MARKET'] %}
{% if (show_paid or show_msrp or show_market) and (p.paid is not none or p.msrp is not none or p.msrp_inflated is not none or p.has_market) %}
<div class="card-body border-bottom-0 pt-0">
<div class="border rounded p-2">
<div class="fw-bold small mb-1"><i class="ri-scales-line me-1"></i>Price comparison</div>
<table class="table table-sm mb-1 small">
<tbody>
{% if show_paid %}
<tr>
<td>Paid</td>
<td class="text-end">{% if p.paid is not none %}{{ '%.2f' | format(p.paid) }} {{ config['PURCHASE_CURRENCY'] }}{% else %}<span class="text-muted"></span>{% endif %}</td>
</tr>
{% endif %}
{% if show_msrp %}
<tr>
<td>Retail (MSRP)</td>
<td class="text-end">{% if p.msrp is not none %}{{ '%.2f' | format(p.msrp) }} {{ p.msrp_currency }}{% else %}<span class="text-muted"></span>{% endif %}</td>
</tr>
{% if p.msrp_inflated is not none %}
<tr>
<td>Retail (inflated)<i class="ri-information-line ms-1 text-muted" title="RRP adjusted for inflation"></i></td>
<td class="text-end text-muted">{{ '%.2f' | format(p.msrp_inflated) }} {{ p.msrp_inflated_currency }}</td>
</tr>
{% endif %}
{% endif %}
{% if show_market %}
<tr>
<td>Worth now (new)</td>
<td class="text-end">{% if p.market_new is not none %}{{ '%.2f' | format(p.market_new) }} {{ p.market_currency }}{% if p.market_min is not none and p.market_max is not none %}<i class="ri-information-line ms-1 text-muted" title="Min {{ '%.2f' | format(p.market_min) }} / Max {{ '%.2f' | format(p.market_max) }} {{ p.market_currency }}"></i>{% endif %}{% else %}<span class="text-muted"></span>{% endif %}</td>
</tr>
<tr>
<td>Worth now (used)</td>
<td class="text-end">{% if p.market_used is not none %}{{ '%.2f' | format(p.market_used) }} {{ p.market_currency }}{% if p.market_used_min is not none and p.market_used_max is not none %}<i class="ri-information-line ms-1 text-muted" title="Min {{ '%.2f' | format(p.market_used_min) }} / Max {{ '%.2f' | format(p.market_used_max) }} {{ p.market_currency }}"></i>{% endif %}{% else %}<span class="text-muted"></span>{% endif %}</td>
</tr>
{% endif %}
{% if show_msrp and p.savings_vs_msrp is defined %}
<tr class="border-top">
<td>Saved vs retail</td>
<td class="text-end {% if p.savings_vs_msrp >= 0 %}text-success{% else %}text-danger{% endif %}">{{ '%+.2f' | format(p.savings_vs_msrp) }}</td>
</tr>
{% endif %}
{% if show_paid and p.gain_vs_paid is defined %}
<tr>
<td>Change vs paid <span class="text-muted">({{ p.market_basis or 'used' }})</span></td>
<td class="text-end {% if p.gain_vs_paid >= 0 %}text-success{% else %}text-danger{% endif %}">{{ '%+.2f' | format(p.gain_vs_paid) }}</td>
</tr>
{% endif %}
</tbody>
</table>
<div class="d-flex justify-content-between align-items-center">
<small class="text-muted">
{% if p.has_market and p.market_fetched_at %}Value fetched {{ p.market_fetched_at }}{% elif not p.has_market %}Market value not fetched yet{% endif %}
</small>
{% if g.login.is_authenticated() %}
<form action="{{ item.url_for_value_refresh() }}" method="post" class="d-inline">
<button type="submit" class="btn btn-sm btn-outline-primary"><i class="ri-refresh-line"></i> {% if p.has_market %}Refresh value{% else %}Fetch value{% endif %}</button>
</form>
{% endif %}
</div>
</div>
</div>
{% endif %}
{# --- Sidecar description, styled like notes. This is the official set blurb,
distinct from the user's own notes, so it is always shown when present. --- #}
{% if s.description and config['SIDECAR_SHOW_DESCRIPTION'] %}
<div class="card-body border-bottom-0 pt-0">
<div class="alert alert-secondary mb-0" role="alert">
<div class="fw-bold small mb-1"><i class="ri-information-line me-1"></i>About this set</div>
<div class="small sidecar-description">{{ s.description }}</div>
</div>
</div>
{% endif %}
{# --- Brickset Notes (web-scraped; trivia / variants / packaging quirks). --- #}
{% if s.notes and config['SIDECAR_SHOW_NOTES'] %}
<div class="card-body border-bottom-0 pt-0">
<div class="alert alert-light border mb-0" role="alert">
<div class="fw-bold small mb-1"><i class="ri-sticky-note-line me-1"></i>Notes</div>
<div class="small sidecar-description">{{ s.notes }}</div>
</div>
</div>
{% endif %}
{% endif %}
+9
View File
@@ -27,6 +27,11 @@
<i class="ri-stack-line"></i> Duplicates
</button>
{% endif %}
{% if g.login.is_authenticated() %}
<button class="btn btn-outline-primary" type="button" id="bulk-select-toggle" title="Select multiple sets to edit at once">
<i class="ri-checkbox-multiple-line"></i> Select
</button>
{% endif %}
</div>
</div>
</div>
@@ -168,6 +173,10 @@
</div>
{% endif %}
{% if g.login.is_authenticated() %}
{% include 'set/bulk_edit.html' %}
{% endif %}
</div>
{% else %}
{% include 'set/empty.html' %}
+89
View File
@@ -155,12 +155,101 @@
<small class="text-dark">Purchase Locations</small>
</div>
</div>
{% if instructions_summary %}
<div class="col-6">
<div class="text-center">
<div class="h4 text-primary mb-0">{{ instructions_summary.sets_with_instructions }}<span class="text-muted fs-6">/{{ instructions_summary.unique_sets }}</span></div>
<small class="text-dark">Sets with Instructions <span class="text-muted">({{ instructions_summary.percentage_with_instructions }}%)</span></small>
</div>
</div>
<div class="col-6">
<div class="text-center">
<div class="h4 text-dark mb-0">{{ instructions_summary.instruction_files }}</div>
<small class="text-dark">Instruction Files</small>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Sidecar pricing comparison: paid / retail / worth now -->
{% if sidecar_pricing %}
{% set sp = sidecar_pricing %}
<div class="row g-3 mb-4">
<div class="col-12">
<div class="card">
<div class="card-header bg-info text-dark d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0"><i class="ri-scales-line"></i> Pricing comparison (sidecar)</h5>
{% if g.login.is_authenticated() %}
<a href="{{ url_for('admin.admin', open_value='true') }}#value" class="btn btn-sm btn-dark">
<i class="ri-funds-line"></i> Value all sets
</a>
{% endif %}
</div>
<div class="card-body">
<div class="row g-2 text-center">
<div class="col-md-6 col-xl-3">
<div class="border rounded p-3">
<div class="h3 mb-0">{{ config['PURCHASE_CURRENCY'] }}{{ "%.2f"|format(sp.total_paid or 0) }}</div>
<small class="text-dark">Total paid <span class="text-muted">({{ sp.sets_with_paid or 0 }} sets)</span></small>
</div>
</div>
<div class="col-md-6 col-xl-3">
<div class="border rounded p-3">
<div class="h3 mb-0">{{ "%.2f"|format(sp.total_msrp or 0) }} {{ sp.retail_currency }}</div>
<small class="text-dark">Total retail / MSRP <span class="text-muted">({{ sp.sets_with_msrp or 0 }} sets)</span></small>
</div>
</div>
<div class="col-md-6 col-xl-3">
<div class="border rounded p-3">
<div class="h3 mb-0">{{ "%.2f"|format(sp.total_market_new or 0) }} {{ sp.market_currency or '' }}</div>
<small class="text-dark">Total worth now (BrickLink new) <span class="text-muted">({{ sp.sets_with_market or 0 }} sets)</span></small>
</div>
</div>
<div class="col-md-6 col-xl-3">
<div class="border rounded p-3">
<div class="h3 mb-0">{{ "%.2f"|format(sp.total_market_used or 0) }} {{ sp.market_currency or '' }}</div>
<small class="text-dark">Total worth now (BrickLink used) <span class="text-muted">({{ sp.sets_with_market_used or 0 }} sets)</span></small>
</div>
</div>
</div>
<div class="row g-2 text-center mt-1">
<div class="col-md-4">
<div class="border rounded p-2">
<span class="h5 {% if (sp.total_saved_vs_msrp or 0) >= 0 %}text-success{% else %}text-danger{% endif %} mb-0">{{ "%+.2f"|format(sp.total_saved_vs_msrp or 0) }} {{ sp.retail_currency }}</span>
<small class="d-block text-dark">Saved vs retail <span class="text-muted">(where both known)</span></small>
</div>
</div>
<div class="col-md-4">
<div class="border rounded p-2">
<span class="h5 {% if (sp.total_gain_vs_paid or 0) >= 0 %}text-success{% else %}text-danger{% endif %} mb-0">{{ "%+.2f"|format(sp.total_gain_vs_paid or 0) }} {{ sp.market_currency or '' }}</span>
<small class="d-block text-dark">Value change vs paid <span class="text-muted">(BrickLink new)</span></small>
</div>
</div>
<div class="col-md-4">
<div class="border rounded p-2">
<span class="h5 {% if (sp.total_gain_vs_paid_used or 0) >= 0 %}text-success{% else %}text-danger{% endif %} mb-0">{{ "%+.2f"|format(sp.total_gain_vs_paid_used or 0) }} {{ sp.market_currency or '' }}</span>
<small class="d-block text-dark">Value change vs paid <span class="text-muted">(BrickLink used)</span></small>
</div>
</div>
</div>
{% if sp.currency_mismatch %}
<div class="alert alert-warning small mb-2 mt-2" role="alert">
<i class="ri-error-warning-line"></i>
Currencies differ: you paid in <strong>{{ sp.paid_currency or '?' }}</strong>{% if sp.retail_currency %}, retail is in <strong>{{ sp.retail_currency }}</strong>{% endif %}{% if sp.market_currency %}, market value is in <strong>{{ sp.market_currency }}</strong>{% endif %}.
The "saved vs retail" and "value change vs paid" figures mix currencies and are <strong>not converted</strong>.
</div>
{% endif %}
<p class="text-muted small mb-0 mt-2"><i class="ri-information-line"></i> Estimate. Only covers sets the sidecar has data for; market value only covers sets whose value has been fetched. Currencies are not converted.</p>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Collection Growth Charts -->
{% if config['STATISTICS_SHOW_CHARTS'] %}
<div class="row g-3 mb-4">
+17 -2
View File
@@ -20,7 +20,7 @@
</thead>
<tbody>
{% for item in table_collection %}
{% with retirement_date = retired.get(item.fields.set) %}
{% with retirement_date = retired.get(item.fields.set), bd_retired = (sidecar_retired.get(item.fields.set) if sidecar_retired is defined and sidecar_retired else none) %}
<tr>
{{ table.image(item.url_for_image(), caption=item.fields.name, alt=item.fields.set) }}
<td>{{ item.fields.set }} {{ table.rebrickable(item) }}</td>
@@ -28,7 +28,22 @@
<td>{{ item.theme.name }}</td>
<td>{{ item.fields.year }}</td>
<td>{{ item.fields.number_of_parts }}</td>
<td>{% if retirement_date %}{{ retirement_date }}{% else %}<span class="badge rounded-pill text-bg-light border">Not found</span>{% endif %}</td>
<td>
{% if retirement_date %}
{# Upcoming-retirements CSV: these are future dates. #}
<span class="badge rounded-pill text-warning-emphasis bg-warning-subtle border border-warning-subtle"><i class="ri-calendar-close-line"></i> {{ retirement_date }}</span>
{% elif bd_retired and bd_retired.date %}
{# BrickData fallback for sets the CSV does not cover (#37):
red when already retired, yellow when still upcoming. #}
{% if bd_retired.retired %}
<span class="badge rounded-pill text-danger-emphasis bg-danger-subtle border border-danger-subtle"><i class="ri-time-line"></i> Retired {{ bd_retired.date }}</span>
{% else %}
<span class="badge rounded-pill text-warning-emphasis bg-warning-subtle border border-warning-subtle"><i class="ri-calendar-close-line"></i> {{ bd_retired.date }}</span>
{% endif %}
{% else %}
<span class="badge rounded-pill text-bg-light border">Not found</span>
{% endif %}
</td>
<td>
{% for owner in owners %}
{{ badge.owner(item, owner) }}