Compare commits

...

18 Commits

Author SHA1 Message Date
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
64 changed files with 3189 additions and 33 deletions
+18
View File
@@ -1,5 +1,23 @@
# 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
- **Statistics: price and instructions stats**: Added a paid / retail / market price comparison and instruction-related statistics
- **New grid filters** (#141): Added parts-range, year-range and "None" filters to the set grid
### Bug Fixes
- **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
+13
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
@@ -155,6 +157,7 @@ def setup_app(app: Flask) -> None:
# 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 +198,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:
+17 -2
View File
@@ -105,8 +105,23 @@ 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', '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': ''},
]
+21 -5
View File
@@ -86,7 +86,14 @@ 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_CURRENCY'
]
# Environment variables that require restart
@@ -114,7 +121,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 +210,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 +348,12 @@ 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_CURRENCY': 'Currency to request BrickLink market values in, e.g. EUR. Empty uses the sidecar default. (The sidecar must support the currency query parameter.)'
}
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,
+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),
+540
View File
@@ -0,0 +1,540 @@
import json
import logging
import os
import time
from typing import Any, Final
from flask import current_app
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 the image-proxy URL for direct use in an <img src> (no server
# round-trip needed to preview). Does not hit the network.
@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 '{base}/images/bricklink/{type}/{ref}.png'.format(
base=BrickSidecar.base_url(),
type=image_type,
ref=ref,
)
# Build the URL of one Brickset additional image (0-indexed) for direct use
# in an <img src>. Unlike box/instruction art these come from Brickset, not
# the BrickLink image proxy. Pass thumbnail=True for the smaller variant.
# Does not hit the network. Returns None when the sidecar is disabled.
@staticmethod
def 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, /) -> bytes | None:
url = BrickSidecar.additional_image_url(ref, index)
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.image_proxy_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
+242
View File
@@ -0,0 +1,242 @@
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.
retired, exit_year = _retired(data.get('exitDate'))
summary['retired'] = retired
summary['exit_year'] = exit_year
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 (using the sealed/new market average,
# falling back to used).
market_ref = market_new if market_new is not None else market_used
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 _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
+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
+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())
+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,
+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
+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, {});
}
}
+70
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;
}
@@ -346,3 +353,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-sidecar-base="{{ sidecar.base_url() }}"
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 base = (document.getElementById('add-check-box-art').getAttribute('data-sidecar-base') || '').replace(/\/$/, '');
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 = base + '/images/bricklink/box/' + encodeURIComponent(ref) + '.png';
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' %}
+63
View File
@@ -543,6 +543,56 @@
</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_PRICE_CACHE_HOURS" class="form-label">
BK_SIDECAR_PRICE_CACHE_HOURS {{ config_badges('BK_SIDECAR_PRICE_CACHE_HOURS') }}
<div class="text-muted small">Hours to cache BrickLink market prices before refetching</div>
</label>
<input type="number" class="form-control config-number" id="BK_SIDECAR_PRICE_CACHE_HOURS" data-var="BK_SIDECAR_PRICE_CACHE_HOURS" min="0" max="8760">
</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 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>
<!-- Advanced Settings -->
<h6 class="fw-bold text-primary border-bottom pb-1 mb-3 mt-4">Advanced Settings</h6>
@@ -1011,6 +1061,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 %}
+11 -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,6 +190,9 @@
{% 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>
@@ -217,6 +221,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_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 %}
+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) }}
+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 %}
+94
View File
@@ -0,0 +1,94 @@
{# 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 %}
<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 --- #}
{% if 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>
<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>
<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 %}
<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 }}{% 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 }}{% else %}<span class="text-muted"></span>{% endif %}</td>
</tr>
{% if 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 p.gain_vs_paid is defined %}
<tr>
<td>Change vs paid</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 %}
<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 %}
<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">