Compare commits

..

59 Commits

Author SHA1 Message Date
FrederikBaerentsen 64dec0cc95 feat(sets): sort sidecar bags chronologically 2026-07-12 16:47:09 +02:00
FrederikBaerentsen 73e1292566 docs: updated changelog for recent features 2026-07-12 12:41:52 +02:00
FrederikBaerentsen c91dd1a0d5 feat(statistics): add total part lots to the collection overview 2026-07-12 11:44:18 +02:00
FrederikBaerentsen 41ffe5929e fix(config): respect explicit True defaults for boolean settings 2026-07-12 11:02:03 +02:00
FrederikBaerentsen a668f1c8f3 fix(sets): parts/year range and None filters work with server-side pagination (#141) 2026-07-12 10:47:38 +02:00
FrederikBaerentsen 494aad196f feat(sets): fixed spare parts showing after using HIDE_SPARE_PARTS 2026-07-12 08:14:21 +02:00
FrederikBaerentsen c5f8e7d63b feat(sets): refresh market value in the background after page load 2026-07-11 01:37:18 +02:00
FrederikBaerentsen 4969d48181 feat(parts): replace per-row quick-add dropdown with a plain button 2026-07-11 00:02:29 +02:00
FrederikBaerentsen 1c89ba742b feat(sets): lazy-load bag tables on first accordion open 2026-07-10 23:18:44 +02:00
FrederikBaerentsen f9c08d4fbe app: gzip large text responses 2026-07-10 20:23:07 +02:00
FrederikBaerentsen 75bf7b36ff feat(sets): per-bag inventory with progress tracking and bag-aware audit 2026-07-10 00:41:52 +02:00
FrederikBaerentsen 675de6b1bf feat(sets): rework bulk edit into a grouped modal with description (#146) 2026-07-09 19:57:13 +02:00
FrederikBaerentsen e5021c6816 update dockerignore 2026-07-09 17:48:36 +02:00
FrederikBaerentsen d56e25ce88 fix(sidecar): make additional-images flag live-toggleable from admin 2026-07-08 20:09:12 +02:00
FrederikBaerentsen 7b77888812 feat(sidecar): show market value min/max on hover (#160) 2026-07-08 02:24:37 +02:00
FrederikBaerentsen 23564953da fix(parts): polish audit mode for mobile and small screens 2026-07-08 01:43:18 +02:00
FrederikBaerentsen 63aedfa2eb feat(parts): add keyboard-driven audit mode for parts tables 2026-07-07 23:36:51 +02:00
FrederikBaerentsen f9a68235c7 fix(parts): stop sortable crash on accordion parts tables 2026-07-06 22:17:33 +02:00
FrederikBaerentsen 655ddfd609 feat(sidecar): per-element BrickData set-detail toggles + price basis 2026-07-06 21:23:47 +02:00
FrederikBaerentsen 67c9a73ebf docs(changelog): document 1.5 enhancements and fixes 2026-07-05 20:34:12 +02:00
FrederikBaerentsen 7350205c7a fix(sidecar): proxy companion-service images through BrickTracker 2026-07-04 21:47:38 +02:00
FrederikBaerentsen fced5374f5 feat(wish): show retirement dates for already-retired sets (#37) 2026-07-03 21:23:51 +02:00
FrederikBaerentsen 75c1906e63 fix(refresh): stop unmappable bricklink parts from re-flagging sets (#163) 2026-07-02 22:36:09 +02:00
FrederikBaerentsen 0f73567db6 fix(part): match grid card title size to sets and minifigures (#167) 2026-07-02 21:52:17 +02:00
FrederikBaerentsen 621d673785 fix(instructions): link sets with alphanumeric ids (#166) 2026-07-02 21:08:43 +02:00
FrederikBaerentsen e9768be79f docs(changelog): add 1.5 section, name BrickData service 2026-07-01 23:14:26 +02:00
FrederikBaerentsen 9a956c54be fix(part): honor HIDE_SPARE_PARTS on set detail and minifigure parts 2026-07-01 22:43:17 +02:00
FrederikBaerentsen 3efae530d6 refactor(sidecar): pull live from brickdata, drop local cache 2026-07-01 22:20:00 +02:00
FrederikBaerentsen 3beca33789 feat(sidecar): render scraped Brickset fields on set detail 2026-06-30 21:50:00 +02:00
FrederikBaerentsen 14c49d3950 feat(set): mass-edit metadata across selected sets (#146) 2026-06-28 21:43:43 +02:00
FrederikBaerentsen c93a14f70b feat(set): admin-defined typed custom fields per set (#146) 2026-06-26 22:18:09 +02:00
FrederikBaerentsen 32a70c7358 feat(set): browse Brickset additional images and use any as cover (#146) 2026-06-24 00:11:43 +02:00
FrederikBaerentsen 52211a7900 fix(add): Fix #162, show purchase date picker on bulk add page. 2026-06-22 21:53:10 +02:00
FrederikBaerentsen 4d09be06ae fix(sets): fixed formatting 2026-06-22 21:39:47 +02:00
FrederikBaerentsen 3a7772db0b feat(set): add parts-range, year-range and None grid filters (#141) 2026-06-22 21:11:05 +02:00
FrederikBaerentsen a43ec39d8c feat(statistics): paid/retail/market comparison and instructions stats 2026-06-21 16:22:38 +02:00
FrederikBaerentsen a3122c52d2 feat(set): box art cover override, sidecar details and badges 2026-06-19 20:48:23 +02:00
FrederikBaerentsen c64541ba43 feat(sidecar): add brickset-sidecar client, cache table and migration 2026-06-18 22:07:51 +02:00
FrederikBaerentsen e7a83817d3 add(config): add sidecar settings, admin UI and live/restart split 2026-06-16 21:34:09 +02:00
FrederikBaerentsen 811a348058 fix(part): compute per-color set/minifig counts on sub-cards (#159) 2026-06-15 19:12:46 +02:00
FrederikBaerentsen 4daab79c3b fix(statistics): divide price coverage by all priced item types and clamp to 100% (#156) 2026-06-15 17:41:01 +02:00
FrederikBaerentsen 2cb3e96e21 Merge pull request 'release/1.4.1' (#161) from release/1.4.1 into master
Reviewed-on: #161
2026-06-14 10:04:00 +02:00
FrederikBaerentsen e94b8d2de5 fix(set): re-apply filters when clearing a field via the eraser button 2026-06-14 09:30:36 +02:00
FrederikBaerentsen 4074aa7c4c feat(set): add per-column header filters to the parts table 2026-06-14 08:35:58 +02:00
FrederikBaerentsen f262411dc4 fix(purchase): treat 0 as valid price throughout and fix minifig import data types 2026-04-30 20:12:14 +02:00
FrederikBaerentsen 711833e5de fix(statistics): show price values with 2 decimal places instead of rounding to whole numbers (see #146) 2026-04-18 14:51:37 +02:00
FrederikBaerentsen 12dead4ded fix(admin): changed browser popup to bootstrap modal 2026-04-18 14:38:48 +02:00
FrederikBaerentsen 0e3ba26010 Merge branch 'bugfix/issue-151' into release/1.4.1 2026-04-18 13:56:53 +02:00
FrederikBaerentsen 6177187103 Merge branch 'bugfix/issue-150' into release/1.4.1 2026-04-18 13:55:49 +02:00
FrederikBaerentsen 5c0daed160 Merge branch 'bugfix/issue-149c' into release/1.4.1 2026-04-18 13:53:55 +02:00
FrederikBaerentsen 66bbed3597 Merge branch 'bugfix/issue-149b' into release/1.4.1 2026-04-18 13:51:13 +02:00
FrederikBaerentsen d3a014765b fix(add): purchase date, price, and notes are now saved when adding sets and minifigures 2026-04-18 11:31:09 +02:00
FrederikBaerentsen 665441c5ac Updated changelog 2026-04-18 10:27:35 +02:00
FrederikBaerentsen d751a3d0af fix(add): replace two-socket approach with single BrickSetSocket for minifigure error where duplicate sets were added (see #150) 2026-04-18 10:16:27 +02:00
FrederikBaerentsen 1b077e86b1 fix(import): handle empty image URLs from Rebrickable for minifigures and parts (see #149) 2026-04-17 19:14:52 +02:00
FrederikBaerentsen ef6bdc823d fix(admin): cast BK_INSTRUCTIONS_ALLOWED_EXTENSIONS as a list not a string (see #149) 2026-04-17 18:07:34 +02:00
FrederikBaerentsen 0567d9817f fix(admin): restore actual defaults on "Reset to Defaults" instead of blanking fields. (see #149) 2026-04-17 17:23:47 +02:00
FrederikBaerentsen fa9e0c3765 Updated changelog 2026-04-17 14:44:15 +02:00
FrederikBaerentsen 69318e7b0b fix(wishes): delete wish owners before wish to avoid FK constraint error 2026-04-17 14:42:40 +02:00
107 changed files with 5438 additions and 242 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ docs/
LICENSE
*.md
*.sample
.code-workspace
*.code-workspace
# Temporary
*.csv
+23
View File
@@ -511,3 +511,26 @@
# Use the same badge keys as BK_BADGE_ORDER_GRID
# Default: theme,tag,year,parts,instance_count,total_minifigures,total_missing,total_damaged,owner,storage,purchase_date,purchase_location,purchase_price,instructions,rebrickable,bricklink
# BK_BADGE_ORDER_DETAIL=theme,tag,year,parts,owner,storage,purchase_date,rebrickable,bricklink
# Optional: Show/hide individual BrickData (sidecar) elements on the set detail page
# All default to true (shown). Badges are controlled separately via BK_BADGE_ORDER_DETAIL.
# These take effect live (no restart). Set to false to hide.
# BK_SIDECAR_SHOW_DESIGNER: the set designer line
# BK_SIDECAR_SHOW_DESCRIPTION: the "About this set" description
# BK_SIDECAR_SHOW_NOTES: the Brickset notes
# BK_SIDECAR_SHOW_PRICE_PAID: price comparison "Paid" row (and "Change vs paid")
# BK_SIDECAR_SHOW_PRICE_MSRP: price comparison retail/MSRP rows (and "Saved vs retail")
# BK_SIDECAR_SHOW_PRICE_MARKET: price comparison "Worth now" market value rows
# Default: true
# BK_SIDECAR_SHOW_DESIGNER=false
# BK_SIDECAR_SHOW_DESCRIPTION=false
# BK_SIDECAR_SHOW_NOTES=false
# BK_SIDECAR_SHOW_PRICE_PAID=false
# BK_SIDECAR_SHOW_PRICE_MSRP=false
# BK_SIDECAR_SHOW_PRICE_MARKET=false
# Optional: which BrickLink market value the set-detail "Change vs paid" compares
# against: used or new (falls back to the other when one is missing). The statistics
# page shows both regardless of this setting. Live-changeable.
# Default: used
# BK_SIDECAR_PRICE_BASIS=new
+69
View File
@@ -1,5 +1,74 @@
# Changelog
## 1.5
### Enhancements
- **BrickData integration**: New BrickData companion service that enriches set detail pages live with data from Brickset and BrickLink (description, extra metadata fields, pricing and badges). Data is pulled on demand rather than cached locally, so it always reflects the current source
- Added BrickData settings with an admin UI and a live/restart split so most options apply without a container restart
- **Box art cover override**: Browse a set's additional images from BrickData and pick any of them (including official box art) as the cover image
- **Admin-defined custom fields per set** (#146): Admins can define typed custom fields that then appear on each set, with a mass-edit screen to apply metadata across multiple selected sets at once.
- **Retail (MSRP) price and savings** (#144): Set detail shows the lego.com retail price for a chosen region (US/UK/CA/DE) as a badge and in the price comparison, plus a "saved vs retail" figure (MSRP minus what you paid) per set and summed on the Statistics page
- **BrickLink market value** (#160): Set detail shows the current BrickLink market value (new/used average, min/max on hover) pulled on demand from BrickData, with a collection-value estimate on the Statistics page. When `BK_SIDECAR_AUTO_FETCH_PRICE` is on, the market value now refreshes in the background after the page has already rendered, instead of holding up the initial page load while BrickLink is queried
- **Instructions badge and stats** (#154): Sets with downloaded instruction files now show an "instructions" badge (respects `BK_HIDE_SET_INSTRUCTIONS` and the badge order), and the Statistics page counts how many sets have instructions and the total number of instruction files
- **Statistics: price and instructions stats**: Added a paid / retail / market price comparison and instruction-related statistics
- **Retirement dates for already-retired sets** (#37): The wishlist now falls back to BrickData for sets that the upcoming-retirements CSV does not cover, so long-retired sets show a retirement date instead of "Not found". Dates are colour-coded (yellow for upcoming, red for already retired), and the set detail "Retired" badge shows the full exit date when available
- **New grid filters** (#141): Added parts-range, year-range and "None" filters to the set grid
- **Audit mode for parts tables**: A new "Start audit" entry in the parts table header menu opens a focused, keyboard-driven popup that walks the visible parts one at a time (respecting the active filter and sort), showing a large image, the part name, color and needed quantity. Arrow keys move between parts, Enter/Space save and advance, and a Missing/Found toggle records the missing count (Found mode works out the missing count from what you found). Optimized for phones with a numeric keypad and the image kept in view
- **Bags**: for sets where BrickData has a per-bag part inventory, the set detail page gets a new Bags section between the parts and minifigures tables, one collapsible table per numbered bag (plus an unnumbered "Extra" bag for loose items), each with its own checkbox and missing field so progress can be tracked bag by bag. Bag tables lazy-load on first opening the Bags section to keep the initial page load light
- Missing counts entered on a bag row sum up into the main Parts table's missing count for that part (a part split across bags adds up correctly); bag checkboxes are separate from the main parts table's checkboxes
- Bag tables support the same burger-menu bulk actions and Start audit as the main parts table, run against just that bag's contents, letting you audit a set bag by bag
- The main parts table's audit popup also shows which bag(s) a part is in when bag data is available
- **Quick-add to individual parts**: replaced the per-row dropdown (button + one-item menu) with a single button that adds the part directly, one click instead of two
### Bug Fixes
- **Fixed instructions never linking to sets with alphanumeric ids** (#166): Instructions for "Pick a Brick" / promotional builds whose set id begins with letters (e.g. `EG00029-1`) were orphaned and never associated with the set; the matching now handles alphanumeric set ids
- **Fixed font size on the landing page** (#167): Item titles in the "Latest added parts" section on the homepage now match the smaller size used by the sets and minifigures sections
- **Fixed sets with unmappable BrickLink parts never clearing the "need refresh" list** (#163): Sets containing parts that Rebrickable has no BrickLink mapping for (e.g. length-specific pneumatic hoses) kept reappearing on the Refresh admin page right after being refreshed; these unmappable parts no longer flag a set as needing a refresh
- **Fixed `BK_HIDE_SPARE_PARTS` looking active when it wasn't saved yet** (#169): The admin configuration page badge showed "Changed" for a toggle the moment you clicked it, identical to how it looks once actually saved, so it was easy to believe the setting was applied when "Save All Changes" was never clicked. The badge now says "Unsaved" until you save
- **Fixed parts/year range and "None" set filters not working with server-side pagination on** (#141): The range inputs had no server-side query support and no event listeners in pagination mode (only the dropdown filters did), and the "None" option for storage/purchase location matched zero rows instead of sets with nothing set. All of it now works with pagination enabled, matching the client-side behavior
- **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
+42 -1
View File
@@ -1,3 +1,4 @@
import gzip
import logging
import os
import sys
@@ -5,17 +6,19 @@ import time
from pathlib import Path
from zoneinfo import ZoneInfo
from flask import current_app, Flask, g
from flask import current_app, Flask, g, request
from werkzeug.middleware.proxy_fix import ProxyFix
from bricktracker.configuration_list import BrickConfigurationList
from bricktracker.login import LoginManager
from bricktracker.navbar import Navbar
from bricktracker.sidecar import BrickSidecar
from bricktracker.sql import close
from bricktracker.template_filters import replace_query_filter
from bricktracker.version import __version__
from bricktracker.views.add import add_page
from bricktracker.views.admin.admin import admin_page
from bricktracker.views.admin.custom_field import admin_custom_field_page
from bricktracker.views.admin.database import admin_database_page
from bricktracker.views.admin.export import admin_export_page
from bricktracker.views.admin.image import admin_image_page
@@ -39,6 +42,7 @@ from bricktracker.views.minifigure import minifigure_page
from bricktracker.views.part import part_page
from bricktracker.views.purchase_location import purchase_location_page
from bricktracker.views.set import set_page
from bricktracker.views.sidecar import sidecar_page
from bricktracker.views.statistics import statistics_page
from bricktracker.views.storage import storage_page
from bricktracker.views.wish import wish_page
@@ -134,6 +138,31 @@ def setup_app(app: Flask) -> None:
x_prefix=1,
)
# Gzip large text responses (a big set page is ~7 MB of HTML, ~0.3 MB
# gzipped). gunicorn does not compress and not every deployment sits
# behind a compressing reverse proxy. Stdlib gzip covers this without
# pulling in flask-compress.
@app.after_request
def compress_response(response): # type: ignore[no-untyped-def]
if (
response.status_code == 200
and not response.direct_passthrough
and response.content_length is not None
and response.content_length > 4096
and 'gzip' not in (response.headers.get('Content-Encoding') or '')
and response.mimetype in (
'text/html', 'text/css', 'text/plain',
'application/json', 'text/javascript',
'application/javascript', 'image/svg+xml',
)
and 'gzip' in request.accept_encodings
):
response.set_data(gzip.compress(response.get_data(), 5))
response.headers['Content-Encoding'] = 'gzip'
response.headers.add('Vary', 'Accept-Encoding')
return response
# Register errors
app.register_error_handler(404, error_404)
@@ -149,12 +178,14 @@ def setup_app(app: Flask) -> None:
app.register_blueprint(part_page)
app.register_blueprint(purchase_location_page)
app.register_blueprint(set_page)
app.register_blueprint(sidecar_page)
app.register_blueprint(statistics_page)
app.register_blueprint(storage_page)
app.register_blueprint(wish_page)
# Register admin routes
app.register_blueprint(admin_page)
app.register_blueprint(admin_custom_field_page)
app.register_blueprint(admin_database_page)
app.register_blueprint(admin_export_page)
app.register_blueprint(admin_image_page)
@@ -195,6 +226,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:
+80
View File
@@ -0,0 +1,80 @@
from typing import Any
from .exceptions import ErrorException
from .sql import BrickSQL
# Per-bag part state (checked/missing) for sets with a sidecar bag inventory.
# The bag composition itself is never stored; only the user's progress is
# (keyed by set record id, bag number and the part identity tuple).
# Plain functions on purpose: a BrickRecord model is overkill for a
# two-column state table.
# Update one state field ('checked' or 'missing') from a changer.js payload.
# Returns the stored value.
def update_state(
set_id: str,
bag: str,
part: str,
color: int,
spare: int,
state: str,
json: Any | None,
/,
) -> Any:
value = json.get('value', '') if json else ''
if state == 'checked':
value = bool(value)
elif state == 'missing':
# Same rules as BrickPart.update_problem: positive integer, '' -> 0
try:
if value == '':
value = 0
value = int(value)
if value < 0:
value = 0
except Exception:
raise ErrorException('"{value}" is not a valid integer'.format(
value=value
))
else:
raise ErrorException('"{state}" is not a valid bag part state'.format(
state=state
))
BrickSQL().execute_and_commit(
'bag_part/update/{state}'.format(state=state),
parameters={
'id': set_id,
'bag': bag,
'part': part,
'color': color,
'spare': spare,
state: value,
},
)
return value
# All stored state for one set:
# {(bag, part, color, spare): {'checked': bool, 'missing': int}}
def list_state(
set_id: str,
/,
) -> dict[tuple[str, str, int, int], dict[str, Any]]:
rows = BrickSQL().fetchall(
'bag_part/list',
parameters={'id': set_id},
)
return {
(row['bag'], row['part'], row['color'], row['spare']): {
'checked': bool(row['checked']),
'missing': row['missing'],
}
for row in rows
}
+29 -2
View File
@@ -105,8 +105,35 @@ CONFIG: Final[list[dict[str, Any]]] = [
{'n': 'STATISTICS_SHOW_CHARTS', 'd': True, 'c': bool},
{'n': 'STATISTICS_DEFAULT_EXPANDED', 'd': True, 'c': bool},
{'n': 'DARK_MODE', 'c': bool},
{'n': 'BADGE_ORDER_GRID', 'd': ['theme', 'year', 'parts', 'total_minifigures', 'owner'], 'c': list},
{'n': 'BADGE_ORDER_DETAIL', 'd': ['theme', 'tag', 'year', 'parts', 'instance_count', 'total_minifigures', 'total_missing', 'total_damaged', 'owner', 'storage', 'purchase_date', 'purchase_location', 'purchase_price', 'instructions', 'rebrickable', 'bricklink'], 'c': list},
{'n': 'BADGE_ORDER_GRID', 'd': ['theme', 'year', 'parts', 'total_minifigures', 'instructions', 'owner'], 'c': list},
{'n': 'BADGE_ORDER_DETAIL', 'd': ['theme', 'tag', 'year', 'retired', 'parts', 'instance_count', 'total_minifigures', 'total_missing', 'total_damaged', 'owner', 'storage', 'purchase_date', 'purchase_location', 'purchase_price', 'msrp', 'value', 'dimensions', 'weight', 'instructions', 'rebrickable', 'bricklink'], 'c': list},
{'n': 'SHOW_NOTES_GRID', 'd': False, 'c': bool},
{'n': 'SHOW_NOTES_DETAIL', 'd': True, 'c': bool},
# Sidecar (brickset-sidecar / BrickData) integration. Opt-in: an empty
# SIDECAR_URL means the whole feature is off and everything degrades.
{'n': 'SIDECAR_URL', 'd': ''},
{'n': 'SIDECAR_TIMEOUT', 'd': 5, 'c': int},
{'n': 'SIDECAR_DEFAULT_COVER', 'd': 'rebrickable'},
# Opt-in browsing of every BrickLink image the sidecar exposes for a set
# (a carousel on the detail page + extra cover sources). Off keeps the
# plain single cover behaviour.
{'n': 'SIDECAR_ADDITIONAL_IMAGES', 'd': False, 'c': bool},
{'n': 'SIDECAR_RETAIL_REGION', 'd': 'US'},
{'n': 'SIDECAR_AUTO_FETCH_PRICE', 'c': bool},
# Currency to request BrickLink market values in (e.g. EUR). Empty lets the
# sidecar use its default. Sent as a query parameter only when set; sidecars
# that do not support it simply ignore it.
{'n': 'SIDECAR_CURRENCY', 'd': ''},
# Per-element show/hide toggles for the BrickData enrichment on the set
# detail page. All default on so behaviour is unchanged; badges are handled
# separately via BADGE_ORDER_DETAIL.
{'n': 'SIDECAR_SHOW_DESIGNER', 'd': True, 'c': bool},
{'n': 'SIDECAR_SHOW_DESCRIPTION', 'd': True, 'c': bool},
{'n': 'SIDECAR_SHOW_NOTES', 'd': True, 'c': bool},
{'n': 'SIDECAR_SHOW_PRICE_PAID', 'd': True, 'c': bool},
{'n': 'SIDECAR_SHOW_PRICE_MSRP', 'd': True, 'c': bool},
{'n': 'SIDECAR_SHOW_PRICE_MARKET', 'd': True, 'c': bool},
# Which BrickLink market value the set-detail "Change vs paid" compares
# against: 'used' or 'new'. Statistics shows both regardless.
{'n': 'SIDECAR_PRICE_BASIS', 'd': 'used'},
]
+39 -6
View File
@@ -86,7 +86,23 @@ LIVE_CHANGEABLE_VARS: Final[List[str]] = [
'BK_RETIRED_SETS_FILE_URL',
'BK_RETIRED_SETS_PATH',
'BK_THEMES_FILE_URL',
'BK_THEMES_PATH'
'BK_THEMES_PATH',
# Sidecar integration (everything but the URL is live-changeable; the URL
# itself requires a restart, see RESTART_REQUIRED_VARS)
'BK_SIDECAR_TIMEOUT',
'BK_SIDECAR_DEFAULT_COVER',
'BK_SIDECAR_RETAIL_REGION',
'BK_SIDECAR_AUTO_FETCH_PRICE',
'BK_SIDECAR_ADDITIONAL_IMAGES',
'BK_SIDECAR_CURRENCY',
# Per-element show/hide toggles for the BrickData set-detail enrichment
'BK_SIDECAR_SHOW_DESIGNER',
'BK_SIDECAR_SHOW_DESCRIPTION',
'BK_SIDECAR_SHOW_NOTES',
'BK_SIDECAR_SHOW_PRICE_PAID',
'BK_SIDECAR_SHOW_PRICE_MSRP',
'BK_SIDECAR_SHOW_PRICE_MARKET',
'BK_SIDECAR_PRICE_BASIS'
]
# Environment variables that require restart
@@ -114,7 +130,10 @@ RESTART_REQUIRED_VARS: Final[List[str]] = [
'BK_PURCHASE_DATE_FORMAT',
'BK_PURCHASE_CURRENCY',
'BK_REBRICKABLE_USER_AGENT',
'BK_USER_AGENT'
'BK_USER_AGENT',
# Sidecar base URL only: changing it rebinds the whole feature, so it needs
# a restart. The other sidecar settings are live-changeable.
'BK_SIDECAR_URL'
]
class ConfigManager:
@@ -192,7 +211,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 +219,13 @@ class ConfigManager:
else:
return []
# Integer variables (pagination sizes, delays, etc.) - Check BEFORE boolean check
if any(keyword in var_name.lower() for keyword in ['_size', '_page', 'delay', 'min_', 'per_page', 'page_size']):
if any(keyword in var_name.lower() for keyword in ['_size', '_page', 'delay', 'min_', 'per_page', 'page_size', '_timeout', '_hours']):
try:
return int(value)
except (ValueError, TypeError):
return 0
# Boolean variables - More specific patterns to avoid conflicts
if any(keyword in var_name.lower() for keyword in ['hide_', 'disable_', 'server_side_pagination', '_links', 'random', 'skip_', 'show_', 'use_', '_consolidation', '_charts', '_expanded']):
if any(keyword in var_name.lower() for keyword in ['hide_', 'disable_', 'server_side_pagination', '_links', 'random', 'skip_', 'show_', 'use_', '_consolidation', '_charts', '_expanded', 'auto_fetch']):
if isinstance(value, str):
return value.lower() in ('true', '1', 'yes', 'on')
return bool(value)
@@ -338,6 +357,20 @@ class ConfigManager:
'BK_USE_REMOTE_IMAGES': 'Use remote images from Rebrickable CDN instead of local',
'BK_STATISTICS_SHOW_CHARTS': 'Show collection growth charts on statistics page',
'BK_STATISTICS_DEFAULT_EXPANDED': 'Expand all statistics sections by default',
'BK_DARK_MODE': 'Enable dark mode theme'
'BK_DARK_MODE': 'Enable dark mode theme',
'BK_SIDECAR_URL': 'Base URL of the brickset-sidecar container (e.g. http://localhost:3335). Leave empty to disable all sidecar features.',
'BK_SIDECAR_TIMEOUT': 'Request timeout in seconds for sidecar calls',
'BK_SIDECAR_DEFAULT_COVER': 'Default cover image source for bulk add: rebrickable, box, or set',
'BK_SIDECAR_RETAIL_REGION': 'LEGO.com retail price region for MSRP: US, UK, CA, or DE',
'BK_SIDECAR_AUTO_FETCH_PRICE': 'Automatically fetch BrickLink market value when viewing a set (respects the cache TTL), so you do not have to press "Fetch value". Off by default as it is the slower live path.',
'BK_SIDECAR_ADDITIONAL_IMAGES': 'Browse every BrickLink image the sidecar exposes for a set: adds a carousel on the set detail page and extra cover sources. Off keeps the plain single cover.',
'BK_SIDECAR_CURRENCY': 'Currency to request BrickLink market values in, e.g. EUR. Empty uses the sidecar default. (The sidecar must support the currency query parameter.)',
'BK_SIDECAR_SHOW_DESIGNER': 'Show the BrickData set designer on the set detail page',
'BK_SIDECAR_SHOW_DESCRIPTION': 'Show the BrickData "About this set" description on the set detail page',
'BK_SIDECAR_SHOW_NOTES': 'Show the BrickData Brickset notes on the set detail page',
'BK_SIDECAR_SHOW_PRICE_PAID': 'Show the "Paid" row (and "Change vs paid") in the BrickData price comparison',
'BK_SIDECAR_SHOW_PRICE_MSRP': 'Show the retail/MSRP rows (and "Saved vs retail") in the BrickData price comparison',
'BK_SIDECAR_SHOW_PRICE_MARKET': 'Show the "Worth now" market value rows in the BrickData price comparison',
'BK_SIDECAR_PRICE_BASIS': 'Which BrickLink market value the set-detail "Change vs paid" compares against: used or new (falls back to the other when one is missing). Statistics shows both regardless.'
}
return help_text.get(var_name, 'No help available for this variable')
+2 -2
View File
@@ -34,8 +34,8 @@ class BrickConfiguration(object):
self.mandatory = m
self.static_path = s
# Default for our booleans is False
if self.cast == bool:
# Booleans that don't specify an explicit default fall back to False
if self.cast == bool and d is None:
self.default = False
# Try default environment name
+21 -3
View File
@@ -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)),
}
+4 -3
View File
@@ -75,10 +75,11 @@ class BrickInstructions(object):
splits = normalized.split('-', 2)
if len(splits) >= 2:
if len(splits) >= 2 and splits[0]:
try:
# Trying to make sense of each part as integers
int(splits[0])
# The set number can be alphanumeric (e.g. Pick-a-Brick
# builds like "EG00029-1"); only the version suffix has
# to be an integer
int(splits[1])
self.set = '-'.join(splits[:2])
+2
View File
@@ -6,6 +6,7 @@ from flask import url_for
from .exceptions import ErrorException, NotFoundException
from .fields import BrickRecordFields
from .record_list import BrickRecordList
from .set_custom_field import BrickSetCustomField
from .set_owner import BrickSetOwner
from .set_purchase_location import BrickSetPurchaseLocation
from .set_status import BrickSetStatus
@@ -17,6 +18,7 @@ logger = logging.getLogger(__name__)
T = TypeVar(
'T',
BrickSetCustomField,
BrickSetOwner,
BrickSetPurchaseLocation,
BrickSetStatus,
+4 -4
View File
@@ -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
+1 -1
View File
@@ -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,
}
+8 -2
View File
@@ -181,8 +181,14 @@ class RebrickablePart(BrickRecord):
'color_rgb': data['color']['rgb'],
'color_transparent': data['color']['is_trans'],
'bricklink_color_id': None,
'bricklink_color_name': None,
'bricklink_part_num': None,
# Empty-string sentinel (not NULL) means "fetched from Rebrickable,
# but no BrickLink mapping exists upstream". need_refresh.sql flags
# only NULL (never fetched), so confirmed-missing parts/colors stop
# reappearing on the refresh list forever (#163). color_id stays
# NULL since it is an INTEGER column and is excluded from the
# refresh trigger instead.
'bricklink_color_name': '',
'bricklink_part_num': '',
'name': data['part']['name'],
'category': data['part']['part_cat_id'],
'image': data['part']['part_img_url'],
+4
View File
@@ -1,5 +1,6 @@
from .instructions_list import BrickInstructionsList
from .retired_list import BrickRetiredList
from .set_custom_field_list import BrickSetCustomFieldList
from .set_owner_list import BrickSetOwnerList
from .set_purchase_location_list import BrickSetPurchaseLocationList
from .set_status_list import BrickSetStatusList
@@ -31,6 +32,9 @@ def reload() -> None:
# Reload the set tags
BrickSetTagList.new(force=True)
# Reload the set custom fields
BrickSetCustomFieldList.new(force=True)
# Reload retired sets
BrickRetiredList(force=True)
+67
View File
@@ -10,6 +10,7 @@ from .exceptions import NotFoundException, DatabaseException, ErrorException
from .minifigure_list import BrickMinifigureList
from .part_list import BrickPartList
from .rebrickable_set import RebrickableSet
from .set_custom_field_list import BrickSetCustomFieldList
from .set_owner_list import BrickSetOwnerList
from .set_purchase_location_list import BrickSetPurchaseLocationList
from .set_status_list import BrickSetStatusList
@@ -79,6 +80,11 @@ class BrickSet(RebrickableSet):
# This must happen before inserting bricktracker_sets due to FK constraint
self.insert_rebrickable()
# Optionally override the freshly downloaded Rebrickable cover with
# the sidecar box art (best-effort; falls back silently to the
# Rebrickable image when unavailable).
self.apply_sidecar_box_art(data)
if not refresh:
# Save the storage
storage = BrickSetStorageList.get(
@@ -225,6 +231,42 @@ class BrickSet(RebrickableSet):
return True
# Override the cover with sidecar box art when requested during add.
# Never raises: a sidecar miss or failure leaves the Rebrickable cover.
def apply_sidecar_box_art(self, data: dict[str, Any], /) -> bool:
if not data.get('box_art'):
return False
try:
from .sidecar import BrickSidecar
if not BrickSidecar.enabled():
return False
ref = self.fields.set
# Honour SIDECAR_DEFAULT_COVER ('box' or 'set'); anything else
# (e.g. 'rebrickable') falls back to the reliable box image. The
# *_large variants almost never exist, so use the normal resolution.
cover = str(current_app.config.get('SIDECAR_DEFAULT_COVER', 'box'))
image_type = cover if cover in ('box', 'set') else 'box'
saved = BrickSidecar.save_cover_override(ref, image_type)
if saved:
logger.info('Set {ref}: cover set from sidecar {type} art'.format(
ref=ref,
type=image_type,
))
else:
logger.info('Set {ref}: no sidecar {type} art, kept Rebrickable cover'.format( # noqa: E501
ref=ref,
type=image_type,
))
return saved
except Exception:
logger.debug('sidecar box art override failed', exc_info=True)
return False
# Purchase date
def purchase_date(self, /, *, standard: bool = False) -> str:
if self.fields.purchase_date is not None:
@@ -293,6 +335,7 @@ class BrickSet(RebrickableSet):
# Load from database
if not self.select(
custom_fields=BrickSetCustomFieldList.as_columns(),
owners=BrickSetOwnerList.as_columns(),
statuses=BrickSetStatusList.as_columns(all=True),
tags=BrickSetTagList.as_columns(),
@@ -456,3 +499,27 @@ class BrickSet(RebrickableSet):
# Update description url
def url_for_description(self, /) -> str:
return url_for('set.update_description', id=self.fields.id)
# Cover override url (image_type: 'box' or 'set')
def url_for_cover(self, image_type: str, /) -> str:
return url_for(
'set.cover_override',
id=self.fields.id,
image_type=image_type,
)
# Cover override url for a Brickset additional image (0-indexed)
def url_for_cover_additional(self, index: int, /) -> str:
return url_for(
'set.cover_override_additional',
id=self.fields.id,
index=index,
)
# Restore the Rebrickable cover url
def url_for_cover_restore(self, /) -> str:
return url_for('set.cover_restore', id=self.fields.id)
# Refresh the BrickLink market value url
def url_for_value_refresh(self, /) -> str:
return url_for('set.value_refresh', id=self.fields.id)
+99
View File
@@ -0,0 +1,99 @@
import logging
from typing import Any, Self
from .exceptions import DatabaseException, ErrorException
from .metadata import BrickMetadata
from .sql import BrickSQL
logger = logging.getLogger(__name__)
# Allowed custom field types. The type only drives the input widget and light
# validation; the value is always stored as TEXT.
CUSTOM_FIELD_TYPES = ('text', 'number', 'date')
# Lego set custom field metadata (a typed, admin-defined per-set value)
class BrickSetCustomField(BrickMetadata):
kind: str = 'custom field'
# Set value endpoint
set_state_endpoint: str = 'set.update_custom_field'
# Queries
delete_query: str = 'set/metadata/custom_field/delete'
insert_query: str = 'set/metadata/custom_field/insert'
select_query: str = 'set/metadata/custom_field/select'
update_field_query: str = 'set/metadata/custom_field/update/field'
update_set_value_query: str = 'set/metadata/custom_field/update/value'
# SQL column name uses an underscore (kind has a space), so override the
# default which would produce "custom-field_{id}".
def as_column(self, /) -> str:
return 'custom_field_{id}'.format(id=self.fields.id)
# Grab data from a form (name + type)
def from_form(self, form: dict[str, str], /) -> Self:
super().from_form(form)
field_type = form.get('type', 'text')
if field_type not in CUSTOM_FIELD_TYPES:
raise ErrorException(
'Unknown custom field type: {type}'.format(type=field_type)
)
self.fields.type = field_type
return self
# Insert into database (carry the type along)
def insert(self, /, **_) -> None:
super().insert(
type=self.fields.type
)
# Update the per-set value of this custom field. Unlike the base
# update_set_value (which targets a fixed column), the column name is
# dynamic, so it is injected into the query like update_set_state does.
def update_value(
self,
brickset: Any,
/,
*,
json: Any | None = None,
value: Any | None = None,
) -> Any:
if value is None and json is not None:
value = json.get('value', '')
if value == '':
value = None
parameters = self.sql_parameters()
parameters['set_id'] = brickset.fields.id
parameters['value'] = value
rows, _ = BrickSQL().execute_and_commit(
self.update_set_value_query,
parameters=parameters,
name=self.as_column(),
)
if rows != 1:
raise DatabaseException(
'Could not update the custom field value for set {set} ({id})'.format( # noqa: E501
set=brickset.fields.set,
id=brickset.fields.id,
)
)
logger.info(
'Custom field "{name}" value changed to "{value}" for set {set} ({id})'.format( # noqa: E501
name=self.fields.name,
value=value,
set=brickset.fields.set,
id=brickset.fields.id,
)
)
return value
+24
View File
@@ -0,0 +1,24 @@
from typing import Self
from .metadata_list import BrickMetadataList
from .set_custom_field import BrickSetCustomField
# Lego sets custom field list
class BrickSetCustomFieldList(BrickMetadataList[BrickSetCustomField]):
kind: str = 'set custom fields'
# Database
table: str = 'bricktracker_set_custom_fields'
order: str = '"bricktracker_metadata_custom_fields"."name"'
# Queries
select_query: str = 'set/metadata/custom_field/list'
# Set value endpoint
set_value_endpoint: str = 'set.update_custom_field'
# Instantiate the list with the proper class
@classmethod
def new(cls, /, *, force: bool = False) -> Self:
return cls(BrickSetCustomField, force=force)
+34 -3
View File
@@ -3,6 +3,7 @@ from typing import Any, Self, Union
from flask import current_app
from .record_list import BrickRecordList
from .set_custom_field_list import BrickSetCustomFieldList
from .set_owner import BrickSetOwner
from .set_owner_list import BrickSetOwnerList
from .set_purchase_location import BrickSetPurchaseLocation
@@ -52,6 +53,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
def all(self, /) -> Self:
# Load the sets from the database with metadata context for filtering
filter_context = {
'custom_fields': BrickSetCustomFieldList.as_columns(),
'owners': BrickSetOwnerList.as_columns(),
'statuses': BrickSetStatusList.as_columns(),
'tags': BrickSetTagList.as_columns(),
@@ -64,6 +66,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
def all_consolidated(self, /) -> Self:
# Load the sets from the database using consolidated query with metadata context
filter_context = {
'custom_fields_dict': BrickSetCustomFieldList.as_column_mapping(),
'owners_dict': BrickSetOwnerList.as_column_mapping(),
'statuses_dict': BrickSetStatusList.as_column_mapping(),
'tags_dict': BrickSetTagList.as_column_mapping(),
@@ -88,6 +91,10 @@ class BrickSetList(BrickRecordList[BrickSet]):
tag_filter: str | None = None,
year_filter: str | None = None,
duplicate_filter: bool = False,
parts_min: int | None = None,
parts_max: int | None = None,
year_min: int | None = None,
year_max: int | None = None,
use_consolidated: bool = True
) -> tuple[Self, int]:
# Convert theme name to theme ID for filtering
@@ -104,7 +111,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
theme_id_filter = self._theme_name_to_id(theme_filter)
# Check if any filters are applied
has_filters = any([status_filter, theme_id_filter, owner_filter, purchase_location_filter, storage_filter, tag_filter, year_filter, duplicate_filter])
has_filters = any([status_filter, theme_id_filter, owner_filter, purchase_location_filter, storage_filter, tag_filter, year_filter, duplicate_filter, parts_min, parts_max, year_min, year_max])
# Prepare filter context
filter_context = {
@@ -117,9 +124,15 @@ class BrickSetList(BrickRecordList[BrickSet]):
'tag_filter': tag_filter,
'year_filter': year_filter,
'duplicate_filter': duplicate_filter,
'parts_min': parts_min,
'parts_max': parts_max,
'year_min': year_min,
'year_max': year_max,
'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(),
@@ -172,7 +185,8 @@ class BrickSetList(BrickRecordList[BrickSet]):
return self._all_filtered_paginated_with_instructions(
search_query, page, per_page, sort_field, sort_order,
status_filter, theme_id_filter, owner_filter,
purchase_location_filter, storage_filter, tag_filter
purchase_location_filter, storage_filter, tag_filter,
parts_min, parts_max, year_min, year_max
)
# Handle special case for set sorting with multiple columns
@@ -318,7 +332,11 @@ class BrickSetList(BrickRecordList[BrickSet]):
owner_filter: str | None,
purchase_location_filter: str | None,
storage_filter: str | None,
tag_filter: str | None
tag_filter: str | None,
parts_min: int | None = None,
parts_max: int | None = None,
year_min: int | None = None,
year_max: int | None = None
) -> tuple[Self, int]:
"""Handle filtering when instructions filter is involved"""
try:
@@ -360,6 +378,14 @@ class BrickSetList(BrickRecordList[BrickSet]):
continue
if tag_filter and not self._matches_tag(record, tag_filter):
continue
if parts_min is not None and record.fields.number_of_parts < parts_min:
continue
if parts_max is not None and record.fields.number_of_parts > parts_max:
continue
if year_min is not None and record.fields.year < year_min:
continue
if year_max is not None and record.fields.year > year_max:
continue
filtered_records.append(record)
@@ -509,10 +535,14 @@ class BrickSetList(BrickRecordList[BrickSet]):
def _matches_purchase_location(self, record, location_filter: str) -> bool:
"""Check if record matches purchase location filter"""
if location_filter == '__none__':
return not record.fields.purchase_location
return record.fields.purchase_location == location_filter
def _matches_storage(self, record, storage_filter: str) -> bool:
"""Check if record matches storage filter"""
if storage_filter == '__none__':
return not record.fields.storage
return record.fields.storage == storage_filter
def _matches_tag(self, record, tag_filter: str) -> bool:
@@ -705,6 +735,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),
+586
View File
@@ -0,0 +1,586 @@
import json
import logging
import os
import time
from typing import Any, Final
from flask import current_app, url_for
import requests
logger = logging.getLogger(__name__)
# The four LEGO.com retail price regions the sidecar exposes. The sidecar
# returns each of these as a JSON *string* (not a nested object), so we parse
# them eagerly in get_set().
RETAIL_REGIONS: Final[list[str]] = ['US', 'UK', 'CA', 'DE']
# Currency code per retail region, used to label MSRP.
REGION_CURRENCY: Final[dict[str, str]] = {
'US': 'USD',
'UK': 'GBP',
'CA': 'CAD',
'DE': 'EUR',
}
# Maps a currency *symbol* (as a user might set BK_PURCHASE_CURRENCY) to the ISO
# code(s) it can stand for, so '$' is treated as the same currency as 'USD' and
# 'kr' as 'DKK'. Symbols are inherently ambiguous (several currencies share one),
# so we map to a set and treat two values as the same currency when their code
# sets overlap. Keys are normalized (lowercased, trailing dots stripped).
CURRENCY_SYMBOLS: Final[dict[str, set[str]]] = {
'$': {'USD', 'CAD', 'AUD', 'NZD', 'HKD', 'SGD', 'MXN', 'BRL'},
'us$': {'USD'},
'ca$': {'CAD'},
'c$': {'CAD'},
'a$': {'AUD'},
'nz$': {'NZD'},
'': {'EUR'},
'£': {'GBP'},
'¥': {'JPY', 'CNY'},
'': {'JPY', 'CNY'},
'kr': {'DKK', 'SEK', 'NOK', 'ISK'},
'': {'PLN'},
'': {'RUB'},
'': {'INR'},
'r$': {'BRL'},
'': {'KRW'},
'฿': {'THB'},
'': {'ILS'},
'chf': {'CHF'},
'fr': {'CHF'},
}
# Valid BrickLink image types served by the sidecar image proxy.
IMAGE_TYPES: Final[list[str]] = [
'box', 'box_large',
'set', 'set_large',
'instruction', 'instruction_large',
]
# How long (seconds) to trust a cached /health result so the UI never blocks
# on a dead sidecar.
HEALTH_CACHE_TTL: Final[int] = 30
# Best-effort client for the brickset-sidecar (BrickData) container.
#
# Design rule: a missing or broken sidecar must NEVER break an existing page.
# Every public method returns None / False on any failure and logs at debug.
# Nothing in here is allowed to raise into a request handler. Guard call sites
# with BrickSidecar.enabled().
class BrickSidecar(object):
# Cache for the health probe: (checked_at, healthy)
_health_cache: tuple[float, bool] | None = None
# --- Configuration helpers ------------------------------------------
# Normalized base URL (no trailing slash), or '' when unset.
@staticmethod
def base_url() -> str:
return str(current_app.config.get('SIDECAR_URL', '') or '').rstrip('/')
# True only when a sidecar URL is configured (the feature toggle).
@staticmethod
def enabled() -> bool:
return BrickSidecar.base_url() != ''
@staticmethod
def timeout() -> int:
return int(current_app.config.get('SIDECAR_TIMEOUT', 5))
# --- Low level ------------------------------------------------------
# Perform a GET and return the parsed JSON, or None on any failure.
@staticmethod
def _get_json(path: str, /, *, params: dict[str, Any] | None = None) -> Any:
if not BrickSidecar.enabled():
return None
url = '{base}{path}'.format(base=BrickSidecar.base_url(), path=path)
try:
response = requests.get(
url,
params=params,
timeout=BrickSidecar.timeout(),
)
if not response.ok:
logger.debug(
'sidecar GET %s -> HTTP %s', url, response.status_code,
)
return None
return response.json()
except (requests.RequestException, ValueError) as exception:
# ValueError covers JSON decode errors. Never propagate.
logger.debug('sidecar GET %s failed: %s', url, exception)
return None
# --- Health ---------------------------------------------------------
# Cached short-TTL health probe. Safe to call on every render.
@staticmethod
def healthy() -> bool:
if not BrickSidecar.enabled():
return False
now = time.monotonic()
cache = BrickSidecar._health_cache
if cache is not None and (now - cache[0]) < HEALTH_CACHE_TTL:
return cache[1]
payload = BrickSidecar._get_json('/health')
healthy = bool(payload) and payload.get('status') == 'ok'
BrickSidecar._health_cache = (now, healthy)
return healthy
# --- Sets -----------------------------------------------------------
# GET /sets/{ref}. Returns the single set dict (the sidecar wraps it in a
# {"sets": [...]} list) with the legoCom* JSON-string fields parsed into
# dicts, or None if missing. When price=True, the BrickLink price block is
# merged in under 'bricklink_price'.
#
# Metadata is read from the persistent cache first (effectively permanent);
# pass refresh=True to force a network fetch and rewrite the cache.
@staticmethod
def get_set(
ref: str,
/,
*,
price: bool = False,
refresh: bool = False,
use_cache: bool = True,
) -> dict[str, Any] | None:
# The sidecar (brickdata) is the single source of truth and cache: it
# serves from its own DB and only hits Brickset on its own staleness
# rules. We always read live from it — no second cache here. (use_cache
# is kept for signature compatibility but no longer caches locally.)
params: dict[str, Any] = {}
if refresh:
params['refresh'] = 'true'
payload = BrickSidecar._get_json(
'/sets/{ref}'.format(ref=ref),
params=params or None,
)
if not payload:
return None
sets = payload.get('sets') or []
if not sets:
return None
data = sets[0]
# The sidecar serializes legoCom* as JSON strings; parse them so call
# sites get a dict (or None) instead of a raw string.
for region in RETAIL_REGIONS:
key = 'legoCom{region}'.format(region=region)
data[key] = BrickSidecar._parse_json_field(data.get(key))
if price:
data = dict(data)
data['bricklink_price'] = BrickSidecar.get_price(ref, refresh=refresh)
return data
# BrickLink market value for a set, or None. The sidecar owns price caching
# (and its own TTL): the normal path returns its cached price (fetching once
# if its cache expired), while refresh=True forces a live re-fetch via the
# dedicated /price endpoint. No local caching here.
@staticmethod
def get_price(
ref: str,
/,
*,
refresh: bool = False,
use_cache: bool = True,
) -> dict[str, Any] | None:
currency = str(current_app.config.get('SIDECAR_CURRENCY', '') or '').strip()
# Forced refresh (explicit user action): the dedicated endpoint
# re-fetches from BrickLink and rewrites the sidecar's price cache.
if refresh:
params: dict[str, Any] = {'refresh': 'true'}
if currency:
params['currency'] = currency
return BrickSidecar._get_json(
'/sets/{ref}/price'.format(ref=ref),
params=params,
)
# Normal path: the sidecar returns its cached price, or fetches once if
# its own TTL expired. The price rides along on the set payload under
# bricklink_price. No local caching — the sidecar is the cache.
params = {'price': 'true'}
if currency:
params['currency'] = currency
payload = BrickSidecar._get_json(
'/sets/{ref}'.format(ref=ref),
params=params,
)
if payload:
sets = payload.get('sets') or []
if sets:
return sets[0].get('bricklink_price') or None
return None
# GET /sets/bulk -> cached metadata (+ cached price) for many sets at once,
# keyed by ref. The sidecar serves this from its own DB with no live
# Brickset/BrickLink calls, so it is cheap enough for collection-wide
# aggregation (stats) without a local cache. Requests are chunked to keep
# the query string within sane limits.
@staticmethod
def get_sets_bulk(
refs: list[str],
/,
*,
price: bool = True,
chunk_size: int = 200,
) -> dict[str, dict[str, Any]]:
wanted = [r for r in refs if r]
if not wanted:
return {}
currency = str(current_app.config.get('SIDECAR_CURRENCY', '') or '').strip()
out: dict[str, dict[str, Any]] = {}
for start in range(0, len(wanted), chunk_size):
chunk = wanted[start:start + chunk_size]
params: dict[str, Any] = {'refs': ','.join(chunk)}
if price:
params['price'] = 'true'
if currency:
params['currency'] = currency
payload = BrickSidecar._get_json('/sets/bulk', params=params)
if not payload:
continue
for s in payload.get('sets') or []:
ref = '{n}-{v}'.format(
n=s.get('number'), v=s.get('numberVariant'),
)
for region in RETAIL_REGIONS:
key = 'legoCom{region}'.format(region=region)
s[key] = BrickSidecar._parse_json_field(s.get(key))
out[ref] = s
return out
# Convenience: MSRP for the configured retail region (SIDECAR_RETAIL_REGION),
# read from an already-fetched get_set() payload. None when unavailable.
@staticmethod
def retail_price(set_data: dict[str, Any], /) -> float | None:
region = BrickSidecar.retail_region()
block = set_data.get('legoCom{region}'.format(region=region))
if not isinstance(block, dict):
return None
value = block.get('retailPrice')
try:
return float(value) if value is not None else None
except (TypeError, ValueError):
return None
# Configured retail region (US/UK/CA/DE), defaulting to US.
@staticmethod
def retail_region() -> str:
region = str(current_app.config.get('SIDECAR_RETAIL_REGION', 'US')).upper()
return region if region in REGION_CURRENCY else 'US'
# Currency code for the configured retail region.
@staticmethod
def retail_currency() -> str:
return REGION_CURRENCY[BrickSidecar.retail_region()]
# The ISO code(s) a currency value can represent. An ISO code maps to itself;
# a known symbol maps to its candidate set (see CURRENCY_SYMBOLS); anything
# else falls back to its own uppercased form.
@staticmethod
def currency_codes(value: Any, /) -> set[str]:
text = str(value or '').strip()
if not text:
return set()
key = text.rstrip('.').lower()
if key in CURRENCY_SYMBOLS:
return set(CURRENCY_SYMBOLS[key])
return {text.upper()}
# Whether two currency values (symbol or ISO code) denote the same currency.
# Empty values are treated as "unknown" -> compatible, so we never warn just
# because one side is unset.
@staticmethod
def same_currency(a: Any, b: Any, /) -> bool:
codes_a = BrickSidecar.currency_codes(a)
codes_b = BrickSidecar.currency_codes(b)
if not codes_a or not codes_b:
return True
return bool(codes_a & codes_b)
# Read a price WITHOUT triggering a live BrickLink fetch: ask the sidecar
# for a cache-only price (cached_only=true), so a render never blocks.
# Returns (price_dict, fetched_at) or (None, None).
@staticmethod
def cached_price(ref: str, /) -> tuple[dict[str, Any] | None, Any]:
params: dict[str, Any] = {'price': 'true', 'cached_only': 'true'}
currency = str(current_app.config.get('SIDECAR_CURRENCY', '') or '').strip()
if currency:
params['currency'] = currency
payload = BrickSidecar._get_json(
'/sets/{ref}'.format(ref=ref),
params=params,
)
price: dict[str, Any] | None = None
if payload:
sets = payload.get('sets') or []
if sets:
price = sets[0].get('bricklink_price') or None
if price is None:
return None, None
return price, price.get('fetched_at')
# --- Images ---------------------------------------------------------
# GET /sets/{ref}/images -> {'brickset': {...}, 'bricklink': {...}}, or None.
@staticmethod
def get_images(ref: str, /) -> dict[str, Any] | None:
return BrickSidecar._get_json('/sets/{ref}/images'.format(ref=ref))
# GET /sets/{ref}/bags -> {'set': ..., 'bag_count': n, 'bags': [...]},
# or None (the sidecar 404s when the set has no bag inventory).
@staticmethod
def get_bags(ref: str, /) -> dict[str, Any] | None:
return BrickSidecar._get_json('/sets/{ref}/bags'.format(ref=ref))
# Build a BrickTracker-served proxy URL for an <img src>. The browser hits
# BrickTracker (same origin), which fetches the image from the sidecar
# server-side. This works even when the sidecar is only reachable on the
# internal Docker network (e.g. http://brickdata:3335), which the browser
# cannot resolve. Returns None when disabled or the type is unknown.
@staticmethod
def image_proxy_url(image_type: str, ref: str, /) -> str | None:
if image_type not in IMAGE_TYPES or not BrickSidecar.enabled():
return None
return url_for('sidecar.image', image_type=image_type, ref=ref)
# The real sidecar image URL, used server-side to fetch the bytes.
@staticmethod
def _remote_image_url(image_type: str, ref: str, /) -> str | None:
if image_type not in IMAGE_TYPES or not BrickSidecar.enabled():
return None
return '{base}/images/bricklink/{type}/{ref}.png'.format(
base=BrickSidecar.base_url(),
type=image_type,
ref=ref,
)
# Build a BrickTracker-served proxy URL for one Brickset additional image
# (0-indexed) for use in an <img src>. Like image_proxy_url, the browser
# talks only to BrickTracker, which fetches from the sidecar server-side.
# Pass thumbnail=True for the smaller variant. Returns None when disabled.
@staticmethod
def additional_image_url(
ref: str,
index: int,
/,
*,
thumbnail: bool = False,
) -> str | None:
if not BrickSidecar.enabled():
return None
return url_for(
'sidecar.additional_image',
ref=ref,
index=index,
thumbnail=1 if thumbnail else None,
)
# The real sidecar additional-image URL, used server-side to fetch bytes.
# Unlike box/instruction art these come from Brickset, not the BrickLink
# image proxy.
@staticmethod
def _remote_additional_image_url(
ref: str,
index: int,
/,
*,
thumbnail: bool = False,
) -> str | None:
if not BrickSidecar.enabled():
return None
url = '{base}/sets/{ref}/additional-images/{index}'.format(
base=BrickSidecar.base_url(),
ref=ref,
index=index,
)
if thumbnail:
url = '{url}?size=thumbnail'.format(url=url)
return url
# Fetch the raw bytes of one Brickset additional image (used when saving it
# as the cover). Returns None on any failure or a 404.
@staticmethod
def fetch_additional_image_bytes(
ref: str,
index: int,
/,
*,
thumbnail: bool = False,
) -> bytes | None:
url = BrickSidecar._remote_additional_image_url(
ref, index, thumbnail=thumbnail,
)
if url is None:
return None
return BrickSidecar._fetch_bytes(url)
# Fetch the raw bytes of a proxied image (used when saving box art locally).
# Returns None on any failure or a 404 (BrickLink has no such image).
@staticmethod
def fetch_image_bytes(image_type: str, ref: str, /) -> bytes | None:
url = BrickSidecar._remote_image_url(image_type, ref)
if url is None:
return None
return BrickSidecar._fetch_bytes(url)
# Download the raw bytes at a sidecar image URL, returning None on any
# failure or a non-OK status (e.g. a 404 when the image does not exist).
@staticmethod
def _fetch_bytes(url: str, /) -> bytes | None:
try:
response = requests.get(url, timeout=BrickSidecar.timeout())
if not response.ok:
logger.debug(
'sidecar image %s -> HTTP %s', url, response.status_code,
)
return None
return response.content
except requests.RequestException as exception:
logger.debug('sidecar image %s failed: %s', url, exception)
return None
# --- Cover override -------------------------------------------------
# Absolute path of the locally stored cover for a set ref, mirroring the
# logic in RebrickableImage.path() (everything is stored as .jpg).
@staticmethod
def cover_path(set_ref: str, /) -> str:
folder: str = current_app.config['SETS_FOLDER']
if folder.startswith('/'):
base_path = folder
else:
base_path = os.path.join(current_app.root_path, folder)
return os.path.join(base_path, '{ref}.jpg'.format(ref=set_ref))
# Backup path used so a box-art override is reversible.
@staticmethod
def cover_backup_path(set_ref: str, /) -> str:
return BrickSidecar.cover_path(set_ref).replace('.jpg', '.bak.jpg')
# Override the local cover image with a sidecar image (box / set). Backs up
# the existing cover the first time so it can be restored. Returns True on
# success, False if the sidecar has no such image or anything fails.
@staticmethod
def save_cover_override(set_ref: str, image_type: str, /) -> bool:
data = BrickSidecar.fetch_image_bytes(image_type, set_ref)
return BrickSidecar._write_cover(set_ref, data)
# Override the local cover with one Brickset additional image (0-indexed).
# Same backup/restore semantics as save_cover_override.
@staticmethod
def save_cover_override_from_additional(set_ref: str, index: int, /) -> bool:
data = BrickSidecar.fetch_additional_image_bytes(set_ref, index)
return BrickSidecar._write_cover(set_ref, data)
# Write image bytes as the local cover, backing up the original Rebrickable
# cover once so it stays restorable. Returns False when data is None (the
# sidecar had no such image) or on any filesystem error.
@staticmethod
def _write_cover(set_ref: str, data: bytes | None, /) -> bool:
if data is None:
return False
path = BrickSidecar.cover_path(set_ref)
backup = BrickSidecar.cover_backup_path(set_ref)
try:
# Preserve the original Rebrickable cover once.
if os.path.exists(path) and not os.path.exists(backup):
os.replace(path, backup)
# os.replace moved the file; re-create it below from bytes.
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, 'wb') as handle:
handle.write(data)
return True
except OSError as exception:
logger.debug('cover override for %s failed: %s', set_ref, exception)
return False
# Restore a previously backed-up Rebrickable cover. Returns True if a backup
# existed and was restored, False otherwise (caller may re-download instead).
@staticmethod
def restore_cover(set_ref: str, /) -> bool:
path = BrickSidecar.cover_path(set_ref)
backup = BrickSidecar.cover_backup_path(set_ref)
if not os.path.exists(backup):
return False
try:
os.replace(backup, path)
return True
except OSError as exception:
logger.debug('cover restore for %s failed: %s', set_ref, exception)
return False
# --- Internal -------------------------------------------------------
# Parse a field the sidecar returns as a JSON string into a dict, tolerating
# None / empty / malformed values.
@staticmethod
def _parse_json_field(value: Any) -> dict[str, Any] | None:
if not value:
return None
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else None
except ValueError:
return None
return None
+521
View File
@@ -0,0 +1,521 @@
import logging
import re
from datetime import datetime, timezone
from html.parser import HTMLParser
from typing import Any
from flask import current_app, url_for
from markupsafe import Markup, escape
from .bag_part import list_state as list_bag_part_state
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')),
# Whether the sidecar holds a per-bag part inventory for this set.
'has_bags': bool(data.get('has_bags')),
}
# Retired status from the exit date. exit_date is the full formatted date
# (BrickData/Brickset has it even for sets retired long ago, which the
# upcoming-retirements CSV does not cover, see #37).
retired, exit_year = _retired(data.get('exitDate'))
summary['retired'] = retired
summary['exit_year'] = exit_year
summary['exit_date'] = _format_date(data.get('exitDate'))
summary['launch_year'] = _year(data.get('launchDate'))
# --- Prices: paid / retail / market --------------------------------
msrp = BrickSidecar.retail_price(data)
# When auto-fetch is on, hit the TTL-aware path (network only on a cache
# miss/expiry); otherwise stay cache-only so the render never blocks.
if fetch_price:
price_payload = BrickSidecar.get_price(set_ref)
price_fetched_at = None
else:
price_payload, price_fetched_at = BrickSidecar.cached_price(set_ref)
market_new = _to_float(price_payload.get('new_avg')) if price_payload else None
market_used = _to_float(price_payload.get('used_avg')) if price_payload else None
paid = _to_float(purchase_price)
prices: dict[str, Any] = {
'paid': paid,
'msrp': msrp,
'msrp_currency': BrickSidecar.retail_currency(),
# Inflation-adjusted RRP, web-scraped by the sidecar (single value in
# its own currency, independent of the configured retail region).
'msrp_inflated': _to_float(data.get('rrpInflated')),
'msrp_inflated_currency': _clean_str(data.get('rrpInflatedCurrency')),
'market_new': market_new,
'market_used': market_used,
'market_min': _to_float(price_payload.get('new_min')) if price_payload else None, # noqa: E501
'market_max': _to_float(price_payload.get('new_max')) if price_payload else None, # noqa: E501
'market_used_min': _to_float(price_payload.get('used_min')) if price_payload else None, # noqa: E501
'market_used_max': _to_float(price_payload.get('used_max')) if price_payload else None, # noqa: E501
'market_currency': (price_payload or {}).get('currency_code'),
'market_fetched_at': (price_payload or {}).get('fetched_at') or price_fetched_at, # noqa: E501
'has_market': price_payload is not None,
}
# Savings vs MSRP (positive = paid less than retail).
if msrp is not None and paid is not None:
prices['savings_vs_msrp'] = round(msrp - paid, 2)
# Value movement vs what was paid. SIDECAR_PRICE_BASIS picks which market
# average to compare against ('used' or 'new'); fall back to the other when
# the preferred one is missing.
basis = str(current_app.config.get('SIDECAR_PRICE_BASIS', 'used')).lower()
if basis == 'new':
market_ref = market_new if market_new is not None else market_used
else:
market_ref = market_used if market_used is not None else market_new
prices['market_basis'] = basis
if market_ref is not None and paid is not None:
prices['gain_vs_paid'] = round(market_ref - paid, 2)
summary['prices'] = prices
return summary
# Chronological sort key for a bag's raw bagNumber: numbered bags first (by
# their leading number, so "1", "1 & 2", and "12 & 13" all land next to their
# lowest number), then anything without a leading number, then "Extra" last.
def _bag_sort_key(bag: dict[str, Any]) -> tuple[int, int, str]:
number = str(bag.get('bagNumber') or '')
if number.strip().lower() == 'extra':
return (2, 0, number)
match = re.match(r'\s*(\d+)', number)
if match:
return (0, int(match.group(1)), number)
return (1, 0, number)
# Join the sidecar's per-bag inventory onto the set's part rows. Returns
# (bags, breakdown):
# - bags: template-ready [{'number', 'is_extra', 'parts': [...]}] in
# chronological bag order (numbered bags first, "Extra" last), each part
# carrying its per-bag quantity, display data, the stored
# per-bag checked/missing state with its changer prefixes/urls, and the DOM
# id of the main row's missing input (the sum-group key for the client-side
# missing sync).
# - breakdown: part row html_id() -> [[bag number, quantity], ...] pairs, for
# the bag column in the normal audit modal.
# Degrades to (None, {}) whenever the sidecar is off or has no bag data; the
# bag composition is joined at render time and never persisted (only the
# per-bag progress lives in bricktracker_bag_parts).
def bag_inventory(
brickset: Any,
parts: Any,
/,
*,
tables: bool = True,
) -> tuple[list[dict[str, Any]] | None, dict[str, list[list[Any]]]]:
if not BrickSidecar.enabled():
return None, {}
payload = BrickSidecar.get_bags(brickset.fields.set)
if not payload or not payload.get('bags'):
return None, {}
# One BrickTracker row per (part, color, spare) for the whole set; bag
# entries reference it many-to-one via (part_ex_id, color_id).
index: dict[tuple[str, int, int], Any] = {
(str(row.fields.part), int(row.fields.color), int(row.fields.spare)): row # noqa: E501
for row in parts
}
# Bags also contain minifigure parts, which live outside the set's part
# list. setdefault: on a key collision the set part row wins.
for minifigure in brickset.minifigures():
for row in minifigure.parts():
index.setdefault(
(str(row.fields.part), int(row.fields.color), int(row.fields.spare)), # noqa: E501
row,
)
# Secondary lookup by BrickLink part number: Brickset sometimes refers
# to the generic mold (2674) where Rebrickable only knows the variants
# (2674a..f), but the bag entry's bl_part_id is exact.
bl_index: dict[tuple[str, int, int], Any] = {}
for row in index.values():
if row.fields.bricklink_part_num:
bl_index.setdefault(
(str(row.fields.bricklink_part_num), int(row.fields.color), int(row.fields.spare)), # noqa: E501
row,
)
# tables=False is the cheap page-render mode: only the audit breakdown
# and the bag list (for the accordion header) are built; the heavy
# per-part table data (incl. state query and thousands of url_for calls)
# is deferred to the lazy fragment request.
# Stored per-bag progress, keyed (bag, part, color, spare)
state = list_bag_part_state(brickset.fields.id) if tables else {}
# The changer prefixes keep the "-missing-"/"-checked-" fragments so the
# bulk operations and audit selectors match, and a per-bag "bag{i}"
# namespace so ids stay unique across bags.
def changer(index: int, kind: str, part: str, color: int, spare: int, bag: str) -> dict[str, Any]: # noqa: E501
return {
'prefix_{kind}'.format(kind=kind): 'bag{index}-part-{kind}-{part}-{color}-{spare}'.format( # noqa: E501
index=index, kind=kind, part=part, color=color, spare=spare,
),
'url_{kind}'.format(kind=kind): url_for(
'set.bag_part_state',
id=brickset.fields.id,
bag=bag,
part=part,
color=color,
spare=spare,
state=kind,
),
}
bags: list[dict[str, Any]] = []
breakdown: dict[str, list[list[Any]]] = {}
for i, bag in enumerate(sorted(payload['bags'], key=_bag_sort_key), start=1): # noqa: E501
# The raw bagNumber stays the stored-state key so upstream label
# changes never orphan progress; label is display-only ("48 & 49"
# for bags opened together, with a note explaining the grouping).
number = str(bag.get('bagNumber') or '?')
label = str(bag.get('label') or number)
# "Extra" bags sometimes hold the spares, so try spare rows first
# there (with a fallback either way, see spare_order below)
is_extra = number.lower() == 'extra'
spare_order = (1, 0) if is_extra else (0, 1)
bag_parts: list[dict[str, Any]] = []
for part in bag.get('parts') or []:
part_ref = str(part.get('part_ex_id') or part.get('bl_part_id') or '') # noqa: E501
try:
color = int(part.get('color_id'))
except (TypeError, ValueError):
color = -1
quantity = part.get('quantity') or 0
row = None
for spare in spare_order:
row = index.get((part_ref, color, spare))
if row is not None:
break
if row is None:
bl_ref = str(part.get('bl_part_id') or '')
for spare in spare_order:
row = bl_index.get((bl_ref, color, spare))
if row is not None:
break
if row is not None and not tables:
breakdown.setdefault(row.html_id(), []).append(
[label, quantity],
)
continue
elif not tables:
continue
if row is not None:
part_state = state.get(
(number, row.fields.part, row.fields.color, row.fields.spare), # noqa: E501
{},
)
entry: dict[str, Any] = {
'quantity': quantity,
'name': row.fields.name,
'color': row.fields.color,
'color_name': row.fields.color_name,
'color_rgb': row.fields.color_rgb,
'spare': bool(row.fields.spare),
'image_url': row.url_for_image(),
'url': row.url(),
# Final DOM id of the main row's missing input
# (macro/form.html renders "{prefix}-{id}").
'missing_input_id': '{prefix}-{id}'.format(
prefix=row.html_id('missing'),
id=row.fields.id,
),
'checked': part_state.get('checked', False),
# None renders an empty input, like the main table
'missing': part_state.get('missing') or None,
}
entry.update(changer(i, 'missing', row.fields.part, row.fields.color, row.fields.spare, number)) # noqa: E501
entry.update(changer(i, 'checked', row.fields.part, row.fields.color, row.fields.spare, number)) # noqa: E501
bag_parts.append(entry)
breakdown.setdefault(row.html_id(), []).append(
[label, quantity],
)
else:
# No matching row (e.g. a part the import assigned to a
# minifigure): display-only data from the sidecar, but the
# per-bag progress is still stored (keyed by the sidecar
# part ref, spare=0). A negative color means the sidecar
# gave no usable identity, so the row gets no inputs.
part_state = state.get((number, part_ref, color, 0), {})
entry = {
'quantity': quantity,
'name': part_ref or str(part.get('partId') or '?'),
'color': None,
'color_name': part.get('colorName'),
'color_rgb': str(part.get('colorHex') or '').lstrip('#') or None, # noqa: E501
'spare': False,
'image_url': None,
'url': None,
'missing_input_id': None,
'checked': part_state.get('checked', False),
'missing': part_state.get('missing') or None,
}
if part_ref and color >= 0:
entry.update(changer(i, 'missing', part_ref, color, 0, number)) # noqa: E501
entry.update(changer(i, 'checked', part_ref, color, 0, number)) # noqa: E501
bag_parts.append(entry)
bags.append({
'number': label,
'note': _clean_str(bag.get('note')),
'is_extra': is_extra,
'parts': bag_parts,
})
return bags, breakdown
if __name__ == '__main__':
# Self-check for _bag_sort_key. Sorting bagNumber as plain text would
# put "10" and "12 & 13" right after "1"/"1 & 2" (alphabetical), ahead
# of "2" and "3", which is wrong. This asserts the real order instead:
# numeric by leading number, "Extra" always last.
# Run: python -m bricktracker.sidecar_set
shuffled = [
{'bagNumber': 'Extra'},
{'bagNumber': '3'},
{'bagNumber': '12 & 13'},
{'bagNumber': '1'},
{'bagNumber': '10'},
{'bagNumber': '1 & 2'},
{'bagNumber': '2'},
]
ordered = [b['bagNumber'] for b in sorted(shuffled, key=_bag_sort_key)]
assert ordered == ['1', '1 & 2', '2', '3', '10', '12 & 13', 'Extra'], ordered # noqa: E501
print('ok:', ordered)
# --- Helpers ------------------------------------------------------------
def _clean_str(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
# Rebuild an HTML fragment keeping only the allowlisted formatting tags, with
# all attributes stripped and all text escaped. convert_charrefs=True turns
# entities (&reg;, &#39;, ...) into real characters before they reach the data
# handler, so they render correctly.
class _DescriptionSanitizer(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.parts: list[str] = []
def handle_starttag(self, tag: str, attrs: Any) -> None:
if tag in _ALLOWED_TAGS:
self.parts.append(f'<{tag}>')
def handle_startendtag(self, tag: str, attrs: Any) -> None:
if tag in _ALLOWED_TAGS:
self.parts.append(f'<{tag}>')
def handle_endtag(self, tag: str) -> None:
if tag in _ALLOWED_TAGS and tag not in _VOID_TAGS:
self.parts.append(f'</{tag}>')
def handle_data(self, data: str) -> None:
self.parts.append(str(escape(data)))
def _description(value: Any) -> Markup | None:
text = _clean_str(value)
if text is None:
return None
parser = _DescriptionSanitizer()
parser.feed(text)
parser.close()
cleaned = ''.join(parser.parts)
# Brickset pads descriptions with empty spacer paragraphs (<p>&nbsp;</p>);
# drop those so the blocks sit flush instead of leaving big gaps. \s also
# matches the &nbsp; (\xa0) the parser leaves behind.
cleaned = re.sub(r'<p>\s*</p>', '', cleaned).strip()
return Markup(cleaned) if cleaned else None
def _to_float(value: Any) -> float | None:
if value is None or value == '':
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _to_int(value: Any) -> int | None:
if value is None or value == '':
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _dimensions(data: dict[str, Any], /) -> str | None:
height = _to_float(data.get('height'))
width = _to_float(data.get('width'))
depth = _to_float(data.get('depth'))
if height is None and width is None and depth is None:
return None
parts = [
'{0:g}'.format(round(value, 1)) if value is not None else '?'
for value in (height, width, depth)
]
return '{0} cm'.format(' × '.join(parts))
def _weight(data: dict[str, Any], /) -> str | None:
weight = _to_float(data.get('weight'))
if weight is None:
return None
return '{0:g} kg'.format(round(weight, 2))
def _tags(value: Any) -> list[str]:
if not value:
return []
return [tag.strip() for tag in str(value).split(',') if tag.strip()]
def _parse_date(value: Any) -> datetime | None:
if not value:
return None
text = str(value).replace('Z', '+00:00')
try:
parsed = datetime.fromisoformat(text)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
def _year(value: Any) -> int | None:
parsed = _parse_date(value)
return parsed.year if parsed is not None else None
def _format_date(value: Any) -> str | None:
parsed = _parse_date(value)
if parsed is None:
return None
return parsed.strftime(current_app.config['PURCHASE_DATE_FORMAT'])
def _retired(exit_date: Any) -> tuple[bool, int | None]:
parsed = _parse_date(exit_date)
if parsed is None:
return False, None
return parsed < datetime.now(timezone.utc), parsed.year
# Bulk retirement lookup for a list of set refs (e.g. the whole wishlist) in a
# single cached BrickData call. Returns {ref: {'date': str|None,
# 'retired': bool}} keyed by both "number-variant" and the bare number so the
# caller can match whichever form it stores. Empty dict when the sidecar is
# disabled or unreachable, so callers degrade to the CSV (see #37).
def retirement_dates(
set_refs: Any,
/,
) -> dict[str, dict[str, Any]]:
if not BrickSidecar.enabled():
return {}
refs = list({str(ref) for ref in set_refs if ref})
if not refs:
return {}
try:
bulk = BrickSidecar.get_sets_bulk(refs, price=False)
except Exception as exception:
logger.debug('sidecar retirement bulk fetch failed: %s', exception)
return {}
out: dict[str, dict[str, Any]] = {}
for ref, data in bulk.items():
retired, _ = _retired(data.get('exitDate'))
info = {
'date': _format_date(data.get('exitDate')),
'retired': retired,
}
out[ref] = info
number, _, _ = ref.partition('-')
out.setdefault(number, info)
return out
+64
View File
@@ -36,6 +36,7 @@ MESSAGES: Final[dict[str, str]] = {
'PART_LOADED': 'part_loaded',
'PROGRESS': 'progress',
'SET_LOADED': 'set_loaded',
'VALUE_ALL_SETS': 'value_all_sets',
}
@@ -298,6 +299,69 @@ class BrickSocket(object):
from .individual_part import IndividualPart
IndividualPart().create_bulk(self, data)
@self.socket.on(MESSAGES['VALUE_ALL_SETS'], namespace=self.namespace)
@authenticated_socket(self)
def value_all_sets(data: dict[str, Any], /) -> None:
logger.debug('Socket: VALUE_ALL_SETS (from: {fr})'.format(
fr=request.sid, # type: ignore
))
from .sidecar import BrickSidecar
from .sql import BrickSQL
if not BrickSidecar.enabled():
self.fail(message='The sidecar is not configured')
return
# Distinct set numbers in the collection (reuses the instructions
# statistics query).
try:
rows = BrickSQL().fetchall('statistics/set_numbers')
except Exception:
rows = []
refs = [row['set'] for row in rows]
self.progress_count = 0
self.update_total(len(refs))
priced = 0 # sets that ended up with a value (cached or fetched)
fetched = 0 # sets that required a live fetch this run
missing = 0 # sets with no value available at all
for ref in refs:
self.auto_progress(
message='Valuing set {ref}'.format(ref=ref),
)
# Does the sidecar already hold a cached price for this set? If
# so, get_price() returns it without a live BrickLink fetch.
cached, fetched_at = BrickSidecar.cached_price(ref)
was_fresh = cached is not None
try:
price = BrickSidecar.get_price(ref)
except Exception:
price = None
if price is not None:
priced += 1
if not was_fresh:
fetched += 1
else:
missing += 1
# Only pause when we actually hit the network, so re-runs over an
# already-cached collection stay fast.
if not was_fresh:
self.socket.sleep(0.2)
self.complete(
message=(
'{priced} set(s) now have a value ({fetched} newly fetched); '
'{missing} had none available.'
).format(priced=priced, fetched=fetched, missing=missing),
)
# Update the progress auto-incrementing
def auto_progress(
self,
+3
View File
@@ -0,0 +1,3 @@
SELECT "bag", "part", "color", "spare", "checked", "missing"
FROM "bricktracker_bag_parts"
WHERE "bricktracker_bag_parts"."id" IS NOT DISTINCT FROM :id
@@ -0,0 +1,4 @@
INSERT INTO "bricktracker_bag_parts" ("id", "bag", "part", "color", "spare", "checked")
VALUES (:id, :bag, :part, :color, :spare, :checked)
ON CONFLICT("id", "bag", "part", "color", "spare")
DO UPDATE SET "checked" = excluded."checked"
@@ -0,0 +1,4 @@
INSERT INTO "bricktracker_bag_parts" ("id", "bag", "part", "color", "spare", "missing")
VALUES (:id, :bag, :part, :color, :spare, :missing)
ON CONFLICT("id", "bag", "part", "color", "spare")
DO UPDATE SET "missing" = excluded."missing"
@@ -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",
+17
View File
@@ -0,0 +1,17 @@
-- description: Add sidecar_set_cache table for brickset-sidecar metadata and price caching
BEGIN TRANSACTION;
-- Caches responses from the optional brickset-sidecar so we never hammer it.
-- payload : JSON of GET /sets/{ref} (effectively permanent, refresh on demand)
-- price_payload : JSON of GET /sets/{ref}/price (honours SIDECAR_PRICE_CACHE_HOURS TTL)
-- *_fetched_at : epoch seconds (REAL) of when each payload was stored
CREATE TABLE IF NOT EXISTS "sidecar_set_cache" (
"set_ref" TEXT NOT NULL PRIMARY KEY,
"payload" TEXT,
"price_payload" TEXT,
"fetched_at" REAL,
"price_fetched_at" REAL
);
COMMIT;
+23
View File
@@ -0,0 +1,23 @@
-- description: Add typed custom fields (admin-defined per-set fields)
BEGIN TRANSACTION;
-- Defines each custom field: an id, a name and a type (text / number / date).
-- The type only drives the input widget + light validation; the per-set value
-- is always stored as TEXT in a dynamic column on bricktracker_set_custom_fields.
CREATE TABLE "bricktracker_metadata_custom_fields" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT NOT NULL DEFAULT 'text',
PRIMARY KEY("id")
);
-- Holds the per-set value of each custom field. A "custom_field_{id}" TEXT
-- column is added for every field created (mirroring the tag/status pattern).
CREATE TABLE "bricktracker_set_custom_fields" (
"id" TEXT NOT NULL,
PRIMARY KEY("id"),
FOREIGN KEY("id") REFERENCES "bricktracker_sets"("id")
);
COMMIT;
+21
View File
@@ -0,0 +1,21 @@
-- description: Add per-bag part state (checked/missing) for sidecar bag inventories
BEGIN TRANSACTION;
-- Per-bag progress for sets with a sidecar bag inventory. The bag composition
-- itself is never persisted (joined from the sidecar at render time); this
-- only stores what the user ticked/typed per bag. The main parts row's
-- missing count is kept as the sum of these per-bag values client-side.
CREATE TABLE "bricktracker_bag_parts" (
"id" TEXT NOT NULL,
"bag" TEXT NOT NULL,
"part" TEXT NOT NULL,
"color" INTEGER NOT NULL,
"spare" INTEGER NOT NULL DEFAULT 0,
"checked" INTEGER NOT NULL DEFAULT 0,
"missing" INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY("id", "bag", "part", "color", "spare"),
FOREIGN KEY("id") REFERENCES "bricktracker_sets"("id")
);
COMMIT;
@@ -65,6 +65,10 @@ INNER JOIN "rebrickable_parts"
ON "parts_combined"."part" = "rebrickable_parts"."part"
AND "parts_combined"."color" = "rebrickable_parts"."color_id"
{% if skip_spare_parts %}
WHERE "parts_combined"."spare" = 0
{% endif %}
GROUP BY
"parts_combined"."part",
"parts_combined"."color",
+31
View File
@@ -5,6 +5,37 @@
{% block total_damaged %}{% endblock %}
{# Per part+color set/minifigure counts so each sub-card shows its own totals
(same #159 inherited-count bug as with_different_color). Mirrors all.sql. #}
{% block total_sets %}
IFNULL(COUNT(DISTINCT CASE WHEN "combined"."source_type" = 'set' THEN "combined"."id" ELSE NULL END), 0) AS "total_sets",
{% endblock %}
{% block total_minifigures %}
SUM(IFNULL("minifigure_quantities"."quantity", 0)) AS "total_minifigures"
{% endblock %}
{% block join %}
-- Join to get minifigure quantities from both set-based and individual minifigures
LEFT JOIN (
SELECT
"bricktracker_minifigures"."id",
"bricktracker_minifigures"."figure",
"bricktracker_minifigures"."quantity"
FROM "bricktracker_minifigures"
UNION ALL
SELECT
"bricktracker_individual_minifigures"."id",
"bricktracker_individual_minifigures"."figure",
"bricktracker_individual_minifigures"."quantity"
FROM "bricktracker_individual_minifigures"
) AS "minifigure_quantities"
ON "combined"."id" IS NOT DISTINCT FROM "minifigure_quantities"."id"
AND "combined"."figure" IS NOT DISTINCT FROM "minifigure_quantities"."figure"
{% endblock %}
{% block where %}
WHERE "rebrickable_parts"."print" IS NOT DISTINCT FROM :print
AND "combined"."color" IS NOT DISTINCT FROM :color
+3
View File
@@ -12,4 +12,7 @@ IFNULL("combined"."damaged", 0) AS "total_damaged",
{% block where %}
WHERE "combined"."id" IS NOT DISTINCT FROM :id
AND "combined"."figure" IS NOT DISTINCT FROM :figure
{% if skip_spare_parts %}
AND "combined"."spare" = 0
{% endif %}
{% endblock %}
@@ -5,6 +5,38 @@
{% block total_damaged %}{% endblock %}
{# Compute per part+color set/minifigure counts so each sub-card shows its own
totals (fixes #159: they used to inherit the parent part's counts). Mirrors
part/list/all.sql. #}
{% block total_sets %}
IFNULL(COUNT(DISTINCT CASE WHEN "combined"."source_type" = 'set' THEN "combined"."id" ELSE NULL END), 0) AS "total_sets",
{% endblock %}
{% block total_minifigures %}
SUM(IFNULL("minifigure_quantities"."quantity", 0)) AS "total_minifigures"
{% endblock %}
{% block join %}
-- Join to get minifigure quantities from both set-based and individual minifigures
LEFT JOIN (
SELECT
"bricktracker_minifigures"."id",
"bricktracker_minifigures"."figure",
"bricktracker_minifigures"."quantity"
FROM "bricktracker_minifigures"
UNION ALL
SELECT
"bricktracker_individual_minifigures"."id",
"bricktracker_individual_minifigures"."figure",
"bricktracker_individual_minifigures"."quantity"
FROM "bricktracker_individual_minifigures"
) AS "minifigure_quantities"
ON "combined"."id" IS NOT DISTINCT FROM "minifigure_quantities"."id"
AND "combined"."figure" IS NOT DISTINCT FROM "minifigure_quantities"."figure"
{% endblock %}
{% block where %}
WHERE "combined"."color" IS DISTINCT FROM :color
AND "combined"."part" IS NOT DISTINCT FROM :part
@@ -58,10 +58,14 @@ INNER JOIN (
) "null_sums"
-- bricklink_color_id is intentionally NOT a trigger: it is an INTEGER
-- column so it cannot carry the empty-string "confirmed missing" sentinel,
-- and BrickLink mappings can be absent upstream forever. color_name and
-- part_num still trigger because NULL there means "never fetched"; once a
-- refresh confirms no mapping they are written as '' and stop matching (#163).
WHERE "null_rgb" > 0
OR "null_transparent" > 0
OR "null_url" > 0
OR "null_bricklink_color_id" > 0
OR "null_bricklink_color_name" > 0
OR "null_bricklink_part_num" > 0
) "null_join"
+3
View File
@@ -23,6 +23,9 @@ SELECT
{% block statuses %}
{% if statuses %}{{ statuses }},{% endif %}
{% endblock %}
{% block custom_fields %}
{% if custom_fields %}{{ custom_fields }},{% endif %}
{% endblock %}
{% block total_missing %}
NULL AS "total_missing", -- dummy for order: total_missing
{% endblock %}
+5
View File
@@ -32,6 +32,11 @@ LEFT JOIN "bricktracker_set_tags"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_tags"."id"
{% endif %}
{% if custom_fields %}
LEFT JOIN "bricktracker_set_custom_fields"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_custom_fields"."id"
{% endif %}
-- LEFT JOIN + SELECT to avoid messing the total
LEFT JOIN (
SELECT
+3
View File
@@ -10,6 +10,9 @@ WHERE "bricktracker_parts"."id" IS NOT DISTINCT FROM '{{ id }}';
DELETE FROM "bricktracker_minifigures"
WHERE "bricktracker_minifigures"."id" IS NOT DISTINCT FROM '{{ id }}';
DELETE FROM "bricktracker_bag_parts"
WHERE "bricktracker_bag_parts"."id" IS NOT DISTINCT FROM '{{ id }}';
DELETE FROM "bricktracker_set_tags"
WHERE "bricktracker_set_tags"."id" IS NOT DISTINCT FROM '{{ id }}';
+26 -2
View File
@@ -24,7 +24,11 @@ AND "rebrickable_sets"."year" = {{ year_filter }}
{% endif %}
{% if storage_filter %}
{% if storage_filter.startswith('-') %}
{% if storage_filter == '__none__' %}
AND ("bricktracker_sets"."storage" IS NULL OR "bricktracker_sets"."storage" = '')
{% elif storage_filter == '-__none__' %}
AND "bricktracker_sets"."storage" IS NOT NULL AND "bricktracker_sets"."storage" != ''
{% elif storage_filter.startswith('-') %}
AND ("bricktracker_sets"."storage" IS NULL OR "bricktracker_sets"."storage" != '{{ storage_filter[1:] }}')
{% else %}
AND "bricktracker_sets"."storage" = '{{ storage_filter }}'
@@ -32,13 +36,33 @@ AND "bricktracker_sets"."storage" = '{{ storage_filter }}'
{% endif %}
{% if purchase_location_filter %}
{% if purchase_location_filter.startswith('-') %}
{% if purchase_location_filter == '__none__' %}
AND ("bricktracker_sets"."purchase_location" IS NULL OR "bricktracker_sets"."purchase_location" = '')
{% elif purchase_location_filter == '-__none__' %}
AND "bricktracker_sets"."purchase_location" IS NOT NULL AND "bricktracker_sets"."purchase_location" != ''
{% elif purchase_location_filter.startswith('-') %}
AND ("bricktracker_sets"."purchase_location" IS NULL OR "bricktracker_sets"."purchase_location" != '{{ purchase_location_filter[1:] }}')
{% else %}
AND "bricktracker_sets"."purchase_location" = '{{ purchase_location_filter }}'
{% endif %}
{% endif %}
{% if parts_min %}
AND "rebrickable_sets"."number_of_parts" >= {{ parts_min }}
{% endif %}
{% if parts_max %}
AND "rebrickable_sets"."number_of_parts" <= {{ parts_max }}
{% endif %}
{% if year_min %}
AND "rebrickable_sets"."year" >= {{ year_min }}
{% endif %}
{% if year_max %}
AND "rebrickable_sets"."year" <= {{ year_max }}
{% endif %}
{% if status_filter %}
{% if status_filter == 'has-missing' %}
AND IFNULL("problem_join"."total_missing", 0) > 0
+56 -2
View File
@@ -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 %}
@@ -108,7 +122,19 @@ AND "rebrickable_sets"."year" = {{ year_filter }}
{% endif %}
{% if storage_filter %}
{% if storage_filter.startswith('-') %}
{% if storage_filter == '__none__' %}
AND EXISTS (
SELECT 1 FROM "bricktracker_sets" bs_filter
WHERE bs_filter."set" = "rebrickable_sets"."set"
AND (bs_filter."storage" IS NULL OR bs_filter."storage" = '')
)
{% elif storage_filter == '-__none__' %}
AND NOT EXISTS (
SELECT 1 FROM "bricktracker_sets" bs_filter
WHERE bs_filter."set" = "rebrickable_sets"."set"
AND (bs_filter."storage" IS NULL OR bs_filter."storage" = '')
)
{% elif storage_filter.startswith('-') %}
AND NOT EXISTS (
SELECT 1 FROM "bricktracker_sets" bs_filter
WHERE bs_filter."set" = "rebrickable_sets"."set"
@@ -124,7 +150,19 @@ AND EXISTS (
{% endif %}
{% if purchase_location_filter %}
{% if purchase_location_filter.startswith('-') %}
{% if purchase_location_filter == '__none__' %}
AND EXISTS (
SELECT 1 FROM "bricktracker_sets" bs_filter
WHERE bs_filter."set" = "rebrickable_sets"."set"
AND (bs_filter."purchase_location" IS NULL OR bs_filter."purchase_location" = '')
)
{% elif purchase_location_filter == '-__none__' %}
AND NOT EXISTS (
SELECT 1 FROM "bricktracker_sets" bs_filter
WHERE bs_filter."set" = "rebrickable_sets"."set"
AND (bs_filter."purchase_location" IS NULL OR bs_filter."purchase_location" = '')
)
{% elif purchase_location_filter.startswith('-') %}
AND NOT EXISTS (
SELECT 1 FROM "bricktracker_sets" bs_filter
WHERE bs_filter."set" = "rebrickable_sets"."set"
@@ -139,6 +177,22 @@ AND EXISTS (
{% endif %}
{% endif %}
{% if parts_min %}
AND "rebrickable_sets"."number_of_parts" >= {{ parts_min }}
{% endif %}
{% if parts_max %}
AND "rebrickable_sets"."number_of_parts" <= {{ parts_max }}
{% endif %}
{% if year_min %}
AND "rebrickable_sets"."year" >= {{ year_min }}
{% endif %}
{% if year_max %}
AND "rebrickable_sets"."year" <= {{ year_max }}
{% endif %}
{% if status_filter %}
{% if status_filter == 'has-storage' %}
AND EXISTS (
@@ -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
+33 -21
View File
@@ -39,6 +39,7 @@ individual_part_stats AS (
COALESCE(SUM("missing"), 0) AS total_missing_individual_parts,
COALESCE(SUM("damaged"), 0) AS total_damaged_individual_parts,
COUNT(CASE WHEN "purchase_price" IS NOT NULL AND "lot_id" IS NULL THEN 1 END) AS individual_parts_with_price,
COUNT(CASE WHEN "lot_id" IS NULL THEN 1 END) AS individual_parts_standalone,
COALESCE(ROUND(SUM(CASE WHEN "lot_id" IS NULL THEN "purchase_price" END), 2), 0) AS individual_parts_total_cost
FROM "bricktracker_individual_parts"
),
@@ -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
@@ -181,6 +189,9 @@ SELECT
minifig_stats.total_minifigures_count,
minifig_stats.unique_minifigures,
-- Part lot statistics
part_lot_stats.total_part_lots,
-- Financial statistics (set-only for backwards compatibility)
set_stats.sets_with_price,
set_stats.total_cost,
@@ -190,6 +201,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,
@@ -201,4 +213,4 @@ SELECT
set_stats.sets_with_storage,
set_stats.sets_with_purchase_location
FROM set_stats, part_stats, minifig_stats, rebrickable_stats, financial_stats
FROM set_stats, part_stats, minifig_stats, part_lot_stats, rebrickable_stats, financial_stats
@@ -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 -3
View File
@@ -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;
+163 -5
View File
@@ -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]:
@@ -78,12 +81,167 @@ class BrickStatistics:
'unique_parts': overview.get('unique_parts') or 0,
'total_minifigures_count': overview.get('total_minifigures_count') or 0,
'unique_minifigures': overview.get('unique_minifigures') or 0,
'total_part_lots': overview.get('total_part_lots') or 0,
'total_missing_parts': overview.get('total_missing_parts') or 0,
'total_damaged_parts': overview.get('total_damaged_parts') or 0,
'storage_locations_used': overview.get('storage_locations_used') or 0,
'purchase_locations_used': overview.get('purchase_locations_used') or 0
}
def get_instructions_summary(self) -> dict[str, Any] | None:
"""Instruction coverage across the collection (#154).
Instructions live on the filesystem (INSTRUCTIONS_FOLDER), not in the
database, so this intersects the distinct collection set numbers with the
cached instructions file list. Returns None when instructions are hidden.
"""
from flask import current_app
if current_app.config.get('HIDE_SET_INSTRUCTIONS', False):
return None
from .instructions_list import BrickInstructionsList
instructions = BrickInstructionsList()
rows = self.sql.fetchall('statistics/set_numbers')
set_numbers = {row['set'] for row in rows}
with_instructions = sum(
1 for number in set_numbers if number in instructions.sets
)
unique_sets = len(set_numbers)
return {
'instruction_files': instructions.sets_total,
'sets_with_instructions': with_instructions,
'unique_sets': unique_sets,
'percentage_with_instructions': min(round(
(with_instructions / max(unique_sets, 1)) * 100, 1
), 100.0),
}
def get_sidecar_pricing_summary(self) -> dict[str, Any] | None:
"""Collection-wide paid / retail (MSRP) / BrickLink market comparison.
Pulls cached metadata + price for the whole collection from the sidecar
in one bulk call and aggregates in Python (the sidecar is the single
cache). Returns None when the sidecar is disabled or unreachable.
"""
from .sidecar import BrickSidecar
if not BrickSidecar.enabled():
return None
try:
instances = self.sql.fetchall('statistics/sidecar_sets')
except Exception as exception:
logger.debug('sidecar pricing summary failed: %s', exception)
return None
instances = [dict(row) for row in instances]
if not instances:
return None
refs = list({row['set_ref'] for row in instances if row.get('set_ref')})
try:
bulk = BrickSidecar.get_sets_bulk(refs)
except Exception as exception:
logger.debug('sidecar bulk fetch failed: %s', exception)
return None
def to_number(value: Any) -> float | None:
try:
return float(value) if value is not None else None
except (TypeError, ValueError):
return None
data: dict[str, Any] = {
'total_sets': 0,
'total_paid': 0.0, 'sets_with_paid': 0,
'total_msrp': 0.0, 'sets_with_msrp': 0,
'total_market_new': 0.0, 'sets_with_market': 0,
'total_market_used': 0.0, 'sets_with_market_used': 0,
'paid_where_msrp': 0.0, 'msrp_where_paid': 0.0,
'paid_where_market': 0.0, 'market_where_paid': 0.0,
'paid_where_market_used': 0.0, 'market_used_where_paid': 0.0,
'market_currency': None,
}
# One row per set instance, mirroring the old per-instance aggregation.
for row in instances:
set_data = bulk.get(row.get('set_ref')) or {}
price_block = set_data.get('bricklink_price') or {}
paid = to_number(row.get('purchase_price'))
msrp = BrickSidecar.retail_price(set_data) if set_data else None
market_new = to_number(price_block.get('new_avg'))
market_used = to_number(price_block.get('used_avg'))
data['total_sets'] += 1
if paid is not None:
data['total_paid'] += paid
data['sets_with_paid'] += 1
if msrp is not None:
data['total_msrp'] += msrp
data['sets_with_msrp'] += 1
if market_new is not None:
data['total_market_new'] += market_new
data['sets_with_market'] += 1
if market_used is not None:
data['total_market_used'] += market_used
data['sets_with_market_used'] += 1
if msrp is not None and paid is not None:
data['paid_where_msrp'] += paid
data['msrp_where_paid'] += msrp
if market_new is not None and paid is not None:
data['paid_where_market'] += paid
data['market_where_paid'] += market_new
if market_used is not None and paid is not None:
data['paid_where_market_used'] += paid
data['market_used_where_paid'] += market_used
if data['market_currency'] is None and price_block.get('currency_code'):
data['market_currency'] = price_block.get('currency_code')
def number(key: str) -> float:
value = data.get(key)
try:
return float(value) if value is not None else 0.0
except (TypeError, ValueError):
return 0.0
# Savings vs retail and value change vs paid, computed only across the
# sets where both sides of the comparison are known.
data['total_saved_vs_msrp'] = round(
number('msrp_where_paid') - number('paid_where_msrp'), 2
)
data['total_gain_vs_paid'] = round(
number('market_where_paid') - number('paid_where_market'), 2
)
data['total_gain_vs_paid_used'] = round(
number('market_used_where_paid') - number('paid_where_market_used'), 2
)
data['retail_currency'] = BrickSidecar.retail_currency()
# Currency the user records purchase prices in (may be a symbol such as
# '$' or 'kr'). Compared against the retail/market ISO codes through the
# symbol map so '$' vs 'USD' and 'kr' vs 'DKK' are NOT flagged.
from flask import current_app
paid_currency = str(
current_app.config.get('PURCHASE_CURRENCY', '') or ''
).strip()
data['paid_currency'] = paid_currency
mismatch = False
if data.get('sets_with_paid'):
if not BrickSidecar.same_currency(paid_currency, data.get('market_currency')):
mismatch = True
if not BrickSidecar.same_currency(paid_currency, data['retail_currency']):
mismatch = True
data['currency_mismatch'] = mismatch
return data
def get_sets_by_year_statistics(self) -> list[dict[str, Any]]:
"""Get statistics grouped by LEGO set release year"""
results = self.sql.fetchall('statistics/sets_by_year')
+2 -2
View File
@@ -1,4 +1,4 @@
from typing import Final
__version__: Final[str] = '1.4.0'
__database_version__: Final[int] = 27
__version__: Final[str] = '1.5.0'
__database_version__: Final[int] = 30
+16
View File
@@ -11,6 +11,8 @@ from ..exceptions import exception_handler
from ...instructions_list import BrickInstructionsList
from ...rebrickable_image import RebrickableImage
from ...retired_list import BrickRetiredList
from ...set_custom_field import BrickSetCustomField
from ...set_custom_field_list import BrickSetCustomFieldList
from ...set_owner import BrickSetOwner
from ...set_owner_list import BrickSetOwnerList
from ...set_purchase_location import BrickSetPurchaseLocation
@@ -23,6 +25,7 @@ from ...set_tag import BrickSetTag
from ...set_tag_list import BrickSetTagList
from ...sql_counter import BrickCounter
from ...sql import BrickSQL
from ...socket import MESSAGES
from ...theme_list import BrickThemeList
logger = logging.getLogger(__name__)
@@ -128,6 +131,7 @@ def admin() -> str:
database_upgrade_needed: bool = False
database_version: int = -1
instructions: BrickInstructionsList | None = None
metadata_custom_fields: list[BrickSetCustomField] = []
metadata_owners: list[BrickSetOwner] = []
metadata_purchase_locations: list[BrickSetPurchaseLocation] = []
metadata_statuses: list[BrickSetStatus] = []
@@ -147,6 +151,7 @@ def admin() -> str:
instructions = BrickInstructionsList()
metadata_custom_fields = BrickSetCustomFieldList.list()
metadata_owners = BrickSetOwnerList.list()
metadata_purchase_locations = BrickSetPurchaseLocationList.list()
metadata_statuses = BrickSetStatusList.list(all=True)
@@ -175,6 +180,7 @@ def admin() -> str:
open_image = request.args.get('open_image', None)
open_instructions = request.args.get('open_instructions', None)
open_logout = request.args.get('open_logout', None)
open_custom_field = request.args.get('open_custom_field', None)
open_metadata = request.args.get('open_metadata', None)
open_owner = request.args.get('open_owner', None)
open_purchase_location = request.args.get('open_purchase_location', None)
@@ -186,6 +192,7 @@ def admin() -> str:
open_metadata = (
open_metadata or
open_custom_field or
open_owner or
open_purchase_location or
open_status or
@@ -211,8 +218,10 @@ def admin() -> str:
open_logout = should_expand('authentication', open_logout)
open_retired = should_expand('retired', open_retired)
open_theme = should_expand('theme', open_theme)
open_value = should_expand('value', request.args.get('open_value', None))
# Metadata sub-sections
open_custom_field = should_expand('custom_field', open_custom_field)
open_owner = should_expand('owner', open_owner)
open_purchase_location = should_expand('purchase_location', open_purchase_location)
open_status = should_expand('status', open_status)
@@ -222,6 +231,7 @@ def admin() -> str:
# Recalculate metadata section based on sub-sections or direct config
open_metadata = (
should_expand('metadata', open_metadata) or
open_custom_field or
open_owner or
open_purchase_location or
open_status or
@@ -265,6 +275,7 @@ def admin() -> str:
database_upgrade_needed=database_upgrade_needed,
database_version=database_version,
instructions=instructions,
metadata_custom_fields=metadata_custom_fields,
metadata_owners=metadata_owners,
metadata_purchase_locations=metadata_purchase_locations,
metadata_statuses=metadata_statuses,
@@ -278,6 +289,7 @@ def admin() -> str:
open_image=open_image,
open_instructions=open_instructions,
open_logout=open_logout,
open_custom_field=open_custom_field,
open_metadata=open_metadata,
open_owner=open_owner,
open_purchase_location=open_purchase_location,
@@ -286,6 +298,10 @@ def admin() -> str:
open_storage=open_storage,
open_tag=open_tag,
open_theme=open_theme,
open_value=open_value,
messages=MESSAGES,
path=current_app.config['SOCKET_PATH'],
namespace=current_app.config['SOCKET_NAMESPACE'],
owner_error=request.args.get('owner_error'),
purchase_location_error=request.args.get('purchase_location_error'),
retired=BrickRetiredList(),
+98
View File
@@ -0,0 +1,98 @@
from flask import (
Blueprint,
jsonify,
redirect,
request,
render_template,
url_for,
)
from flask_login import login_required
from werkzeug.wrappers.response import Response
from ..exceptions import exception_handler
from ...reload import reload
from ...set_custom_field import BrickSetCustomField
admin_custom_field_page = Blueprint(
'admin_custom_field',
__name__,
url_prefix='/admin/custom_field'
)
# Add a metadata custom field
@admin_custom_field_page.route('/add', methods=['POST'])
@login_required
@exception_handler(
__file__,
post_redirect='admin.admin',
error_name='custom_field_error',
open_custom_field=True
)
def add() -> Response:
BrickSetCustomField().from_form(request.form).insert()
reload()
return redirect(url_for('admin.admin', open_custom_field=True))
# Delete the metadata custom field
@admin_custom_field_page.route('<id>/delete', methods=['GET'])
@login_required
@exception_handler(__file__)
def delete(*, id: str) -> str:
return render_template(
'admin.html',
delete_custom_field=True,
custom_field=BrickSetCustomField().select_specific(id),
custom_field_error=request.args.get('custom_field_error')
)
# Actually delete the metadata custom field
@admin_custom_field_page.route('<id>/delete', methods=['POST'])
@login_required
@exception_handler(
__file__,
post_redirect='admin_custom_field.delete',
error_name='custom_field_error'
)
def do_delete(*, id: str) -> Response:
custom_field = BrickSetCustomField().select_specific(id)
custom_field.delete()
reload()
return redirect(url_for('admin.admin', open_custom_field=True))
# Change a field of a metadata custom field (name or type)
@admin_custom_field_page.route('/<id>/field/<name>', methods=['POST'])
@login_required
@exception_handler(__file__, json=True)
def update_field(*, id: str, name: str) -> Response:
custom_field = BrickSetCustomField().select_specific(id)
value = custom_field.update_field(name, json=request.json)
reload()
return jsonify({'value': value})
# Rename the metadata custom field
@admin_custom_field_page.route('<id>/rename', methods=['POST'])
@login_required
@exception_handler(
__file__,
post_redirect='admin.admin',
error_name='custom_field_error',
open_custom_field=True
)
def rename(*, id: str) -> Response:
custom_field = BrickSetCustomField().select_specific(id)
custom_field.from_form(request.form).rename()
reload()
return redirect(url_for('admin.admin', open_custom_field=True))
+8 -1
View File
@@ -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,
}
)
+9 -2
View File
@@ -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})
+368
View File
@@ -1,4 +1,5 @@
import logging
import os
from flask import (
Blueprint,
@@ -13,12 +14,18 @@ from flask_login import login_required
from werkzeug.wrappers.response import Response
from .exceptions import exception_handler
from ..bag_part import update_state as update_bag_part_state
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 bag_inventory as sidecar_bag_inventory
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 +33,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__)
@@ -49,6 +57,21 @@ def list() -> str:
year_filter = request.args.get('year')
duplicate_filter = request.args.get('duplicate', '').lower() == 'true'
# Numeric range filters (#141): blank/invalid means "no bound"
def _int_arg(name: str) -> int | None:
value = request.args.get(name)
if not value:
return None
try:
return int(value)
except ValueError:
return None
parts_min = _int_arg('parts_min')
parts_max = _int_arg('parts_max')
year_min = _int_arg('year_min')
year_max = _int_arg('year_max')
# Get pagination configuration
per_page, is_mobile = get_pagination_config('sets')
use_pagination = per_page > 0
@@ -69,6 +92,10 @@ def list() -> str:
tag_filter=tag_filter,
year_filter=year_filter,
duplicate_filter=duplicate_filter,
parts_min=parts_min,
parts_max=parts_max,
year_min=year_min,
year_max=year_max,
use_consolidated=current_app.config['SETS_CONSOLIDATION']
)
@@ -105,6 +132,10 @@ def list() -> str:
'current_tag_filter': tag_filter,
'current_year_filter': year_filter,
'current_duplicate_filter': duplicate_filter,
'current_parts_min_filter': parts_min,
'current_parts_max_filter': parts_max,
'current_year_min_filter': year_min,
'current_year_max_filter': year_max,
'brickset_statuses': BrickSetStatusList.list(),
**set_metadata_lists(as_class=True)
}
@@ -225,6 +256,107 @@ 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
has_description = 'description' 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', '')}
)
if has_description:
brickset.update_description(
{'value': changes.get('description', '')}
)
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 +406,30 @@ def details(*, id: str) -> str:
# Load the specific set
item = BrickSet().select_specific(id)
# Sidecar enrichment + price comparison, always cache-only here: when
# SIDECAR_AUTO_FETCH_PRICE is on, the browser refreshes the price card
# in the background after load (price_card(), TTL-aware), so the
# potentially multi-second BrickLink scrape never blocks a page render.
sidecar_summary = sidecar_summarize(
item.fields.set,
purchase_price=item.fields.purchase_price,
)
# Load the parts once; reused by the bag join and the template
parts = item.parts()
# Per-bag inventory (BrickData): joined onto the part rows at render
# time, never persisted. Any failure degrades to "no bag UI".
# tables=False: only the bag list + audit breakdown are needed here; the
# bag tables themselves are lazy-loaded via bags_tables() on first open.
sidecar_bags, bag_breakdown = None, {}
if sidecar_summary and sidecar_summary.get('has_bags'):
sidecar_bags, bag_breakdown = sidecar_bag_inventory(
item,
parts,
tables=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 +454,10 @@ 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,
sidecar_bags=sidecar_bags,
bag_breakdown=bag_breakdown,
set_parts=parts,
**set_metadata_lists(as_class=True)
)
else:
@@ -307,6 +467,10 @@ def details(*, id: str) -> str:
item=item,
open_instructions=request.args.get('open_instructions'),
brickset_statuses=BrickSetStatusList.list(all=True),
sidecar_summary=sidecar_summary,
sidecar_bags=sidecar_bags,
bag_breakdown=bag_breakdown,
set_parts=parts,
**set_metadata_lists(as_class=True)
)
@@ -401,6 +565,83 @@ def checked_part(
return jsonify({'checked': checked})
# TTL-aware market value refresh, fetched by the browser after the page
# renders (SIDECAR_AUTO_FETCH_PRICE). Returns the re-rendered price card.
@set_page.route('/<id>/price/card', methods=['GET'])
@exception_handler(__file__)
def price_card(*, id: str) -> str:
item = BrickSet().select_specific(id)
sidecar_summary = sidecar_summarize(
item.fields.set,
purchase_price=item.fields.purchase_price,
fetch_price=True,
)
return render_template(
'set/price_card.html',
item=item,
sidecar_summary=sidecar_summary,
)
# Lazy-loaded bag tables fragment, fetched when the Bags accordion is
# first opened (keeps ~half the DOM out of the initial page load)
@set_page.route('/<id>/bags/tables', methods=['GET'])
@exception_handler(__file__)
def bags_tables(*, id: str) -> str:
item = BrickSet().select_specific(id)
sidecar_bags, _ = sidecar_bag_inventory(item, item.parts())
return render_template(
'set/bags_tables.html',
item=item,
sidecar_bags=sidecar_bags or [],
)
# Update per-bag part state (sidecar bag inventory walkthrough)
@set_page.route('/<id>/bags/<bag>/parts/<part>/<int:color>/<int:spare>/<state>', methods=['POST']) # noqa: E501
@login_required
@exception_handler(__file__, json=True)
def bag_part_state(
*,
id: str,
bag: str,
part: str,
color: int,
spare: int,
state: str,
) -> Response:
# Validates the set exists (raises otherwise)
brickset = BrickSet().select_specific(id)
value = update_bag_part_state(
brickset.fields.id,
bag,
part,
color,
spare,
state,
request.json,
)
# Info
logger.info('Set {set} ({id}): updated bag {bag} part ({part} color: {color}, spare: {spare}) {state} to {value}'.format( # noqa: E501
set=brickset.fields.set,
id=brickset.fields.id,
bag=bag,
part=part,
color=color,
spare=spare,
state=state,
value=value,
))
return jsonify({state: value})
# Refresh a set
@set_page.route('/refresh/<set>/', methods=['GET'])
@set_page.route('/<id>/refresh', methods=['GET'])
@@ -422,3 +663,130 @@ def refresh(*, id: str | None = None, set: str | None = None) -> str:
namespace=current_app.config['SOCKET_NAMESPACE'],
messages=MESSAGES
)
# Override the cover image with a sidecar image (box / set art). Best quality
# first: tries the *_large variant, falls back to the normal resolution (which
# BrickLink almost always has where _large is missing).
@set_page.route('/<id>/cover/<image_type>', methods=['POST'])
@login_required
@exception_handler(__file__, post_redirect='set.details')
def cover_override(*, id: str, image_type: str) -> Response:
if image_type not in ('box', 'set'):
raise ErrorException('Unknown cover image type: {type}'.format(
type=image_type,
))
if not BrickSidecar.enabled():
raise ErrorException('The sidecar is not configured')
brickset = BrickSet().select_light(id)
ref = brickset.fields.set
# BrickLink box/set covers only reliably exist at normal resolution
# (the *_large variants are almost always 404), so use the normal image.
saved = BrickSidecar.save_cover_override(ref, image_type)
if not saved:
raise ErrorException(
'The sidecar has no {type} image for set {ref}'.format(
type=image_type,
ref=ref,
)
)
logger.info('Set {ref} ({id}): cover overridden with sidecar {type} art'.format( # noqa: E501
ref=ref,
id=id,
type=image_type,
))
return redirect(brickset.url())
# Override the cover with one of the Brickset additional images (0-indexed).
@set_page.route('/<id>/cover/additional/<int:index>', methods=['POST'])
@login_required
@exception_handler(__file__, post_redirect='set.details')
def cover_override_additional(*, id: str, index: int) -> Response:
if not current_app.config['SIDECAR_ADDITIONAL_IMAGES']:
raise ErrorException('Additional images are not enabled')
if not BrickSidecar.enabled():
raise ErrorException('The sidecar is not configured')
brickset = BrickSet().select_light(id)
ref = brickset.fields.set
saved = BrickSidecar.save_cover_override_from_additional(ref, index)
if not saved:
raise ErrorException(
'The sidecar has no additional image {index} for set {ref}'.format(
index=index,
ref=ref,
)
)
logger.info('Set {ref} ({id}): cover overridden with additional image {index}'.format( # noqa: E501
ref=ref,
id=id,
index=index,
))
return redirect(brickset.url())
# Restore the original Rebrickable cover: use the backup if we made one,
# otherwise delete the local file and re-download from Rebrickable.
@set_page.route('/<id>/cover/restore', methods=['POST'])
@login_required
@exception_handler(__file__, post_redirect='set.details')
def cover_restore(*, id: str) -> Response:
brickset = BrickSet().select_specific(id)
ref = brickset.fields.set
if not BrickSidecar.restore_cover(ref):
# No backup to restore: drop the local file and pull from Rebrickable.
path = BrickSidecar.cover_path(ref)
try:
if os.path.exists(path):
os.remove(path)
except OSError:
pass
RebrickableImage(brickset).download()
logger.info('Set {ref} ({id}): cover restored to Rebrickable image'.format(
ref=ref,
id=id,
))
return redirect(brickset.url())
# Lazily (re)fetch the BrickLink market value for a set and cache it. This is
# the expensive live path, so it only runs on an explicit user action.
@set_page.route('/<id>/value/refresh', methods=['POST'])
@login_required
@exception_handler(__file__, post_redirect='set.details')
def value_refresh(*, id: str) -> Response:
if not BrickSidecar.enabled():
raise ErrorException('The sidecar is not configured')
brickset = BrickSet().select_light(id)
ref = brickset.fields.set
price = BrickSidecar.get_price(ref, refresh=True)
if price is None:
raise ErrorException(
'Could not fetch a market value for set {ref}'.format(ref=ref),
)
logger.info('Set {ref} ({id}): market value refreshed'.format(
ref=ref,
id=id,
))
return redirect(brickset.url())
+53
View File
@@ -0,0 +1,53 @@
from flask import Blueprint, Response, abort, request
from ..sidecar import BrickSidecar
# Proxies sidecar (BrickData) images through BrickTracker so the browser only
# ever talks to BrickTracker. This is what makes box art, the additional-images
# carousel and the cover previews work when the sidecar is only reachable on
# the internal Docker network (e.g. http://brickdata:3335), which the browser
# itself cannot resolve. Public (no login) because these images appear on pages
# anonymous users can already see.
sidecar_page = Blueprint('sidecar', __name__, url_prefix='/sidecar')
def _image_response(data: bytes | None, mimetype: str, /) -> Response:
if data is None:
abort(404)
response = Response(data, mimetype=mimetype)
# Cache in the browser so viewing a set does not re-proxy every image.
response.headers['Cache-Control'] = 'public, max-age=86400'
return response
# Box / set art (BrickLink image proxy), e.g. /sidecar/image/box/8250-1.png
@sidecar_page.route('/image/<image_type>/<ref>.png', methods=['GET'])
def image(*, image_type: str, ref: str) -> Response:
if not BrickSidecar.enabled():
abort(404)
return _image_response(
BrickSidecar.fetch_image_bytes(image_type, ref), 'image/png',
)
# One Brickset additional image, e.g. /sidecar/additional-images/8250-1/0.
# Path kept plural ("additional-images") so it matches the BaguetteBox gallery
# filter in base.html, which keys on that string for extensionless image URLs.
@sidecar_page.route(
'/additional-images/<ref>/<int:index>', methods=['GET'],
)
def additional_image(*, ref: str, index: int) -> Response:
if not BrickSidecar.enabled():
abort(404)
thumbnail = request.args.get('thumbnail') is not None
return _image_response(
BrickSidecar.fetch_additional_image_bytes(
ref, index, thumbnail=thumbnail,
),
'image/jpeg',
)
+4
View File
@@ -32,6 +32,8 @@ def overview() -> str:
purchase_location_stats = stats.get_purchase_location_statistics()
financial_summary = stats.get_financial_summary()
collection_summary = stats.get_collection_summary()
instructions_summary = stats.get_instructions_summary()
sidecar_pricing = stats.get_sidecar_pricing_summary()
sets_by_year_stats = stats.get_sets_by_year_statistics()
purchases_by_year_stats = stats.get_purchases_by_year_statistics()
year_summary = stats.get_year_summary()
@@ -57,6 +59,8 @@ def overview() -> str:
purchase_location_statistics=purchase_location_stats,
financial_summary=financial_summary,
collection_summary=collection_summary,
instructions_summary=instructions_summary,
sidecar_pricing=sidecar_pricing,
sets_by_year_statistics=sets_by_year_stats,
purchases_by_year_statistics=purchases_by_year_stats,
year_summary=year_summary,
+8 -1
View File
@@ -11,6 +11,7 @@ from werkzeug.wrappers.response import Response
from .exceptions import exception_handler
from ..retired_list import BrickRetiredList
from ..sidecar_set import retirement_dates
from ..wish import BrickWish
from ..wish_list import BrickWishList
from ..wish_owner_list import BrickWishOwnerList
@@ -23,10 +24,16 @@ wish_page = Blueprint('wish', __name__, url_prefix='/wishes')
@wish_page.route('/', methods=['GET'])
@exception_handler(__file__)
def list() -> str:
table_collection = BrickWishList().all()
return render_template(
'wishes.html',
table_collection=BrickWishList().all(),
table_collection=table_collection,
retired=BrickRetiredList(),
# BrickData fallback for already-retired sets the CSV does not cover.
sidecar_retired=retirement_dates(
[item.fields.set for item in table_collection]
),
error=request.args.get('error'),
owners=BrickWishOwnerList.list(),
)
+7
View File
@@ -18,7 +18,14 @@ services:
# The app automatically detects and uses data/.env (no env_file needed)
# Uncomment below only if you keep .env in root for backward compatibility
# env_file: .env
networks:
- default
- brickshare
develop:
watch:
- action: rebuild
path: requirements.txt
networks:
brickshare:
external: true
+10 -64
View File
@@ -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();
}
};
});
+37 -20
View File
@@ -63,7 +63,12 @@ function handleConfigChange(element) {
// Update badge display
function updateConfigBadge(varName, value) {
const defaultValue = window.DEFAULT_CONFIG[varName];
const savedValue = window.CURRENT_CONFIG[varName];
const isChanged = JSON.stringify(value) !== JSON.stringify(defaultValue);
// Differs from what's actually persisted (current_app.config / .env), not just
// from the default: toggling a checkbox alone doesn't save it, only "Save All
// Changes" does, so this needs its own warning or the badge lies about what's live.
const isUnsaved = JSON.stringify(value) !== JSON.stringify(savedValue);
// Remove existing badges but keep them inline
const existingBadges = document.querySelectorAll(`[data-badge-var="${varName}"]`);
@@ -114,11 +119,14 @@ function updateConfigBadge(varName, value) {
label.appendChild(valueBadge);
}
// Add changed badge if needed
if (isChanged) {
// Add changed/unsaved badge if needed. Unsaved takes priority: it means this
// toggle is not actually in effect yet, no matter what it differs from.
if (isUnsaved || isChanged) {
const changedBadge = document.createElement('span');
changedBadge.className = 'badge rounded-pill text-bg-warning ms-1';
changedBadge.textContent = 'Changed';
changedBadge.className = isUnsaved
? 'badge rounded-pill text-bg-info ms-1'
: 'badge rounded-pill text-bg-warning ms-1';
changedBadge.textContent = isUnsaved ? 'Unsaved - click Save All Changes' : 'Changed';
changedBadge.setAttribute('data-badge-var', varName);
changedBadge.setAttribute('data-badge-type', 'changed');
@@ -204,14 +212,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 +299,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
+92
View File
@@ -0,0 +1,92 @@
// Keeps the main Parts table's missing count equal to the sum of the per-bag
// missing inputs for that part (a part can span several bags). Bag rows carry
// data-target-missing = the main row's input id; writing the main input and
// dispatching "change" lets the existing BrickChanger persist it.
//
// The sum always wins: a manual edit of the main field is overwritten by the
// next bag edit for that part. Parts without bag inputs stay fully manual.
(() => {
const sync = (target) => {
const main = document.getElementById(target);
if (!main || main.disabled) {
return;
}
let sum = 0;
document.querySelectorAll(`table[data-bag-table] tr[data-target-missing="${CSS.escape(target)}"] input[id*="-missing-"]`).forEach(input => {
sum += parseInt(input.value, 10) || 0;
});
// Empty string clears the field (0 missing), matching the "clear all
// missing" convention in parts-bulk-operations.js.
const value = sum > 0 ? String(sum) : '';
if (main.value !== value) {
main.value = value;
main.dispatchEvent(new Event('change', { bubbles: true }));
}
};
const handle = (e) => {
// Trusted "input" events fire per keystroke; the sum only needs to
// follow once the value is committed ("change"). The untrusted case
// is the BrickChanger clear button, which POSTs directly and only
// dispatches a programmatic "input" event.
if (e.type === 'input' && e.isTrusted) {
return;
}
const input = e.target;
if (!input.matches || !input.matches('input[id*="-missing-"]')) {
return;
}
const row = input.closest('tr[data-target-missing]');
if (row && input.closest('table[data-bag-table]')) {
sync(row.dataset.targetMissing);
}
};
document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('change', handle);
document.addEventListener('input', handle);
});
})();
// Lazy-load the bag tables the first time the Bags accordion is opened: the
// page only ships the accordion shell, the tables (hundreds of rows/inputs)
// come from the bags_tables fragment. After inserting, re-run the idempotent
// wiring helpers. The sortable library and quick-add already work through
// document-level delegation, so they pick the new tables up on their own.
(() => {
document.addEventListener('DOMContentLoaded', () => {
const container = document.getElementById('bags-lazy');
const collapse = document.getElementById('bags-inventory');
if (!container || !collapse) {
return;
}
let loaded = false;
collapse.addEventListener('show.bs.collapse', async (e) => {
// Nested bag collapses bubble the same event up
if (loaded || e.target !== collapse) {
return;
}
loaded = true;
try {
const response = await fetch(container.dataset.bagsSrc);
if (!response.ok) {
throw new Error(`Response status: ${response.status}`);
}
container.innerHTML = await response.text();
setup_changers();
window.initPartsTableFilters?.();
window.initPartsBulkOperations?.();
window.initPartsAuditModes?.();
} catch (error) {
console.log(error.message);
loaded = false; // allow retry on next open
container.innerHTML = '<div class="text-center text-danger p-3"><i class="ri-alert-line"></i> Could not load the bags. Close and reopen to retry.</div>';
}
});
});
})();
+19 -7
View File
@@ -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));
}
@@ -197,12 +201,20 @@ class BrickChanger {
}
}
// Helper to setup the changer
// Helper to setup the changer. Safe to call again after inserting new
// content (e.g. the lazy-loaded bag tables): already-wired elements are
// skipped so listeners never double up.
const setup_changers = () => document.querySelectorAll("*[data-changer-id]").forEach(
el => new BrickChanger(
el.dataset.changerPrefix,
el.dataset.changerId,
el.dataset.changerUrl,
el.dataset.changerParent
)
el => {
if (el.dataset.changerWired === "true") {
return;
}
el.dataset.changerWired = "true";
new BrickChanger(
el.dataset.changerPrefix,
el.dataset.changerId,
el.dataset.changerUrl,
el.dataset.changerParent
);
}
);
+60
View File
@@ -46,6 +46,18 @@ class BrickGridFilter {
});
}
// Numeric range filters (e.g. parts 100-500, year 2015-2020). Each input
// carries data-filter-range="<attribute>" and data-filter-bound="min"|"max".
this.ranges = [];
if (this.html_filter) {
this.html_filter.querySelectorAll("input[data-filter-range]").forEach(input => {
input.addEventListener("input", ((gridfilter) => () => {
gridfilter.filter();
})(this));
this.ranges.push(input);
});
}
if (this.html_theme) {
this.html_theme.addEventListener("change", ((grid) => () => {
grid.filter();
@@ -65,6 +77,28 @@ class BrickGridFilter {
options.search = this.html_search.value.toLowerCase();
}
// Build numeric range filters
options.ranges = {};
for (const input of this.ranges) {
const value = input.value.trim();
if (value === "") {
continue;
}
const number = parseFloat(value);
if (isNaN(number)) {
continue;
}
const attribute = input.dataset.filterRange;
const bound = input.dataset.filterBound;
if (!options.ranges[attribute]) {
options.ranges[attribute] = {};
}
options.ranges[attribute][bound] = number;
}
// Build filters
for (const select of this.selects) {
// Get the actual filter value (includes "-" prefix if toggle is in NOT mode)
@@ -140,6 +174,17 @@ class BrickGridFilter {
const isNot = filter.value.startsWith('-');
const actualValue = isNot ? filter.value.substring(1) : filter.value;
// "None" option: match cards that have no value for this
// attribute (inverted = cards that DO have a value).
if (actualValue === "__none__") {
const isEmpty = (attribute == null || attribute === "");
if (isNot ? isEmpty : !isEmpty) {
current.parentElement.classList.add("d-none");
return;
}
continue;
}
if (attribute == null) {
// If attribute is missing
if (isNot) {
@@ -191,6 +236,21 @@ class BrickGridFilter {
}
}
// Process numeric range filters (parts, year, ...)
for (const attribute in options.ranges) {
const bounds = options.ranges[attribute];
const raw = current.getAttribute(`data-${attribute}`);
const number = (raw == null || raw === "") ? NaN : parseFloat(raw);
// No value, or outside the configured bounds: hide.
if (isNaN(number)
|| (bounds.min != null && number < bounds.min)
|| (bounds.max != null && number > bounds.max)) {
current.parentElement.classList.add("d-none");
return;
}
}
// Check all searchable fields for a match
if (options.search) {
// Browse the whole dataset
+431
View File
@@ -0,0 +1,431 @@
// Audit mode: a focused, keyboard-driven modal that walks the visible parts of
// one accordion parts table, letting you check each part off and record missing
// counts without hunting for tiny checkboxes.
//
// Saving reuses the existing BrickChanger auto-save: set the value on the row's
// missing input / checked checkbox and dispatch a "change" event, exactly like
// parts-bulk-operations.js does.
class PartsAuditMode {
constructor(accordionId) {
this.accordionId = accordionId;
this.trigger = document.getElementById(`audit-mode-${accordionId}`);
if (!this.trigger) {
return;
}
this.rows = [];
this.index = 0;
this.mode = 'missing'; // 'missing' or 'found'
// Bag tables (set/bags.html) carry their own per-bag inputs, so the
// audit works exactly like on the parts table; the flag only tweaks
// the modal wording (spares note, unmatched-row message).
const accordion = document.getElementById(accordionId);
this.bagMode = !!(accordion && accordion.querySelector('table[data-bag-table]'));
this.setupModal();
this.cacheElements();
this.setupEventListeners();
}
// Build the shared modal once and reuse it across every accordion.
setupModal() {
if (document.getElementById('partsAuditModal')) {
return;
}
const modalHTML = `
<div class="modal fade" id="partsAuditModal" tabindex="-1" aria-labelledby="partsAuditModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header d-block">
<div class="d-flex align-items-center">
<h5 class="modal-title mb-0" id="partsAuditModalLabel"><i class="ri-eye-line me-2"></i>Audit parts</h5>
<span class="ms-auto me-3 text-secondary"><span id="audit-position">0 / 0</span></span>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="progress mt-2" role="progressbar" style="height: 6px;">
<div id="audit-progress-bar" class="progress-bar" style="width: 0%;"></div>
</div>
<small class="text-secondary"><span id="audit-checked-count">0</span> checked</small>
</div>
<div class="modal-body text-center">
<div class="mb-2">
<img id="audit-image" class="img-fluid border rounded audit-image" alt="">
</div>
<div class="mb-1">Quantity <span id="audit-qty">0</span></div>
<div class="mb-3"><span id="audit-name" class="fs-6"></span> <span id="audit-color" class="text-secondary ms-1"></span></div>
<div id="audit-bags" class="mb-1 d-none">
<table class="table table-sm table-bordered w-auto mx-auto mb-1">
<thead><tr><th>Bag</th><th>Qty</th></tr></thead>
<tbody id="audit-bags-body"></tbody>
</table>
</div>
<div id="audit-spares-note" class="text-secondary small mb-2 d-none">Spare parts are not included in bag quantities.</div>
<div id="audit-input-area">
<div class="btn-group mb-2" role="group" aria-label="Audit mode">
<button type="button" class="btn btn-sm" id="audit-mode-missing">Missing</button>
<button type="button" class="btn btn-sm" id="audit-mode-found">Found</button>
</div>
<div class="row justify-content-center mb-1">
<div class="col-auto">
<div class="input-group">
<span class="input-group-text" id="audit-input-label">Missing</span>
<input type="number" inputmode="numeric" pattern="[0-9]*" min="0" class="form-control text-center" id="audit-number" style="max-width: 6rem;" autocomplete="off">
</div>
</div>
</div>
<small class="text-secondary">type a number</small>
</div>
<div id="audit-no-input" class="text-secondary d-none">This section only tracks checked status.</div>
</div>
<div class="modal-footer d-flex">
<button type="button" class="btn btn-outline-secondary" id="audit-prev"><i class="ri-arrow-left-line"></i> Prev</button>
<button type="button" class="btn btn-primary ms-auto" id="audit-next">Check &amp; Next <i class="ri-arrow-right-line"></i></button>
</div>
<div class="modal-footer py-1">
<small class="text-secondary w-100 text-center">
←/→ move · type number · Enter / Space save+next
</small>
</div>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHTML);
}
cacheElements() {
this.modalEl = document.getElementById('partsAuditModal');
this.modal = bootstrap.Modal.getOrCreateInstance(this.modalEl);
this.el = {
position: document.getElementById('audit-position'),
progress: document.getElementById('audit-progress-bar'),
checkedCount: document.getElementById('audit-checked-count'),
image: document.getElementById('audit-image'),
name: document.getElementById('audit-name'),
color: document.getElementById('audit-color'),
qty: document.getElementById('audit-qty'),
bags: document.getElementById('audit-bags'),
bagsBody: document.getElementById('audit-bags-body'),
sparesNote: document.getElementById('audit-spares-note'),
inputArea: document.getElementById('audit-input-area'),
noInput: document.getElementById('audit-no-input'),
modeMissing: document.getElementById('audit-mode-missing'),
modeFound: document.getElementById('audit-mode-found'),
inputLabel: document.getElementById('audit-input-label'),
number: document.getElementById('audit-number'),
prev: document.getElementById('audit-prev'),
next: document.getElementById('audit-next'),
};
}
setupEventListeners() {
this.trigger.addEventListener('click', (e) => {
e.preventDefault();
this.start();
});
// These shared-modal controls get wired by whichever instance is created
// first; subsequent instances just re-point the handlers at the active one
// via the `activeInstance` lookup performed inside start().
if (this.modalEl.dataset.auditWired === 'true') {
return;
}
this.modalEl.dataset.auditWired = 'true';
this.el.prev.addEventListener('click', () => PartsAuditMode.active && PartsAuditMode.active.go(-1));
this.el.next.addEventListener('click', () => PartsAuditMode.active && PartsAuditMode.active.commitAndNext());
this.el.modeMissing.addEventListener('click', () => PartsAuditMode.active && PartsAuditMode.active.setMode('missing'));
this.el.modeFound.addEventListener('click', () => PartsAuditMode.active && PartsAuditMode.active.setMode('found'));
// Keyboard handling in the capture phase so navigation keys win over the
// focused number input's default cursor movement.
this.modalEl.addEventListener('keydown', (e) => {
const active = PartsAuditMode.active;
if (!active) {
return;
}
switch (e.key) {
case 'ArrowLeft':
e.preventDefault();
active.go(-1);
break;
case 'ArrowRight':
e.preventDefault();
active.go(1);
break;
case 'Enter':
e.preventDefault();
active.commitAndNext();
break;
case ' ':
case 'Spacebar':
e.preventDefault();
active.commitAndNext();
break;
// digits / Backspace fall through to the number input
}
}, true);
// Focus the number box whenever the modal opens (desktop only).
this.modalEl.addEventListener('shown.bs.modal', () => {
const active = PartsAuditMode.active;
if (active) {
active.focusInput();
}
});
}
// On phones we never auto-focus the number box: doing so pops the keyboard
// and scrolls the part image off-screen. The audit flow there is touch
// driven (tap the field only when entering a count, then Check & Next), so
// the image stays visible while you identify the part.
isMobileLayout() {
return window.matchMedia('(max-width: 575.98px)').matches;
}
focusInput() {
if (this.isMobileLayout()) {
return;
}
if (this.el.number && !this.el.inputArea.classList.contains('d-none')) {
this.el.number.focus();
this.el.number.select();
}
}
start() {
const accordion = document.getElementById(this.accordionId);
if (!accordion) {
return;
}
// Snapshot the currently visible rows in DOM (sort) order. Using a
// snapshot means saving (which re-runs the header filter) can't drop the
// row we are sitting on.
this.rows = Array.from(accordion.querySelectorAll('tbody tr')).filter(
row => !row.classList.contains('parts-filtered-out') && row.offsetParent !== null
);
if (!this.rows.length) {
return;
}
PartsAuditMode.active = this;
this.index = 0;
this.render();
this.modal.show();
}
setMode(mode) {
this.mode = mode;
this.render();
this.focusInput();
}
currentRow() {
return this.rows[this.index];
}
neededQty(row) {
const cell = row.querySelector('[data-col="quantity"]');
const value = cell ? parseInt(cell.textContent.trim(), 10) : NaN;
return Number.isNaN(value) ? 1 : value;
}
missingInput(row) {
// ids look like "part-missing-...-{id}" / "individual-part-missing-...",
// so match the same "-missing-" fragment parts-bulk-operations.js uses.
return row.querySelector('input[id*="-missing-"]');
}
checkedBox(row) {
return row.querySelector('input[id*="-checked-"][type="checkbox"]');
}
render() {
const row = this.currentRow();
if (!row) {
return;
}
// Image: prefer the full-size lightbox target, fall back to the
// thumbnail. Unmatched bag parts have none; hide the broken box.
const link = row.querySelector('a[data-lightbox]');
const img = row.querySelector('td img');
const imageSrc = (link && link.getAttribute('href')) || (img && img.getAttribute('src')) || '';
this.el.image.src = imageSrc;
this.el.image.classList.toggle('d-none', !imageSrc);
// Unmatched bag parts render as plain text without a link.
const nameLink = row.querySelector('[data-col="name"] a');
const nameCell = row.querySelector('[data-col="name"]');
this.el.name.textContent = nameLink
? nameLink.textContent.trim()
: (nameCell ? nameCell.textContent.trim() : '');
const colorCell = row.querySelector('[data-col="color"]');
this.el.color.innerHTML = colorCell ? colorCell.innerHTML : '';
const needed = this.neededQty(row);
this.el.qty.textContent = needed;
// Per-part bag breakdown (set pages with sidecar bag data). Bag-audit
// rows never carry data-bags, so this stays hidden there.
let bagData = null;
if (row.dataset.bags) {
try {
bagData = JSON.parse(row.dataset.bags);
} catch (e) {
bagData = null;
}
}
if (bagData && bagData.length) {
this.el.bagsBody.replaceChildren(...bagData.map(([bag, qty]) => {
const tr = document.createElement('tr');
const bagCell = document.createElement('td');
bagCell.textContent = bag;
const qtyCell = document.createElement('td');
qtyCell.textContent = qty;
tr.append(bagCell, qtyCell);
return tr;
}));
this.el.bags.classList.remove('d-none');
} else {
this.el.bags.classList.add('d-none');
}
// Bag inventories never list spare parts, so say so whenever bag
// quantities are on screen.
const showSparesNote = (bagData && bagData.length > 0) || this.bagMode;
this.el.sparesNote.classList.toggle('d-none', !showSparesNote);
// Toggle the input area depending on whether this table tracks missing.
const missing = this.missingInput(row);
if (missing) {
this.el.inputArea.classList.remove('d-none');
this.el.noInput.classList.add('d-none');
// Mode button styling.
this.el.modeMissing.className = 'btn btn-sm ' + (this.mode === 'missing' ? 'btn-primary' : 'btn-outline-secondary');
this.el.modeFound.className = 'btn btn-sm ' + (this.mode === 'found' ? 'btn-primary' : 'btn-outline-secondary');
this.el.inputLabel.textContent = this.mode === 'found' ? 'Found' : 'Missing';
// Prefill: missing mode shows the current missing value, found starts empty.
this.el.number.value = this.mode === 'missing' ? (missing.value || '') : '';
// Remember what the box started with so a commit that didn't change
// the number leaves the missing field (and the database) untouched.
this.originalNumber = this.el.number.value.trim();
} else {
this.el.inputArea.classList.add('d-none');
this.el.noInput.classList.remove('d-none');
this.originalNumber = '';
}
this.updateProgress();
}
updateProgress() {
const total = this.rows.length;
this.el.position.textContent = `${this.index + 1} / ${total}`;
this.el.progress.style.width = total ? `${Math.round(((this.index + 1) / total) * 100)}%` : '0%';
const checked = this.rows.filter(row => {
const box = this.checkedBox(row);
return box && box.checked;
}).length;
this.el.checkedCount.textContent = checked;
}
// Plain navigation (arrows / Prev) — clamps, never saves or closes.
go(delta) {
const next = this.index + delta;
if (next < 0 || next >= this.rows.length) {
return;
}
this.index = next;
this.render();
this.focusInput();
}
setMissingValue(input, value) {
if (input.value !== value) {
input.value = value;
input.dispatchEvent(new Event('change', { bubbles: true }));
}
}
check(row) {
const box = this.checkedBox(row);
if (box && !box.checked) {
box.checked = true;
box.dispatchEvent(new Event('change', { bubbles: true }));
}
}
// Enter / Space: record the typed number per mode, tick checked, advance.
// If the number box is unchanged from what the part loaded with, we leave the
// missing field (and the database) completely alone and only tick checked, so
// glancing past a part never triggers a redundant POST.
commitAndNext() {
const row = this.currentRow();
if (!row) {
return;
}
const input = this.missingInput(row);
if (input) {
const typed = this.el.number.value.trim();
if (typed !== this.originalNumber) {
const number = parseInt(typed, 10) || 0;
let missing;
if (this.mode === 'found') {
missing = Math.max(0, this.neededQty(row) - number);
} else {
missing = Math.max(0, number);
}
// Empty string clears the field (0 missing), matching the
// "clear all missing" convention in parts-bulk-operations.js.
this.setMissingValue(input, missing > 0 ? String(missing) : '');
}
}
this.check(row);
this.advance();
}
// Move forward after a commit; close the modal once the last part is done.
advance() {
this.updateProgress();
if (this.index + 1 >= this.rows.length) {
this.modal.hide();
return;
}
this.index += 1;
this.render();
this.focusInput();
}
}
PartsAuditMode.active = null;
// Wire up an instance per accordion that exposes an audit menu item.
// Idempotent: call again after inserting new tables (lazy-loaded bags).
const initPartsAuditModes = () => {
document.querySelectorAll('[id^="audit-mode-"]').forEach(item => {
if (item.dataset.auditTriggerWired === 'true') {
return;
}
item.dataset.auditTriggerWired = 'true';
const accordionId = item.id.replace('audit-mode-', '');
new PartsAuditMode(accordionId);
});
};
window.initPartsAuditModes = initPartsAuditModes;
document.addEventListener('DOMContentLoaded', initPartsAuditModes);
+17 -4
View File
@@ -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
@@ -171,12 +177,19 @@ class PartsBulkOperations {
}
}
// Initialize bulk operations for all part accordions when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
// Find all hamburger menus and initialize bulk operations
// Initialize bulk operations for all part accordions.
// Idempotent: call again after inserting new tables (lazy-loaded bags).
const initPartsBulkOperations = () => {
const hamburgerMenus = document.querySelectorAll('button[id^="hamburger-"]');
hamburgerMenus.forEach(button => {
if (button.dataset.bulkWired === 'true') {
return;
}
button.dataset.bulkWired = 'true';
const accordionId = button.id.replace('hamburger-', '');
new PartsBulkOperations(accordionId);
});
});
};
window.initPartsBulkOperations = initPartsBulkOperations;
document.addEventListener('DOMContentLoaded', initPartsBulkOperations);
+198
View File
@@ -0,0 +1,198 @@
// 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;
}
// Render the empty-state message as a sibling of the table rather than a
// tbody row. The sortable library iterates every <tr> in the tbody and
// reads row.cells[columnIndex], so a single colspan cell would make it
// crash with "can't access property dataset" when sorting any column
// past the first.
if (!this.emptyMessage) {
this.emptyMessage = document.createElement('div');
this.emptyMessage.className = 'parts-filter-empty text-center text-body-secondary py-3';
this.emptyMessage.textContent = 'No matching parts';
this.table.insertAdjacentElement('afterend', this.emptyMessage);
}
this.emptyMessage.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);
};
}
}
// Idempotent: call again after inserting new tables (lazy-loaded bags).
const initPartsTableFilters = () => {
document.querySelectorAll('table[data-parts-filterable="true"]').forEach(table => {
if (table.dataset.filterWired === 'true') {
return;
}
table.dataset.filterWired = 'true';
new PartsTableFilter(table);
});
};
window.initPartsTableFilters = initPartsTableFilters;
document.addEventListener('DOMContentLoaded', initPartsTableFilters);
+16
View File
@@ -1,5 +1,21 @@
// Set details page functionality
document.addEventListener('DOMContentLoaded', function() {
// Background market-value refresh (SIDECAR_AUTO_FETCH_PRICE): the page
// renders with cached values, then this swaps in the TTL-refreshed price
// card so the BrickLink scrape never blocks the page load.
const priceCard = document.getElementById('sidecar-price-card');
if (priceCard && priceCard.dataset.priceSrc) {
fetch(priceCard.dataset.priceSrc)
.then(response => {
if (!response.ok) {
throw new Error(`Response status: ${response.status}`);
}
return response.text();
})
.then(html => { priceCard.innerHTML = html; })
.catch(error => console.log(error.message));
}
const collapseElement = document.getElementById('all-instances');
const toggleIcon = document.getElementById('copies-toggle-icon');
+311
View File
@@ -0,0 +1,311 @@
// 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.Modal.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);
} else if (field === "description") {
changes.description = 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);
})();
+32 -1
View File
@@ -348,6 +348,16 @@ function setupPaginationFilterDropdowns() {
});
});
// Numeric range filters (#141: parts/year range) aren't <select> elements,
// so they're not covered by the loop above. "change" (not "input") so a
// full page navigation only fires once the field is done being edited.
const rangeInputs = document.querySelectorAll('#grid-filter input[data-filter-range]');
rangeInputs.forEach(input => {
input.addEventListener('change', () => {
performServerFilter();
});
});
function performServerFilter() {
const currentUrl = new URL(window.location);
@@ -419,6 +429,22 @@ function setupPaginationFilterDropdowns() {
currentUrl.searchParams.delete('tag');
}
// Numeric range filters (#141)
const rangeParams = {
'grid-parts-min': 'parts_min',
'grid-parts-max': 'parts_max',
'grid-year-min': 'year_min',
'grid-year-max': 'year_max',
};
for (const [inputId, param] of Object.entries(rangeParams)) {
const value = document.getElementById(inputId)?.value.trim();
if (value) {
currentUrl.searchParams.set(param, value);
} else {
currentUrl.searchParams.delete(param);
}
}
// Reset to page 1 when filtering
currentUrl.searchParams.set('page', '1');
window.location.href = currentUrl.toString();
@@ -744,7 +770,7 @@ function initializeClearFiltersButton() {
const currentUrl = new URL(window.location);
// Remove all filter parameters
const filterParams = ['status', 'theme', 'year', 'owner', 'purchase_location', 'storage', 'tag', 'duplicate'];
const filterParams = ['status', 'theme', 'year', 'owner', 'purchase_location', 'storage', 'tag', 'duplicate', 'parts_min', 'parts_max', 'year_min', 'year_max'];
filterParams.forEach(param => {
currentUrl.searchParams.delete(param);
});
@@ -775,6 +801,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')) {
+35 -1
View File
@@ -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;
}
+8
View File
@@ -23,6 +23,7 @@ class BrickSetSocket extends BrickSocket {
this.html_storage = document.getElementById(`${id}-storage`);
this.html_tags = document.getElementById(`${id}-tags`);
this.html_description = document.getElementById(`${id}-description`);
this.html_box_art = document.getElementById(`${id}-box-art`);
// Card elements
this.html_card = document.getElementById(`${id}-card`);
@@ -213,6 +214,12 @@ class BrickSetSocket extends BrickSocket {
description = this.html_description.value;
}
// Grab the box art checkbox (sidecar cover override)
let box_art = false;
if (this.html_box_art) {
box_art = this.html_box_art.checked;
}
this.socket.emit(this.messages.IMPORT_SET, {
set: (set !== undefined) ? set : this.html_input.value,
owners: owners,
@@ -223,6 +230,7 @@ class BrickSetSocket extends BrickSocket {
storage: storage,
tags: tags,
description: description,
box_art: box_art,
refresh: this.refresh
});
} else {
+35
View File
@@ -0,0 +1,35 @@
// Socket client for the bulk "value all sets" admin action. Connects, emits a
// single VALUE_ALL_SETS message and lets the generic BrickSocket base render
// the progress bar / completion message while the server prices each set.
class BrickValueSocket extends BrickSocket {
constructor(id, path, namespace, messages) {
// Single-shot operation (not bulk): the base complete() then stops the
// spinner, shows the success message and re-enables the button.
super(id, path, namespace, messages, false);
this.html_button = document.getElementById(`${id}-button`);
if (this.html_button) {
this.html_button.addEventListener("click", ((bricksocket) => () => {
bricksocket.start();
})(this));
}
}
// Kick off the server-side valuation
start() {
if (this.disabled) {
return;
}
// Connect lazily on first use so the host page (e.g. the admin page)
// does not open a socket until the action is actually triggered.
this.setup();
this.clear();
this.spinner(true);
this.toggle(false);
this.socket.emit(this.messages.VALUE_ALL_SETS, {});
}
}
+118
View File
@@ -12,6 +12,13 @@
backdrop-filter: blur(8px) contrast(60%);
}
/* When the cover is a carousel, each slide carries its own blurred backdrop
so every image keeps the same framed, fixed-height look as a single cover. */
.card-img.carousel .carousel-item {
background-repeat: no-repeat;
background-size: cover;
}
.card-img img.card-last-img {
max-height: 100px;
}
@@ -46,6 +53,18 @@
object-fit:contain;
}
/* Audit mode part image: large on desktop, compact on phones so the part
image, needed quantity and input all stay visible above the keyboard. */
.audit-image {
max-height: 220px;
}
@media (max-width: 575.98px) {
.audit-image {
max-height: 130px;
}
}
.table-td-input {
max-width: 150px;
}
@@ -331,3 +350,102 @@
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;
right: 0.5rem;
z-index: 5;
line-height: 1;
}
.bulk-select-check .form-check-input {
width: 1.4rem;
height: 1.4rem;
margin: 0;
/* A halo in the body background colour lifts the checkbox off the header
so it reads as a floating control rather than overlapping content.
Don't set background-color here, it would override the Bootstrap
:checked fill and make ticked boxes look empty. */
box-shadow: 0 0 0 2px var(--bs-body-bg);
}
body.bulk-select-mode .bulk-select-check {
display: block;
}
/* Always reserve room on the right of grid card headers so the name truncates
at a fixed point. Entering select mode never reflows it, and the floating
checkbox sits in this gutter without covering the name. */
#grid .card > .card-header {
padding-right: 2.4rem;
}
/* Selection ring drawn as an overlay so it paints above full-bleed children
(cover image, badge row) instead of being hidden behind them. */
#grid .card.bulk-selected::after {
content: "";
position: absolute;
inset: 0;
border: 3px solid var(--bs-primary);
border-radius: var(--bs-card-border-radius);
pointer-events: none;
z-index: 4;
}
.bulk-control.bulk-touched {
border-color: var(--bs-primary);
}
/* Brickset set description: trim the trailing margin so the rendered
<p>/<ul> blocks sit flush inside the alert box. */
.sidecar-description > :last-child {
margin-bottom: 0;
}
.sidecar-description ul,
.sidecar-description ol {
padding-left: 1.2rem;
margin-bottom: 0.5rem;
}
+61
View File
@@ -44,6 +44,7 @@
<input type="text" class="form-control" id="add-set" placeholder="{% if not bulk %}{% if not config.get('HIDE_INDIVIDUAL_MINIFIGURES', False) and not config.get('DISABLE_INDIVIDUAL_MINIFIGURES', False) %}107-1 or fig-001234 or ...{% else %}107-1 or ...{% endif %}{% else %}107-1, 1642-1, ...{% endif %}"
data-path="{{ path }}"
data-namespace="{{ namespace }}"
data-bulk="{% if bulk %}true{% else %}false{% endif %}"
data-msg-complete="{{ messages['COMPLETE'] }}"
data-msg-fail="{{ messages['FAIL'] }}"
data-msg-import-set="{{ messages['IMPORT_SET'] }}"
@@ -61,6 +62,66 @@
Add without confirmation
</label>
</div>
{% if sidecar_enabled %}
<div class="form-check">
<input type="checkbox" class="form-check-input" id="add-box-art" {% if bulk and config['SIDECAR_DEFAULT_COVER'] in ['box', 'set'] %}checked{% endif %}>
<label class="form-check-label" for="add-box-art">
{% if bulk %}Force box art (fallback to normal image){% else %}Use box art as cover (if available){% endif %}
</label>
</div>
{% if not bulk %}
<div class="mt-2">
<button type="button" class="btn btn-sm btn-outline-secondary" id="add-check-box-art"
data-proxy-url="{{ sidecar.image_proxy_url('box', '__REF__') }}"
data-bs-toggle="modal" data-bs-target="#addBoxArtModal">
<i class="ri-eye-line"></i> Check box art
</button>
</div>
<div class="modal fade" id="addBoxArtModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Box art preview</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-center">
<p id="add-box-art-empty" class="text-muted d-none">Enter a set number first (e.g. 42043-1).</p>
<img id="add-box-art-image" src="" alt="Box art preview" class="img-fluid d-none" style="max-height: 60vh;"
onerror="this.classList.add('d-none'); document.getElementById('add-box-art-missing').classList.remove('d-none');">
<p id="add-box-art-missing" class="text-muted d-none"><i class="ri-error-warning-line"></i> The sidecar has no box art for this set.</p>
</div>
</div>
</div>
</div>
<script>
(function () {
var modal = document.getElementById('addBoxArtModal');
if (!modal) { return; }
modal.addEventListener('show.bs.modal', function () {
var proxy = document.getElementById('add-check-box-art').getAttribute('data-proxy-url') || '';
var ref = (document.getElementById('add-set').value || '').trim().split(',')[0].trim();
// BrickTracker appends "-1" to a bare set number; match that so
// the sidecar lookup works (e.g. "10179" -> "10179-1").
if (ref && ref.indexOf('-') === -1) {
ref = ref + '-1';
}
var image = document.getElementById('add-box-art-image');
var empty = document.getElementById('add-box-art-empty');
var missing = document.getElementById('add-box-art-missing');
missing.classList.add('d-none');
if (!ref) {
image.classList.add('d-none');
empty.classList.remove('d-none');
return;
}
empty.classList.add('d-none');
image.src = proxy.replace('__REF__', encodeURIComponent(ref));
image.classList.remove('d-none');
});
})();
</script>
{% endif %}
{% endif %}
<h6 class="border-bottom mt-2">Metadata</h6>
<div class="accordion accordion" id="metadata">
{% if brickset_owners | length %}
+4
View File
@@ -20,6 +20,8 @@
{% include 'admin/owner/delete.html' %}
{% elif delete_purchase_location %}
{% include 'admin/purchase_location/delete.html' %}
{% elif delete_custom_field %}
{% include 'admin/custom_field/delete.html' %}
{% elif delete_status %}
{% include 'admin/status/delete.html' %}
{% elif delete_storage %}
@@ -45,12 +47,14 @@
{% endif %}
{% include 'admin/theme.html' %}
{% include 'admin/retired.html' %}
{% include 'admin/value.html' %}
{{ accordion.header('Set metadata', 'metadata', 'admin', expanded=open_metadata, icon='profile-line', class='p-0') }}
{% include 'admin/owner.html' %}
{% include 'admin/purchase_location.html' %}
{% include 'admin/status.html' %}
{% include 'admin/storage.html' %}
{% include 'admin/tag.html' %}
{% include 'admin/custom_field.html' %}
{{ accordion.footer() }}
{% include 'admin/refresh.html' %}
{% include 'admin/database.html' %}
+157
View File
@@ -543,6 +543,124 @@
</div>
</div>
<!-- Sidecar (BrickData) -->
<h6 class="fw-bold text-primary border-bottom pb-1 mb-3 mt-4">Sidecar (BrickData)</h6>
<p class="text-muted small">These take effect without a restart. The sidecar base URL itself is in the Static Settings section (it needs a restart).</p>
<div class="row g-3">
<div class="col-md-6">
<label for="BK_SIDECAR_TIMEOUT" class="form-label">
BK_SIDECAR_TIMEOUT {{ config_badges('BK_SIDECAR_TIMEOUT') }}
<div class="text-muted small">Request timeout in seconds for sidecar calls</div>
</label>
<input type="number" class="form-control config-number" id="BK_SIDECAR_TIMEOUT" data-var="BK_SIDECAR_TIMEOUT" min="1" max="120">
</div>
<div class="col-md-6">
<label for="BK_SIDECAR_RETAIL_REGION" class="form-label">
BK_SIDECAR_RETAIL_REGION {{ config_badges('BK_SIDECAR_RETAIL_REGION') }}
<div class="text-muted small">LEGO.com MSRP region: US, UK, CA or DE</div>
</label>
<input type="text" class="form-control config-text" id="BK_SIDECAR_RETAIL_REGION" data-var="BK_SIDECAR_RETAIL_REGION" {{ is_locked('BK_SIDECAR_RETAIL_REGION') }}>
</div>
<div class="col-md-6">
<label for="BK_SIDECAR_CURRENCY" class="form-label">
BK_SIDECAR_CURRENCY {{ config_badges('BK_SIDECAR_CURRENCY') }}
<div class="text-muted small">Currency for BrickLink market values, e.g. EUR. Empty = sidecar default.</div>
</label>
<input type="text" class="form-control config-text" id="BK_SIDECAR_CURRENCY" data-var="BK_SIDECAR_CURRENCY" {{ is_locked('BK_SIDECAR_CURRENCY') }}>
</div>
<div class="col-md-6">
<label for="BK_SIDECAR_DEFAULT_COVER" class="form-label">
BK_SIDECAR_DEFAULT_COVER {{ config_badges('BK_SIDECAR_DEFAULT_COVER') }}
<div class="text-muted small">Default bulk-add cover source: rebrickable, box or set</div>
</label>
<input type="text" class="form-control config-text" id="BK_SIDECAR_DEFAULT_COVER" data-var="BK_SIDECAR_DEFAULT_COVER" {{ is_locked('BK_SIDECAR_DEFAULT_COVER') }}>
</div>
<div class="col-md-6">
<label for="BK_SIDECAR_PRICE_BASIS" class="form-label">
BK_SIDECAR_PRICE_BASIS {{ config_badges('BK_SIDECAR_PRICE_BASIS') }}
<div class="text-muted small">Market value the set-detail "Change vs paid" uses: used or new</div>
</label>
<input type="text" class="form-control config-text" id="BK_SIDECAR_PRICE_BASIS" data-var="BK_SIDECAR_PRICE_BASIS" {{ is_locked('BK_SIDECAR_PRICE_BASIS') }}>
</div>
<div class="col-md-6 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_SIDECAR_AUTO_FETCH_PRICE" data-var="BK_SIDECAR_AUTO_FETCH_PRICE" {{ is_locked('BK_SIDECAR_AUTO_FETCH_PRICE') }}>
<label class="form-check-label" for="BK_SIDECAR_AUTO_FETCH_PRICE">
BK_SIDECAR_AUTO_FETCH_PRICE {{ config_badges('BK_SIDECAR_AUTO_FETCH_PRICE') }}
<div class="text-muted small">Auto-fetch BrickLink market value when viewing a set (respects cache TTL)</div>
</label>
</div>
</div>
<div class="col-md-6 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_SIDECAR_ADDITIONAL_IMAGES" data-var="BK_SIDECAR_ADDITIONAL_IMAGES" {{ is_locked('BK_SIDECAR_ADDITIONAL_IMAGES') }}>
<label class="form-check-label" for="BK_SIDECAR_ADDITIONAL_IMAGES">
BK_SIDECAR_ADDITIONAL_IMAGES {{ config_badges('BK_SIDECAR_ADDITIONAL_IMAGES') }}
<div class="text-muted small">Show additional BrickLink images (detail-page carousel + extra cover sources)</div>
</label>
</div>
</div>
</div>
<h6 class="fw-bold text-secondary mb-3 mt-3">Set detail display</h6>
<p class="text-muted small">Show or hide individual BrickData elements on the set detail page. Badges are controlled separately via BK_BADGE_ORDER_DETAIL.</p>
<div class="row g-3">
<div class="col-md-6 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_SIDECAR_SHOW_DESIGNER" data-var="BK_SIDECAR_SHOW_DESIGNER" {{ is_locked('BK_SIDECAR_SHOW_DESIGNER') }}>
<label class="form-check-label" for="BK_SIDECAR_SHOW_DESIGNER">
BK_SIDECAR_SHOW_DESIGNER {{ config_badges('BK_SIDECAR_SHOW_DESIGNER') }}
<div class="text-muted small">Show the set designer</div>
</label>
</div>
</div>
<div class="col-md-6 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_SIDECAR_SHOW_DESCRIPTION" data-var="BK_SIDECAR_SHOW_DESCRIPTION" {{ is_locked('BK_SIDECAR_SHOW_DESCRIPTION') }}>
<label class="form-check-label" for="BK_SIDECAR_SHOW_DESCRIPTION">
BK_SIDECAR_SHOW_DESCRIPTION {{ config_badges('BK_SIDECAR_SHOW_DESCRIPTION') }}
<div class="text-muted small">Show the "About this set" description</div>
</label>
</div>
</div>
<div class="col-md-6 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_SIDECAR_SHOW_NOTES" data-var="BK_SIDECAR_SHOW_NOTES" {{ is_locked('BK_SIDECAR_SHOW_NOTES') }}>
<label class="form-check-label" for="BK_SIDECAR_SHOW_NOTES">
BK_SIDECAR_SHOW_NOTES {{ config_badges('BK_SIDECAR_SHOW_NOTES') }}
<div class="text-muted small">Show the Brickset notes</div>
</label>
</div>
</div>
<div class="col-md-6 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_SIDECAR_SHOW_PRICE_PAID" data-var="BK_SIDECAR_SHOW_PRICE_PAID" {{ is_locked('BK_SIDECAR_SHOW_PRICE_PAID') }}>
<label class="form-check-label" for="BK_SIDECAR_SHOW_PRICE_PAID">
BK_SIDECAR_SHOW_PRICE_PAID {{ config_badges('BK_SIDECAR_SHOW_PRICE_PAID') }}
<div class="text-muted small">Price comparison: "Paid" row (and "Change vs paid")</div>
</label>
</div>
</div>
<div class="col-md-6 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_SIDECAR_SHOW_PRICE_MSRP" data-var="BK_SIDECAR_SHOW_PRICE_MSRP" {{ is_locked('BK_SIDECAR_SHOW_PRICE_MSRP') }}>
<label class="form-check-label" for="BK_SIDECAR_SHOW_PRICE_MSRP">
BK_SIDECAR_SHOW_PRICE_MSRP {{ config_badges('BK_SIDECAR_SHOW_PRICE_MSRP') }}
<div class="text-muted small">Price comparison: retail/MSRP rows (and "Saved vs retail")</div>
</label>
</div>
</div>
<div class="col-md-6 d-flex align-items-center">
<div class="form-check form-switch">
<input class="form-check-input config-toggle" type="checkbox" id="BK_SIDECAR_SHOW_PRICE_MARKET" data-var="BK_SIDECAR_SHOW_PRICE_MARKET" {{ is_locked('BK_SIDECAR_SHOW_PRICE_MARKET') }}>
<label class="form-check-label" for="BK_SIDECAR_SHOW_PRICE_MARKET">
BK_SIDECAR_SHOW_PRICE_MARKET {{ config_badges('BK_SIDECAR_SHOW_PRICE_MARKET') }}
<div class="text-muted small">Price comparison: "Worth now" market value rows</div>
</label>
</div>
</div>
</div>
<!-- Advanced Settings -->
<h6 class="fw-bold text-primary border-bottom pb-1 mb-3 mt-4">Advanced Settings</h6>
@@ -1011,6 +1129,19 @@
</div>
</div>
<!-- Sidecar (BrickData). Only the base URL is static (restart required);
the rest are live-changeable and live in the Live Settings section. -->
<h6 class="fw-bold text-secondary border-bottom pb-1 mb-3 mt-4">Sidecar (BrickData)</h6>
<div class="row g-3">
<div class="col-12">
<label for="static-BK_SIDECAR_URL" class="form-label">
BK_SIDECAR_URL {{ config_badges('BK_SIDECAR_URL') }}
<div class="text-muted small">Base URL of the brickset-sidecar (e.g. http://localhost:3335). Empty disables all sidecar features. Changing it requires a restart. The other sidecar settings are in the Live Settings section above.</div>
</label>
<input type="text" class="form-control config-static" id="static-BK_SIDECAR_URL" data-var="BK_SIDECAR_URL" {{ is_locked('BK_SIDECAR_URL') }}>
</div>
</div>
<!-- Save Button for Static Settings -->
<div class="d-flex gap-2 justify-content-end mt-4">
<button id="config-save-static" class="btn btn-warning">
@@ -1019,6 +1150,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 = {
+65
View File
@@ -0,0 +1,65 @@
{% import 'macro/accordion.html' as accordion %}
{{ accordion.header('Set custom fields', 'custom_field', 'metadata', expanded=open_custom_field, icon='edit-line', class='p-0') }}
{% if custom_field_error %}<div class="alert alert-danger m-2" role="alert"><strong>Error:</strong> {{ custom_field_error }}.</div>{% endif %}
<ul class="list-group list-group-flush">
{% if metadata_custom_fields | length %}
{% for custom_field in metadata_custom_fields %}
<li class="list-group-item">
<form action="{{ url_for('admin_custom_field.rename', id=custom_field.fields.id) }}" method="post" class="row row-cols-lg-auto g-3 align-items-center">
<div class="col-12 flex-grow-1">
<label class="visually-hidden" for="name-{{ custom_field.fields.id }}">Name</label>
<div class="input-group">
<div class="input-group-text">Name</div>
<input type="text" class="form-control" id="name-{{ custom_field.fields.id }}" name="name" value="{{ custom_field.fields.name }}">
<button type="submit" class="btn btn-primary"><i class="ri-edit-line"></i> Rename</button>
</div>
</div>
<div class="col-12">
<div class="input-group">
<div class="input-group-text"><i class="ri-shape-line"></i> Type</div>
<select class="form-select" id="type-{{ custom_field.fields.id }}"
data-changer-id="{{ custom_field.fields.id }}" data-changer-prefix="type" data-changer-url="{{ url_for('admin_custom_field.update_field', id=custom_field.fields.id, name='type') }}"
autocomplete="off">
<option value="text" {% if custom_field.fields.type == 'text' %}selected{% endif %}>Text</option>
<option value="number" {% if custom_field.fields.type == 'number' %}selected{% endif %}>Number</option>
<option value="date" {% if custom_field.fields.type == 'date' %}selected{% endif %}>Date</option>
</select>
<span id="status-type-{{ custom_field.fields.id }}" class="input-group-text ri-save-line px-1"></span>
</div>
</div>
<div class="col-12">
<a href="{{ url_for('admin_custom_field.delete', id=custom_field.fields.id) }}" class="btn btn-danger" role="button"><i class="ri-delete-bin-2-line"></i> Delete</a>
</div>
</form>
</li>
{% endfor %}
{% else %}
<li class="list-group-item text-center"><i class="ri-error-warning-line"></i> No custom field found.</li>
{% endif %}
<li class="list-group-item">
<form action="{{ url_for('admin_custom_field.add') }}" method="post" class="row row-cols-lg-auto g-3 align-items-center">
<div class="col-12 flex-grow-1">
<label class="visually-hidden" for="name">Name</label>
<div class="input-group">
<div class="input-group-text">Name</div>
<input type="text" class="form-control" id="name" name="name" value="">
</div>
</div>
<div class="col-12">
<div class="input-group">
<div class="input-group-text"><i class="ri-shape-line"></i> Type</div>
<select class="form-select" id="type" name="type" autocomplete="off">
<option value="text" selected>Text</option>
<option value="number">Number</option>
<option value="date">Date</option>
</select>
</div>
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary"><i class="ri-add-circle-line"></i> Add</button>
</div>
</form>
</li>
</ul>
{{ accordion.footer() }}
+19
View File
@@ -0,0 +1,19 @@
{% import 'macro/accordion.html' as accordion %}
{{ accordion.header('Set custom fields danger zone', 'custom-field-danger', 'admin', expanded=true, danger=true, class='text-end') }}
<form action="{{ url_for('admin_custom_field.do_delete', id=custom_field.fields.id) }}" method="post">
{% if custom_field_error %}<div class="alert alert-danger text-start" role="alert"><strong>Error:</strong> {{ custom_field_error }}.</div>{% endif %}
<div class="alert alert-danger text-center" role="alert">You are about to <strong>delete a custom field</strong> and every value stored in it. This action is irreversible.</div>
<div class="row row-cols-lg-auto g-3 align-items-center">
<div class="col-12 flex-grow-1">
<div class="input-group">
<div class="input-group-text">Name</div>
<input type="text" class="form-control" value="{{ custom_field.fields.name }}" disabled>
</div>
</div>
</div>
<hr class="border-bottom">
<a class="btn btn-danger" href="{{ url_for('admin.admin', open_custom_field=true) }}" role="button"><i class="ri-arrow-left-long-line"></i> Back to the admin</a>
<button type="submit" class="btn btn-danger"><i class="ri-delete-bin-2-line"></i> Delete <strong>the custom field</strong></button>
</form>
{{ accordion.footer() }}
+73
View File
@@ -0,0 +1,73 @@
{% import 'macro/accordion.html' as accordion %}
{% if sidecar_enabled %}
{{ accordion.header('Value', 'value', 'admin', expanded=open_value, icon='funds-line') }}
<p>
Fetch and cache the BrickLink market value for every set in your collection through the
sidecar. Sets whose value is still fresh (within the cache TTL) are skipped, so this is
safe to run again. It is the slow live path, so it runs one set at a time, politely.
</p>
<div id="value-fail" class="alert alert-danger d-none" role="alert"></div>
<div id="value-complete"></div>
<div class="mb-3">
<p>
Progress <span id="value-count"></span>
<span id="value-spinner" class="d-none">
<span class="spinner-border spinner-border-sm" aria-hidden="true"></span>
<span class="visually-hidden" role="status">Loading...</span>
</span>
</p>
<div id="value-progress" class="progress" role="progressbar">
<div id="value-progress-bar" class="progress-bar" style="width: 0%"></div>
</div>
<p id="value-progress-message" class="text-center d-none"></p>
</div>
<div class="d-flex align-items-center justify-content-end">
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#valueConfirmModal">
<i class="ri-funds-line"></i> Update all sets
</button>
</div>
<!-- Confirmation modal -->
<div class="modal fade" id="valueConfirmModal" tabindex="-1" aria-labelledby="valueConfirmModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="valueConfirmModalLabel">Update all set values?</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
This will fetch the BrickLink market value for every set that is not already cached.
Depending on how many sets need pricing this can take a while and makes live calls
through the sidecar. Sets with a fresh cached value are skipped.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" id="value-button" class="btn btn-primary" data-bs-dismiss="modal">
<i class="ri-funds-line"></i> Update all sets
</button>
</div>
</div>
</div>
</div>
<script type="text/javascript">
document.addEventListener("DOMContentLoaded", () => {
new BrickValueSocket(
'value',
'{{ path }}',
'{{ namespace }}',
{
COMPLETE: '{{ messages['COMPLETE'] }}',
FAIL: '{{ messages['FAIL'] }}',
PROGRESS: '{{ messages['PROGRESS'] }}',
VALUE_ALL_SETS: '{{ messages['VALUE_ALL_SETS'] }}',
}
);
});
</script>
{{ accordion.footer() }}
{% endif %}
+16 -1
View File
@@ -170,8 +170,9 @@
<script src="{{ url_for('static', filename='scripts/socket/instructions.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/socket/set.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/socket/minifigure.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/socket/value.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/table.js') }}"></script>
{% if request.endpoint == 'add.add' %}
{% if request.endpoint in ['add.add', 'add.bulk'] %}
<script src="{{ url_for('static', filename='scripts/add.js') }}"></script>
{% endif %}
{% if request.endpoint == 'add.parts' %}
@@ -189,9 +190,15 @@
{% endif %}
{% if request.endpoint == 'set.list' %}
<script src="{{ url_for('static', filename='scripts/sets.js') }}"></script>
{% if g.login.is_authenticated() %}
<script src="{{ url_for('static', filename='scripts/sets-bulk-edit.js') }}"></script>
{% endif %}
{% endif %}
{% if request.endpoint == 'set.details' %}
<script src="{{ url_for('static', filename='scripts/parts-table-filter.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/parts-bulk-operations.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/parts-audit-mode.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/bag-missing-sync.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/set-details.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/quick-add-individual-part.js') }}"></script>
{% endif %}
@@ -200,9 +207,11 @@
{% endif %}
{% if request.endpoint == 'individual_part.lot_details' %}
<script src="{{ url_for('static', filename='scripts/parts-bulk-operations.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/parts-audit-mode.js') }}"></script>
{% endif %}
{% if request.endpoint == 'individual_minifigure.details' %}
<script src="{{ url_for('static', filename='scripts/parts-bulk-operations.js') }}"></script>
<script src="{{ url_for('static', filename='scripts/parts-audit-mode.js') }}"></script>
{% endif %}
{% if request.endpoint == 'statistics.overview' %}
<script src="{{ url_for('static', filename='scripts/statistics.js') }}"></script>
@@ -216,6 +225,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">
+4 -4
View File
@@ -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">
+2 -2
View File
@@ -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
View File
@@ -39,7 +39,11 @@
{% endmacro %}
{% macro instructions(item, solo=false, last=false) %}
{{ badge(url=item.url_for_instructions(), solo=solo, last=last, blank=true, color='light border', icon='file-line', collapsible='Instructions:', text=item.instructions | length, alt='Instructions') }}
{# Positive "instructions downloaded" indicator: only render when the set
actually has instruction files (#154). #}
{% if item.instructions | length %}
{{ badge(url=item.url_for_instructions(), solo=solo, last=last, blank=true, color='light border', icon='file-line', collapsible='Instructions:', text=item.instructions | length, alt='Instructions') }}
{% endif %}
{% endmacro %}
{% macro parts(parts, solo=false, last=false) %}
@@ -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_date or summary.exit_year or 'Yes'), alt='Retired') }}
{% endif %}
{% endmacro %}
{% macro msrp(summary, solo=false, last=false) %}
{% if summary and summary.prices and summary.prices.msrp is not none %}
{{ badge(check=true, solo=solo, last=last, color='light border', icon='price-tag-3-line', collapsible='MSRP:', text='%.2f %s' | format(summary.prices.msrp, summary.prices.msrp_currency), alt='MSRP') }}
{% endif %}
{% endmacro %}
{% macro value(summary, solo=false, last=false) %}
{% if summary and summary.prices and summary.prices.has_market and summary.prices.market_new is not none %}
{{ badge(check=true, solo=solo, last=last, color='light border', icon='funds-line', collapsible='Value:', text='%.2f %s' | format(summary.prices.market_new, summary.prices.market_currency), alt='Value', tooltip='BrickLink new' ~ (' — fetched ' ~ summary.prices.market_fetched_at if summary.prices.market_fetched_at else '')) }}
{% endif %}
{% endmacro %}
{% macro dimensions(summary, solo=false, last=false) %}
{% if summary and summary.dimensions %}
{{ badge(check=true, solo=solo, last=last, color='light border', icon='ruler-line', collapsible='Size:', text=summary.dimensions, alt='Dimensions') }}
{% endif %}
{% endmacro %}
{% macro weight(summary, solo=false, last=false) %}
{% if summary and summary.weight %}
{{ badge(check=true, solo=solo, last=last, color='light border', icon='scales-3-line', collapsible='Weight:', text=summary.weight, alt='Weight') }}
{% endif %}
{% endmacro %}
{% macro render_ordered_badges(item, brickset_tags, brickset_owners, brickset_storages, brickset_purchase_locations, solo=false, last=false, context='grid', sidecar_summary=none) %}
{# Get badge order from config based on context (grid or detail) #}
{% if context == 'detail' %}
{% set badge_order = config.get('BADGE_ORDER_DETAIL', ['theme', 'tag', 'year', 'parts', 'instance_count', 'total_minifigures', 'total_missing', 'total_damaged', 'owner', 'storage', 'purchase_date', 'purchase_location', 'purchase_price', 'instructions', 'rebrickable', 'bricklink']) %}
{% set badge_order = config.get('BADGE_ORDER_DETAIL', ['theme', 'tag', 'year', 'retired', 'parts', 'instance_count', 'total_minifigures', 'total_missing', 'total_damaged', 'owner', 'storage', 'purchase_date', 'purchase_location', 'purchase_price', 'msrp', 'value', 'dimensions', 'weight', 'instructions', 'rebrickable', 'bricklink']) %}
{% else %}
{% set badge_order = config.get('BADGE_ORDER_GRID', ['theme', 'year', 'parts', 'total_minifigures', 'owner']) %}
{% set badge_order = config.get('BADGE_ORDER_GRID', ['theme', 'year', 'parts', 'total_minifigures', 'instructions', 'owner']) %}
{% endif %}
{# Render each badge in the configured order #}
@@ -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 %}
+42 -6
View File
@@ -19,11 +19,47 @@
</div>
{% endmacro %}
{% macro image(item, solo=false, last=false, caption=none, alt=none, medium=none) %}
{% macro image(item, solo=false, last=false, caption=none, alt=none, medium=none, sidecar=none, extra_count=0) %}
{% set image_url=item.url_for_image() %}
<div class="card-img border-bottom" style="background-image: url({{ image_url }})">
<a {% if solo %}data-lightbox {% if caption %}data-caption="{{ caption }}"{% endif %} href="{{ image_url }}" target="_blank"{% else %}href="{{ item.url() }}"{% endif %}>
<img {% if solo and medium %}class="card-medium-img"{% endif %}{% if last %}class="card-last-img"{% endif %} src="{{ image_url }}" {% if alt %}alt="{{ alt }}"{% endif %} loading="lazy">
</a>
</div>
{% if solo and sidecar and extra_count and extra_count > 0 %}
{# Cover becomes a carousel: the set cover first, then the Brickset
additional images. Each slide reuses .card-img sizing (object-fit:
contain + max-height) so every image keeps its aspect ratio at a fixed
height, regardless of source resolution. #}
{% set carousel_id = 'setImages-' ~ item.fields.id %}
{# bt-gallery groups every slide anchor into a single BaguetteBox lightbox
gallery (prev/next buttons + arrow-key navigation), instead of each
image opening as its own single-image lightbox. #}
<div id="{{ carousel_id }}" class="card-img border-bottom carousel slide carousel-dark bt-gallery">
<div class="carousel-inner">
<div class="carousel-item active" style="background-image: url({{ image_url }})">
<a {% if caption %}data-caption="{{ caption }} (1/{{ extra_count + 1 }})"{% endif %} href="{{ image_url }}" target="_blank">
<img src="{{ image_url }}" {% if alt %}alt="{{ alt }}"{% endif %} loading="lazy">
</a>
</div>
{% for i in range(extra_count) %}
{% set extra_url = sidecar.additional_image_url(item.fields.set, i) %}
<div class="carousel-item" style="background-image: url({{ extra_url }})">
<a {% if caption %}data-caption="{{ caption }} ({{ loop.index + 1 }}/{{ extra_count + 1 }})"{% endif %} href="{{ extra_url }}" target="_blank">
<img src="{{ extra_url }}" {% if alt %}alt="{{ alt }} image {{ loop.index + 1 }}"{% endif %} loading="lazy">
</a>
</div>
{% endfor %}
</div>
<button class="carousel-control-prev" type="button" data-bs-target="#{{ carousel_id }}" data-bs-slide="prev">
<span class="carousel-control-prev-icon" aria-hidden="true"></span>
<span class="visually-hidden">Previous</span>
</button>
<button class="carousel-control-next" type="button" data-bs-target="#{{ carousel_id }}" data-bs-slide="next">
<span class="carousel-control-next-icon" aria-hidden="true"></span>
<span class="visually-hidden">Next</span>
</button>
</div>
{% else %}
<div class="card-img border-bottom" style="background-image: url({{ image_url }})">
<a {% if solo %}data-lightbox {% if caption %}data-caption="{{ caption }}"{% endif %} href="{{ image_url }}" target="_blank"{% else %}href="{{ item.url() }}"{% endif %}>
<img {% if solo and medium %}class="card-medium-img"{% endif %}{% if last %}class="card-last-img"{% endif %} src="{{ image_url }}" {% if alt %}alt="{{ alt }}"{% endif %} loading="lazy">
</a>
</div>
{% endif %}
{% endmacro %}
+1 -1
View File
@@ -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 %}
+72 -1
View File
@@ -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 %}
@@ -53,12 +53,83 @@
<li><a class="dropdown-item" href="#" id="check-all-{{ accordion_id }}"><i class="ri-checkbox-line me-2"></i>Check all</a></li>
<li><a class="dropdown-item" href="#" id="uncheck-all-{{ accordion_id }}"><i class="ri-checkbox-blank-line me-2"></i>Uncheck all</a></li>
{% endif %}
{% if show_missing_menu or show_checked_menu %}
<li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">Audit</h6></li>
<li><a class="dropdown-item" href="#" id="audit-mode-{{ accordion_id }}"><i class="ri-eye-line me-2"></i>Start audit</a></li>
{% endif %}
</ul>
</div>
</th>
{% 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 %}
+10 -3
View File
@@ -4,7 +4,7 @@
<div class="card mb-3 flex-fill {% if solo %}card-solo{% endif %}">
<div class="card-header {% if solo %}d-flex justify-content-between align-items-center{% endif %}">
<h5 class="mb-0">
<h5 class="mb-0 {% if not solo %}fs-6 text-nowrap overflow-x-hidden text-truncate{% endif %}">
{{ badge.identifier(item.fields.part, icon='shapes-line', solo=solo, header=true) }}
{% if solo %}
{{ badge.color(item, header=true) }}
@@ -29,8 +29,15 @@
{{ badge.color(item) }}
{{ badge.print(item) }}
{% endif %}
{{ badge.total_sets(sets_using | length, solo=solo, last=last) }}
{{ badge.total_minifigures(minifigures_using | length, solo=solo, last=last) }}
{% if solo %}
{{ badge.total_sets(sets_using | length, solo=solo, last=last) }}
{{ badge.total_minifigures(minifigures_using | length, solo=solo, last=last) }}
{% else %}
{# Sub-cards (different color / same print) carry their own per-color counts
from the query, not the parent's inherited sets_using/minifigures_using (#159). #}
{{ badge.total_sets(item.fields.total_sets, solo=solo, last=last) }}
{{ badge.total_minifigures(item.fields.total_minifigures, solo=solo, last=last) }}
{% endif %}
{{ badge.total_quantity(item.fields.total_quantity, solo=solo, last=last) }}
{{ badge.total_spare(item.fields.total_spare, solo=solo, last=last) }}
{{ badge.total_missing(item.fields.total_missing, solo=solo, last=last) }}
+23 -27
View File
@@ -2,13 +2,14 @@
{% 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>
{# tojson escapes ' but not ", so the attribute must be single-quoted #}
<tr {% if bag_breakdown is defined and bag_breakdown and item.html_id() in bag_breakdown %}data-bags='{{ bag_breakdown[item.html_id()] | tojson }}'{% endif %}>
{{ 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,24 +17,24 @@
{{ 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>
{% if not no_quantity %}
{% if all %}
<td>{{ item.fields.total_quantity }}</td>
<td data-col="quantity">{{ item.fields.total_quantity }}</td>
{% else %}
<td>{% if quantity %}{{ item.fields.quantity * quantity }}{% else %}{{ item.fields.quantity }}{% endif %}</td>
<td data-col="quantity">{% if quantity %}{{ item.fields.quantity * quantity }}{% else %}{{ item.fields.quantity }}{% endif %}</td>
{% endif %}
{% endif %}
{% if not config['HIDE_TABLE_MISSING_PARTS'] %}
<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 +43,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 %}
@@ -53,23 +54,18 @@
{% if show_missing_menu or show_checked_menu or show_quick_add %}
<td class="text-end">
{% if show_quick_add %}
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="ri-more-2-fill"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a class="dropdown-item quick-add-individual-part" href="#"
data-part="{{ item.fields.part }}"
data-color="{{ item.fields.color }}"
data-name="{{ item.fields.name }}"
data-color-name="{{ item.fields.color_name }}"
data-image="{{ item.url_for_image() }}">
<i class="ri-add-line me-2"></i>Add to individual parts
</a>
</li>
</ul>
</div>
{# The shared click handler in quick-add-individual-part.js
picks this up; keep the row light, big sets render
hundreds of these #}
<button type="button" class="btn btn-sm btn-outline-secondary quick-add-individual-part"
title="Add to individual parts" aria-label="Add to individual parts"
data-part="{{ item.fields.part }}"
data-color="{{ item.fields.color }}"
data-name="{{ item.fields.name }}"
data-color-name="{{ item.fields.color_name }}"
data-image="{{ item.url_for_image() }}">
<i class="ri-add-line"></i>
</button>
{% endif %}
</td>
{% endif %}
+14
View File
@@ -0,0 +1,14 @@
{% import 'macro/accordion.html' as accordion %}
{# The bag tables are heavy (hundreds of rows/inputs), so only the accordion
shell renders with the page; the tables are fetched from bags_tables()
the first time the accordion is opened (see bag-missing-sync.js). #}
{{ accordion.header('Bags', 'bags-inventory', 'set-details', quantity=sidecar_bags | length, icon='archive-line', class='p-0') }}
<div id="bags-lazy" data-bags-src="{{ url_for('set.bags_tables', id=item.fields.id) }}">
<div class="text-center p-4">
<div class="spinner-border text-secondary" role="status">
<span class="visually-hidden">Loading bags…</span>
</div>
</div>
</div>
{{ accordion.footer() }}
+59
View File
@@ -0,0 +1,59 @@
{% import 'macro/accordion.html' as accordion %}
{% import 'macro/form.html' as form %}
{% import 'macro/table.html' as table %}
<div class="accordion accordion-flush" id="bags-list">
{% for bag in sidecar_bags %}
{% set bag_title = bag.number if bag.is_extra else 'Bag ' ~ bag.number %}
{% set bag_accordion_id = 'bag-' ~ loop.index %}
{{ accordion.header(bag_title, bag_accordion_id, 'bags-list', quantity=bag.parts | length, icon='archive-line', class='p-0') }}
<div class="p-2 border-bottom">
{% if bag.note %}<small class="text-secondary d-block">{{ bag.note }}</small>{% endif %}
<small class="text-secondary">Spare parts are not included in bag inventories.</small>
</div>
<div class="table-responsive-sm">
<table class="table table-striped align-middle sortable mb-0" data-parts-filterable="true" data-bag-table="true">
{{ table.header(color=true, quantity=true, missing=true, damaged=false, checked=true, hamburger_menu=g.login.is_authenticated(), accordion_id=bag_accordion_id, filters=true) }}
<tbody>
{% for p in bag.parts %}
<tr {% if p.missing_input_id %}data-target-missing="{{ p.missing_input_id }}"{% endif %}>
{% if p.image_url %}
{{ table.image(p.image_url, caption=p.name, alt=p.name) }}
{% else %}
<td class="py-0"></td>
{% endif %}
<td data-sort="{{ p.name }}" data-col="name">
{% if p.url %}<a class="text-reset" href="{{ p.url }}">{{ p.name }}</a>{% else %}{{ p.name }}{% endif %}
{% if p.spare %}<span class="badge rounded-pill text-bg-warning fw-normal"><i class="ri-loop-left-line"></i> Spare</span>{% endif %}
</td>
<td data-sort="{{ p.color_name }}" data-col="color">
{% if p.color_rgb %}<span class="color-rgb color-rgb-table {% if p.color == 9999 %}color-any{% endif %} align-middle border border-black" {% if p.color != 9999 %}style="background-color: #{{ p.color_rgb }};"{% endif %}></span>{% endif %}
<span class="align-middle">{{ p.color_name }}</span>
</td>
<td data-col="quantity">{{ p.quantity }}</td>
{% if not config['HIDE_TABLE_MISSING_PARTS'] %}
<td data-sort="{{ p.missing or 0 }}" data-col="missing" class="table-td-input">
{% if p.url_missing %}
{{ form.input('Missing', item.fields.id, p.prefix_missing, p.url_missing, p.missing) }}
{% endif %}
</td>
{% endif %}
{% if not config['HIDE_TABLE_CHECKED_PARTS'] %}
<td data-sort="{{ p.checked | int }}" data-col="checked" class="table-td-input">
{% if p.url_checked %}
<center>{{ form.checkbox('', item.fields.id, p.prefix_checked, p.url_checked, p.checked) }}</center>
{% endif %}
</td>
{% endif %}
{% if g.login.is_authenticated() and (not config['HIDE_TABLE_MISSING_PARTS'] or not config['HIDE_TABLE_CHECKED_PARTS'] or (not config['DISABLE_QUICK_ADD_INDIVIDUAL_PARTS'] and not config['HIDE_INDIVIDUAL_PARTS'])) %}
{# Filler cell under the hamburger column so row/header cell counts match #}
<td></td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{{ accordion.footer() }}
{% endfor %}
</div>
+131
View File
@@ -0,0 +1,131 @@
{# 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="modal fade" tabindex="-1" id="bulk-edit-panel" aria-labelledby="bulk-edit-panel-label" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-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="modal" aria-label="Close"></button>
</div>
<div class="modal-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>
{# --- Purchase --- #}
<h6 class="text-muted">Purchase</h6>
<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-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>
{# --- Notes --- #}
<hr>
<h6 class="text-muted">Notes</h6>
<div class="mb-3">
<label class="form-label visually-hidden" for="bulk-description">Notes</label>
<textarea class="form-control bulk-control" id="bulk-description" data-bulk-field="description" data-card-attr="description" rows="2"></textarea>
</div>
{# --- Storage --- #}
<hr>
<h6 class="text-muted">Storage</h6>
<div class="mb-3">
<label class="form-label visually-hidden" 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>
{# --- 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="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="bulk-edit-apply"><i class="ri-save-line"></i> Apply changes</button>
</div>
</div>
</div>
</div>
+25 -4
View File
@@ -1,4 +1,6 @@
{% import 'macro/accordion.html' as accordion %}
{# "with context" so includes inside the macros (part/table.html) can see
render kwargs like bag_breakdown #}
{% import 'macro/accordion.html' as accordion with context %}
{% import 'macro/badge.html' as badge %}
{% import 'macro/card.html' as card %}
{% import 'macro/form.html' as form %}
@@ -21,6 +23,7 @@
{% endif %}
{% if item.fields.purchase_date is not none %}data-purchase-date="{{ item.fields.purchase_date }}"{% endif %}
{% if item.fields.purchase_price is not none %}data-purchase-price="{{ item.fields.purchase_price }}"{% endif %}
{% if item.fields.description is not none and item.fields.description | trim %}data-description="{{ item.fields.description }}"{% endif %}
data-has-purchase-location="{{ item.fields.purchase_location is not none | int }}"
{% if item.fields.purchase_location is not none %}
data-purchase-location="{{ item.fields.purchase_location }}"
@@ -52,14 +55,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 +148,10 @@
</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(set_parts if set_parts is defined else item.parts(), 'Parts', 'parts-inventory', 'set-details', 'part/table.html', icon='shapes-line', hamburger_menu=g.login.is_authenticated(), filters=true)}}
{% if sidecar_bags is defined and sidecar_bags %}
{% include 'set/bags.html' %}
{% endif %}
{% 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 %}

Some files were not shown because too many files have changed in this diff Show More