Compare commits
36 Commits
v1.4.0
...
0f73567db6
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f73567db6 | |||
| 621d673785 | |||
| e9768be79f | |||
| 9a956c54be | |||
| 3efae530d6 | |||
| 3beca33789 | |||
| 14c49d3950 | |||
| c93a14f70b | |||
| 32a70c7358 | |||
| 52211a7900 | |||
| 4d09be06ae | |||
| 3a7772db0b | |||
| a43ec39d8c | |||
| a3122c52d2 | |||
| c64541ba43 | |||
| e7a83817d3 | |||
| 811a348058 | |||
| 4daab79c3b | |||
| 2cb3e96e21 | |||
| e94b8d2de5 | |||
| 4074aa7c4c | |||
| f262411dc4 | |||
| 711833e5de | |||
| 12dead4ded | |||
| 0e3ba26010 | |||
| 6177187103 | |||
| 5c0daed160 | |||
| 66bbed3597 | |||
| d3a014765b | |||
| 665441c5ac | |||
| d751a3d0af | |||
| 1b077e86b1 | |||
| ef6bdc823d | |||
| 0567d9817f | |||
| fa9e0c3765 | |||
| 69318e7b0b |
@@ -1,5 +1,59 @@
|
||||
# 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
|
||||
|
||||
- **Added per-column header filters to the Parts table on the set details page**: The main Parts table now has an always-visible filter row under the column headers
|
||||
- Name: case-insensitive substring search
|
||||
- Color: dropdown populated from the colors present in the current results
|
||||
- Missing / Damaged: All / With / Without dropdowns
|
||||
- Checked: All / Checked / Unchecked
|
||||
- Filters combine (AND) and re-evaluate live as missing/damaged values or checkboxes change. Bulk actions (mark all missing, check all, etc.) only affect the rows visible under the active filter. Scoped to the main Parts table only; per-minifigure tables stay sort-only
|
||||
- **"Reset to Defaults" confirmation now uses a Bootstrap modal instead of a browser dialog**: Replaced the native browser `confirm()` popup with a consistent Bootstrap modal matching the style of BrickTracker
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Fixed prices on the Statistics page being rounded to whole numbers** (Issue #146): All price values now display with two decimal places (`%.2f`) instead of being rounded to whole numbers (`%.0f`)
|
||||
|
||||
- **Fixed "Reset to Defaults" blanking all settings instead of restoring them** (Issue #149a, branch `bugfix/issue-149a`): "Reset to Defaults" was clearing all fields to empty/false instead of populating them with their actual default values
|
||||
- `resetToDefaults()` now reads from `window.DEFAULT_CONFIG` and restores each field to its proper default, matching the same logic used on initial page load
|
||||
- **Fixed `BK_INSTRUCTIONS_ALLOWED_EXTENSIONS` being treated as a string instead of a list** (Issue #149b, branch `bugfix/issue-149b`): When this setting was saved via the admin panel, it was stored and cast as a plain string rather than a list, causing it to be iterated character by character (e.g. `['.', 'p', 'd', 'f']` instead of `['.pdf']`)
|
||||
- Added `allowed_extensions` to the list-type keyword detection in `_cast_value()`, matching the existing pattern used for `badge_order` settings
|
||||
- **Fixed crash when importing sets containing minifigures or parts with no image on Rebrickable** (Issue #149c, branch `bugfix/issue-149c`): Adding or refreshing a set would fail entirely if any minifigure or part had no image URL, with error `Invalid URL '': No scheme supplied`
|
||||
- Rebrickable returns an empty string (not `None`) for missing images; normalize empty strings to `None` at the point of ingestion in `rebrickable_minifigure.py` and `individual_minifigure.py`, matching the existing pattern in `rebrickable_set.py`
|
||||
- Updated `rebrickable_image.py` to treat empty strings the same as `None` throughout, falling back to the configured nil placeholder image
|
||||
- Note: the originally reported sets could no longer reproduce the crash (images may have since been added on Rebrickable), so this fix is based on assumptions only
|
||||
- **Fixed previously added set being re-added when adding an individual minifigure** (Issue #150, branch `bugfix/issue-150`): After adding a set, entering a `fig-` ID and confirming would add the previous set again instead of the minifigure, if user did not reload inbetween.
|
||||
- `add.js` was creating a second `BrickMinifigureSocket` with its own listeners on the same button and input as `BrickSetSocket`, causing duplicate socket events and cross-socket state confusion
|
||||
- **Fixed purchase date, price, and notes not being saved when adding an individual minifigure** (Issue #151, branch `bugfix/issue-151`): Filling in purchase date, price, or notes before clicking Add had no effect, only purchase location was saved
|
||||
- `BrickMinifigureSocket` was missing references to `#add-purchase-date`, `#add-purchase-price`, and `#add-description`, so those fields were never read or included in the socket emit
|
||||
- The backend already supported all three fields. This was just a frontend error
|
||||
- **Fixed purchase date and price not being converted when adding an individual minifigure** (Issue #151 follow-up): Purchase date was stored as a raw `YYYY/MM/DD` string and price as a raw string instead of a Unix epoch float and float respectively, causing them to be silently dropped from statistics aggregations
|
||||
- `IndividualMinifigure.download()` now mirrors the conversion logic already present in `BrickSet.download()`: date parsed via `datetime.strptime` to timestamp, price cast to `float`
|
||||
- **Fixed a price of 0 being treated as no price** (Issue #153): Setting a purchase price of `0` on sets, individual minifigures, or parts was indistinguishable from having no price set at all
|
||||
- Replaced truthiness checks (`if price`, `price or ''`) with explicit `None` checks throughout badge display, management input fields, and inline price update endpoints
|
||||
- **Fixed deleting a wish with an owner assigned** (Issue #152, branch `bugfix/issue-152`): Resolved foreign key constraint error when removing a set from the wishlist that had an owner assigned
|
||||
- Wish owners are now deleted before the wish itself, respecting the FK constraint
|
||||
|
||||
## 1.4
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -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
@@ -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': ''},
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
@@ -192,7 +202,7 @@ class ConfigManager:
|
||||
def _cast_value(self, var_name: str, value: Any) -> Any:
|
||||
"""Cast value to appropriate type based on variable name"""
|
||||
# List variables (admin sections, badge order) - Check this FIRST before boolean check
|
||||
if any(keyword in var_name.lower() for keyword in ['sections', 'badge_order']):
|
||||
if any(keyword in var_name.lower() for keyword in ['sections', 'badge_order', 'allowed_extensions']):
|
||||
if isinstance(value, str):
|
||||
return [section.strip() for section in value.split(',') if section.strip()]
|
||||
elif isinstance(value, list):
|
||||
@@ -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')
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from typing import Any, Self, TYPE_CHECKING
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -67,8 +68,25 @@ class IndividualMinifigure(RebrickableMinifigure):
|
||||
self.fields.purchase_location = purchase_location.fields.id if purchase_location else None
|
||||
|
||||
# Save purchase date and price
|
||||
self.fields.purchase_date = data.get('purchase_date', None)
|
||||
self.fields.purchase_price = data.get('purchase_price', None)
|
||||
purchase_date = data.get('purchase_date', None)
|
||||
if purchase_date == '':
|
||||
purchase_date = None
|
||||
if purchase_date is not None:
|
||||
try:
|
||||
purchase_date = datetime.strptime(purchase_date, '%Y/%m/%d').timestamp()
|
||||
except Exception:
|
||||
purchase_date = None
|
||||
self.fields.purchase_date = purchase_date
|
||||
|
||||
purchase_price = data.get('purchase_price', None)
|
||||
if purchase_price == '':
|
||||
purchase_price = None
|
||||
if purchase_price is not None:
|
||||
try:
|
||||
purchase_price = float(purchase_price)
|
||||
except Exception:
|
||||
purchase_price = None
|
||||
self.fields.purchase_price = purchase_price
|
||||
|
||||
# Save quantity and description
|
||||
self.fields.quantity = int(data.get('quantity', 1))
|
||||
@@ -543,6 +561,6 @@ class IndividualMinifigure(RebrickableMinifigure):
|
||||
'figure': str(data['set_num']),
|
||||
'number': int(number),
|
||||
'name': str(data['set_name']),
|
||||
'image': data.get('set_img_url'),
|
||||
'image': str(data['set_img_url']) if data.get('set_img_url') else None,
|
||||
'number_of_parts': int(data.get('num_parts', 0)),
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -55,7 +55,7 @@ class RebrickableImage(object):
|
||||
|
||||
# Get the URL (this handles nil images via url() method)
|
||||
url = self.url()
|
||||
if url is None:
|
||||
if not url:
|
||||
return
|
||||
|
||||
# Grab the image
|
||||
@@ -88,7 +88,7 @@ class RebrickableImage(object):
|
||||
return self.part.fields.image_id
|
||||
|
||||
if self.minifigure is not None:
|
||||
if self.minifigure.fields.image is None:
|
||||
if not self.minifigure.fields.image:
|
||||
return RebrickableImage.nil_minifigure_name()
|
||||
else:
|
||||
return self.minifigure.fields.figure
|
||||
@@ -113,13 +113,13 @@ class RebrickableImage(object):
|
||||
# Return the url depending on the objects provided
|
||||
def url(self, /) -> str:
|
||||
if self.part is not None:
|
||||
if self.part.fields.image is None:
|
||||
if not self.part.fields.image:
|
||||
return current_app.config['REBRICKABLE_IMAGE_NIL']
|
||||
else:
|
||||
return self.part.fields.image
|
||||
|
||||
if self.minifigure is not None:
|
||||
if self.minifigure.fields.image is None:
|
||||
if not self.minifigure.fields.image:
|
||||
return current_app.config['REBRICKABLE_IMAGE_NIL_MINIFIGURE']
|
||||
else:
|
||||
return self.minifigure.fields.image
|
||||
|
||||
@@ -110,5 +110,5 @@ class RebrickableMinifigure(BrickRecord):
|
||||
'number': int(number),
|
||||
'name': str(data['set_name']),
|
||||
'quantity': int(data['quantity']),
|
||||
'image': data['set_img_url'],
|
||||
'image': str(data['set_img_url']) if data['set_img_url'] else None,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
|
||||
@@ -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'},
|
||||
'zł': {'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
|
||||
@@ -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 (®, ', ...) 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> </p>);
|
||||
# drop those so the blocks sit flush instead of leaving big gaps. \s also
|
||||
# matches the (\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
|
||||
@@ -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,
|
||||
|
||||
@@ -6,6 +6,8 @@ SELECT
|
||||
"bricktracker_individual_minifigures"."description",
|
||||
"bricktracker_individual_minifigures"."storage",
|
||||
"bricktracker_individual_minifigures"."purchase_location",
|
||||
"bricktracker_individual_minifigures"."purchase_date",
|
||||
"bricktracker_individual_minifigures"."purchase_price",
|
||||
"rebrickable_minifigures"."number",
|
||||
"rebrickable_minifigures"."name",
|
||||
"rebrickable_minifigures"."image",
|
||||
|
||||
@@ -6,6 +6,8 @@ SELECT
|
||||
"bricktracker_individual_minifigures"."description",
|
||||
"bricktracker_individual_minifigures"."storage",
|
||||
"bricktracker_individual_minifigures"."purchase_location",
|
||||
"bricktracker_individual_minifigures"."purchase_date",
|
||||
"bricktracker_individual_minifigures"."purchase_price",
|
||||
"rebrickable_minifigures"."number",
|
||||
"rebrickable_minifigures"."name",
|
||||
"rebrickable_minifigures"."image",
|
||||
|
||||
@@ -6,6 +6,8 @@ SELECT
|
||||
"bricktracker_individual_minifigures"."description",
|
||||
"bricktracker_individual_minifigures"."storage",
|
||||
"bricktracker_individual_minifigures"."purchase_location",
|
||||
"bricktracker_individual_minifigures"."purchase_date",
|
||||
"bricktracker_individual_minifigures"."purchase_price",
|
||||
"rebrickable_minifigures"."number",
|
||||
"rebrickable_minifigures"."name",
|
||||
"rebrickable_minifigures"."image",
|
||||
|
||||
@@ -6,6 +6,8 @@ SELECT
|
||||
"bricktracker_individual_minifigures"."description",
|
||||
"bricktracker_individual_minifigures"."storage",
|
||||
"bricktracker_individual_minifigures"."purchase_location",
|
||||
"bricktracker_individual_minifigures"."purchase_date",
|
||||
"bricktracker_individual_minifigures"."purchase_price",
|
||||
"rebrickable_minifigures"."number",
|
||||
"rebrickable_minifigures"."name",
|
||||
"rebrickable_minifigures"."image",
|
||||
|
||||
@@ -6,6 +6,8 @@ SELECT
|
||||
"bricktracker_individual_minifigures"."description",
|
||||
"bricktracker_individual_minifigures"."storage",
|
||||
"bricktracker_individual_minifigures"."purchase_location",
|
||||
"bricktracker_individual_minifigures"."purchase_date",
|
||||
"bricktracker_individual_minifigures"."purchase_price",
|
||||
"rebrickable_minifigures"."number",
|
||||
"rebrickable_minifigures"."name",
|
||||
"rebrickable_minifigures"."image",
|
||||
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
),
|
||||
@@ -100,6 +101,23 @@ part_lot_stats AS (
|
||||
FROM "bricktracker_individual_part_lots"
|
||||
),
|
||||
|
||||
-- Combined min/max price across all item types (separate CTE to avoid scalar subquery issues in SQLite)
|
||||
all_prices AS (
|
||||
SELECT "purchase_price" AS price FROM "bricktracker_sets" WHERE "purchase_price" IS NOT NULL AND "purchase_price" != ''
|
||||
UNION ALL
|
||||
SELECT "purchase_price" FROM "bricktracker_individual_parts" WHERE "purchase_price" IS NOT NULL AND "purchase_price" != '' AND "lot_id" IS NULL
|
||||
UNION ALL
|
||||
SELECT "purchase_price" FROM "bricktracker_individual_minifigures" WHERE "purchase_price" IS NOT NULL AND "purchase_price" != ''
|
||||
UNION ALL
|
||||
SELECT "purchase_price" FROM "bricktracker_individual_part_lots" WHERE "purchase_price" IS NOT NULL AND "purchase_price" != ''
|
||||
),
|
||||
price_range AS (
|
||||
SELECT
|
||||
MIN(price) AS combined_minimum_cost,
|
||||
MAX(price) AS combined_maximum_cost
|
||||
FROM all_prices
|
||||
),
|
||||
|
||||
-- Rebrickable sets count (for sets we actually own)
|
||||
rebrickable_stats AS (
|
||||
SELECT COUNT(*) AS unique_rebrickable_sets
|
||||
@@ -116,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) +
|
||||
@@ -140,26 +165,9 @@ financial_stats AS (
|
||||
END AS combined_average_cost,
|
||||
|
||||
-- Min/Max price across all item types
|
||||
(SELECT MIN(price) FROM (
|
||||
SELECT "purchase_price" AS price FROM "bricktracker_sets" WHERE "purchase_price" IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT "purchase_price" FROM "bricktracker_individual_parts" WHERE "purchase_price" IS NOT NULL AND "lot_id" IS NULL
|
||||
UNION ALL
|
||||
SELECT "purchase_price" FROM "bricktracker_individual_minifigures" WHERE "purchase_price" IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT "purchase_price" FROM "bricktracker_individual_part_lots" WHERE "purchase_price" IS NOT NULL
|
||||
)) AS combined_minimum_cost,
|
||||
|
||||
(SELECT MAX(price) FROM (
|
||||
SELECT "purchase_price" AS price FROM "bricktracker_sets" WHERE "purchase_price" IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT "purchase_price" FROM "bricktracker_individual_parts" WHERE "purchase_price" IS NOT NULL AND "lot_id" IS NULL
|
||||
UNION ALL
|
||||
SELECT "purchase_price" FROM "bricktracker_individual_minifigures" WHERE "purchase_price" IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT "purchase_price" FROM "bricktracker_individual_part_lots" WHERE "purchase_price" IS NOT NULL
|
||||
)) AS combined_maximum_cost
|
||||
FROM set_stats, individual_part_stats, individual_minifig_stats, part_lot_stats
|
||||
price_range.combined_minimum_cost,
|
||||
price_range.combined_maximum_cost
|
||||
FROM set_stats, individual_part_stats, individual_minifig_stats, part_lot_stats, price_range
|
||||
)
|
||||
|
||||
-- Final select combining all statistics
|
||||
@@ -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"
|
||||
@@ -3,10 +3,10 @@
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
DELETE FROM "bricktracker_wishes"
|
||||
WHERE "bricktracker_wishes"."set" IS NOT DISTINCT FROM '{{ set }}';
|
||||
|
||||
DELETE FROM "bricktracker_wish_owners"
|
||||
WHERE "bricktracker_wish_owners"."set" IS NOT DISTINCT FROM '{{ set }}';
|
||||
|
||||
DELETE FROM "bricktracker_wishes"
|
||||
WHERE "bricktracker_wishes"."set" IS NOT DISTINCT FROM '{{ set }}';
|
||||
|
||||
COMMIT;
|
||||
+162
-5
@@ -58,14 +58,17 @@ class BrickStatistics:
|
||||
return {
|
||||
'total_cost': overview.get('combined_total_cost') or 0,
|
||||
'average_cost': overview.get('combined_average_cost') or 0,
|
||||
'minimum_cost': overview.get('combined_minimum_cost') or 0,
|
||||
'maximum_cost': overview.get('combined_maximum_cost') or 0,
|
||||
'minimum_cost': float(overview['combined_minimum_cost']) if overview.get('combined_minimum_cost') not in (None, '') else None,
|
||||
'maximum_cost': float(overview['combined_maximum_cost']) if overview.get('combined_maximum_cost') not in (None, '') else None,
|
||||
'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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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))
|
||||
@@ -304,6 +304,13 @@ def update_purchase_date(*, id: str):
|
||||
def update_purchase_price(*, id: str):
|
||||
item = IndividualMinifigure().select_by_id(id)
|
||||
purchase_price = request.json.get('value')
|
||||
if purchase_price is not None and str(purchase_price).strip() != '':
|
||||
try:
|
||||
purchase_price = float(purchase_price)
|
||||
except (ValueError, TypeError):
|
||||
purchase_price = None
|
||||
else:
|
||||
purchase_price = None
|
||||
|
||||
BrickSQL().execute_and_commit(
|
||||
'individual_minifigure/update',
|
||||
@@ -314,7 +321,7 @@ def update_purchase_price(*, id: str):
|
||||
'storage': item.fields.storage,
|
||||
'purchase_location': item.fields.purchase_location if hasattr(item.fields, 'purchase_location') else None,
|
||||
'purchase_date': item.fields.purchase_date if hasattr(item.fields, 'purchase_date') else None,
|
||||
'purchase_price': purchase_price if purchase_price else None,
|
||||
'purchase_price': purchase_price,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -311,7 +311,7 @@ def update_purchase_price(*, id: str):
|
||||
purchase_price = request.json.get('value', '')
|
||||
|
||||
# Convert to float if provided, otherwise None
|
||||
if purchase_price and str(purchase_price).strip():
|
||||
if purchase_price is not None and str(purchase_price).strip() != '':
|
||||
try:
|
||||
price = float(purchase_price)
|
||||
item.update_field('purchase_price', price)
|
||||
@@ -662,7 +662,14 @@ def update_lot_purchase_price(*, lot_id: str):
|
||||
|
||||
from ..sql import BrickSQL
|
||||
sql = BrickSQL()
|
||||
sql.execute_and_commit('individual_part_lot/update/purchase_price', parameters={'purchase_price': purchase_price if purchase_price else None, 'id': lot_id})
|
||||
if purchase_price is not None and str(purchase_price).strip() != '':
|
||||
try:
|
||||
purchase_price = float(purchase_price)
|
||||
except (ValueError, TypeError):
|
||||
purchase_price = None
|
||||
else:
|
||||
purchase_price = None
|
||||
sql.execute_and_commit('individual_part_lot/update/purchase_price', parameters={'purchase_price': purchase_price, 'id': lot_id})
|
||||
|
||||
logger.info('Updated lot {lot_id} purchase_price to: {price}'.format(lot_id=lot_id, price=purchase_price))
|
||||
return jsonify({'success': True})
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
+10
-64
@@ -1,6 +1,6 @@
|
||||
// Add page - handles both sets and individual minifigures
|
||||
// Server auto-routes fig- numbers to IndividualMinifigure via LOAD_SET / IMPORT_SET
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
// Initialize date pickers
|
||||
document.querySelectorAll('[data-add-date="true"]').forEach(el => {
|
||||
new Datepicker(el, {
|
||||
buttonClass: 'btn',
|
||||
@@ -8,81 +8,27 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Get template data from data attributes
|
||||
const addContainer = document.getElementById('add-set');
|
||||
if (!addContainer) return;
|
||||
|
||||
// Read data from data attributes
|
||||
const templateData = {
|
||||
path: addContainer.dataset.path,
|
||||
namespace: addContainer.dataset.namespace,
|
||||
messages: {
|
||||
// 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,
|
||||
addContainer.dataset.namespace,
|
||||
{
|
||||
COMPLETE: addContainer.dataset.msgComplete,
|
||||
FAIL: addContainer.dataset.msgFail,
|
||||
IMPORT_SET: addContainer.dataset.msgImportSet,
|
||||
LOAD_SET: addContainer.dataset.msgLoadSet,
|
||||
PROGRESS: addContainer.dataset.msgProgress,
|
||||
SET_LOADED: addContainer.dataset.msgSetLoaded,
|
||||
IMPORT_MINIFIGURE: addContainer.dataset.msgImportMinifigure,
|
||||
LOAD_MINIFIGURE: addContainer.dataset.msgLoadMinifigure,
|
||||
MINIFIGURE_LOADED: addContainer.dataset.msgMinifigureLoaded,
|
||||
}
|
||||
};
|
||||
|
||||
// Default: create set socket
|
||||
const setSocket = new BrickSetSocket(
|
||||
'add',
|
||||
templateData.path,
|
||||
templateData.namespace,
|
||||
{
|
||||
COMPLETE: templateData.messages.COMPLETE,
|
||||
FAIL: templateData.messages.FAIL,
|
||||
IMPORT_SET: templateData.messages.IMPORT_SET,
|
||||
LOAD_SET: templateData.messages.LOAD_SET,
|
||||
PROGRESS: templateData.messages.PROGRESS,
|
||||
SET_LOADED: templateData.messages.SET_LOADED,
|
||||
},
|
||||
false,
|
||||
false
|
||||
);
|
||||
|
||||
// Override the execute method to check for minifigures
|
||||
const originalExecute = setSocket.execute.bind(setSocket);
|
||||
let minifigSocket = null;
|
||||
|
||||
setSocket.execute = function() {
|
||||
const inputValue = document.getElementById('add-set').value.trim();
|
||||
|
||||
if (inputValue.startsWith('fig-') || inputValue.match(/^fig\d/i)) {
|
||||
// It's a minifigure - create minifig socket if needed and execute when ready
|
||||
if (!minifigSocket) {
|
||||
minifigSocket = new BrickMinifigureSocket(
|
||||
'add',
|
||||
templateData.path,
|
||||
templateData.namespace,
|
||||
{
|
||||
COMPLETE: templateData.messages.COMPLETE,
|
||||
FAIL: templateData.messages.FAIL,
|
||||
IMPORT_MINIFIGURE: templateData.messages.IMPORT_MINIFIGURE,
|
||||
LOAD_MINIFIGURE: templateData.messages.LOAD_MINIFIGURE,
|
||||
MINIFIGURE_LOADED: templateData.messages.MINIFIGURE_LOADED,
|
||||
PROGRESS: templateData.messages.PROGRESS,
|
||||
}
|
||||
);
|
||||
|
||||
// Wait for socket to connect before executing
|
||||
const checkConnection = setInterval(() => {
|
||||
if (minifigSocket.socket && minifigSocket.socket.connected) {
|
||||
clearInterval(checkConnection);
|
||||
minifigSocket.execute();
|
||||
}
|
||||
}, 100);
|
||||
} else {
|
||||
minifigSocket.execute();
|
||||
}
|
||||
} else {
|
||||
// It's a set - use original execute
|
||||
originalExecute();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
@@ -204,14 +204,20 @@ function setupButtonHandlers() {
|
||||
});
|
||||
}
|
||||
|
||||
// Reset button
|
||||
// Reset button. Opens Bootstrap modal instead of browser confirm()
|
||||
const resetBtn = document.getElementById('config-reset');
|
||||
if (resetBtn) {
|
||||
resetBtn.addEventListener('click', () => {
|
||||
console.log('Reset clicked');
|
||||
if (confirm('Are you sure you want to reset all settings to default values? This action cannot be undone.')) {
|
||||
resetToDefaults();
|
||||
}
|
||||
const modal = new bootstrap.Modal(document.getElementById('resetDefaultsModal'));
|
||||
modal.show();
|
||||
});
|
||||
}
|
||||
|
||||
// Confirm reset inside the modal
|
||||
const confirmResetBtn = document.getElementById('confirm-reset-defaults');
|
||||
if (confirmResetBtn) {
|
||||
confirmResetBtn.addEventListener('click', () => {
|
||||
resetToDefaults();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -285,18 +291,21 @@ function saveLiveConfiguration() {
|
||||
function resetToDefaults() {
|
||||
console.log('Resetting to defaults');
|
||||
|
||||
// Reset all form inputs
|
||||
document.querySelectorAll('.config-toggle, .config-number, .config-text').forEach(input => {
|
||||
if (input.type === 'checkbox') {
|
||||
input.checked = false;
|
||||
} else {
|
||||
input.value = '';
|
||||
}
|
||||
});
|
||||
Object.keys(window.DEFAULT_CONFIG).forEach(varName => {
|
||||
const defaultValue = window.DEFAULT_CONFIG[varName];
|
||||
|
||||
// Update badges
|
||||
Object.keys(window.CURRENT_CONFIG).forEach(varName => {
|
||||
updateConfigBadge(varName, null);
|
||||
const toggle = document.getElementById(varName);
|
||||
if (toggle && toggle.type === 'checkbox') {
|
||||
toggle.checked = defaultValue === true;
|
||||
}
|
||||
|
||||
document.querySelectorAll(`input[data-var="${varName}"]:not(.config-static)`).forEach(input => {
|
||||
if (input.type !== 'checkbox') {
|
||||
input.value = defaultValue !== null && defaultValue !== undefined ? defaultValue : '';
|
||||
}
|
||||
});
|
||||
|
||||
updateConfigBadge(varName, defaultValue);
|
||||
});
|
||||
|
||||
// Show status message
|
||||
|
||||
@@ -55,6 +55,10 @@ class BrickChanger {
|
||||
this.html_clear.addEventListener("click", ((changer) => (e) => {
|
||||
changer.html_element.value = "";
|
||||
changer.change();
|
||||
// change() only POSTs to the server; dispatch an input event so
|
||||
// client-side listeners (e.g. the parts table filter) react to
|
||||
// the programmatic clear the same way they do to typing.
|
||||
changer.html_element.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
})(this));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -110,6 +110,9 @@ class PartsBulkOperations {
|
||||
// Find all rows in this accordion
|
||||
const rows = accordionElement.querySelectorAll('tbody tr');
|
||||
rows.forEach(row => {
|
||||
// Skip rows hidden by an active header filter
|
||||
if (row.classList.contains('parts-filtered-out')) return;
|
||||
|
||||
// Find the quantity cell (usually 4th column)
|
||||
const quantityCell = row.cells[3]; // Index 3 for quantity column
|
||||
const missingInput = row.querySelector('input[id*="-missing-"]');
|
||||
@@ -134,6 +137,7 @@ class PartsBulkOperations {
|
||||
|
||||
const missingInputs = accordionElement.querySelectorAll('input[id*="-missing-"]');
|
||||
missingInputs.forEach(input => {
|
||||
if (input.closest('tr')?.classList.contains('parts-filtered-out')) return;
|
||||
if (input.value !== '') {
|
||||
input.value = '';
|
||||
// Trigger change event to activate BrickChanger
|
||||
@@ -148,6 +152,7 @@ class PartsBulkOperations {
|
||||
|
||||
const checkboxes = accordionElement.querySelectorAll('input[id*="-checked-"][type="checkbox"]');
|
||||
checkboxes.forEach(checkbox => {
|
||||
if (checkbox.closest('tr')?.classList.contains('parts-filtered-out')) return;
|
||||
if (!checkbox.checked) {
|
||||
checkbox.checked = true;
|
||||
// Trigger change event to activate BrickChanger
|
||||
@@ -162,6 +167,7 @@ class PartsBulkOperations {
|
||||
|
||||
const checkboxes = accordionElement.querySelectorAll('input[id*="-checked-"][type="checkbox"]');
|
||||
checkboxes.forEach(checkbox => {
|
||||
if (checkbox.closest('tr')?.classList.contains('parts-filtered-out')) return;
|
||||
if (checkbox.checked) {
|
||||
checkbox.checked = false;
|
||||
// Trigger change event to activate BrickChanger
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
// Per-column header filters for the accordion parts tables on the set details page.
|
||||
class PartsTableFilter {
|
||||
constructor(table) {
|
||||
this.table = table;
|
||||
this.body = table.querySelector('tbody');
|
||||
this.rows = Array.from(this.body ? this.body.querySelectorAll('tr') : []);
|
||||
|
||||
this.nameInput = table.querySelector('[data-parts-filter="name"]');
|
||||
this.colorSelect = table.querySelector('[data-parts-filter="color"]');
|
||||
this.missingSelect = table.querySelector('[data-parts-filter="missing"]');
|
||||
this.damagedSelect = table.querySelector('[data-parts-filter="damaged"]');
|
||||
this.checkedSelect = table.querySelector('[data-parts-filter="checked"]');
|
||||
this.clearButton = table.querySelector('[data-parts-filter-clear]');
|
||||
|
||||
this.setupListeners();
|
||||
this.apply();
|
||||
}
|
||||
|
||||
setupListeners() {
|
||||
const debouncedApply = this.debounce(() => this.apply(), 150);
|
||||
|
||||
// Name search is debounced so typing stays smooth on big tables.
|
||||
if (this.nameInput) {
|
||||
this.nameInput.addEventListener('input', debouncedApply);
|
||||
}
|
||||
|
||||
// Dropdowns re-filter immediately.
|
||||
[this.colorSelect, this.missingSelect, this.damagedSelect, this.checkedSelect].forEach(select => {
|
||||
if (select) {
|
||||
select.addEventListener('change', () => this.apply());
|
||||
}
|
||||
});
|
||||
|
||||
if (this.clearButton) {
|
||||
this.clearButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.clear();
|
||||
});
|
||||
}
|
||||
|
||||
// Live re-evaluation: editing a missing/damaged value or toggling a
|
||||
// checkbox in the body should re-run the active filter.
|
||||
if (this.body) {
|
||||
this.body.addEventListener('change', () => this.apply());
|
||||
this.body.addEventListener('input', debouncedApply);
|
||||
}
|
||||
}
|
||||
|
||||
cellText(row, col) {
|
||||
const cell = row.querySelector(`[data-col="${col}"]`);
|
||||
if (!cell) {
|
||||
return '';
|
||||
}
|
||||
const sort = cell.getAttribute('data-sort');
|
||||
return (sort !== null ? sort : cell.textContent).trim().toLowerCase();
|
||||
}
|
||||
|
||||
// Numeric value for missing/damaged, reading the live input when present.
|
||||
cellNumber(row, col) {
|
||||
const cell = row.querySelector(`[data-col="${col}"]`);
|
||||
if (!cell) {
|
||||
return 0;
|
||||
}
|
||||
const input = cell.querySelector('input');
|
||||
const raw = input ? input.value : (cell.getAttribute('data-sort') || cell.textContent);
|
||||
const value = parseInt(raw, 10);
|
||||
return Number.isNaN(value) ? 0 : value;
|
||||
}
|
||||
|
||||
rowChecked(row) {
|
||||
const cell = row.querySelector('[data-col="checked"]');
|
||||
if (!cell) {
|
||||
return false;
|
||||
}
|
||||
const checkbox = cell.querySelector('input[type="checkbox"]');
|
||||
if (checkbox) {
|
||||
return checkbox.checked;
|
||||
}
|
||||
return cell.getAttribute('data-sort') === '1';
|
||||
}
|
||||
|
||||
matchesNumberFilter(value, mode) {
|
||||
if (mode === 'with') {
|
||||
return value > 0;
|
||||
}
|
||||
if (mode === 'without') {
|
||||
return value === 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
apply() {
|
||||
const nameTerm = (this.nameInput ? this.nameInput.value : '').trim().toLowerCase();
|
||||
const colorValue = (this.colorSelect ? this.colorSelect.value : '').trim().toLowerCase();
|
||||
const missingMode = this.missingSelect ? this.missingSelect.value : '';
|
||||
const damagedMode = this.damagedSelect ? this.damagedSelect.value : '';
|
||||
const checkedValue = this.checkedSelect ? this.checkedSelect.value : '';
|
||||
|
||||
// Colors available among rows that pass every filter except color, so
|
||||
// the picker only offers colors still present in the current results.
|
||||
const availableColors = new Set();
|
||||
let visibleCount = 0;
|
||||
|
||||
this.rows.forEach(row => {
|
||||
const passNonColor =
|
||||
(!nameTerm || this.cellText(row, 'name').includes(nameTerm)) &&
|
||||
this.matchesNumberFilter(this.cellNumber(row, 'missing'), missingMode) &&
|
||||
this.matchesNumberFilter(this.cellNumber(row, 'damaged'), damagedMode) &&
|
||||
(checkedValue === '' ||
|
||||
(checkedValue === '1' ? this.rowChecked(row) : !this.rowChecked(row)));
|
||||
|
||||
if (passNonColor) {
|
||||
const colorCell = row.querySelector('[data-col="color"]');
|
||||
if (colorCell) {
|
||||
const name = colorCell.textContent.trim();
|
||||
if (name) {
|
||||
availableColors.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const passColor = !colorValue || this.cellText(row, 'color') === colorValue;
|
||||
const visible = passNonColor && passColor;
|
||||
row.classList.toggle('parts-filtered-out', !visible);
|
||||
if (visible) {
|
||||
visibleCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
this.updateColorOptions(availableColors);
|
||||
this.updateEmptyState(visibleCount);
|
||||
}
|
||||
|
||||
// Rebuild the color dropdown from the colors currently in the results,
|
||||
// keeping the user's current selection if it is still available.
|
||||
updateColorOptions(colors) {
|
||||
if (!this.colorSelect) {
|
||||
return;
|
||||
}
|
||||
const current = this.colorSelect.value;
|
||||
const sorted = Array.from(colors).sort((a, b) => a.localeCompare(b));
|
||||
const options = ['<option value="">All colors</option>']
|
||||
.concat(sorted.map(name => `<option value="${name.toLowerCase()}">${name}</option>`));
|
||||
this.colorSelect.innerHTML = options.join('');
|
||||
// Restore selection (the <option> values are lowercased to match).
|
||||
this.colorSelect.value = sorted.some(n => n.toLowerCase() === current) ? current : '';
|
||||
}
|
||||
|
||||
updateEmptyState(visibleCount) {
|
||||
if (!this.body) {
|
||||
return;
|
||||
}
|
||||
if (!this.emptyRow) {
|
||||
const columns = this.table.querySelectorAll('thead tr:first-child th').length || 1;
|
||||
this.emptyRow = document.createElement('tr');
|
||||
this.emptyRow.className = 'parts-filter-empty no-sort';
|
||||
this.emptyRow.innerHTML = `<td colspan="${columns}" class="text-center text-body-secondary py-3">No matching parts</td>`;
|
||||
this.body.appendChild(this.emptyRow);
|
||||
}
|
||||
this.emptyRow.style.display = visibleCount === 0 ? '' : 'none';
|
||||
}
|
||||
|
||||
clear() {
|
||||
if (this.nameInput) { this.nameInput.value = ''; }
|
||||
[this.colorSelect, this.missingSelect, this.damagedSelect, this.checkedSelect].forEach(select => {
|
||||
if (select) {
|
||||
select.value = '';
|
||||
}
|
||||
});
|
||||
this.apply();
|
||||
}
|
||||
|
||||
debounce(fn, wait) {
|
||||
let timer = null;
|
||||
return (...args) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn.apply(this, args), wait);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('table[data-parts-filterable="true"]').forEach(table => {
|
||||
new PartsTableFilter(table);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
})();
|
||||
@@ -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')) {
|
||||
|
||||
@@ -13,9 +13,12 @@ class BrickMinifigureSocket extends BrickSocket {
|
||||
this.html_input = document.getElementById(`${id}-set`);
|
||||
this.html_no_confim = document.getElementById(`${id}-no-confirm`);
|
||||
this.html_owners = document.getElementById(`${id}-owners`);
|
||||
this.html_purchase_date = document.getElementById(`${id}-purchase-date`);
|
||||
this.html_purchase_price = document.getElementById(`${id}-purchase-price`);
|
||||
this.html_purchase_location = document.getElementById(`${id}-purchase-location`);
|
||||
this.html_storage = document.getElementById(`${id}-storage`);
|
||||
this.html_tags = document.getElementById(`${id}-tags`);
|
||||
this.html_description = document.getElementById(`${id}-description`);
|
||||
|
||||
// Card elements
|
||||
this.html_card = document.getElementById(`${id}-card`);
|
||||
@@ -98,12 +101,28 @@ class BrickMinifigureSocket extends BrickSocket {
|
||||
});
|
||||
}
|
||||
|
||||
// Grab the purchase location
|
||||
// Grab the purchase info
|
||||
let purchase_date = null;
|
||||
if (this.html_purchase_date) {
|
||||
purchase_date = this.html_purchase_date.value || null;
|
||||
}
|
||||
|
||||
let purchase_price = null;
|
||||
if (this.html_purchase_price) {
|
||||
purchase_price = this.html_purchase_price.value || null;
|
||||
}
|
||||
|
||||
let purchase_location = null;
|
||||
if (this.html_purchase_location) {
|
||||
purchase_location = this.html_purchase_location.value;
|
||||
}
|
||||
|
||||
// Grab the description (notes)
|
||||
let description = '';
|
||||
if (this.html_description) {
|
||||
description = this.html_description.value || '';
|
||||
}
|
||||
|
||||
// Grab the storage
|
||||
let storage = null;
|
||||
if (this.html_storage) {
|
||||
@@ -129,9 +148,12 @@ class BrickMinifigureSocket extends BrickSocket {
|
||||
this.socket.emit(this.messages.IMPORT_MINIFIGURE, {
|
||||
figure: (figure !== undefined) ? figure : this.html_input.value,
|
||||
owners: owners,
|
||||
purchase_date: purchase_date,
|
||||
purchase_price: purchase_price,
|
||||
purchase_location: purchase_location,
|
||||
storage: storage,
|
||||
tags: tags,
|
||||
description: description,
|
||||
quantity: 1
|
||||
});
|
||||
} else {
|
||||
@@ -235,10 +257,22 @@ class BrickMinifigureSocket extends BrickSocket {
|
||||
this.html_owners.querySelectorAll('input').forEach(input => input.disabled = !enabled);
|
||||
}
|
||||
|
||||
if (this.html_purchase_date) {
|
||||
this.html_purchase_date.disabled = !enabled;
|
||||
}
|
||||
|
||||
if (this.html_purchase_price) {
|
||||
this.html_purchase_price.disabled = !enabled;
|
||||
}
|
||||
|
||||
if (this.html_purchase_location) {
|
||||
this.html_purchase_location.disabled = !enabled;
|
||||
}
|
||||
|
||||
if (this.html_description) {
|
||||
this.html_description.disabled = !enabled;
|
||||
}
|
||||
|
||||
if (this.html_storage) {
|
||||
this.html_storage.disabled = !enabled;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, {});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -331,3 +338,81 @@
|
||||
background-color: #e9ecef;
|
||||
border-color: #dee2e6;
|
||||
}
|
||||
|
||||
/* Accordion parts table header filters */
|
||||
.parts-filter-row > th {
|
||||
padding: 0.35rem 0.5rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.parts-filter-row .parts-filter-input,
|
||||
.parts-filter-row .parts-filter-select {
|
||||
min-width: 6rem;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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' %}
|
||||
|
||||
@@ -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">
|
||||
@@ -1019,6 +1082,32 @@
|
||||
</div>
|
||||
{{ accordion.footer() }}
|
||||
|
||||
<!-- Reset to Defaults Confirmation Modal -->
|
||||
<div class="modal fade" id="resetDefaultsModal" tabindex="-1" aria-labelledby="resetDefaultsModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-warning">
|
||||
<h5 class="modal-title" id="resetDefaultsModalLabel">
|
||||
<i class="ri-restart-line"></i> Reset to Defaults
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>This will reset all live settings to their default values.</p>
|
||||
<p class="text-muted small"><i class="ri-information-line"></i> Changes are not saved until you click <strong>Save All Changes</strong>.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="ri-close-line"></i> Cancel
|
||||
</button>
|
||||
<button type="button" class="btn btn-warning" id="confirm-reset-defaults" data-bs-dismiss="modal">
|
||||
<i class="ri-restart-line"></i> Reset to Defaults
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Initialize Configuration Data -->
|
||||
<script type="text/javascript">
|
||||
window.CURRENT_CONFIG = {
|
||||
|
||||
@@ -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() }}
|
||||
@@ -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() }}
|
||||
@@ -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 %}
|
||||
+12
-1
@@ -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,8 +190,12 @@
|
||||
{% endif %}
|
||||
{% if request.endpoint == 'set.list' %}
|
||||
<script src="{{ url_for('static', filename='scripts/sets.js') }}"></script>
|
||||
{% if g.login.is_authenticated() %}
|
||||
<script src="{{ url_for('static', filename='scripts/sets-bulk-edit.js') }}"></script>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if request.endpoint == 'set.details' %}
|
||||
<script src="{{ url_for('static', filename='scripts/parts-table-filter.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='scripts/parts-bulk-operations.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='scripts/set-details.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='scripts/quick-add-individual-part.js') }}"></script>
|
||||
@@ -216,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>
|
||||
|
||||
|
||||
@@ -35,12 +35,12 @@
|
||||
{% if management_read_only %}
|
||||
<div class="input-group">
|
||||
<span class="input-group-text px-1"><i class="ri-wallet-3-line me-1"></i><span class="ms-1 d-none d-md-inline"> Price</span></span>
|
||||
<input class="form-control form-control-sm flex-shrink-1 px-1" type="text" value="{{ item.fields.purchase_price or '' }}" disabled autocomplete="off">
|
||||
<input class="form-control form-control-sm flex-shrink-1 px-1" type="text" value="{{ (item.fields.purchase_price if item.fields.purchase_price is not none else '') }}" disabled autocomplete="off">
|
||||
<span class="input-group-text d-none d-md-inline px-1">{{ config['PURCHASE_CURRENCY'] }}</span>
|
||||
<span class="input-group-text ri-prohibited-line px-1"></span>
|
||||
</div>
|
||||
{% else %}
|
||||
{{ form.input('Price', item.fields.id, 'purchase_price', url_for('individual_minifigure.update_purchase_price', id=item.fields.id), item.fields.purchase_price or '', suffix=config['PURCHASE_CURRENCY'], icon='wallet-3-line') }}
|
||||
{{ form.input('Price', item.fields.id, 'purchase_price', url_for('individual_minifigure.update_purchase_price', id=item.fields.id), (item.fields.purchase_price if item.fields.purchase_price is not none else ''), suffix=config['PURCHASE_CURRENCY'], icon='wallet-3-line') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-12 flex-grow-1">
|
||||
|
||||
@@ -53,12 +53,12 @@
|
||||
{% if management_read_only %}
|
||||
<div class="input-group">
|
||||
<span class="input-group-text px-1"><i class="ri-wallet-3-line me-1"></i><span class="ms-1 d-none d-md-inline"> Price</span></span>
|
||||
<input class="form-control form-control-sm flex-shrink-1 px-1" type="text" value="{{ item.fields.purchase_price or '' }}" disabled autocomplete="off">
|
||||
<input class="form-control form-control-sm flex-shrink-1 px-1" type="text" value="{{ (item.fields.purchase_price if item.fields.purchase_price is not none else '') }}" disabled autocomplete="off">
|
||||
<span class="input-group-text d-none d-md-inline px-1">{{ config['PURCHASE_CURRENCY'] }}</span>
|
||||
<span class="input-group-text ri-prohibited-line px-1"></span>
|
||||
</div>
|
||||
{% else %}
|
||||
{{ form.input('Price', item.fields.id, 'purchase_price', url_for('individual_part.update_lot_purchase_price', lot_id=item.fields.id), item.fields.purchase_price or '', suffix=config['PURCHASE_CURRENCY'], icon='wallet-3-line') }}
|
||||
{{ form.input('Price', item.fields.id, 'purchase_price', url_for('individual_part.update_lot_purchase_price', lot_id=item.fields.id), (item.fields.purchase_price if item.fields.purchase_price is not none else ''), suffix=config['PURCHASE_CURRENCY'], icon='wallet-3-line') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-12 flex-grow-1">
|
||||
@@ -112,12 +112,12 @@
|
||||
{% if management_read_only %}
|
||||
<div class="input-group">
|
||||
<span class="input-group-text px-1"><i class="ri-wallet-3-line me-1"></i><span class="ms-1 d-none d-md-inline"> Price</span></span>
|
||||
<input class="form-control form-control-sm flex-shrink-1 px-1" type="text" value="{{ item.fields.purchase_price or '' }}" disabled autocomplete="off">
|
||||
<input class="form-control form-control-sm flex-shrink-1 px-1" type="text" value="{{ (item.fields.purchase_price if item.fields.purchase_price is not none else '') }}" disabled autocomplete="off">
|
||||
<span class="input-group-text d-none d-md-inline px-1">{{ config['PURCHASE_CURRENCY'] }}</span>
|
||||
<span class="input-group-text ri-prohibited-line px-1"></span>
|
||||
</div>
|
||||
{% else %}
|
||||
{{ form.input('Price', item.fields.id, 'purchase_price', item.url_for_purchase_price(), item.fields.purchase_price or '', suffix=config['PURCHASE_CURRENCY'], icon='wallet-3-line') }}
|
||||
{{ form.input('Price', item.fields.id, 'purchase_price', item.url_for_purchase_price(), (item.fields.purchase_price if item.fields.purchase_price is not none else ''), suffix=config['PURCHASE_CURRENCY'], icon='wallet-3-line') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-12 flex-grow-1">
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro table(table_collection, title, id, parent, target, quantity=none, icon=none, image=none, alt=none, details=none, read_only=none, hamburger_menu=none) %}
|
||||
{% macro table(table_collection, title, id, parent, target, quantity=none, icon=none, image=none, alt=none, details=none, read_only=none, hamburger_menu=none, filters=false) %}
|
||||
{% set size=table_collection | length %}
|
||||
{% if size %}
|
||||
{{ header(title, id, parent, quantity=quantity, icon=icon, class='p-0', image=image, alt=alt, hamburger_menu=hamburger_menu) }}
|
||||
@@ -57,7 +57,7 @@
|
||||
<a class="btn border bg-secondary-text" href="{{ details }}">{% if icon %}<i class="ri-{{ icon }}"></i>{% endif %} Details</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% with solo=true, all=false, accordion_id=id, read_only=read_only, hamburger_menu=hamburger_menu %}
|
||||
{% with solo=true, all=false, accordion_id=id, read_only=read_only, hamburger_menu=hamburger_menu, filters=filters %}
|
||||
{% include target %}
|
||||
{% endwith %}
|
||||
{{ footer() }}
|
||||
|
||||
+60
-12
@@ -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) %}
|
||||
@@ -118,12 +122,14 @@
|
||||
{% endmacro %}
|
||||
|
||||
{% macro purchase_price(price, solo=false, last=false) %}
|
||||
{% if last %}
|
||||
{% set tooltip=price %}
|
||||
{% else %}
|
||||
{% set text=price %}
|
||||
{% if price is not none %}
|
||||
{% if last %}
|
||||
{% set tooltip = price | string %}
|
||||
{% else %}
|
||||
{% set text = price | string %}
|
||||
{% endif %}
|
||||
{{ badge(check=true, solo=solo, last=last, color='light border', icon='wallet-3-line', text=text, tooltip=tooltip, collapsible='Price:') }}
|
||||
{% endif %}
|
||||
{{ badge(check=price, solo=solo, last=last, color='light border', icon='wallet-3-line', text=text, tooltip=tooltip, collapsible='Price:') }}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro set(set, solo=false, last=false, url=None, id=None) %}
|
||||
@@ -219,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 #}
|
||||
@@ -294,13 +332,13 @@
|
||||
{% if not last %}
|
||||
{% if item.purchase_price is defined and item.purchase_price is callable %}
|
||||
{{ purchase_price(item.purchase_price(), solo=solo, last=last) }}
|
||||
{% elif item.fields.purchase_price is defined and item.fields.purchase_price %}
|
||||
{% elif item.fields.purchase_price is defined and item.fields.purchase_price is not none %}
|
||||
{{ purchase_price(item.fields.purchase_price, solo=solo, last=last) }}
|
||||
{% 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' %}
|
||||
@@ -313,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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<label class="visually-hidden" for="{{ prefix }}-{{ id }}">{{ name }}</label>
|
||||
<div class="input-group">
|
||||
{% if icon %}<span class="input-group-text px-1"><i class="ri-{{ icon }} me-1"></i><span class="ms-1 d-none d-md-inline"> {{ name }}</span></span>{% endif %}
|
||||
<input class="form-control form-control-sm flex-shrink-1 px-1" type="text" id="{{ prefix }}-{{ id }}" value="{% if value %}{{ value }}{% endif %}"
|
||||
<input class="form-control form-control-sm flex-shrink-1 px-1" type="text" id="{{ prefix }}-{{ id }}" value="{% if value is not none and value != '' %}{{ value }}{% endif %}"
|
||||
{% if g.login.is_authenticated() %}
|
||||
data-changer-id="{{ id }}" data-changer-prefix="{{ prefix }}" data-changer-url="{{ url }}"
|
||||
{% if date %}data-changer-date="true"{% endif %}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{% macro header(image=true, color=false, parts=false, quantity=false, missing=true, missing_parts=false, damaged=true, damaged_parts=false, sets=false, minifigures=false, checked=false, hamburger_menu=false, accordion_id='') %}
|
||||
{% macro header(image=true, color=false, parts=false, quantity=false, missing=true, missing_parts=false, damaged=true, damaged_parts=false, sets=false, minifigures=false, checked=false, hamburger_menu=false, accordion_id='', filters=false) %}
|
||||
<thead>
|
||||
<tr>
|
||||
{% if image %}
|
||||
@@ -59,6 +59,72 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% if filters %}
|
||||
<tr class="parts-filter-row">
|
||||
{% if image %}
|
||||
<th class="no-sort"></th>
|
||||
{% endif %}
|
||||
<th class="no-sort">
|
||||
<input type="search" class="form-control form-control-sm parts-filter-input" data-parts-filter="name" placeholder="Search…" aria-label="Filter by name" autocomplete="off">
|
||||
</th>
|
||||
{% if color %}
|
||||
<th class="no-sort">
|
||||
<select class="form-select form-select-sm parts-filter-select" data-parts-filter="color" aria-label="Filter by color">
|
||||
<option value="">All colors</option>
|
||||
</select>
|
||||
</th>
|
||||
{% endif %}
|
||||
{% if parts %}
|
||||
<th class="no-sort"></th>
|
||||
{% endif %}
|
||||
{% if quantity %}
|
||||
<th class="no-sort"></th>
|
||||
{% endif %}
|
||||
{% if missing and not config['HIDE_TABLE_MISSING_PARTS'] %}
|
||||
<th class="no-sort">
|
||||
<select class="form-select form-select-sm parts-filter-select" data-parts-filter="missing" aria-label="Filter by missing">
|
||||
<option value="">All</option>
|
||||
<option value="with">With missing</option>
|
||||
<option value="without">Without missing</option>
|
||||
</select>
|
||||
</th>
|
||||
{% endif %}
|
||||
{% if damaged and not config['HIDE_TABLE_DAMAGED_PARTS'] %}
|
||||
<th class="no-sort">
|
||||
<select class="form-select form-select-sm parts-filter-select" data-parts-filter="damaged" aria-label="Filter by damaged">
|
||||
<option value="">All</option>
|
||||
<option value="with">With damaged</option>
|
||||
<option value="without">Without damaged</option>
|
||||
</select>
|
||||
</th>
|
||||
{% endif %}
|
||||
{% if sets %}
|
||||
<th class="no-sort"></th>
|
||||
{% endif %}
|
||||
{% if minifigures %}
|
||||
<th class="no-sort"></th>
|
||||
{% endif %}
|
||||
{% if checked and not config['HIDE_TABLE_CHECKED_PARTS'] %}
|
||||
<th class="no-sort">
|
||||
<select class="form-select form-select-sm parts-filter-select" data-parts-filter="checked" aria-label="Filter by checked">
|
||||
<option value="">All</option>
|
||||
<option value="1">Checked</option>
|
||||
<option value="0">Unchecked</option>
|
||||
</select>
|
||||
</th>
|
||||
{% endif %}
|
||||
{% if hamburger_menu and g.login.is_authenticated() %}
|
||||
{% set show_missing_menu = not config['HIDE_TABLE_MISSING_PARTS'] %}
|
||||
{% set show_checked_menu = not config['HIDE_TABLE_CHECKED_PARTS'] %}
|
||||
{% set show_quick_add = not config['DISABLE_QUICK_ADD_INDIVIDUAL_PARTS'] and not config['HIDE_INDIVIDUAL_PARTS'] %}
|
||||
{% if show_missing_menu or show_checked_menu or show_quick_add %}
|
||||
<th class="no-sort text-end">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary parts-filter-clear" data-parts-filter-clear title="Clear filters"><i class="ri-filter-off-line"></i></button>
|
||||
</th>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endif %}
|
||||
</thead>
|
||||
{% endmacro %}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
<div class="card mb-3 flex-fill {% if solo %}card-solo{% endif %}">
|
||||
<div class="card-header {% if solo %}d-flex justify-content-between align-items-center{% endif %}">
|
||||
<h5 class="mb-0">
|
||||
<h5 class="mb-0 {% if not solo %}fs-6 text-nowrap overflow-x-hidden text-truncate{% endif %}">
|
||||
{{ badge.identifier(item.fields.part, icon='shapes-line', solo=solo, header=true) }}
|
||||
{% if solo %}
|
||||
{{ badge.color(item, header=true) }}
|
||||
@@ -29,8 +29,15 @@
|
||||
{{ badge.color(item) }}
|
||||
{{ badge.print(item) }}
|
||||
{% endif %}
|
||||
{{ badge.total_sets(sets_using | length, solo=solo, last=last) }}
|
||||
{{ badge.total_minifigures(minifigures_using | length, solo=solo, last=last) }}
|
||||
{% if solo %}
|
||||
{{ badge.total_sets(sets_using | length, solo=solo, last=last) }}
|
||||
{{ badge.total_minifigures(minifigures_using | length, solo=solo, last=last) }}
|
||||
{% else %}
|
||||
{# Sub-cards (different color / same print) carry their own per-color counts
|
||||
from the query, not the parent's inherited sets_using/minifigures_using (#159). #}
|
||||
{{ badge.total_sets(item.fields.total_sets, solo=solo, last=last) }}
|
||||
{{ badge.total_minifigures(item.fields.total_minifigures, solo=solo, last=last) }}
|
||||
{% endif %}
|
||||
{{ badge.total_quantity(item.fields.total_quantity, solo=solo, last=last) }}
|
||||
{{ badge.total_spare(item.fields.total_spare, solo=solo, last=last) }}
|
||||
{{ badge.total_missing(item.fields.total_missing, solo=solo, last=last) }}
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
{% import 'macro/table.html' as table %}
|
||||
|
||||
<div class="table-responsive-sm">
|
||||
<table data-table="{% if all %}true{% endif %}" class="table table-striped align-middle {% if not all %}sortable mb-0{% endif %}" {% if all %}id="parts"{% endif %}>
|
||||
{{ table.header(color=true, quantity=not no_quantity, sets=all, minifigures=all, checked=not all and not read_only, hamburger_menu=not all and not read_only, accordion_id=accordion_id|default('')) }}
|
||||
<table data-table="{% if all %}true{% endif %}" class="table table-striped align-middle {% if not all %}sortable mb-0{% endif %}" {% if all %}id="parts"{% endif %} {% if filters %}data-parts-filterable="true"{% endif %}>
|
||||
{{ table.header(color=true, quantity=not no_quantity, sets=all, minifigures=all, checked=not all and not read_only, hamburger_menu=not all and not read_only, accordion_id=accordion_id|default(''), filters=filters|default(false)) }}
|
||||
<tbody>
|
||||
{% for item in table_collection %}
|
||||
<tr>
|
||||
{{ table.image(item.url_for_image(), caption=item.fields.name, alt=item.fields.part, accordion=solo) }}
|
||||
<td data-sort="{{ item.fields.name }}">
|
||||
<td data-sort="{{ item.fields.name }}" data-col="name">
|
||||
<a class="text-reset" href="{{ item.url() }}">{{ item.fields.name }}</a>
|
||||
{% if item.fields.spare %}<span class="badge rounded-pill text-bg-warning fw-normal"><i class="ri-loop-left-line"></i> Spare</span>{% endif %}
|
||||
{% if all %}
|
||||
@@ -16,7 +16,7 @@
|
||||
{{ table.bricklink(item) }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td data-sort="{{ item.fields.color_name }}">
|
||||
<td data-sort="{{ item.fields.color_name }}" data-col="color">
|
||||
{% if item.fields.color_rgb %}<span class="color-rgb color-rgb-table {% if item.fields.color == 9999 %}color-any{% endif %} align-middle border border-black" {% if item.fields.color != 9999 %}style="background-color: #{{ item.fields.color_rgb }};"{% endif %}></span>{% endif %}
|
||||
<span class="align-middle">{{ item.fields.color_name }}</span>
|
||||
</td>
|
||||
@@ -28,12 +28,12 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if not config['HIDE_TABLE_MISSING_PARTS'] %}
|
||||
<td data-sort="{{ item.fields.total_missing }}" class="table-td-input">
|
||||
<td data-sort="{{ item.fields.total_missing }}" data-col="missing" class="table-td-input">
|
||||
{{ form.input('Missing', item.fields.id, item.html_id('missing'), item.url_for_problem('missing'), item.fields.total_missing, all=all, read_only=read_only) }}
|
||||
</td>
|
||||
{% endif %}
|
||||
{% if not config['HIDE_TABLE_DAMAGED_PARTS'] %}
|
||||
<td data-sort="{{ item.fields.total_damaged }}" class="table-td-input">
|
||||
<td data-sort="{{ item.fields.total_damaged }}" data-col="damaged" class="table-td-input">
|
||||
{{ form.input('Damaged', item.fields.id, item.html_id('damaged'), item.url_for_problem('damaged'), item.fields.total_damaged, all=all, read_only=read_only) }}
|
||||
</td>
|
||||
{% endif %}
|
||||
@@ -42,7 +42,7 @@
|
||||
<td>{{ item.fields.total_minifigures }}</td>
|
||||
{% else %}
|
||||
{% if not config['HIDE_TABLE_CHECKED_PARTS'] and not read_only %}
|
||||
<td data-sort="{{ item.fields.checked | default(0) | int }}" class="table-td-input">
|
||||
<td data-sort="{{ item.fields.checked | default(0) | int }}" data-col="checked" class="table-td-input">
|
||||
<center>{{ form.checkbox('', item.fields.id, item.html_id('checked'), item.url_for_checked(), item.fields.checked | default(false), parent='part', delete=read_only) }}</center>
|
||||
</td>
|
||||
{% endif %}
|
||||
|
||||
@@ -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>
|
||||
+18
-3
@@ -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']) %}
|
||||
@@ -130,7 +145,7 @@
|
||||
</div>
|
||||
{{ accordion.footer() }}
|
||||
{% endif %}
|
||||
{{ accordion.table(item.parts(), 'Parts', 'parts-inventory', 'set-details', 'part/table.html', icon='shapes-line', hamburger_menu=g.login.is_authenticated())}}
|
||||
{{ accordion.table(item.parts(), 'Parts', 'parts-inventory', 'set-details', 'part/table.html', icon='shapes-line', hamburger_menu=g.login.is_authenticated(), filters=true)}}
|
||||
{% for minifigure in item.minifigures() %}
|
||||
{{ accordion.table(minifigure.parts(), minifigure.fields.name, minifigure.fields.figure, 'set-details', 'part/table.html', quantity=minifigure.fields.quantity, icon='group-line', image=minifigure.url_for_image(), alt=minifigure.fields.figure, details=minifigure.url(), hamburger_menu=g.login.is_authenticated())}}
|
||||
{% endfor %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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' %}
|
||||
|
||||
+105
-12
@@ -99,7 +99,11 @@
|
||||
<div class="col-6">
|
||||
<div class="text-center">
|
||||
<div class="text-dark small">Range</div>
|
||||
{% if financial_summary.minimum_cost is not none and financial_summary.maximum_cost is not none %}
|
||||
<div class="fw-bold">{{ config['PURCHASE_CURRENCY'] }}{{ "%.2f"|format(financial_summary.minimum_cost) }} - {{ config['PURCHASE_CURRENCY'] }}{{ "%.2f"|format(financial_summary.maximum_cost) }}</div>
|
||||
{% else %}
|
||||
<div class="fw-bold">-</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -151,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">
|
||||
@@ -245,7 +338,7 @@
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if theme.total_spent %}
|
||||
<small class="text-dark">{{ config['PURCHASE_CURRENCY'] }}{{ "%.0f"|format(theme.total_spent) }}</small>
|
||||
<small class="text-dark">{{ config['PURCHASE_CURRENCY'] }}{{ "%.2f"|format(theme.total_spent) }}</small>
|
||||
{% else %}
|
||||
<small class="text-dark">-</small>
|
||||
{% endif %}
|
||||
@@ -309,8 +402,8 @@
|
||||
<small class="text-dark">{{ storage.total_minifigures }}{% if storage.individual_minifig_count %} ({{ storage.individual_minifig_count }} individual){% endif %}</small>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if storage.total_value %}
|
||||
<small class="text-dark">{{ config['PURCHASE_CURRENCY'] }}{{ "%.0f"|format(storage.total_value) }}</small>
|
||||
{% if storage.total_value is not none %}
|
||||
<small class="text-dark">{{ config['PURCHASE_CURRENCY'] }}{{ "%.2f"|format(storage.total_value) }}</small>
|
||||
{% else %}
|
||||
<small class="text-dark">-</small>
|
||||
{% endif %}
|
||||
@@ -380,15 +473,15 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if location.avg_price %}
|
||||
{% if location.avg_price is not none %}
|
||||
<small class="text-dark">{{ config['PURCHASE_CURRENCY'] }}{{ "%.2f"|format(location.avg_price) }}</small>
|
||||
{% else %}
|
||||
<small class="text-dark">-</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if location.min_price and location.max_price %}
|
||||
<small class="text-dark">{{ config['PURCHASE_CURRENCY'] }}{{ "%.0f"|format(location.min_price) }}-{{ "%.0f"|format(location.max_price) }}</small>
|
||||
{% if location.min_price is not none and location.max_price is not none %}
|
||||
<small class="text-dark">{{ config['PURCHASE_CURRENCY'] }}{{ "%.2f"|format(location.min_price) }}-{{ "%.2f"|format(location.max_price) }}</small>
|
||||
{% else %}
|
||||
<small class="text-dark">-</small>
|
||||
{% endif %}
|
||||
@@ -460,14 +553,14 @@
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if year.total_spent %}
|
||||
<small class="text-dark">{{ config['PURCHASE_CURRENCY'] }}{{ "%.0f"|format(year.total_spent) }}</small>
|
||||
<small class="text-dark">{{ config['PURCHASE_CURRENCY'] }}{{ "%.2f"|format(year.total_spent) }}</small>
|
||||
{% else %}
|
||||
<small class="text-dark">-</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if year.avg_price_per_set %}
|
||||
<small class="text-dark">{{ config['PURCHASE_CURRENCY'] }}{{ "%.0f"|format(year.avg_price_per_set) }}</small>
|
||||
{% if year.avg_price_per_set is not none %}
|
||||
<small class="text-dark">{{ config['PURCHASE_CURRENCY'] }}{{ "%.2f"|format(year.avg_price_per_set) }}</small>
|
||||
{% else %}
|
||||
<small class="text-dark">-</small>
|
||||
{% endif %}
|
||||
@@ -542,8 +635,8 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if year.avg_price_per_set %}
|
||||
<small class="text-dark">{{ config['PURCHASE_CURRENCY'] }}{{ "%.0f"|format(year.avg_price_per_set) }}</small>
|
||||
{% if year.avg_price_per_set is not none %}
|
||||
<small class="text-dark">{{ config['PURCHASE_CURRENCY'] }}{{ "%.2f"|format(year.avg_price_per_set) }}</small>
|
||||
{% else %}
|
||||
<small class="text-dark">-</small>
|
||||
{% endif %}
|
||||
@@ -560,7 +653,7 @@
|
||||
<small class="text-dark">Peak Year</small><br>
|
||||
{% if year_summary.peak_spending_year %}
|
||||
<strong>{{ year_summary.peak_spending_year }}</strong>
|
||||
<small class="text-success d-block">{{ config['PURCHASE_CURRENCY'] }}{{ "%.0f"|format(year_summary.max_spending) }}</small>
|
||||
<small class="text-success d-block">{{ config['PURCHASE_CURRENCY'] }}{{ "%.2f"|format(year_summary.max_spending) }}</small>
|
||||
{% else %}
|
||||
<small class="text-dark">N/A</small>
|
||||
{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user