Compare commits

..
6 Commits
125 changed files with 1087 additions and 6588 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ docs/
LICENSE LICENSE
*.md *.md
*.sample *.sample
*.code-workspace .code-workspace
# Temporary # Temporary
*.csv *.csv
-23
View File
@@ -511,26 +511,3 @@
# Use the same badge keys as BK_BADGE_ORDER_GRID # 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 # 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 # 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
-5
View File
@@ -26,11 +26,6 @@ run_local.sh
settings.local.json settings.local.json
/offline/ /offline/
scripts/
/*.md
/Screenshot_*.png
# Apple idiocy # Apple idiocy
.DS_Store .DS_Store
+2 -31
View File
@@ -1,40 +1,11 @@
# Changelog # Changelog
## 1.5 ## 1.4.2
### 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 ### 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)
- **Fixed instructions downloads failing with "No instructions found on Rebrickable or Peeron"** (#171): Rebrickable now blocks scraping behind a Cloudflare challenge, so instructions are fetched from LEGO's own official building-instructions page instead - **Fixed instructions downloads failing with "No instructions found on Rebrickable or Peeron"** (#171): Rebrickable now blocks scraping behind a Cloudflare challenge, so instructions are fetched from LEGO's own official building-instructions page instead
- **Fixed the homepage minifigure section showing figures from deleted sets** (#178): With `BK_RANDOM` enabled, "Random selection of minifigures" could show minifigures belonging to sets that had already been deleted. Catalog data for a minifigure is deliberately kept when a set is removed (two sets can share the same figure, and refetching costs a Rebrickable call), but the homepage list treated every cached figure as owned. It now only picks from figures you still own, whether through a set or as an individual minifigure. Without `BK_RANDOM` the problem was invisible, since those figures always sorted to the bottom of the list. The sets and parts sections on the same page were already correct - **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 Peeron sets stored as a single PDF being reported as having no instructions** (#177): Peeron keeps some sets (e.g. `8501-1`) as one scanned PDF instead of separate page images, and sends those to a PDF viewer of theirs that currently returns an error. The file itself is still there, so those sets now show up as a normal instructions download instead of "No instructions found on LEGO.com or Peeron"
## 1.4.1 ## 1.4.1
+1 -88
View File
@@ -1,4 +1,3 @@
import gzip
import logging import logging
import os import os
import sys import sys
@@ -6,20 +5,17 @@ import time
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from flask import current_app, Flask, g, request from flask import current_app, Flask, g
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
from bricktracker.configuration_list import BrickConfigurationList from bricktracker.configuration_list import BrickConfigurationList
from bricktracker.login import LoginManager from bricktracker.login import LoginManager
from bricktracker.navbar import Navbar from bricktracker.navbar import Navbar
from bricktracker.sidecar import BrickSidecar
from bricktracker.sql import close from bricktracker.sql import close
from bricktracker.telemetry import BrickTelemetry
from bricktracker.template_filters import replace_query_filter from bricktracker.template_filters import replace_query_filter
from bricktracker.version import __version__ from bricktracker.version import __version__
from bricktracker.views.add import add_page from bricktracker.views.add import add_page
from bricktracker.views.admin.admin import admin_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.database import admin_database_page
from bricktracker.views.admin.export import admin_export_page from bricktracker.views.admin.export import admin_export_page
from bricktracker.views.admin.image import admin_image_page from bricktracker.views.admin.image import admin_image_page
@@ -43,7 +39,6 @@ from bricktracker.views.minifigure import minifigure_page
from bricktracker.views.part import part_page from bricktracker.views.part import part_page
from bricktracker.views.purchase_location import purchase_location_page from bricktracker.views.purchase_location import purchase_location_page
from bricktracker.views.set import set_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.statistics import statistics_page
from bricktracker.views.storage import storage_page from bricktracker.views.storage import storage_page
from bricktracker.views.wish import wish_page from bricktracker.views.wish import wish_page
@@ -106,10 +101,6 @@ def setup_app(app: Flask) -> None:
# Load the configuration # Load the configuration
BrickConfigurationList(app) BrickConfigurationList(app)
# Generate the anonymous telemetry installation ID exactly once, if
# telemetry is enabled and no ID exists yet (see bricktracker/telemetry.py)
BrickTelemetry.generate_installation_id(app)
# Set the logging level # Set the logging level
if app.config['DEBUG']: if app.config['DEBUG']:
logging.basicConfig( logging.basicConfig(
@@ -143,51 +134,6 @@ def setup_app(app: Flask) -> None:
x_prefix=1, 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
# Anonymous pageview telemetry (Usage category) plus opportunistic
# collection stats (Collection category). No-ops entirely unless the
# user has opted in. See bricktracker/telemetry.py.
@app.after_request
def track_telemetry(response): # type: ignore[no-untyped-def]
if request.method == 'GET' and response.status_code == 200:
name = BrickTelemetry.pageview_name(request.endpoint)
if name is not None:
BrickTelemetry.track_pageview(name)
BrickTelemetry.maybe_send_collection_stats()
# Checked on every request, not just tracked pageviews. AJAX-driven
# actions (add to wishlist, add a set, etc.) never match a pageview
# name above, and would otherwise never give a queued backlog a
# chance to flush.
BrickTelemetry.maybe_flush_queue()
return response
# Register errors # Register errors
app.register_error_handler(404, error_404) app.register_error_handler(404, error_404)
@@ -203,14 +149,12 @@ def setup_app(app: Flask) -> None:
app.register_blueprint(part_page) app.register_blueprint(part_page)
app.register_blueprint(purchase_location_page) app.register_blueprint(purchase_location_page)
app.register_blueprint(set_page) app.register_blueprint(set_page)
app.register_blueprint(sidecar_page)
app.register_blueprint(statistics_page) app.register_blueprint(statistics_page)
app.register_blueprint(storage_page) app.register_blueprint(storage_page)
app.register_blueprint(wish_page) app.register_blueprint(wish_page)
# Register admin routes # Register admin routes
app.register_blueprint(admin_page) app.register_blueprint(admin_page)
app.register_blueprint(admin_custom_field_page)
app.register_blueprint(admin_database_page) app.register_blueprint(admin_database_page)
app.register_blueprint(admin_export_page) app.register_blueprint(admin_export_page)
app.register_blueprint(admin_image_page) app.register_blueprint(admin_image_page)
@@ -251,38 +195,7 @@ def setup_app(app: Flask) -> None:
# Register custom Jinja2 filters # Register custom Jinja2 filters
app.jinja_env.filters['replace_query'] = replace_query_filter 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,
}
# Show the first-run telemetry splash to authenticated users until they
# have made a decision (Enable or Disable), then never again.
@app.context_processor
def inject_telemetry() -> dict[str, object]:
return {
'telemetry_show_splash': (
LoginManager.is_authenticated()
and not current_app.config.get('TELEMETRY_PROMPTED')
),
}
# Make sure all connections are closed at the end # Make sure all connections are closed at the end
@app.teardown_request @app.teardown_request
def teardown_request(_: BaseException | None) -> None: def teardown_request(_: BaseException | None) -> None:
close() close()
# Report BrickTracker version + database schema version once at
# startup (Installation category). No-op unless telemetry is enabled.
with app.app_context():
BrickTelemetry.send_installation_info()
# Always attempt a flush at boot. This picks up anything a previous
# run left queued (crash, or shutdown mid-interval) without waiting
# for the next opportunistic check.
BrickTelemetry.maybe_flush_queue(force=True)
-80
View File
@@ -1,80 +0,0 @@
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
}
+2 -45
View File
@@ -105,51 +105,8 @@ CONFIG: Final[list[dict[str, Any]]] = [
{'n': 'STATISTICS_SHOW_CHARTS', 'd': True, 'c': bool}, {'n': 'STATISTICS_SHOW_CHARTS', 'd': True, 'c': bool},
{'n': 'STATISTICS_DEFAULT_EXPANDED', 'd': True, 'c': bool}, {'n': 'STATISTICS_DEFAULT_EXPANDED', 'd': True, 'c': bool},
{'n': 'DARK_MODE', 'c': bool}, {'n': 'DARK_MODE', 'c': bool},
{'n': 'BADGE_ORDER_GRID', 'd': ['theme', 'year', 'parts', 'total_minifigures', 'instructions', 'owner'], 'c': list}, {'n': 'BADGE_ORDER_GRID', 'd': ['theme', 'year', 'parts', 'total_minifigures', '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': 'BADGE_ORDER_DETAIL', 'd': ['theme', 'tag', 'year', 'parts', 'instance_count', 'total_minifigures', 'total_missing', 'total_damaged', 'owner', 'storage', 'purchase_date', 'purchase_location', 'purchase_price', 'instructions', 'rebrickable', 'bricklink'], 'c': list},
{'n': 'SHOW_NOTES_GRID', 'd': False, 'c': bool}, {'n': 'SHOW_NOTES_GRID', 'd': False, 'c': bool},
{'n': 'SHOW_NOTES_DETAIL', 'd': True, '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'},
# Opt-in anonymous telemetry (see bricktracker/telemetry.py). Off by
# default; only takes effect after a restart, by design, so "off" is
# always certain. TELEMETRY_INSTALLATION_ID is intentionally absent from
# the admin UI and from LIVE_CHANGEABLE_VARS/RESTART_REQUIRED_VARS in
# config_manager.py. It is generated once by BrickTelemetry and never
# user-editable.
{'n': 'TELEMETRY_ENABLED', 'c': bool},
{'n': 'TELEMETRY_PROMPTED', 'c': bool},
{'n': 'TELEMETRY_CATEGORY_INSTALLATION', 'd': True, 'c': bool},
{'n': 'TELEMETRY_CATEGORY_USAGE', 'd': True, 'c': bool},
{'n': 'TELEMETRY_CATEGORY_COLLECTION', 'd': True, 'c': bool},
{'n': 'TELEMETRY_INSTALLATION_ID', 'd': ''},
# Where queued-but-not-yet-flushed telemetry events are persisted between
# sends. Internal implementation detail, not user-editable. Absent from
# LIVE_CHANGEABLE_VARS/RESTART_REQUIRED_VARS like TELEMETRY_INSTALLATION_ID.
{'n': 'TELEMETRY_QUEUE_PATH', 'd': 'data/telemetry_queue.json'},
] ]
+5 -49
View File
@@ -86,23 +86,7 @@ LIVE_CHANGEABLE_VARS: Final[List[str]] = [
'BK_RETIRED_SETS_FILE_URL', 'BK_RETIRED_SETS_FILE_URL',
'BK_RETIRED_SETS_PATH', 'BK_RETIRED_SETS_PATH',
'BK_THEMES_FILE_URL', '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 # Environment variables that require restart
@@ -130,17 +114,7 @@ RESTART_REQUIRED_VARS: Final[List[str]] = [
'BK_PURCHASE_DATE_FORMAT', 'BK_PURCHASE_DATE_FORMAT',
'BK_PURCHASE_CURRENCY', 'BK_PURCHASE_CURRENCY',
'BK_REBRICKABLE_USER_AGENT', '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',
# Telemetry: restart-required by design, so "off" is always certain.
# BK_TELEMETRY_INSTALLATION_ID is deliberately absent. It is generated
# once by BrickTelemetry and is never user-editable.
'BK_TELEMETRY_ENABLED',
'BK_TELEMETRY_CATEGORY_INSTALLATION',
'BK_TELEMETRY_CATEGORY_USAGE',
'BK_TELEMETRY_CATEGORY_COLLECTION',
] ]
class ConfigManager: class ConfigManager:
@@ -226,13 +200,13 @@ class ConfigManager:
else: else:
return [] return []
# Integer variables (pagination sizes, delays, etc.) - Check BEFORE boolean check # 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', '_timeout', '_hours']): if any(keyword in var_name.lower() for keyword in ['_size', '_page', 'delay', 'min_', 'per_page', 'page_size']):
try: try:
return int(value) return int(value)
except (ValueError, TypeError): except (ValueError, TypeError):
return 0 return 0
# Boolean variables - More specific patterns to avoid conflicts # 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', 'auto_fetch']): if any(keyword in var_name.lower() for keyword in ['hide_', 'disable_', 'server_side_pagination', '_links', 'random', 'skip_', 'show_', 'use_', '_consolidation', '_charts', '_expanded']):
if isinstance(value, str): if isinstance(value, str):
return value.lower() in ('true', '1', 'yes', 'on') return value.lower() in ('true', '1', 'yes', 'on')
return bool(value) return bool(value)
@@ -364,24 +338,6 @@ class ConfigManager:
'BK_USE_REMOTE_IMAGES': 'Use remote images from Rebrickable CDN instead of local', '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_SHOW_CHARTS': 'Show collection growth charts on statistics page',
'BK_STATISTICS_DEFAULT_EXPANDED': 'Expand all statistics sections by default', '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.',
'BK_TELEMETRY_ENABLED': 'Enable anonymous telemetry to a self-hosted Matomo instance. Off by default. See "View Privacy Information" for exactly what is (and is not) sent.',
'BK_TELEMETRY_CATEGORY_INSTALLATION': 'Report BrickTracker version and database schema version on container start and after a migration.',
'BK_TELEMETRY_CATEGORY_USAGE': 'Report which pages are visited (page names only, no data).',
'BK_TELEMETRY_CATEGORY_COLLECTION': 'Report aggregate collection counts (totals only, never names or IDs) once every 24 hours.',
} }
return help_text.get(var_name, 'No help available for this variable') 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.mandatory = m
self.static_path = s self.static_path = s
# Booleans that don't specify an explicit default fall back to False # Default for our booleans is False
if self.cast == bool and d is None: if self.cast == bool:
self.default = False self.default = False
# Try default environment name # Try default environment name
-12
View File
@@ -15,7 +15,6 @@ from werkzeug.utils import secure_filename
import re import re
from .exceptions import ErrorException, DownloadException from .exceptions import ErrorException, DownloadException
from .peeron_instructions import get_peeron_instruction_url
if TYPE_CHECKING: if TYPE_CHECKING:
from .rebrickable_set import RebrickableSet from .rebrickable_set import RebrickableSet
from .socket import BrickSocket from .socket import BrickSocket
@@ -122,21 +121,10 @@ class BrickInstructions(object):
'Cache-Control': 'max-age=0' 'Cache-Control': 'max-age=0'
}) })
# Peeron only hands out scans to a session that visited the set's
# scan page first, that request is what sets the PeeronSID cookie.
# Without it the download silently comes back as a placeholder image
peeron_set = re.search(r'peeron\.com/scans/([^/]+)/', path)
if peeron_set:
number, _, version = peeron_set.group(1).partition('-')
session.get(get_peeron_instruction_url(number, version or '1'))
resp = session.get(path, stream=True, allow_redirects=True) resp = session.get(path, stream=True, allow_redirects=True)
if not resp.ok: if not resp.ok:
raise DownloadException(f"Failed to download: HTTP {resp.status_code}") raise DownloadException(f"Failed to download: HTTP {resp.status_code}")
if peeron_set and 'pdf' not in resp.headers.get('Content-Type', ''):
raise DownloadException('Peeron did not return a PDF, the session may have expired, try again')
# Tell the socket how many bytes in total # Tell the socket how many bytes in total
total = int(resp.headers.get("Content-Length", 0)) total = int(resp.headers.get("Content-Length", 0))
self.socket.update_total(total) self.socket.update_total(total)
-32
View File
@@ -6,7 +6,6 @@ from flask import url_for
from .exceptions import ErrorException, NotFoundException from .exceptions import ErrorException, NotFoundException
from .fields import BrickRecordFields from .fields import BrickRecordFields
from .record_list import BrickRecordList from .record_list import BrickRecordList
from .set_custom_field import BrickSetCustomField
from .set_owner import BrickSetOwner from .set_owner import BrickSetOwner
from .set_purchase_location import BrickSetPurchaseLocation from .set_purchase_location import BrickSetPurchaseLocation
from .set_status import BrickSetStatus from .set_status import BrickSetStatus
@@ -18,7 +17,6 @@ logger = logging.getLogger(__name__)
T = TypeVar( T = TypeVar(
'T', 'T',
BrickSetCustomField,
BrickSetOwner, BrickSetOwner,
BrickSetPurchaseLocation, BrickSetPurchaseLocation,
BrickSetStatus, BrickSetStatus,
@@ -206,33 +204,3 @@ class BrickMetadataList(BrickRecordList[T]):
endpoint, endpoint,
id=minifigure_id, id=minifigure_id,
) )
# Owner and tag filter values are interpolated into queries as column names, which
# no amount of quoting makes safe. Only values naming metadata we already know about
# are allowed through, anything else is dropped and the filter does not apply.
#
# Values look like "owner-<id>", or "-owner-<id>" for the != form.
def known_metadata_filter(
value: str | None,
kind: str,
items: list,
/,
) -> str | None:
if not value:
return None
candidate = value[1:] if value.startswith('-') else value
if not candidate.startswith('{kind}-'.format(kind=kind)):
return None
if candidate not in {item.as_dataset() for item in items}:
logger.warning('Ignoring unknown {kind} filter: {value}'.format(
kind=kind,
value=value,
))
return None
return value
+1 -13
View File
@@ -28,12 +28,6 @@ def get_pagination_config(entity_type: str) -> Tuple[int, bool]:
def build_pagination_context(page: int, per_page: int, total_count: int, is_mobile: bool) -> Dict[str, Any]: def build_pagination_context(page: int, per_page: int, total_count: int, is_mobile: bool) -> Dict[str, Any]:
"""Build pagination context for templates""" """Build pagination context for templates"""
total_pages = (total_count + per_page - 1) // per_page if total_count > 0 else 1 total_pages = (total_count + per_page - 1) // per_page if total_count > 0 else 1
# ponytail: a page past the end still runs its query and comes back empty. This
# only keeps the nav links sane so the user can walk back. Re-running the query
# against the clamped page would mean counting before selecting.
page = min(max(page, 1), total_pages)
has_prev = page > 1 has_prev = page > 1
has_next = page < total_pages has_next = page < total_pages
@@ -53,12 +47,6 @@ def get_request_params() -> Tuple[str, str, str, int]:
search_query = request.args.get('search', '').strip() search_query = request.args.get('search', '').strip()
sort_field = request.args.get('sort', '') sort_field = request.args.get('sort', '')
sort_order = request.args.get('order', 'asc') sort_order = request.args.get('order', 'asc')
page = int(request.args.get('page', 1))
# ?page=abc used to raise straight out of the view, and ?page=0 gave a negative
# offset. Anything that is not a sensible page number is just page one.
try:
page = max(int(request.args.get('page', 1)), 1)
except ValueError:
page = 1
return search_query, sort_field, sort_order, page return search_query, sort_field, sort_order, page
+196 -145
View File
@@ -14,46 +14,6 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# The filters both the parts page and the problem page offer. 'all' or empty means
# the filter is off.
FILTER_NAMES: tuple[str, ...] = (
'owner_id',
'color_id',
'theme_id',
'year',
'storage_id',
'tag_id',
'status_id',
)
# Filters whose value is data and can be bound as a SQL parameter. owner, tag and
# status are missing on purpose: those become column names, so they get validated
# against the known metadata ids instead.
BOUND_FILTERS: tuple[str, ...] = ('color_id', 'theme_id', 'year', 'storage_id')
# Storage has two sentinel values on top of the real ids
STORAGE_SENTINELS: tuple[str, ...] = ('__none__', '-__none__')
# Strip the leading "-" a negated filter carries
def without_negation(value: str, /) -> str:
return value[1:] if value.startswith('-') else value
# owner, tag and status ids are interpolated into the query as column names, which
# no amount of quoting makes safe. Only ids we already know about get through.
def known_metadata_id(value: str | None, known: set[str], /) -> str | None:
if not value or value == 'all':
return None
if without_negation(value) not in known:
logger.warning('Ignoring unknown metadata filter: {value}'.format(
value=value,
))
return None
return value
# Lego set or minifig parts # Lego set or minifig parts
class BrickPartList(BrickRecordList[BrickPart]): class BrickPartList(BrickRecordList[BrickPart]):
@@ -61,139 +21,130 @@ class BrickPartList(BrickRecordList[BrickPart]):
minifigure: 'BrickMinifigure | None' minifigure: 'BrickMinifigure | None'
individual_minifigure: 'IndividualMinifigure | None' individual_minifigure: 'IndividualMinifigure | None'
order: str order: str
filter_parameters: dict[str, Any]
# Queries # Queries
all_query: str = 'part/list/filtered' all_query: str = 'part/list/all'
filtered_query: str = 'part/list/filtered' all_by_owner_query: str = 'part/list/all_by_owner'
different_color_query = 'part/list/with_different_color' different_color_query = 'part/list/with_different_color'
last_query: str = 'part/list/last' last_query: str = 'part/list/last'
minifigure_query: str = 'part/list/from_minifigure' minifigure_query: str = 'part/list/from_minifigure'
problem_query: str = 'part/list/problem'
print_query: str = 'part/list/from_print' print_query: str = 'part/list/from_print'
select_query: str = 'part/list/specific' select_query: str = 'part/list/specific'
# Sortable columns, shared by both pages
field_mapping: dict[str, str] = {
'name': '"rebrickable_parts"."name"',
'color': '"rebrickable_parts"."color_name"',
'quantity': '"total_quantity"',
'missing': '"total_missing"',
'damaged': '"total_damaged"',
'sets': '"total_sets"',
'minifigures': '"total_minifigures"',
}
def __init__(self, /): def __init__(self, /):
super().__init__() super().__init__()
# Placeholders # Placeholders
self.brickset = None self.brickset = None
self.minifigure = None self.minifigure = None
self.filter_parameters = {}
# Store the order for this list # Store the order for this list
self.order = current_app.config['PARTS_DEFAULT_ORDER'] self.order = current_app.config['PARTS_DEFAULT_ORDER']
# Build the Jinja context for the filtered query, and stash the values that get # Load all parts
# bound as SQL parameters. Both pages go through here, so a filter either works def all(self, /) -> Self:
# on both or on neither. self.list(override_query=self.all_query)
def filter_context(
self,
/,
*,
problem_only: bool = False,
individuals_filter: str | None = None,
search_query: str | None = None,
**filters: str | None,
) -> dict[str, Any]:
context: dict[str, Any] = {}
if problem_only:
context['problem_only'] = True
for name in FILTER_NAMES:
value = filters.get(name)
if value and value != 'all':
context[name] = value
if individuals_filter == 'only':
context['individuals_filter'] = True
if search_query:
context['search_query'] = search_query
# Hide spare parts from display if configured
if current_app.config.get('HIDE_SPARE_PARTS', False):
context['skip_spare_parts'] = True
# Everything that is data rather than a column name gets bound. The query
# decides what "not this" means, so the value itself is bound without its
# leading "-".
self.filter_parameters = {}
for name in BOUND_FILTERS:
value = context.get(name)
if value is None:
continue
if name == 'storage_id' and value in STORAGE_SENTINELS:
continue
self.filter_parameters[name] = without_negation(value)
if search_query:
self.filter_parameters['search_query'] = '%{query}%'.format(
query=search_query.lower(),
)
return context
# Load parts with filters. problem_only narrows to parts with something missing
# or damaged, which is all the /parts/problem page is.
def filtered(
self,
/,
*,
problem_only: bool = False,
individuals_filter: str | None = None,
**filters: str | None,
) -> Self:
context = self.filter_context(
problem_only=problem_only,
individuals_filter=individuals_filter,
**filters
)
self.list(override_query=self.filtered_query, **context)
return self return self
# Same thing, one page at a time # Load all parts by owner
def paginated_filtered( def all_by_owner(self, owner_id: str | None = None, /) -> Self:
# Save the owner_id parameter
self.fields.owner_id = owner_id
# Load the parts from the database
self.list(override_query=self.all_by_owner_query)
return self
# Load all parts with filters (owner, color, theme, year, individuals)
def all_filtered(self, owner_id: str | None = None, color_id: str | None = None, theme_id: str | None = None, year: str | None = None, individuals_filter: str | None = None, /) -> Self:
# Save the filter parameters
if owner_id is not None:
self.fields.owner_id = owner_id
if color_id is not None:
self.fields.color_id = color_id
# Choose query based on whether owner filtering is needed
if owner_id and owner_id != 'all':
query = self.all_by_owner_query
else:
query = self.all_query
# Prepare context for query
context = {}
# Hide spare parts from display if configured
if current_app.config.get('HIDE_SPARE_PARTS', False):
context['skip_spare_parts'] = True
if theme_id and theme_id != 'all':
context['theme_id'] = theme_id
if year and year != 'all':
context['year'] = year
if individuals_filter and individuals_filter == 'only':
context['individuals_filter'] = True
# Load the parts from the database
self.list(override_query=query, **context)
return self
# Load parts with pagination support
def all_filtered_paginated(
self, self,
/, owner_id: str | None = None,
*, color_id: str | None = None,
problem_only: bool = False, theme_id: str | None = None,
year: str | None = None,
individuals_filter: str | None = None, individuals_filter: str | None = None,
search_query: str | None = None, search_query: str | None = None,
page: int = 1, page: int = 1,
per_page: int = 50, per_page: int = 50,
sort_field: str | None = None, sort_field: str | None = None,
sort_order: str = 'asc', sort_order: str = 'asc'
**filters: str | None,
) -> tuple[Self, int]: ) -> tuple[Self, int]:
context = self.filter_context( # Prepare filter context
problem_only=problem_only, filter_context = {}
individuals_filter=individuals_filter, if owner_id and owner_id != 'all':
search_query=search_query, filter_context['owner_id'] = owner_id
**filters list_query = self.all_by_owner_query
) else:
list_query = self.all_query
if color_id and color_id != 'all':
filter_context['color_id'] = color_id
if theme_id and theme_id != 'all':
filter_context['theme_id'] = theme_id
if year and year != 'all':
filter_context['year'] = year
if individuals_filter and individuals_filter == 'only':
filter_context['individuals_filter'] = True
if search_query:
filter_context['search_query'] = search_query
# Hide spare parts from display if configured
if current_app.config.get('HIDE_SPARE_PARTS', False):
filter_context['skip_spare_parts'] = True
# Field mapping for sorting
field_mapping = {
'name': '"rebrickable_parts"."name"',
'color': '"rebrickable_parts"."color_name"',
'quantity': '"total_quantity"',
'missing': '"total_missing"',
'damaged': '"total_damaged"',
'sets': '"total_sets"',
'minifigures': '"total_minifigures"'
}
# Use the base pagination method
return self.paginate( return self.paginate(
page=page, page=page,
per_page=per_page, per_page=per_page,
sort_field=sort_field, sort_field=sort_field,
sort_order=sort_order, sort_order=sort_order,
list_query=self.filtered_query, list_query=list_query,
field_mapping=self.field_mapping, field_mapping=field_mapping,
**context **filter_context
) )
# Base part list # Base part list
@@ -225,14 +176,25 @@ class BrickPartList(BrickRecordList[BrickPart]):
else: else:
individual_minifigure = None individual_minifigure = None
# Load the sets from the database. Filters arrive in context, built by # Prepare template context for filtering
# filter_context, rather than being read back off self.fields. context_vars = {}
if hasattr(self.fields, 'owner_id') and self.fields.owner_id is not None:
context_vars['owner_id'] = self.fields.owner_id
if hasattr(self.fields, 'color_id') and self.fields.color_id is not None:
context_vars['color_id'] = self.fields.color_id
if hasattr(self.fields, 'search_query') and self.fields.search_query:
context_vars['search_query'] = self.fields.search_query
# Merge with any additional context passed in
context_vars.update(context)
# Load the sets from the database
for record in super().select( for record in super().select(
override_query=override_query, override_query=override_query,
order=order, order=order,
limit=limit, limit=limit,
offset=offset, offset=offset,
**context **context_vars
): ):
part = BrickPart( part = BrickPart(
brickset=brickset, brickset=brickset,
@@ -339,13 +301,102 @@ class BrickPartList(BrickRecordList[BrickPart]):
return self return self
# Load problematic parts
def problem(self, /) -> Self:
self.list(override_query=self.problem_query)
return self
def problem_filtered(self, owner_id: str | None = None, color_id: str | None = None, theme_id: str | None = None, year: str | None = None, storage_id: str | None = None, tag_id: str | None = None, /) -> Self:
# Save the filter parameters for client-side filtering
if owner_id is not None:
self.fields.owner_id = owner_id
if color_id is not None:
self.fields.color_id = color_id
# Prepare context for query
context = {}
if owner_id and owner_id != 'all':
context['owner_id'] = owner_id
if color_id and color_id != 'all':
context['color_id'] = color_id
if theme_id and theme_id != 'all':
context['theme_id'] = theme_id
if year and year != 'all':
context['year'] = year
if storage_id and storage_id != 'all':
context['storage_id'] = storage_id
if tag_id and tag_id != 'all':
context['tag_id'] = tag_id
# Hide spare parts from display if configured
if current_app.config.get('HIDE_SPARE_PARTS', False):
context['skip_spare_parts'] = True
# Load the problematic parts from the database
self.list(override_query=self.problem_query, **context)
return self
def problem_paginated(
self,
owner_id: str | None = None,
color_id: str | None = None,
theme_id: str | None = None,
year: str | None = None,
storage_id: str | None = None,
tag_id: str | None = None,
search_query: str | None = None,
page: int = 1,
per_page: int = 50,
sort_field: str | None = None,
sort_order: str = 'asc'
) -> tuple[Self, int]:
# Prepare filter context
filter_context = {}
if owner_id and owner_id != 'all':
filter_context['owner_id'] = owner_id
if color_id and color_id != 'all':
filter_context['color_id'] = color_id
if theme_id and theme_id != 'all':
filter_context['theme_id'] = theme_id
if year and year != 'all':
filter_context['year'] = year
if storage_id and storage_id != 'all':
filter_context['storage_id'] = storage_id
if tag_id and tag_id != 'all':
filter_context['tag_id'] = tag_id
if search_query:
filter_context['search_query'] = search_query
# Hide spare parts from display if configured
if current_app.config.get('HIDE_SPARE_PARTS', False):
filter_context['skip_spare_parts'] = True
# Field mapping for sorting
field_mapping = {
'name': '"rebrickable_parts"."name"',
'color': '"rebrickable_parts"."color_name"',
'quantity': '"total_quantity"',
'missing': '"total_missing"',
'damaged': '"total_damaged"',
'sets': '"total_sets"',
'minifigures': '"total_minifigures"'
}
# Use the base pagination method with problem query
return self.paginate(
page=page,
per_page=per_page,
sort_field=sort_field,
sort_order=sort_order,
list_query=self.problem_query,
field_mapping=field_mapping,
**filter_context
)
# Return a dict with common SQL parameters for a parts list # Return a dict with common SQL parameters for a parts list
def sql_parameters(self, /) -> dict[str, Any]: def sql_parameters(self, /) -> dict[str, Any]:
parameters: dict[str, Any] = super().sql_parameters() parameters: dict[str, Any] = super().sql_parameters()
# Filter values that are data rather than column names
parameters.update(self.filter_parameters)
# Set id # Set id
if self.brickset is not None: if self.brickset is not None:
parameters['id'] = self.brickset.fields.id parameters['id'] = self.brickset.fields.id
-41
View File
@@ -51,26 +51,6 @@ def get_peeron_scan_url(set_number: str, version_number: str):
return pattern.format(set_number=set_number, version_number=version_number) return pattern.format(set_number=set_number, version_number=version_number)
def get_peeron_pdf_scan_url(response_url: str, set_number: str, version_number: str) -> str | None:
"""
Peeron keeps some sets as a single PDF instead of scanned pages. Their scan
page then redirects to a PDF viewer (/pdf/<set>/<page>/) that answers with
HTTP 500, but the file itself still sits at the regular scan URL.
Returns that URL, or None for the usual gallery of pages.
"""
if '/pdf/' not in response_url:
return None
page_number = response_url.rstrip('/').rsplit('/', 1)[-1]
if not page_number.isdigit():
page_number = '1'
return '{base}{page}/'.format(
base=get_peeron_scan_url(set_number, version_number),
page=page_number,
)
def create_peeron_scraper(): def create_peeron_scraper():
"""Create a requests session configured for Peeron""" """Create a requests session configured for Peeron"""
session = requests.Session() session = requests.Session()
@@ -287,7 +267,6 @@ class PeeronInstructions(object):
set_number: str set_number: str
version_number: str version_number: str
pages: list[PeeronPage] pages: list[PeeronPage]
pdf_url: str | None
def __init__( def __init__(
self, self,
@@ -312,9 +291,6 @@ class PeeronInstructions(object):
# Placeholder for pages # Placeholder for pages
self.pages = [] self.pages = []
# Set once we know Peeron only has this set as a single PDF
self.pdf_url = None
# Check if instructions exist on Peeron (lightweight) # Check if instructions exist on Peeron (lightweight)
def exists(self, /) -> bool: def exists(self, /) -> bool:
"""Check if the set exists on Peeron without caching thumbnails""" """Check if the set exists on Peeron without caching thumbnails"""
@@ -323,14 +299,6 @@ class PeeronInstructions(object):
scraper = create_peeron_scraper() scraper = create_peeron_scraper()
response = scraper.get(base_url) response = scraper.get(base_url)
# A single PDF set has no gallery to look at, but it does have
# instructions, so say so and remember where the file lives
self.pdf_url = get_peeron_pdf_scan_url(
response.url, self.set_number, self.version_number
)
if self.pdf_url:
return True
if response.status_code != 200: if response.status_code != 200:
return False return False
@@ -367,15 +335,6 @@ class PeeronInstructions(object):
logger.debug(f"[find_pages] Establishing session by visiting: {base_url}") logger.debug(f"[find_pages] Establishing session by visiting: {base_url}")
response = scraper.get(base_url) response = scraper.get(base_url)
logger.debug(f"[find_pages] Main page visit: HTTP {response.status_code}") logger.debug(f"[find_pages] Main page visit: HTTP {response.status_code}")
# Nothing to scrape when the set is one PDF, the download page
# handles those on its own
self.pdf_url = get_peeron_pdf_scan_url(
response.url, self.set_number, self.version_number
)
if self.pdf_url:
raise ErrorException(f'Peeron has {self.set_number}-{self.version_number} as a single PDF, not as scanned pages')
if response.status_code != 200: if response.status_code != 200:
raise ErrorException(f'Failed to load Peeron page for {self.set_number}-{self.version_number}. HTTP {response.status_code}') raise ErrorException(f'Failed to load Peeron page for {self.set_number}-{self.version_number}. HTTP {response.status_code}')
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
+2 -8
View File
@@ -181,14 +181,8 @@ class RebrickablePart(BrickRecord):
'color_rgb': data['color']['rgb'], 'color_rgb': data['color']['rgb'],
'color_transparent': data['color']['is_trans'], 'color_transparent': data['color']['is_trans'],
'bricklink_color_id': None, 'bricklink_color_id': None,
# Empty-string sentinel (not NULL) means "fetched from Rebrickable, 'bricklink_color_name': None,
# but no BrickLink mapping exists upstream". need_refresh.sql flags 'bricklink_part_num': None,
# 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'], 'name': data['part']['name'],
'category': data['part']['part_cat_id'], 'category': data['part']['part_cat_id'],
'image': data['part']['part_img_url'], 'image': data['part']['part_img_url'],
+1 -6
View File
@@ -119,12 +119,7 @@ class BrickRecordList(Generic[T]):
# Wrap in COUNT(*) # Wrap in COUNT(*)
wrapped_sql = f"SELECT COUNT(*) as total_count FROM ({count_sql.strip()})" wrapped_sql = f"SELECT COUNT(*) as total_count FROM ({count_sql.strip()})"
# Same bound values as the list query, otherwise any named placeholder count_result = BrickSQL().raw_execute(wrapped_sql, {}).fetchone()
# in the filters blows up the count
count_result = BrickSQL().raw_execute(
wrapped_sql,
self.sql_parameters(),
).fetchone()
total_count = count_result['total_count'] if count_result else 0 total_count = count_result['total_count'] if count_result else 0
# Prepare sort order # Prepare sort order
-4
View File
@@ -1,6 +1,5 @@
from .instructions_list import BrickInstructionsList from .instructions_list import BrickInstructionsList
from .retired_list import BrickRetiredList from .retired_list import BrickRetiredList
from .set_custom_field_list import BrickSetCustomFieldList
from .set_owner_list import BrickSetOwnerList from .set_owner_list import BrickSetOwnerList
from .set_purchase_location_list import BrickSetPurchaseLocationList from .set_purchase_location_list import BrickSetPurchaseLocationList
from .set_status_list import BrickSetStatusList from .set_status_list import BrickSetStatusList
@@ -32,9 +31,6 @@ def reload() -> None:
# Reload the set tags # Reload the set tags
BrickSetTagList.new(force=True) BrickSetTagList.new(force=True)
# Reload the set custom fields
BrickSetCustomFieldList.new(force=True)
# Reload retired sets # Reload retired sets
BrickRetiredList(force=True) BrickRetiredList(force=True)
-67
View File
@@ -10,7 +10,6 @@ from .exceptions import NotFoundException, DatabaseException, ErrorException
from .minifigure_list import BrickMinifigureList from .minifigure_list import BrickMinifigureList
from .part_list import BrickPartList from .part_list import BrickPartList
from .rebrickable_set import RebrickableSet from .rebrickable_set import RebrickableSet
from .set_custom_field_list import BrickSetCustomFieldList
from .set_owner_list import BrickSetOwnerList from .set_owner_list import BrickSetOwnerList
from .set_purchase_location_list import BrickSetPurchaseLocationList from .set_purchase_location_list import BrickSetPurchaseLocationList
from .set_status_list import BrickSetStatusList from .set_status_list import BrickSetStatusList
@@ -80,11 +79,6 @@ class BrickSet(RebrickableSet):
# This must happen before inserting bricktracker_sets due to FK constraint # This must happen before inserting bricktracker_sets due to FK constraint
self.insert_rebrickable() 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: if not refresh:
# Save the storage # Save the storage
storage = BrickSetStorageList.get( storage = BrickSetStorageList.get(
@@ -231,42 +225,6 @@ class BrickSet(RebrickableSet):
return True 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 # Purchase date
def purchase_date(self, /, *, standard: bool = False) -> str: def purchase_date(self, /, *, standard: bool = False) -> str:
if self.fields.purchase_date is not None: if self.fields.purchase_date is not None:
@@ -335,7 +293,6 @@ class BrickSet(RebrickableSet):
# Load from database # Load from database
if not self.select( if not self.select(
custom_fields=BrickSetCustomFieldList.as_columns(),
owners=BrickSetOwnerList.as_columns(), owners=BrickSetOwnerList.as_columns(),
statuses=BrickSetStatusList.as_columns(all=True), statuses=BrickSetStatusList.as_columns(all=True),
tags=BrickSetTagList.as_columns(), tags=BrickSetTagList.as_columns(),
@@ -499,27 +456,3 @@ class BrickSet(RebrickableSet):
# Update description url # Update description url
def url_for_description(self, /) -> str: def url_for_description(self, /) -> str:
return url_for('set.update_description', id=self.fields.id) 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
@@ -1,99 +0,0 @@
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
@@ -1,24 +0,0 @@
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)
+22 -107
View File
@@ -3,7 +3,6 @@ from typing import Any, Self, Union
from flask import current_app from flask import current_app
from .record_list import BrickRecordList from .record_list import BrickRecordList
from .set_custom_field_list import BrickSetCustomFieldList
from .set_owner import BrickSetOwner from .set_owner import BrickSetOwner
from .set_owner_list import BrickSetOwnerList from .set_owner_list import BrickSetOwnerList
from .set_purchase_location import BrickSetPurchaseLocation from .set_purchase_location import BrickSetPurchaseLocation
@@ -53,7 +52,6 @@ class BrickSetList(BrickRecordList[BrickSet]):
def all(self, /) -> Self: def all(self, /) -> Self:
# Load the sets from the database with metadata context for filtering # Load the sets from the database with metadata context for filtering
filter_context = { filter_context = {
'custom_fields': BrickSetCustomFieldList.as_columns(),
'owners': BrickSetOwnerList.as_columns(), 'owners': BrickSetOwnerList.as_columns(),
'statuses': BrickSetStatusList.as_columns(), 'statuses': BrickSetStatusList.as_columns(),
'tags': BrickSetTagList.as_columns(), 'tags': BrickSetTagList.as_columns(),
@@ -66,7 +64,6 @@ class BrickSetList(BrickRecordList[BrickSet]):
def all_consolidated(self, /) -> Self: def all_consolidated(self, /) -> Self:
# Load the sets from the database using consolidated query with metadata context # Load the sets from the database using consolidated query with metadata context
filter_context = { filter_context = {
'custom_fields_dict': BrickSetCustomFieldList.as_column_mapping(),
'owners_dict': BrickSetOwnerList.as_column_mapping(), 'owners_dict': BrickSetOwnerList.as_column_mapping(),
'statuses_dict': BrickSetStatusList.as_column_mapping(), 'statuses_dict': BrickSetStatusList.as_column_mapping(),
'tags_dict': BrickSetTagList.as_column_mapping(), 'tags_dict': BrickSetTagList.as_column_mapping(),
@@ -91,10 +88,6 @@ class BrickSetList(BrickRecordList[BrickSet]):
tag_filter: str | None = None, tag_filter: str | None = None,
year_filter: str | None = None, year_filter: str | None = None,
duplicate_filter: bool = False, 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 use_consolidated: bool = True
) -> tuple[Self, int]: ) -> tuple[Self, int]:
# Convert theme name to theme ID for filtering # Convert theme name to theme ID for filtering
@@ -110,6 +103,9 @@ class BrickSetList(BrickRecordList[BrickSet]):
else: else:
theme_id_filter = self._theme_name_to_id(theme_filter) 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])
# Prepare filter context # Prepare filter context
filter_context = { filter_context = {
'search_query': search_query, 'search_query': search_query,
@@ -121,15 +117,9 @@ class BrickSetList(BrickRecordList[BrickSet]):
'tag_filter': tag_filter, 'tag_filter': tag_filter,
'year_filter': year_filter, 'year_filter': year_filter,
'duplicate_filter': duplicate_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(), 'owners': BrickSetOwnerList.as_columns(),
'statuses': BrickSetStatusList.as_columns(), 'statuses': BrickSetStatusList.as_columns(),
'tags': BrickSetTagList.as_columns(), 'tags': BrickSetTagList.as_columns(),
'custom_fields_dict': BrickSetCustomFieldList.as_column_mapping(),
'owners_dict': BrickSetOwnerList.as_column_mapping(), 'owners_dict': BrickSetOwnerList.as_column_mapping(),
'statuses_dict': BrickSetStatusList.as_column_mapping(), 'statuses_dict': BrickSetStatusList.as_column_mapping(),
'tags_dict': BrickSetTagList.as_column_mapping(), 'tags_dict': BrickSetTagList.as_column_mapping(),
@@ -166,8 +156,11 @@ class BrickSetList(BrickRecordList[BrickSet]):
'purchase-price': '"bricktracker_sets"."purchase_price"' 'purchase-price': '"bricktracker_sets"."purchase_price"'
} }
# Choose query based on consolidation preference # Choose query based on consolidation preference and filter complexity
if use_consolidated: # Owner/tag filters still need to fall back to non-consolidated for now
# due to complex aggregation requirements
complex_filters = [owner_filter, tag_filter]
if use_consolidated and not any(complex_filters):
query_to_use = self.consolidated_query query_to_use = self.consolidated_query
else: else:
# Use filtered query when consolidation is disabled or complex filters applied # Use filtered query when consolidation is disabled or complex filters applied
@@ -179,9 +172,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
return self._all_filtered_paginated_with_instructions( return self._all_filtered_paginated_with_instructions(
search_query, page, per_page, sort_field, sort_order, search_query, page, per_page, sort_field, sort_order,
status_filter, theme_id_filter, owner_filter, 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,
year_filter, duplicate_filter
) )
# Handle special case for set sorting with multiple columns # Handle special case for set sorting with multiple columns
@@ -327,13 +318,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
owner_filter: str | None, owner_filter: str | None,
purchase_location_filter: str | None, purchase_location_filter: str | None,
storage_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,
year_filter: str | None = None,
duplicate_filter: str | None = None
) -> tuple[Self, int]: ) -> tuple[Self, int]:
"""Handle filtering when instructions filter is involved""" """Handle filtering when instructions filter is involved"""
try: try:
@@ -350,17 +335,6 @@ class BrickSetList(BrickRecordList[BrickSet]):
instructions_list = BrickInstructionsList() instructions_list = BrickInstructionsList()
instruction_sets = set(instructions_list.sets.keys()) instruction_sets = set(instructions_list.sets.keys())
# Which set numbers appear more than once, worked out once rather than
# rescanned for every record
duplicate_sets: set[str] = set()
if duplicate_filter:
seen: dict[str, int] = {}
for record in all_sets.records:
seen[record.fields.set] = seen.get(record.fields.set, 0) + 1
duplicate_sets = {
number for number, count in seen.items() if count > 1
}
# Apply all filters manually # Apply all filters manually
filtered_records = [] filtered_records = []
for record in all_sets.records: for record in all_sets.records:
@@ -386,24 +360,6 @@ class BrickSetList(BrickRecordList[BrickSet]):
continue continue
if tag_filter and not self._matches_tag(record, tag_filter): if tag_filter and not self._matches_tag(record, tag_filter):
continue continue
if year_filter and not self._matches_year(record, year_filter):
continue
if duplicate_filter and record.fields.set not in duplicate_sets:
continue
# A set with no part count used to raise here, and the bare except
# below turned that into "instructions filter silently ignored"
number_of_parts = record.fields.number_of_parts or 0
if parts_min is not None and number_of_parts < parts_min:
continue
if parts_max is not None and number_of_parts > parts_max:
continue
year = record.fields.year or 0
if year_min is not None and year < year_min:
continue
if year_max is not None and year > year_max:
continue
filtered_records.append(record) filtered_records.append(record)
@@ -496,10 +452,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
theme_list = BrickThemeList() theme_list = BrickThemeList()
themes = set() themes = set()
for record in theme_records: for record in theme_records:
# sqlite3.Row has no .get(). This used to raise on every call, which theme_id = record.get('theme_id')
# sent the whole thing into the fallback below and quietly loaded the
# entire collection unfiltered on every render.
theme_id = record['theme_id'] if 'theme_id' in record.keys() else None
if theme_id: if theme_id:
theme = theme_list.get(theme_id) theme = theme_list.get(theme_id)
if theme and hasattr(theme, 'name'): if theme and hasattr(theme, 'name'):
@@ -539,76 +492,39 @@ class BrickSetList(BrickRecordList[BrickSet]):
return (search_lower in record.fields.name.lower() or return (search_lower in record.fields.name.lower() or
search_lower in record.fields.set.lower()) search_lower in record.fields.set.lower())
# Every filter value on the sets page can carry a leading "-" meaning "not this",
# produced by the != toggle. This path used to ignore it, so a negated filter
# either did nothing at all or matched nothing at all.
@staticmethod
def _is_negated(value: str) -> bool:
return value.startswith('-')
@staticmethod
def _without_negation(value: str) -> str:
return value[1:] if value.startswith('-') else value
@classmethod
def _apply_negation(cls, matched: bool, value: str) -> bool:
return not matched if cls._is_negated(value) else matched
def _matches_theme(self, record, theme_id: str) -> bool: def _matches_theme(self, record, theme_id: str) -> bool:
"""Check if record matches theme filter""" """Check if record matches theme filter"""
matched = str(record.fields.theme_id) == self._without_negation(theme_id) return str(record.fields.theme_id) == theme_id
return self._apply_negation(matched, theme_id)
def _matches_year(self, record, year_filter: str) -> bool:
"""Check if record matches year filter"""
matched = str(record.fields.year) == self._without_negation(year_filter)
return self._apply_negation(matched, year_filter)
def _matches_owner(self, record, owner_filter: str) -> bool: def _matches_owner(self, record, owner_filter: str) -> bool:
"""Check if record matches owner filter""" """Check if record matches owner filter"""
value = self._without_negation(owner_filter) if not owner_filter.startswith('owner-'):
if not value.startswith('owner-'):
return True return True
# Convert owner-uuid format to owner_uuid column name # Convert owner-uuid format to owner_uuid column name
owner_column = value.replace('-', '_') owner_column = owner_filter.replace('-', '_')
matched = getattr(record.fields, owner_column, 0) == 1
return self._apply_negation(matched, owner_filter) # Check if record has this owner attribute set to 1
return hasattr(record.fields, owner_column) and getattr(record.fields, owner_column) == 1
def _matches_purchase_location(self, record, location_filter: str) -> bool: def _matches_purchase_location(self, record, location_filter: str) -> bool:
"""Check if record matches purchase location filter""" """Check if record matches purchase location filter"""
value = self._without_negation(location_filter) return record.fields.purchase_location == location_filter
if value == '__none__':
matched = not record.fields.purchase_location
else:
matched = record.fields.purchase_location == value
return self._apply_negation(matched, location_filter)
def _matches_storage(self, record, storage_filter: str) -> bool: def _matches_storage(self, record, storage_filter: str) -> bool:
"""Check if record matches storage filter""" """Check if record matches storage filter"""
value = self._without_negation(storage_filter) return record.fields.storage == storage_filter
if value == '__none__':
matched = not record.fields.storage
else:
matched = record.fields.storage == value
return self._apply_negation(matched, storage_filter)
def _matches_tag(self, record, tag_filter: str) -> bool: def _matches_tag(self, record, tag_filter: str) -> bool:
"""Check if record matches tag filter""" """Check if record matches tag filter"""
value = self._without_negation(tag_filter) if not tag_filter.startswith('tag-'):
if not value.startswith('tag-'):
return True return True
# Convert tag-uuid format to tag_uuid column name # Convert tag-uuid format to tag_uuid column name
tag_column = value.replace('-', '_') tag_column = tag_filter.replace('-', '_')
matched = getattr(record.fields, tag_column, 0) == 1
return self._apply_negation(matched, tag_filter) # Check if record has this tag attribute set to 1
return hasattr(record.fields, tag_column) and getattr(record.fields, tag_column) == 1
def _sort_records(self, records, sort_field: str, sort_order: str): def _sort_records(self, records, sort_field: str, sort_order: str):
"""Sort records manually""" """Sort records manually"""
@@ -789,7 +705,6 @@ def set_metadata_lists(
] ]
]: ]:
return { return {
'brickset_custom_fields': BrickSetCustomFieldList.list(),
'brickset_owners': BrickSetOwnerList.list(), 'brickset_owners': BrickSetOwnerList.list(),
'brickset_purchase_locations': BrickSetPurchaseLocationList.list(as_class=as_class), # noqa: E501 'brickset_purchase_locations': BrickSetPurchaseLocationList.list(as_class=as_class), # noqa: E501
'brickset_storages': BrickSetStorageList.list(as_class=as_class), 'brickset_storages': BrickSetStorageList.list(as_class=as_class),
-586
View File
@@ -1,586 +0,0 @@
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
@@ -1,521 +0,0 @@
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
-77
View File
@@ -11,7 +11,6 @@ from .peeron_pdf import PeeronPDF
from .set import BrickSet from .set import BrickSet
from .socket_decorator import authenticated_socket, rebrickable_socket from .socket_decorator import authenticated_socket, rebrickable_socket
from .sql import close as sql_close from .sql import close as sql_close
from .telemetry import BrickTelemetry
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -37,7 +36,6 @@ MESSAGES: Final[dict[str, str]] = {
'PART_LOADED': 'part_loaded', 'PART_LOADED': 'part_loaded',
'PROGRESS': 'progress', 'PROGRESS': 'progress',
'SET_LOADED': 'set_loaded', 'SET_LOADED': 'set_loaded',
'VALUE_ALL_SETS': 'value_all_sets',
} }
@@ -138,7 +136,6 @@ class BrickSocket(object):
except Exception: except Exception:
pass pass
BrickTelemetry.track_action('DownloadInstructions')
instructions.download(path) instructions.download(path)
BrickInstructionsList(force=True) BrickInstructionsList(force=True)
@@ -186,8 +183,6 @@ class BrickSocket(object):
if not pages_data: if not pages_data:
raise ValueError("No pages selected") raise ValueError("No pages selected")
BrickTelemetry.track_action('DownloadPeeronInstructions')
# Parse set number # Parse set number
if '-' in set_number: if '-' in set_number:
parts = set_number.split('-', 1) parts = set_number.split('-', 1)
@@ -231,7 +226,6 @@ class BrickSocket(object):
fr=request.sid, # type: ignore fr=request.sid, # type: ignore
)) ))
BrickTelemetry.track_action('AddSet')
BrickSet().download(self, data) BrickSet().download(self, data)
@self.socket.on(MESSAGES['LOAD_SET'], namespace=self.namespace) @self.socket.on(MESSAGES['LOAD_SET'], namespace=self.namespace)
@@ -251,8 +245,6 @@ class BrickSocket(object):
fr=request.sid, # type: ignore fr=request.sid, # type: ignore
)) ))
BrickTelemetry.track_action('AddMinifigure')
from .individual_minifigure import IndividualMinifigure from .individual_minifigure import IndividualMinifigure
IndividualMinifigure().download(self, data) IndividualMinifigure().download(self, data)
@@ -273,8 +265,6 @@ class BrickSocket(object):
fr=request.sid, # type: ignore fr=request.sid, # type: ignore
)) ))
BrickTelemetry.track_action('AddPartSingle')
from .individual_part import IndividualPart from .individual_part import IndividualPart
IndividualPart().add(self, data) IndividualPart().add(self, data)
@@ -295,8 +285,6 @@ class BrickSocket(object):
fr=request.sid, # type: ignore fr=request.sid, # type: ignore
)) ))
BrickTelemetry.track_action('AddPartLot')
from .individual_part_lot import IndividualPartLot from .individual_part_lot import IndividualPartLot
IndividualPartLot().create(self, data) IndividualPartLot().create(self, data)
@@ -307,74 +295,9 @@ class BrickSocket(object):
fr=request.sid, # type: ignore fr=request.sid, # type: ignore
)) ))
BrickTelemetry.track_action('AddPartBulk')
from .individual_part import IndividualPart from .individual_part import IndividualPart
IndividualPart().create_bulk(self, data) 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 # Update the progress auto-incrementing
def auto_progress( def auto_progress(
self, self,
-3
View File
@@ -1,3 +0,0 @@
SELECT "bag", "part", "color", "spare", "checked", "missing"
FROM "bricktracker_bag_parts"
WHERE "bricktracker_bag_parts"."id" IS NOT DISTINCT FROM :id
@@ -1,4 +0,0 @@
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"
@@ -1,4 +0,0 @@
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"
-17
View File
@@ -1,17 +0,0 @@
-- 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
@@ -1,23 +0,0 @@
-- 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
@@ -1,21 +0,0 @@
-- 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;
-11
View File
@@ -14,17 +14,6 @@ ON "bricktracker_minifigures"."id" IS NOT DISTINCT FROM "bricktracker_parts"."id
AND "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "bricktracker_parts"."figure" AND "rebrickable_minifigures"."figure" IS NOT DISTINCT FROM "bricktracker_parts"."figure"
{% endblock %} {% endblock %}
{% block where %}
-- rebrickable_minifigures is a catalog cache that keeps entries after a set is
-- deleted, so only keep figures still owned through a set or individually
WHERE "bricktracker_minifigures"."figure" IS NOT NULL
OR EXISTS (
SELECT 1
FROM "bricktracker_individual_minifigures"
WHERE "bricktracker_individual_minifigures"."figure" IS NOT DISTINCT FROM "rebrickable_minifigures"."figure"
)
{% endblock %}
{% block group %} {% block group %}
GROUP BY GROUP BY
"rebrickable_minifigures"."figure", "rebrickable_minifigures"."figure",
+10 -57
View File
@@ -1,14 +1,9 @@
{# Colours that actually occur among the parts, so the dropdown never offers one that
cannot match. All three part sources are covered: a colour that only exists on an
individual part or on an individual minifigure part used to be unselectable.
problem_only narrows it to parts with something missing or damaged. #}
SELECT DISTINCT SELECT DISTINCT
"color_id", "color_id",
"color_name", "color_name",
"color_rgb" "color_rgb"
FROM ( FROM (
-- Set parts -- Colors from set-based parts
SELECT DISTINCT SELECT DISTINCT
"rebrickable_parts"."color_id" AS "color_id", "rebrickable_parts"."color_id" AS "color_id",
"rebrickable_parts"."color_name" AS "color_name", "rebrickable_parts"."color_name" AS "color_name",
@@ -17,24 +12,17 @@ FROM (
INNER JOIN "bricktracker_parts" INNER JOIN "bricktracker_parts"
ON "bricktracker_parts"."part" IS NOT DISTINCT FROM "rebrickable_parts"."part" ON "bricktracker_parts"."part" IS NOT DISTINCT FROM "rebrickable_parts"."part"
AND "bricktracker_parts"."color" IS NOT DISTINCT FROM "rebrickable_parts"."color_id" AND "bricktracker_parts"."color" IS NOT DISTINCT FROM "rebrickable_parts"."color_id"
{% if owner_id %} {% if owner_id and owner_id != 'all' %}
INNER JOIN "bricktracker_sets"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
INNER JOIN "bricktracker_set_owners" INNER JOIN "bricktracker_set_owners"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id" ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
{% endif %} WHERE "bricktracker_set_owners"."owner_{{ owner_id }}" = 1
{% set set_conditions = [] %}
{% if problem_only %}
{% set _ = set_conditions.append('("bricktracker_parts"."missing" > 0 OR "bricktracker_parts"."damaged" > 0)') %}
{% endif %}
{% if owner_id %}
{% set _ = set_conditions.append('"bricktracker_set_owners"."owner_' ~ owner_id ~ '" = 1') %}
{% endif %}
{% if set_conditions %}
WHERE {{ set_conditions | join(' AND ') }}
{% endif %} {% endif %}
UNION UNION
-- Individual parts -- Colors from individual parts
SELECT DISTINCT SELECT DISTINCT
"rebrickable_parts"."color_id" AS "color_id", "rebrickable_parts"."color_id" AS "color_id",
"rebrickable_parts"."color_name" AS "color_name", "rebrickable_parts"."color_name" AS "color_name",
@@ -43,45 +31,10 @@ FROM (
INNER JOIN "bricktracker_individual_parts" INNER JOIN "bricktracker_individual_parts"
ON "bricktracker_individual_parts"."part" IS NOT DISTINCT FROM "rebrickable_parts"."part" ON "bricktracker_individual_parts"."part" IS NOT DISTINCT FROM "rebrickable_parts"."part"
AND "bricktracker_individual_parts"."color" IS NOT DISTINCT FROM "rebrickable_parts"."color_id" AND "bricktracker_individual_parts"."color" IS NOT DISTINCT FROM "rebrickable_parts"."color_id"
{% if owner_id %} {% if owner_id and owner_id != 'all' %}
INNER JOIN "bricktracker_set_owners" INNER JOIN "bricktracker_set_owners"
ON "bricktracker_individual_parts"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id" ON "bricktracker_individual_parts"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
{% endif %} WHERE "bricktracker_set_owners"."owner_{{ owner_id }}" = 1
{% set individual_conditions = [] %}
{% if problem_only %}
{% set _ = individual_conditions.append('("bricktracker_individual_parts"."missing" > 0 OR "bricktracker_individual_parts"."damaged" > 0)') %}
{% endif %}
{% if owner_id %}
{% set _ = individual_conditions.append('"bricktracker_set_owners"."owner_' ~ owner_id ~ '" = 1') %}
{% endif %}
{% if individual_conditions %}
WHERE {{ individual_conditions | join(' AND ') }}
{% endif %}
UNION
-- Individual minifigure parts
SELECT DISTINCT
"rebrickable_parts"."color_id" AS "color_id",
"rebrickable_parts"."color_name" AS "color_name",
"rebrickable_parts"."color_rgb" AS "color_rgb"
FROM "rebrickable_parts"
INNER JOIN "bricktracker_individual_minifigure_parts"
ON "bricktracker_individual_minifigure_parts"."part" IS NOT DISTINCT FROM "rebrickable_parts"."part"
AND "bricktracker_individual_minifigure_parts"."color" IS NOT DISTINCT FROM "rebrickable_parts"."color_id"
{% if owner_id %}
INNER JOIN "bricktracker_set_owners"
ON "bricktracker_individual_minifigure_parts"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
{% endif %}
{% set minifigure_conditions = [] %}
{% if problem_only %}
{% set _ = minifigure_conditions.append('("bricktracker_individual_minifigure_parts"."missing" > 0 OR "bricktracker_individual_minifigure_parts"."damaged" > 0)') %}
{% endif %}
{% if owner_id %}
{% set _ = minifigure_conditions.append('"bricktracker_set_owners"."owner_' ~ owner_id ~ '" = 1') %}
{% endif %}
{% if minifigure_conditions %}
WHERE {{ minifigure_conditions | join(' AND ') }}
{% endif %} {% endif %}
) )
ORDER BY "color_name" ASC ORDER BY "color_name" ASC
@@ -0,0 +1,19 @@
SELECT DISTINCT
"rebrickable_parts"."color_id" AS "color_id",
"rebrickable_parts"."color_name" AS "color_name",
"rebrickable_parts"."color_rgb" AS "color_rgb"
FROM "rebrickable_parts"
INNER JOIN "bricktracker_parts"
ON "bricktracker_parts"."part" IS NOT DISTINCT FROM "rebrickable_parts"."part"
AND "bricktracker_parts"."color" IS NOT DISTINCT FROM "rebrickable_parts"."color_id"
{% if owner_id and owner_id != 'all' %}
INNER JOIN "bricktracker_sets"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
INNER JOIN "bricktracker_set_owners"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
{% endif %}
WHERE ("bricktracker_parts"."missing" > 0 OR "bricktracker_parts"."damaged" > 0)
{% if owner_id and owner_id != 'all' %}
AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1
{% endif %}
ORDER BY "rebrickable_parts"."color_name" ASC
+69
View File
@@ -0,0 +1,69 @@
{% extends 'part/base/base.sql' %}
{% block total_missing %}
SUM("combined"."missing") AS "total_missing",
{% endblock %}
{% block total_damaged %}
SUM("combined"."damaged") AS "total_damaged",
{% endblock %}
{% block total_quantity %}
SUM("combined"."quantity" * IFNULL("minifigure_quantities"."quantity", 1)) AS "total_quantity",
{% endblock %}
{% 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 %}
{% set conditions = [] %}
{% if color_id and color_id != 'all' %}
{% set _ = conditions.append('"combined"."color" = ' ~ color_id) %}
{% endif %}
{% if search_query %}
{% set search_condition = '(LOWER("rebrickable_parts"."name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("rebrickable_parts"."color_name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("combined"."part") LIKE LOWER(\'%' ~ search_query ~ '%\'))' %}
{% set _ = conditions.append(search_condition) %}
{% endif %}
{% if skip_spare_parts %}
{% set _ = conditions.append('"combined"."spare" = 0') %}
{% endif %}
{% if individuals_filter %}
{% set _ = conditions.append('"combined"."source_type" = \'individual_part\'') %}
{% endif %}
{% if conditions %}
WHERE {{ conditions | join(' AND ') }}
{% endif %}
{% endblock %}
{% block group %}
GROUP BY
"combined"."part",
"combined"."color",
"combined"."spare"
{% endblock %}
+146
View File
@@ -0,0 +1,146 @@
{% extends 'part/base/base.sql' %}
{% block total_missing %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE
WHEN "combined"."source_type" = 'set' AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."missing"
WHEN "combined"."source_type" = 'individual_minifigure' AND "individual_minifigure_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."missing"
WHEN "combined"."source_type" = 'individual_part' AND ("individual_part_owners"."owner_{{ owner_id }}" = 1 OR "individual_part_lot_owners"."owner_{{ owner_id }}" = 1) THEN "combined"."missing"
ELSE 0
END) AS "total_missing",
{% else %}
SUM("combined"."missing") AS "total_missing",
{% endif %}
{% endblock %}
{% block total_damaged %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE
WHEN "combined"."source_type" = 'set' AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."damaged"
WHEN "combined"."source_type" = 'individual_minifigure' AND "individual_minifigure_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."damaged"
WHEN "combined"."source_type" = 'individual_part' AND ("individual_part_owners"."owner_{{ owner_id }}" = 1 OR "individual_part_lot_owners"."owner_{{ owner_id }}" = 1) THEN "combined"."damaged"
ELSE 0
END) AS "total_damaged",
{% else %}
SUM("combined"."damaged") AS "total_damaged",
{% endif %}
{% endblock %}
{% block total_quantity %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE
WHEN "combined"."source_type" = 'set' AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)
WHEN "combined"."source_type" = 'individual_minifigure' AND "individual_minifigure_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."quantity"
WHEN "combined"."source_type" = 'individual_part' AND ("individual_part_owners"."owner_{{ owner_id }}" = 1 OR "individual_part_lot_owners"."owner_{{ owner_id }}" = 1) THEN "combined"."quantity"
ELSE 0
END) AS "total_quantity",
{% else %}
SUM(CASE
WHEN "combined"."source_type" = 'set' THEN "combined"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)
ELSE "combined"."quantity"
END) AS "total_quantity",
{% endif %}
{% endblock %}
{% block total_sets %}
{% if owner_id and owner_id != 'all' %}
COUNT(DISTINCT CASE WHEN "combined"."source_type" = 'set' AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."id" ELSE NULL END) AS "total_sets",
{% else %}
COUNT(DISTINCT CASE WHEN "combined"."source_type" = 'set' THEN "combined"."id" ELSE NULL END) AS "total_sets",
{% endif %}
{% endblock %}
{% block total_minifigures %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE
WHEN "combined"."source_type" = 'set' AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN IFNULL("bricktracker_minifigures"."quantity", 0)
WHEN "combined"."source_type" = 'individual_minifigure' AND "individual_minifigure_owners"."owner_{{ owner_id }}" = 1 THEN 1
ELSE 0
END) AS "total_minifigures"
{% else %}
SUM(CASE
WHEN "combined"."source_type" = 'set' THEN IFNULL("bricktracker_minifigures"."quantity", 0)
WHEN "combined"."source_type" = 'individual_minifigure' THEN 1
ELSE 0
END) AS "total_minifigures"
{% endif %}
{% endblock %}
{% block join %}
-- Left join with sets (for set-based parts)
LEFT JOIN "bricktracker_sets"
ON "combined"."source_type" = 'set'
AND "combined"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
-- Left join with set owners (using dynamic columns)
LEFT JOIN "bricktracker_set_owners"
ON "combined"."source_type" = 'set'
AND "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
-- Left join with set-based minifigures
LEFT JOIN "bricktracker_minifigures"
ON "combined"."source_type" = 'set'
AND "combined"."id" IS NOT DISTINCT FROM "bricktracker_minifigures"."id"
AND "combined"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure"
-- Left join with individual minifigures (for individual minifigure parts)
LEFT JOIN "bricktracker_individual_minifigures"
ON "combined"."source_type" = 'individual_minifigure'
AND "combined"."id" IS NOT DISTINCT FROM "bricktracker_individual_minifigures"."id"
-- Left join with set owners for individual minifigures (using dynamic columns) - reuse set_owners table
LEFT JOIN "bricktracker_set_owners" AS "individual_minifigure_owners"
ON "combined"."source_type" = 'individual_minifigure'
AND "bricktracker_individual_minifigures"."id" IS NOT DISTINCT FROM "individual_minifigure_owners"."id"
-- Left join with individual parts (for standalone parts and lot parts)
LEFT JOIN "bricktracker_individual_parts"
ON "combined"."source_type" = 'individual_part'
AND "combined"."id" IS NOT DISTINCT FROM "bricktracker_individual_parts"."id"
-- Left join with set owners for individual parts (using dynamic columns) - for standalone parts
LEFT JOIN "bricktracker_set_owners" AS "individual_part_owners"
ON "combined"."source_type" = 'individual_part'
AND "bricktracker_individual_parts"."id" IS NOT DISTINCT FROM "individual_part_owners"."id"
-- Left join with individual part lots (for parts belonging to a lot)
LEFT JOIN "bricktracker_individual_part_lots"
ON "combined"."source_type" = 'individual_part'
AND "bricktracker_individual_parts"."lot_id" IS NOT DISTINCT FROM "bricktracker_individual_part_lots"."id"
-- Left join with set owners for individual part lots (using dynamic columns)
LEFT JOIN "bricktracker_set_owners" AS "individual_part_lot_owners"
ON "combined"."source_type" = 'individual_part'
AND "bricktracker_individual_part_lots"."id" IS NOT DISTINCT FROM "individual_part_lot_owners"."id"
{% endblock %}
{% block where %}
{% set conditions = [] %}
{% if owner_id and owner_id != 'all' %}
{% set owner_condition = '(("combined"."source_type" = \'set\' AND "bricktracker_set_owners"."owner_' ~ owner_id ~ '" = 1) OR ("combined"."source_type" = \'individual_minifigure\' AND "individual_minifigure_owners"."owner_' ~ owner_id ~ '" = 1) OR ("combined"."source_type" = \'individual_part\' AND ("individual_part_owners"."owner_' ~ owner_id ~ '" = 1 OR "individual_part_lot_owners"."owner_' ~ owner_id ~ '" = 1)))' %}
{% set _ = conditions.append(owner_condition) %}
{% endif %}
{% if color_id and color_id != 'all' %}
{% set _ = conditions.append('"combined"."color" = ' ~ color_id) %}
{% endif %}
{% if search_query %}
{% set search_condition = '(LOWER("rebrickable_parts"."name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("rebrickable_parts"."color_name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("combined"."part") LIKE LOWER(\'%' ~ search_query ~ '%\'))' %}
{% set _ = conditions.append(search_condition) %}
{% endif %}
{% if skip_spare_parts %}
{% set _ = conditions.append('"combined"."spare" = 0') %}
{% endif %}
{% if individuals_filter %}
{% set _ = conditions.append('"combined"."source_type" = \'individual_part\'') %}
{% endif %}
{% if conditions %}
WHERE {{ conditions | join(' AND ') }}
{% endif %}
{% endblock %}
{% block group %}
GROUP BY
"combined"."part",
"combined"."color",
"combined"."spare"
{% endblock %}
-203
View File
@@ -1,203 +0,0 @@
{#
The one filtered parts list. Used by /parts and by /parts/problem, which passes
problem_only.
Filters: owner, color, theme, year, storage, tag, status, plus search, spare parts
and individuals only. Every one of them is a single WHERE condition. There is no
CASE inside the aggregates on purpose: WHERE runs before GROUP BY, so a row that
fails a filter never reaches the SUM in the first place.
Joins are pulled in only when a filter actually needs them, so an unfiltered page
costs the same as it did before.
owner, status and tag become column names, so they cannot be bound as parameters.
The view validates them against the known metadata ids before they get here.
#}
{% extends 'part/base/base.sql' %}
{% block total_missing %}
SUM("combined"."missing") AS "total_missing",
{% endblock %}
{% block total_damaged %}
SUM("combined"."damaged") AS "total_damaged",
{% endblock %}
{% block total_quantity %}
SUM("combined"."quantity" * IFNULL("minifigure_quantities"."quantity", 1)) AS "total_quantity",
{% endblock %}
{% 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 %}
-- Minifigure quantities, from set minifigures and individual ones alike
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"
{% if theme_id or year or storage_id %}
-- Sets, for theme, year and set level storage
LEFT JOIN "bricktracker_sets"
ON "combined"."source_type" = 'set'
AND "combined"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
{% endif %}
{% if theme_id or year %}
LEFT JOIN "rebrickable_sets"
ON "bricktracker_sets"."set" IS NOT DISTINCT FROM "rebrickable_sets"."set"
{% endif %}
{% if storage_id %}
-- Individual minifigures carry their own storage
LEFT JOIN "bricktracker_individual_minifigures"
ON "combined"."source_type" = 'individual_minifigure'
AND "combined"."id" IS NOT DISTINCT FROM "bricktracker_individual_minifigures"."id"
{% endif %}
{% if storage_id or owner_id %}
-- Individual parts, and the lot they may belong to. A part with no storage or owner
-- of its own inherits the lot's.
LEFT JOIN "bricktracker_individual_parts"
ON "combined"."source_type" = 'individual_part'
AND "combined"."id" IS NOT DISTINCT FROM "bricktracker_individual_parts"."id"
LEFT JOIN "bricktracker_individual_part_lots"
ON "bricktracker_individual_parts"."lot_id" IS NOT DISTINCT FROM "bricktracker_individual_part_lots"."id"
{% endif %}
{% if owner_id %}
-- Owners, statuses and tags all live in the set metadata tables keyed by item id,
-- shared by sets, individual minifigures and individual parts, so no source check.
LEFT JOIN "bricktracker_set_owners"
ON "combined"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
LEFT JOIN "bricktracker_set_owners" AS "lot_owners"
ON "bricktracker_individual_part_lots"."id" IS NOT DISTINCT FROM "lot_owners"."id"
{% endif %}
{% if status_id %}
LEFT JOIN "bricktracker_set_statuses"
ON "combined"."id" IS NOT DISTINCT FROM "bricktracker_set_statuses"."id"
{% endif %}
{% if tag_id %}
LEFT JOIN "bricktracker_set_tags"
ON "combined"."id" IS NOT DISTINCT FROM "bricktracker_set_tags"."id"
{% endif %}
{% endblock %}
{% block where %}
{% set conditions = [] %}
{% if problem_only %}
{% set _ = conditions.append('("combined"."missing" > 0 OR "combined"."damaged" > 0)') %}
{% endif %}
{% if skip_spare_parts %}
{% set _ = conditions.append('"combined"."spare" = 0') %}
{% endif %}
{% if individuals_filter %}
{% set _ = conditions.append('"combined"."source_type" = \'individual_part\'') %}
{% endif %}
{% if color_id %}
{% set _ = conditions.append('"combined"."color" = :color_id') %}
{% endif %}
{% if search_query %}
{% set _ = conditions.append('(LOWER("rebrickable_parts"."name") LIKE :search_query OR LOWER("rebrickable_parts"."color_name") LIKE :search_query OR LOWER("combined"."part") LIKE :search_query)') %}
{% endif %}
{# Theme and year only exist for set sourced parts. The LEFT JOIN gives NULL for
everything else, and NULL = x is never true, so individual parts drop out on
their own. #}
{# The negated forms stay set only too. A part with no set behind it has no theme and
no year, so it is neither "from 2023" nor "from a year that is not 2023". #}
{% if theme_id %}
{% if theme_id.startswith('-') %}
{% set _ = conditions.append('"rebrickable_sets"."theme_id" IS NOT NULL AND "rebrickable_sets"."theme_id" != :theme_id') %}
{% else %}
{% set _ = conditions.append('"rebrickable_sets"."theme_id" = :theme_id') %}
{% endif %}
{% endif %}
{% if year %}
{% if year.startswith('-') %}
{% set _ = conditions.append('"rebrickable_sets"."year" IS NOT NULL AND "rebrickable_sets"."year" != :year') %}
{% else %}
{% set _ = conditions.append('"rebrickable_sets"."year" = :year') %}
{% endif %}
{% endif %}
{% if storage_id %}
{% set storage_value = 'COALESCE("bricktracker_sets"."storage", "bricktracker_individual_minifigures"."storage", "bricktracker_individual_parts"."storage", "bricktracker_individual_part_lots"."storage")' %}
{% if storage_id == '__none__' %}
{% set _ = conditions.append(storage_value ~ ' IS NULL') %}
{% elif storage_id == '-__none__' %}
{% set _ = conditions.append(storage_value ~ ' IS NOT NULL') %}
{% elif storage_id.startswith('-') %}
{# IS NOT rather than <> so parts with no storage at all still count as "not this one" #}
{% set _ = conditions.append(storage_value ~ ' IS NOT :storage_id') %}
{% else %}
{% set _ = conditions.append(storage_value ~ ' IS :storage_id') %}
{% endif %}
{% endif %}
{% if owner_id %}
{% if owner_id.startswith('-') %}
{% set _ = conditions.append('(IFNULL("bricktracker_set_owners"."owner_' ~ owner_id[1:] ~ '", 0) = 0 AND IFNULL("lot_owners"."owner_' ~ owner_id[1:] ~ '", 0) = 0)') %}
{% else %}
{% set _ = conditions.append('("bricktracker_set_owners"."owner_' ~ owner_id ~ '" = 1 OR "lot_owners"."owner_' ~ owner_id ~ '" = 1)') %}
{% endif %}
{% endif %}
{# Negated forms use IFNULL so an item with no metadata row counts as not having the
status or tag, which is what "everything except assembled" means. #}
{% if status_id %}
{% if status_id.startswith('-') %}
{% set _ = conditions.append('IFNULL("bricktracker_set_statuses"."status_' ~ status_id[1:] ~ '", 0) = 0') %}
{% else %}
{% set _ = conditions.append('"bricktracker_set_statuses"."status_' ~ status_id ~ '" = 1') %}
{% endif %}
{% endif %}
{% if tag_id %}
{% if tag_id.startswith('-') %}
{% set _ = conditions.append('IFNULL("bricktracker_set_tags"."tag_' ~ tag_id[1:] ~ '", 0) = 0') %}
{% else %}
{% set _ = conditions.append('"bricktracker_set_tags"."tag_' ~ tag_id ~ '" = 1') %}
{% endif %}
{% endif %}
{% if conditions %}
WHERE {{ conditions | join(' AND ') }}
{% endif %}
{% endblock %}
{% block group %}
GROUP BY
"combined"."part",
"combined"."color",
"combined"."spare"
{% endblock %}
@@ -65,10 +65,6 @@ INNER JOIN "rebrickable_parts"
ON "parts_combined"."part" = "rebrickable_parts"."part" ON "parts_combined"."part" = "rebrickable_parts"."part"
AND "parts_combined"."color" = "rebrickable_parts"."color_id" AND "parts_combined"."color" = "rebrickable_parts"."color_id"
{% if skip_spare_parts %}
WHERE "parts_combined"."spare" = 0
{% endif %}
GROUP BY GROUP BY
"parts_combined"."part", "parts_combined"."part",
"parts_combined"."color", "parts_combined"."color",
-31
View File
@@ -5,37 +5,6 @@
{% block total_damaged %}{% endblock %} {% 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 %} {% block where %}
WHERE "rebrickable_parts"."print" IS NOT DISTINCT FROM :print WHERE "rebrickable_parts"."print" IS NOT DISTINCT FROM :print
AND "combined"."color" IS NOT DISTINCT FROM :color AND "combined"."color" IS NOT DISTINCT FROM :color
+119
View File
@@ -0,0 +1,119 @@
{% extends 'part/base/base.sql' %}
{% block total_missing %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE
WHEN "combined"."source_type" = 'set' AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."missing"
WHEN "combined"."source_type" = 'individual' AND "ind_minifig_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."missing"
ELSE 0
END) AS "total_missing",
{% else %}
SUM("combined"."missing") AS "total_missing",
{% endif %}
{% endblock %}
{% block total_damaged %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE
WHEN "combined"."source_type" = 'set' AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."damaged"
WHEN "combined"."source_type" = 'individual' AND "ind_minifig_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."damaged"
ELSE 0
END) AS "total_damaged",
{% else %}
SUM("combined"."damaged") AS "total_damaged",
{% endif %}
{% endblock %}
{% block total_quantity %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE
WHEN "combined"."source_type" = 'set' AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)
WHEN "combined"."source_type" = 'individual' AND "ind_minifig_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."quantity" * IFNULL("bricktracker_individual_minifigures"."quantity", 1)
ELSE 0
END) AS "total_quantity",
{% else %}
SUM(CASE
WHEN "combined"."source_type" = 'set' THEN "combined"."quantity" * IFNULL("bricktracker_minifigures"."quantity", 1)
WHEN "combined"."source_type" = 'individual' THEN "combined"."quantity" * IFNULL("bricktracker_individual_minifigures"."quantity", 1)
ELSE "combined"."quantity"
END) AS "total_quantity",
{% endif %}
{% endblock %}
{% block total_sets %}
{% if owner_id and owner_id != 'all' %}
COUNT(DISTINCT CASE WHEN "combined"."source_type" = 'set' AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN "combined"."id" ELSE NULL END) AS "total_sets",
{% else %}
COUNT(DISTINCT CASE WHEN "combined"."source_type" = 'set' THEN "combined"."id" ELSE NULL END) AS "total_sets",
{% endif %}
{% endblock %}
{% block total_minifigures %}
{% if owner_id and owner_id != 'all' %}
SUM(CASE
WHEN "combined"."source_type" = 'set' AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1 THEN IFNULL("bricktracker_minifigures"."quantity", 0)
WHEN "combined"."source_type" = 'individual' AND "ind_minifig_owners"."owner_{{ owner_id }}" = 1 THEN IFNULL("bricktracker_individual_minifigures"."quantity", 0)
ELSE 0
END) AS "total_minifigures"
{% else %}
SUM(CASE
WHEN "combined"."source_type" = 'set' THEN IFNULL("bricktracker_minifigures"."quantity", 0)
WHEN "combined"."source_type" = 'individual' THEN IFNULL("bricktracker_individual_minifigures"."quantity", 0)
ELSE 0
END) AS "total_minifigures"
{% endif %}
{% endblock %}
{% block join %}
-- Left join with sets for set-based parts
LEFT JOIN "bricktracker_sets"
ON "combined"."source_type" = 'set'
AND "combined"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
-- Left join with set owners (using dynamic columns)
LEFT JOIN "bricktracker_set_owners"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
-- Left join with set-based minifigures
LEFT JOIN "bricktracker_minifigures"
ON "combined"."source_type" = 'set'
AND "combined"."id" IS NOT DISTINCT FROM "bricktracker_minifigures"."id"
AND "combined"."figure" IS NOT DISTINCT FROM "bricktracker_minifigures"."figure"
-- Left join with individual minifigures
LEFT JOIN "bricktracker_individual_minifigures"
ON "combined"."source_type" = 'individual'
AND "combined"."id" IS NOT DISTINCT FROM "bricktracker_individual_minifigures"."id"
-- Left join with individual minifigure owners (using consolidated metadata table)
LEFT JOIN "bricktracker_set_owners" AS "ind_minifig_owners"
ON "bricktracker_individual_minifigures"."id" IS NOT DISTINCT FROM "ind_minifig_owners"."id"
{% endblock %}
{% block where %}
{% set conditions = [] %}
-- Always filter for problematic parts
{% set _ = conditions.append('("combined"."missing" > 0 OR "combined"."damaged" > 0)') %}
{% if owner_id and owner_id != 'all' %}
{% set owner_condition = '(("combined"."source_type" = \'set\' AND "bricktracker_set_owners"."owner_' ~ owner_id ~ '" = 1) OR ("combined"."source_type" = \'individual\' AND "ind_minifig_owners"."owner_' ~ owner_id ~ '" = 1))' %}
{% set _ = conditions.append(owner_condition) %}
{% endif %}
{% if color_id and color_id != 'all' %}
{% set _ = conditions.append('"combined"."color" = ' ~ color_id) %}
{% endif %}
{% if search_query %}
{% set search_condition = '(LOWER("rebrickable_parts"."name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("rebrickable_parts"."color_name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("combined"."part") LIKE LOWER(\'%' ~ search_query ~ '%\'))' %}
{% set _ = conditions.append(search_condition) %}
{% endif %}
{% if skip_spare_parts %}
{% set _ = conditions.append('"combined"."spare" = 0') %}
{% endif %}
WHERE {{ conditions | join(' AND ') }}
{% endblock %}
{% block group %}
GROUP BY
"combined"."part",
"combined"."color",
"combined"."spare"
{% endblock %}
-3
View File
@@ -12,7 +12,4 @@ IFNULL("combined"."damaged", 0) AS "total_damaged",
{% block where %} {% block where %}
WHERE "combined"."id" IS NOT DISTINCT FROM :id WHERE "combined"."id" IS NOT DISTINCT FROM :id
AND "combined"."figure" IS NOT DISTINCT FROM :figure AND "combined"."figure" IS NOT DISTINCT FROM :figure
{% if skip_spare_parts %}
AND "combined"."spare" = 0
{% endif %}
{% endblock %} {% endblock %}
@@ -5,38 +5,6 @@
{% block total_damaged %}{% endblock %} {% 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/filtered.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 %} {% block where %}
WHERE "combined"."color" IS DISTINCT FROM :color WHERE "combined"."color" IS DISTINCT FROM :color
AND "combined"."part" IS NOT DISTINCT FROM :part AND "combined"."part" IS NOT DISTINCT FROM :part
@@ -0,0 +1,21 @@
-- Get distinct storages from problem parts' sets
SELECT DISTINCT
"bricktracker_sets"."storage" AS "storage_id",
"bricktracker_metadata_storages"."name" AS "storage_name",
COUNT(DISTINCT "bricktracker_parts"."part") as "part_count"
FROM "bricktracker_parts"
INNER JOIN "bricktracker_sets"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
LEFT JOIN "bricktracker_metadata_storages"
ON "bricktracker_sets"."storage" IS NOT DISTINCT FROM "bricktracker_metadata_storages"."id"
{% if owner_id and owner_id != 'all' %}
INNER JOIN "bricktracker_set_owners"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
{% endif %}
WHERE ("bricktracker_parts"."missing" > 0 OR "bricktracker_parts"."damaged" > 0)
AND "bricktracker_sets"."storage" IS NOT NULL
{% if owner_id and owner_id != 'all' %}
AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1
{% endif %}
GROUP BY "bricktracker_sets"."storage", "bricktracker_metadata_storages"."name"
ORDER BY "bricktracker_metadata_storages"."name" ASC
@@ -0,0 +1,7 @@
-- Get list of all tags (simplified - filtering happens at application level)
-- Tags use dynamic columns in bricktracker_set_tags, making direct SQL filtering complex
SELECT
"bricktracker_metadata_tags"."id" AS "tag_id",
"bricktracker_metadata_tags"."name" AS "tag_name"
FROM "bricktracker_metadata_tags"
ORDER BY "bricktracker_metadata_tags"."name" ASC
+3 -14
View File
@@ -1,6 +1,4 @@
{# Themes present among the parts. Only set sourced parts have a theme, so individual -- Get distinct themes from parts' sets
parts and individual minifigure parts are out of scope here by definition.
problem_only narrows it to parts with something missing or damaged. #}
SELECT DISTINCT SELECT DISTINCT
"rebrickable_sets"."theme_id", "rebrickable_sets"."theme_id",
COUNT(DISTINCT "bricktracker_parts"."part") as "part_count" COUNT(DISTINCT "bricktracker_parts"."part") as "part_count"
@@ -9,19 +7,10 @@ INNER JOIN "bricktracker_sets"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id" ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
INNER JOIN "rebrickable_sets" INNER JOIN "rebrickable_sets"
ON "bricktracker_sets"."set" IS NOT DISTINCT FROM "rebrickable_sets"."set" ON "bricktracker_sets"."set" IS NOT DISTINCT FROM "rebrickable_sets"."set"
{% if owner_id %} {% if owner_id and owner_id != 'all' %}
INNER JOIN "bricktracker_set_owners" INNER JOIN "bricktracker_set_owners"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id" ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
{% endif %} WHERE "bricktracker_set_owners"."owner_{{ owner_id }}" = 1
{% set conditions = [] %}
{% if problem_only %}
{% set _ = conditions.append('("bricktracker_parts"."missing" > 0 OR "bricktracker_parts"."damaged" > 0)') %}
{% endif %}
{% if owner_id %}
{% set _ = conditions.append('"bricktracker_set_owners"."owner_' ~ owner_id ~ '" = 1') %}
{% endif %}
{% if conditions %}
WHERE {{ conditions | join(' AND ') }}
{% endif %} {% endif %}
GROUP BY "rebrickable_sets"."theme_id" GROUP BY "rebrickable_sets"."theme_id"
ORDER BY "rebrickable_sets"."theme_id" ASC ORDER BY "rebrickable_sets"."theme_id" ASC
@@ -0,0 +1,19 @@
-- Get distinct themes from problem parts' sets
SELECT DISTINCT
"rebrickable_sets"."theme_id",
COUNT(DISTINCT "bricktracker_parts"."part") as "part_count"
FROM "bricktracker_parts"
INNER JOIN "bricktracker_sets"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
INNER JOIN "rebrickable_sets"
ON "bricktracker_sets"."set" IS NOT DISTINCT FROM "rebrickable_sets"."set"
{% if owner_id and owner_id != 'all' %}
INNER JOIN "bricktracker_set_owners"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
{% endif %}
WHERE ("bricktracker_parts"."missing" > 0 OR "bricktracker_parts"."damaged" > 0)
{% if owner_id and owner_id != 'all' %}
AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1
{% endif %}
GROUP BY "rebrickable_sets"."theme_id"
ORDER BY "rebrickable_sets"."theme_id" ASC
+3 -14
View File
@@ -1,6 +1,4 @@
{# Years present among the parts. Only set sourced parts have a year, so individual -- Get distinct years from parts' sets
parts and individual minifigure parts are out of scope here by definition.
problem_only narrows it to parts with something missing or damaged. #}
SELECT DISTINCT SELECT DISTINCT
"rebrickable_sets"."year", "rebrickable_sets"."year",
COUNT(DISTINCT "bricktracker_parts"."part") as "part_count" COUNT(DISTINCT "bricktracker_parts"."part") as "part_count"
@@ -9,19 +7,10 @@ INNER JOIN "bricktracker_sets"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id" ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
INNER JOIN "rebrickable_sets" INNER JOIN "rebrickable_sets"
ON "bricktracker_sets"."set" IS NOT DISTINCT FROM "rebrickable_sets"."set" ON "bricktracker_sets"."set" IS NOT DISTINCT FROM "rebrickable_sets"."set"
{% if owner_id %} {% if owner_id and owner_id != 'all' %}
INNER JOIN "bricktracker_set_owners" INNER JOIN "bricktracker_set_owners"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id" ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
{% endif %} WHERE "bricktracker_set_owners"."owner_{{ owner_id }}" = 1
{% set conditions = [] %}
{% if problem_only %}
{% set _ = conditions.append('("bricktracker_parts"."missing" > 0 OR "bricktracker_parts"."damaged" > 0)') %}
{% endif %}
{% if owner_id %}
{% set _ = conditions.append('"bricktracker_set_owners"."owner_' ~ owner_id ~ '" = 1') %}
{% endif %}
{% if conditions %}
WHERE {{ conditions | join(' AND ') }}
{% endif %} {% endif %}
GROUP BY "rebrickable_sets"."year" GROUP BY "rebrickable_sets"."year"
ORDER BY "rebrickable_sets"."year" DESC ORDER BY "rebrickable_sets"."year" DESC
@@ -0,0 +1,19 @@
-- Get distinct years from problem parts' sets
SELECT DISTINCT
"rebrickable_sets"."year",
COUNT(DISTINCT "bricktracker_parts"."part") as "part_count"
FROM "bricktracker_parts"
INNER JOIN "bricktracker_sets"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
INNER JOIN "rebrickable_sets"
ON "bricktracker_sets"."set" IS NOT DISTINCT FROM "rebrickable_sets"."set"
{% if owner_id and owner_id != 'all' %}
INNER JOIN "bricktracker_set_owners"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
{% endif %}
WHERE ("bricktracker_parts"."missing" > 0 OR "bricktracker_parts"."damaged" > 0)
{% if owner_id and owner_id != 'all' %}
AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1
{% endif %}
GROUP BY "rebrickable_sets"."year"
ORDER BY "rebrickable_sets"."year" DESC
@@ -58,14 +58,10 @@ INNER JOIN (
) "null_sums" ) "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 WHERE "null_rgb" > 0
OR "null_transparent" > 0 OR "null_transparent" > 0
OR "null_url" > 0 OR "null_url" > 0
OR "null_bricklink_color_id" > 0
OR "null_bricklink_color_name" > 0 OR "null_bricklink_color_name" > 0
OR "null_bricklink_part_num" > 0 OR "null_bricklink_part_num" > 0
) "null_join" ) "null_join"
-3
View File
@@ -23,9 +23,6 @@ SELECT
{% block statuses %} {% block statuses %}
{% if statuses %}{{ statuses }},{% endif %} {% if statuses %}{{ statuses }},{% endif %}
{% endblock %} {% endblock %}
{% block custom_fields %}
{% if custom_fields %}{{ custom_fields }},{% endif %}
{% endblock %}
{% block total_missing %} {% block total_missing %}
NULL AS "total_missing", -- dummy for order: total_missing NULL AS "total_missing", -- dummy for order: total_missing
{% endblock %} {% endblock %}
-5
View File
@@ -32,11 +32,6 @@ LEFT JOIN "bricktracker_set_tags"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_tags"."id" ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_tags"."id"
{% endif %} {% 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 to avoid messing the total
LEFT JOIN ( LEFT JOIN (
SELECT SELECT
-3
View File
@@ -10,9 +10,6 @@ WHERE "bricktracker_parts"."id" IS NOT DISTINCT FROM '{{ id }}';
DELETE FROM "bricktracker_minifigures" DELETE FROM "bricktracker_minifigures"
WHERE "bricktracker_minifigures"."id" IS NOT DISTINCT FROM '{{ id }}'; 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" DELETE FROM "bricktracker_set_tags"
WHERE "bricktracker_set_tags"."id" IS NOT DISTINCT FROM '{{ id }}'; WHERE "bricktracker_set_tags"."id" IS NOT DISTINCT FROM '{{ id }}';
+2 -26
View File
@@ -24,11 +24,7 @@ AND "rebrickable_sets"."year" = {{ year_filter }}
{% endif %} {% endif %}
{% if storage_filter %} {% if storage_filter %}
{% if storage_filter == '__none__' %} {% if storage_filter.startswith('-') %}
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:] }}') AND ("bricktracker_sets"."storage" IS NULL OR "bricktracker_sets"."storage" != '{{ storage_filter[1:] }}')
{% else %} {% else %}
AND "bricktracker_sets"."storage" = '{{ storage_filter }}' AND "bricktracker_sets"."storage" = '{{ storage_filter }}'
@@ -36,33 +32,13 @@ AND "bricktracker_sets"."storage" = '{{ storage_filter }}'
{% endif %} {% endif %}
{% if purchase_location_filter %} {% if purchase_location_filter %}
{% if purchase_location_filter == '__none__' %} {% if purchase_location_filter.startswith('-') %}
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:] }}') AND ("bricktracker_sets"."purchase_location" IS NULL OR "bricktracker_sets"."purchase_location" != '{{ purchase_location_filter[1:] }}')
{% else %} {% else %}
AND "bricktracker_sets"."purchase_location" = '{{ purchase_location_filter }}' AND "bricktracker_sets"."purchase_location" = '{{ purchase_location_filter }}'
{% endif %} {% endif %}
{% 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 %}
{% if status_filter == 'has-missing' %} {% if status_filter == 'has-missing' %}
AND IFNULL("problem_join"."total_missing", 0) > 0 AND IFNULL("problem_join"."total_missing", 0) > 0
+2 -100
View File
@@ -43,15 +43,6 @@ SELECT
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% endblock %} {% 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" FROM "bricktracker_sets"
INNER JOIN "rebrickable_sets" INNER JOIN "rebrickable_sets"
@@ -93,11 +84,6 @@ LEFT JOIN "bricktracker_set_tags"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_tags"."id" ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_tags"."id"
{% endif %} {% 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 %} {% block where %}
WHERE 1=1 WHERE 1=1
{% if search_query %} {% if search_query %}
@@ -122,19 +108,7 @@ AND "rebrickable_sets"."year" = {{ year_filter }}
{% endif %} {% endif %}
{% if storage_filter %} {% if storage_filter %}
{% if storage_filter == '__none__' %} {% if storage_filter.startswith('-') %}
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 ( AND NOT EXISTS (
SELECT 1 FROM "bricktracker_sets" bs_filter SELECT 1 FROM "bricktracker_sets" bs_filter
WHERE bs_filter."set" = "rebrickable_sets"."set" WHERE bs_filter."set" = "rebrickable_sets"."set"
@@ -150,19 +124,7 @@ AND EXISTS (
{% endif %} {% endif %}
{% if purchase_location_filter %} {% if purchase_location_filter %}
{% if purchase_location_filter == '__none__' %} {% if purchase_location_filter.startswith('-') %}
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 ( AND NOT EXISTS (
SELECT 1 FROM "bricktracker_sets" bs_filter SELECT 1 FROM "bricktracker_sets" bs_filter
WHERE bs_filter."set" = "rebrickable_sets"."set" WHERE bs_filter."set" = "rebrickable_sets"."set"
@@ -177,66 +139,6 @@ AND EXISTS (
{% endif %} {% endif %}
{% endif %} {% endif %}
{# Owner and tag used to be missing here, which forced the whole page off the
consolidated query: picking an owner silently turned grouped cards into
per instance cards. Same EXISTS shape as storage above, so a group shows when any
of its instances matches. #}
{% if owner_filter %}
{% if owner_filter.startswith('-owner-') %}
AND NOT EXISTS (
SELECT 1 FROM "bricktracker_sets" bs_filter
INNER JOIN "bricktracker_set_owners" bso_filter
ON bs_filter."id" IS NOT DISTINCT FROM bso_filter."id"
WHERE bs_filter."set" = "rebrickable_sets"."set"
AND bso_filter."{{ owner_filter[1:].replace('-', '_') }}" = 1
)
{% elif owner_filter.startswith('owner-') %}
AND EXISTS (
SELECT 1 FROM "bricktracker_sets" bs_filter
INNER JOIN "bricktracker_set_owners" bso_filter
ON bs_filter."id" IS NOT DISTINCT FROM bso_filter."id"
WHERE bs_filter."set" = "rebrickable_sets"."set"
AND bso_filter."{{ owner_filter.replace('-', '_') }}" = 1
)
{% endif %}
{% endif %}
{% if tag_filter %}
{% if tag_filter.startswith('-tag-') %}
AND NOT EXISTS (
SELECT 1 FROM "bricktracker_sets" bs_filter
INNER JOIN "bricktracker_set_tags" bst_filter
ON bs_filter."id" IS NOT DISTINCT FROM bst_filter."id"
WHERE bs_filter."set" = "rebrickable_sets"."set"
AND bst_filter."{{ tag_filter[1:].replace('-', '_') }}" = 1
)
{% elif tag_filter.startswith('tag-') %}
AND EXISTS (
SELECT 1 FROM "bricktracker_sets" bs_filter
INNER JOIN "bricktracker_set_tags" bst_filter
ON bs_filter."id" IS NOT DISTINCT FROM bst_filter."id"
WHERE bs_filter."set" = "rebrickable_sets"."set"
AND bst_filter."{{ tag_filter.replace('-', '_') }}" = 1
)
{% 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 %}
{% if status_filter == 'has-storage' %} {% if status_filter == 'has-storage' %}
AND EXISTS ( AND EXISTS (
@@ -1,11 +0,0 @@
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 %}
@@ -1,9 +0,0 @@
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;
@@ -1,17 +0,0 @@
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;
@@ -1 +0,0 @@
{% extends 'set/metadata/custom_field/base.sql' %}
@@ -1,5 +0,0 @@
{% extends 'set/metadata/custom_field/base.sql' %}
{% block where %}
WHERE "bricktracker_metadata_custom_fields"."id" IS NOT DISTINCT FROM :id
{% endblock %}
@@ -1,3 +0,0 @@
UPDATE "bricktracker_metadata_custom_fields"
SET "{{field}}" = :value
WHERE "bricktracker_metadata_custom_fields"."id" IS NOT DISTINCT FROM :id
@@ -1,10 +0,0 @@
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
+1 -13
View File
@@ -39,7 +39,6 @@ individual_part_stats AS (
COALESCE(SUM("missing"), 0) AS total_missing_individual_parts, COALESCE(SUM("missing"), 0) AS total_missing_individual_parts,
COALESCE(SUM("damaged"), 0) AS total_damaged_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 "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 COALESCE(ROUND(SUM(CASE WHEN "lot_id" IS NULL THEN "purchase_price" END), 2), 0) AS individual_parts_total_cost
FROM "bricktracker_individual_parts" FROM "bricktracker_individual_parts"
), ),
@@ -134,13 +133,6 @@ financial_stats AS (
COALESCE(individual_minifig_stats.individual_minifigs_with_price, 0) + COALESCE(individual_minifig_stats.individual_minifigs_with_price, 0) +
COALESCE(part_lot_stats.part_lots_with_price, 0) AS total_items_with_price, 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 -- Total cost across all item types
ROUND(COALESCE(set_stats.total_cost, 0) + ROUND(COALESCE(set_stats.total_cost, 0) +
COALESCE(individual_part_stats.individual_parts_total_cost, 0) + COALESCE(individual_part_stats.individual_parts_total_cost, 0) +
@@ -189,9 +181,6 @@ SELECT
minifig_stats.total_minifigures_count, minifig_stats.total_minifigures_count,
minifig_stats.unique_minifigures, minifig_stats.unique_minifigures,
-- Part lot statistics
part_lot_stats.total_part_lots,
-- Financial statistics (set-only for backwards compatibility) -- Financial statistics (set-only for backwards compatibility)
set_stats.sets_with_price, set_stats.sets_with_price,
set_stats.total_cost, set_stats.total_cost,
@@ -201,7 +190,6 @@ SELECT
-- Combined financial statistics (all item types) -- Combined financial statistics (all item types)
financial_stats.total_items_with_price, financial_stats.total_items_with_price,
financial_stats.total_items,
financial_stats.combined_total_cost, financial_stats.combined_total_cost,
financial_stats.combined_average_cost, financial_stats.combined_average_cost,
financial_stats.combined_minimum_cost, financial_stats.combined_minimum_cost,
@@ -213,4 +201,4 @@ SELECT
set_stats.sets_with_storage, set_stats.sets_with_storage,
set_stats.sets_with_purchase_location set_stats.sets_with_purchase_location
FROM set_stats, part_stats, minifig_stats, part_lot_stats, rebrickable_stats, financial_stats FROM set_stats, part_stats, minifig_stats, rebrickable_stats, financial_stats
@@ -1,5 +0,0 @@
-- 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"
@@ -1,8 +0,0 @@
-- 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 -161
View File
@@ -63,12 +63,9 @@ class BrickStatistics:
'items_with_price': overview.get('total_items_with_price') or 0, 'items_with_price': overview.get('total_items_with_price') or 0,
'sets_with_price': overview.get('sets_with_price') or 0, 'sets_with_price': overview.get('sets_with_price') or 0,
'total_sets': overview.get('total_sets') or 0, 'total_sets': overview.get('total_sets') or 0,
'total_items': overview.get('total_items') or 0, 'percentage_with_price': round(
# #156: divide by the matching total of all priceable item types ((overview.get('total_items_with_price') or 0) / max((overview.get('total_sets') or 0), 1)) * 100, 1
# (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]: def get_collection_summary(self) -> dict[str, Any]:
@@ -81,167 +78,12 @@ class BrickStatistics:
'unique_parts': overview.get('unique_parts') or 0, 'unique_parts': overview.get('unique_parts') or 0,
'total_minifigures_count': overview.get('total_minifigures_count') or 0, 'total_minifigures_count': overview.get('total_minifigures_count') or 0,
'unique_minifigures': overview.get('unique_minifigures') 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_missing_parts': overview.get('total_missing_parts') or 0,
'total_damaged_parts': overview.get('total_damaged_parts') or 0, 'total_damaged_parts': overview.get('total_damaged_parts') or 0,
'storage_locations_used': overview.get('storage_locations_used') or 0, 'storage_locations_used': overview.get('storage_locations_used') or 0,
'purchase_locations_used': overview.get('purchase_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]]: def get_sets_by_year_statistics(self) -> list[dict[str, Any]]:
"""Get statistics grouped by LEGO set release year""" """Get statistics grouped by LEGO set release year"""
results = self.sql.fetchall('statistics/sets_by_year') results = self.sql.fetchall('statistics/sets_by_year')
-408
View File
@@ -1,408 +0,0 @@
import json
import logging
import random
import time
import uuid
from typing import Any, Callable, Final
from urllib.parse import urlencode
from flask import Flask, current_app
import gevent
import requests
from .config_manager import ConfigManager
from .sidecar import BrickSidecar
from .sql import BrickSQL, close
from .statistics import BrickStatistics
from .version import __version__
from .wish_list import BrickWishList
logger = logging.getLogger(__name__)
# Self-hosted Matomo instance. Not user-configurable: every opted-in
# installation reports to this one site so the counts are actually aggregable.
MATOMO_URL: Final[str] = 'https://matomo.baerentsen.space'
MATOMO_SITE_ID: Final[int] = 6
# Short timeout: a slow/offline Matomo must never be felt by a real user.
TIMEOUT: Final[int] = 2
# Collection stats are cheap but pointless to send more than once a day.
COLLECTION_STATS_INTERVAL: Final[int] = 24 * 60 * 60
# Queued events are flushed as one Matomo Bulk Tracking request at most
# this often, per the throttle in BrickTelemetry.maybe_flush_queue().
FLUSH_INTERVAL: Final[int] = 15 * 60
# GET endpoints that count as a "page view" for the Usage category. Endpoints
# not listed here are simply never tracked. There is no default-on catch-all.
PAGEVIEW_ENDPOINTS: Final[dict[str, str]] = {
'index.index': 'Dashboard',
'statistics.overview': 'Statistics',
'wish.list': 'Wishlist',
'minifigure.list': 'Minifigures',
'set.list': 'Sets',
'part.list': 'Parts',
'storage.list': 'Storage',
'admin.admin': 'Settings',
}
# Blueprints with several GET routes that all count as the same page for
# telemetry purposes (e.g. every export format is still just "Exports").
PAGEVIEW_BLUEPRINTS: Final[dict[str, str]] = {
'add': 'Imports',
'admin_export': 'Exports',
}
# Best-effort anonymous telemetry client, reporting to a self-hosted Matomo
# instance. See templates/telemetry/_privacy_explainer.html for the full
# privacy explanation shown to users.
class BrickTelemetry(object):
# Throttle cache for collection stats, mirrors BrickSidecar._health_cache.
_last_collection_stats_at: float | None = None
# Throttle cache for queue flushes, same shape as above.
_last_flush_attempt_at: float | None = None
# Lazily hydrated from TELEMETRY_QUEUE_PATH on first access this process.
# None means "not loaded yet"; [] means "loaded, currently empty".
_queue: list[dict[str, Any]] | None = None
# --- Configuration helpers ------------------------------------------
@staticmethod
def enabled() -> bool:
return (
bool(current_app.config.get('TELEMETRY_ENABLED'))
and bool(current_app.config.get('TELEMETRY_INSTALLATION_ID'))
)
@staticmethod
def category_enabled(category_config_name: str, /) -> bool:
return (
BrickTelemetry.enabled()
and bool(current_app.config.get(category_config_name))
)
# Generate the installation UUID exactly once, at boot, if telemetry is
# enabled and no ID exists yet. Never called again afterwards: there is
# deliberately no regenerate/reset path, so the ID (and the Matomo
# visitor history behind it) stays stable for the life of the install.
@staticmethod
def generate_installation_id(app: Flask, /) -> None:
if not app.config.get('TELEMETRY_ENABLED'):
return
if app.config.get('TELEMETRY_INSTALLATION_ID'):
return
installation_id = str(uuid.uuid4())
app.config['TELEMETRY_INSTALLATION_ID'] = installation_id
try:
ConfigManager()._update_env_file(
'BK_TELEMETRY_INSTALLATION_ID', installation_id,
)
except Exception as exception:
logger.debug(
'telemetry: failed to persist installation id: %s', exception,
)
# Matomo's visitor ID format: exactly 16 hex characters.
@staticmethod
def _visitor_id() -> str:
installation_id = str(
current_app.config.get('TELEMETRY_INSTALLATION_ID') or ''
)
return installation_id.replace('-', '')[:16]
# --- Queue persistence -------------------------------------------------
@staticmethod
def _load_queue() -> list[dict[str, Any]]:
if BrickTelemetry._queue is not None:
return BrickTelemetry._queue
path = current_app.config.get('TELEMETRY_QUEUE_PATH')
queue: list[dict[str, Any]] = []
try:
with open(path, 'r') as queue_file:
loaded = json.load(queue_file)
if isinstance(loaded, list):
queue = loaded
except (OSError, ValueError):
pass
BrickTelemetry._queue = queue
return queue
@staticmethod
def _save_queue() -> None:
path = current_app.config.get('TELEMETRY_QUEUE_PATH')
try:
with open(path, 'w') as queue_file:
json.dump(BrickTelemetry._queue or [], queue_file)
except OSError as exception:
logger.debug(
'telemetry: failed to persist queue: %s', exception,
)
@staticmethod
def _enqueue(params: dict[str, Any], /) -> None:
BrickTelemetry._load_queue().append(params)
# --- Background dispatch ---------------------------------------------
# Nothing here ever runs on the request thread. The greenlet outlives
# the request, so it needs its own real app object and its own app
# context to talk to config, the db, etc.
@staticmethod
def _async(func: Callable[[], None], /) -> None:
try:
# current_app is a proxy that only works during a request.
# Unwrap it now, while we still can, and hand the real object
# to the greenlet.
app = current_app._get_current_object() # type: ignore[attr-defined] # noqa: E501
except RuntimeError:
return
def runner() -> None:
with app.app_context():
try:
func()
except Exception as exception:
logger.debug(
'telemetry: background task failed: %s', exception,
)
finally:
# Save even on failure, so whatever got queued before
# things broke isn't lost, and always close the db
# connection this greenlet opened.
BrickTelemetry._save_queue()
close()
gevent.spawn(runner)
# --- Public tracking API ----------------------------------------------
@staticmethod
def pageview_name(endpoint: str | None, /) -> str | None:
if not endpoint:
return None
if endpoint in PAGEVIEW_ENDPOINTS:
return PAGEVIEW_ENDPOINTS[endpoint]
blueprint = endpoint.split('.', 1)[0]
return PAGEVIEW_BLUEPRINTS.get(blueprint)
@staticmethod
def track_pageview(name: str, /) -> None:
if not BrickTelemetry.category_enabled('TELEMETRY_CATEGORY_USAGE'):
return
BrickTelemetry._async(lambda: BrickTelemetry._send_pageview(name))
@staticmethod
def track_action(name: str, /) -> None:
if not BrickTelemetry.category_enabled('TELEMETRY_CATEGORY_USAGE'):
return
BrickTelemetry._async(lambda: BrickTelemetry._send_action(name))
@staticmethod
def send_installation_info() -> None:
if not BrickTelemetry.category_enabled(
'TELEMETRY_CATEGORY_INSTALLATION',
):
return
BrickTelemetry._async(BrickTelemetry._send_installation_info)
@staticmethod
def maybe_send_collection_stats() -> None:
if not BrickTelemetry.category_enabled(
'TELEMETRY_CATEGORY_COLLECTION',
):
return
now = time.monotonic()
last = BrickTelemetry._last_collection_stats_at
if last is not None and (now - last) < COLLECTION_STATS_INTERVAL:
return
BrickTelemetry._last_collection_stats_at = now
BrickTelemetry._async(BrickTelemetry._send_collection_stats)
@staticmethod
def maybe_flush_queue(*, force: bool = False) -> None:
if not BrickTelemetry.enabled():
return
now = time.monotonic()
last = BrickTelemetry._last_flush_attempt_at
if not force and last is not None and (now - last) < FLUSH_INTERVAL:
return
BrickTelemetry._last_flush_attempt_at = now
BrickTelemetry._async(BrickTelemetry._flush_queue)
# --- Payload builders + senders (run inside the background greenlet) --
@staticmethod
def _base_params(url_suffix: str, /) -> dict[str, Any]:
return {
'idsite': MATOMO_SITE_ID,
'rec': 1,
'apiv': 1,
'rand': random.randint(0, 1_000_000_000),
'cid': BrickTelemetry._visitor_id(),
'url': 'app://bricktracker/{suffix}'.format(suffix=url_suffix),
}
@staticmethod
def _send_pageview(name: str, /) -> None:
params = BrickTelemetry._base_params(name)
params['action_name'] = name
BrickTelemetry._enqueue(params)
@staticmethod
def _send_action(name: str, /) -> None:
params = BrickTelemetry._base_params(name)
params.update({'e_c': 'Usage', 'e_a': name})
BrickTelemetry._enqueue(params)
@staticmethod
def _send_installation_info() -> None:
version_params = BrickTelemetry._base_params('Version')
version_params.update({
'e_c': 'Installation', 'e_a': 'Version', 'e_n': __version__,
})
BrickTelemetry._enqueue(version_params)
schema_params = BrickTelemetry._base_params('Schema')
schema_params.update({
'e_c': 'Installation',
'e_a': 'Schema',
'e_v': BrickSQL(failsafe=True).version,
})
BrickTelemetry._enqueue(schema_params)
flags = {
'SidecarEnabled': BrickSidecar.enabled(),
'SetsServerSidePagination': bool(
current_app.config.get('SETS_SERVER_SIDE_PAGINATION'),
),
'PartsServerSidePagination': bool(
current_app.config.get('PARTS_SERVER_SIDE_PAGINATION'),
),
'MinifiguresServerSidePagination': bool(
current_app.config.get('MINIFIGURES_SERVER_SIDE_PAGINATION'),
),
'ProblemsServerSidePagination': bool(
current_app.config.get('PROBLEMS_SERVER_SIDE_PAGINATION'),
),
}
for action, flag in flags.items():
flag_params = BrickTelemetry._base_params(action)
flag_params.update({
'e_c': 'Installation', 'e_a': action, 'e_v': int(flag),
})
BrickTelemetry._enqueue(flag_params)
@staticmethod
def _send_collection_stats() -> None:
summary = BrickStatistics().get_collection_summary()
counts = {
'Sets': summary['total_sets'],
'Minifigures': summary['total_minifigures_count'],
'Parts': summary['total_parts_count'],
'Lots': summary['total_part_lots'],
'MissingParts': summary['total_missing_parts'],
'DamagedParts': summary['total_damaged_parts'],
'Wishlist': len(BrickWishList().all()),
}
for action, value in counts.items():
params = BrickTelemetry._base_params(action)
params.update({'e_c': 'Collection', 'e_a': action, 'e_v': value})
BrickTelemetry._enqueue(params)
@staticmethod
def _bulk_payload(queue: list[dict[str, Any]], /) -> dict[str, Any]:
return {
'requests': [
'?{qs}'.format(qs=urlencode(params)) for params in queue
],
}
@staticmethod
def _send_bulk(queue: list[dict[str, Any]], /) -> bool:
try:
response = requests.post(
'{base}/matomo.php'.format(base=MATOMO_URL),
json=BrickTelemetry._bulk_payload(queue),
timeout=TIMEOUT,
)
response.raise_for_status()
return True
except Exception as exception:
logger.debug('telemetry: bulk send failed: %s', exception)
return False
@staticmethod
def _flush_queue() -> None:
queue = BrickTelemetry._load_queue()
if queue and BrickTelemetry._send_bulk(queue):
BrickTelemetry._queue = []
if __name__ == '__main__':
sample_payloads = [
{'action_name': 'Dashboard'},
{'e_c': 'Installation', 'e_a': 'Version', 'e_n': '1.5.0'},
{'e_c': 'Installation', 'e_a': 'Schema', 'e_v': 30},
{'e_c': 'Collection', 'e_a': 'Sets', 'e_v': 42},
{'e_c': 'Collection', 'e_a': 'Wishlist', 'e_v': 3},
{'e_c': 'Usage', 'e_a': 'AddSet'},
]
for payload in sample_payloads:
for key, value in payload.items():
assert key in (
'action_name', 'e_c', 'e_a', 'e_n', 'e_v',
), 'unexpected telemetry param key: {key}'.format(key=key)
assert isinstance(value, (str, int)), (
'unexpected telemetry param type for {key}'.format(key=key)
)
print('telemetry payload self-check OK ({count} samples)'.format(
count=len(sample_payloads),
))
empty_payload = BrickTelemetry._bulk_payload([])
assert empty_payload == {'requests': []}, empty_payload
bulk_payload = BrickTelemetry._bulk_payload(sample_payloads)
assert len(bulk_payload['requests']) == len(sample_payloads)
for request_qs, payload in zip(bulk_payload['requests'], sample_payloads):
assert request_qs.startswith('?'), request_qs
for key, value in payload.items():
assert '{key}={value}'.format(key=key, value=value) in request_qs, (
request_qs
)
print('telemetry bulk payload self-check OK ({count} samples)'.format(
count=len(bulk_payload['requests']),
))
+2 -2
View File
@@ -1,4 +1,4 @@
from typing import Final from typing import Final
__version__: Final[str] = '1.5.0' __version__: Final[str] = '1.4.2'
__database_version__: Final[int] = 30 __database_version__: Final[int] = 27
-75
View File
@@ -11,8 +11,6 @@ from ..exceptions import exception_handler
from ...instructions_list import BrickInstructionsList from ...instructions_list import BrickInstructionsList
from ...rebrickable_image import RebrickableImage from ...rebrickable_image import RebrickableImage
from ...retired_list import BrickRetiredList 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 import BrickSetOwner
from ...set_owner_list import BrickSetOwnerList from ...set_owner_list import BrickSetOwnerList
from ...set_purchase_location import BrickSetPurchaseLocation from ...set_purchase_location import BrickSetPurchaseLocation
@@ -25,8 +23,6 @@ from ...set_tag import BrickSetTag
from ...set_tag_list import BrickSetTagList from ...set_tag_list import BrickSetTagList
from ...sql_counter import BrickCounter from ...sql_counter import BrickCounter
from ...sql import BrickSQL from ...sql import BrickSQL
from ...socket import MESSAGES
from ...telemetry import BrickTelemetry
from ...theme_list import BrickThemeList from ...theme_list import BrickThemeList
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -132,7 +128,6 @@ def admin() -> str:
database_upgrade_needed: bool = False database_upgrade_needed: bool = False
database_version: int = -1 database_version: int = -1
instructions: BrickInstructionsList | None = None instructions: BrickInstructionsList | None = None
metadata_custom_fields: list[BrickSetCustomField] = []
metadata_owners: list[BrickSetOwner] = [] metadata_owners: list[BrickSetOwner] = []
metadata_purchase_locations: list[BrickSetPurchaseLocation] = [] metadata_purchase_locations: list[BrickSetPurchaseLocation] = []
metadata_statuses: list[BrickSetStatus] = [] metadata_statuses: list[BrickSetStatus] = []
@@ -152,7 +147,6 @@ def admin() -> str:
instructions = BrickInstructionsList() instructions = BrickInstructionsList()
metadata_custom_fields = BrickSetCustomFieldList.list()
metadata_owners = BrickSetOwnerList.list() metadata_owners = BrickSetOwnerList.list()
metadata_purchase_locations = BrickSetPurchaseLocationList.list() metadata_purchase_locations = BrickSetPurchaseLocationList.list()
metadata_statuses = BrickSetStatusList.list(all=True) metadata_statuses = BrickSetStatusList.list(all=True)
@@ -181,7 +175,6 @@ def admin() -> str:
open_image = request.args.get('open_image', None) open_image = request.args.get('open_image', None)
open_instructions = request.args.get('open_instructions', None) open_instructions = request.args.get('open_instructions', None)
open_logout = request.args.get('open_logout', 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_metadata = request.args.get('open_metadata', None)
open_owner = request.args.get('open_owner', None) open_owner = request.args.get('open_owner', None)
open_purchase_location = request.args.get('open_purchase_location', None) open_purchase_location = request.args.get('open_purchase_location', None)
@@ -193,7 +186,6 @@ def admin() -> str:
open_metadata = ( open_metadata = (
open_metadata or open_metadata or
open_custom_field or
open_owner or open_owner or
open_purchase_location or open_purchase_location or
open_status or open_status or
@@ -219,10 +211,8 @@ def admin() -> str:
open_logout = should_expand('authentication', open_logout) open_logout = should_expand('authentication', open_logout)
open_retired = should_expand('retired', open_retired) open_retired = should_expand('retired', open_retired)
open_theme = should_expand('theme', open_theme) open_theme = should_expand('theme', open_theme)
open_value = should_expand('value', request.args.get('open_value', None))
# Metadata sub-sections # Metadata sub-sections
open_custom_field = should_expand('custom_field', open_custom_field)
open_owner = should_expand('owner', open_owner) open_owner = should_expand('owner', open_owner)
open_purchase_location = should_expand('purchase_location', open_purchase_location) open_purchase_location = should_expand('purchase_location', open_purchase_location)
open_status = should_expand('status', open_status) open_status = should_expand('status', open_status)
@@ -232,7 +222,6 @@ def admin() -> str:
# Recalculate metadata section based on sub-sections or direct config # Recalculate metadata section based on sub-sections or direct config
open_metadata = ( open_metadata = (
should_expand('metadata', open_metadata) or should_expand('metadata', open_metadata) or
open_custom_field or
open_owner or open_owner or
open_purchase_location or open_purchase_location or
open_status or open_status or
@@ -276,7 +265,6 @@ def admin() -> str:
database_upgrade_needed=database_upgrade_needed, database_upgrade_needed=database_upgrade_needed,
database_version=database_version, database_version=database_version,
instructions=instructions, instructions=instructions,
metadata_custom_fields=metadata_custom_fields,
metadata_owners=metadata_owners, metadata_owners=metadata_owners,
metadata_purchase_locations=metadata_purchase_locations, metadata_purchase_locations=metadata_purchase_locations,
metadata_statuses=metadata_statuses, metadata_statuses=metadata_statuses,
@@ -290,7 +278,6 @@ def admin() -> str:
open_image=open_image, open_image=open_image,
open_instructions=open_instructions, open_instructions=open_instructions,
open_logout=open_logout, open_logout=open_logout,
open_custom_field=open_custom_field,
open_metadata=open_metadata, open_metadata=open_metadata,
open_owner=open_owner, open_owner=open_owner,
open_purchase_location=open_purchase_location, open_purchase_location=open_purchase_location,
@@ -299,10 +286,6 @@ def admin() -> str:
open_storage=open_storage, open_storage=open_storage,
open_tag=open_tag, open_tag=open_tag,
open_theme=open_theme, 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'), owner_error=request.args.get('owner_error'),
purchase_location_error=request.args.get('purchase_location_error'), purchase_location_error=request.args.get('purchase_location_error'),
retired=BrickRetiredList(), retired=BrickRetiredList(),
@@ -411,61 +394,3 @@ def update_static_config() -> str:
'status': 'error', 'status': 'error',
'message': str(e) 'message': str(e)
}), 500 }), 500
# Route for the first-run telemetry splash: the user chose Enable. See the
# comment further down for why this takes effect immediately instead of
# waiting for a restart.
@admin_page.route('/api/telemetry/consent/enable', methods=['POST'])
@login_required
@exception_handler(__file__)
def telemetry_consent_enable() -> str:
# All categories start enabled. Written explicitly (not just relying on
# their True default) so Static Settings shows them as actually On, and
# so a later re-toggle there has something explicit to diff against.
category_vars = (
'BK_TELEMETRY_CATEGORY_INSTALLATION',
'BK_TELEMETRY_CATEGORY_USAGE',
'BK_TELEMETRY_CATEGORY_COLLECTION',
)
config_manager = ConfigManager()
config_manager._update_env_file('BK_TELEMETRY_ENABLED', True)
config_manager._update_env_file('BK_TELEMETRY_PROMPTED', True)
for var_name in category_vars:
config_manager._update_env_file(var_name, True)
# First-run consent takes effect immediately, since the user just said
# yes, so telemetry actually starts now. Unlike this, a later re-toggle
# via Static Settings still requires a restart (see update_static_config).
app = current_app._get_current_object()
app.config['TELEMETRY_ENABLED'] = True
app.config['TELEMETRY_PROMPTED'] = True
for var_name in category_vars:
app.config[var_name.removeprefix('BK_')] = True
BrickTelemetry.generate_installation_id(app)
BrickTelemetry.send_installation_info()
BrickTelemetry.maybe_send_collection_stats()
BrickTelemetry.maybe_flush_queue(force=True)
return jsonify({
'status': 'success',
'message': 'Telemetry enabled.',
})
# First-run telemetry splash: the user chose Disable.
@admin_page.route('/api/telemetry/consent/disable', methods=['POST'])
@login_required
@exception_handler(__file__)
def telemetry_consent_disable() -> str:
config_manager = ConfigManager()
config_manager._update_env_file('BK_TELEMETRY_ENABLED', False)
config_manager._update_env_file('BK_TELEMETRY_PROMPTED', True)
current_app.config['TELEMETRY_PROMPTED'] = True
return jsonify({
'status': 'success',
'message': 'Telemetry stays disabled.',
})
-98
View File
@@ -1,98 +0,0 @@
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))
-5
View File
@@ -18,7 +18,6 @@ from werkzeug.wrappers.response import Response
from ..exceptions import exception_handler from ..exceptions import exception_handler
from ...reload import reload from ...reload import reload
from ...sql_integrity import BrickIntegrityCheck from ...sql_integrity import BrickIntegrityCheck
from ...telemetry import BrickTelemetry
from ...sql_migration_list import BrickSQLMigrationList from ...sql_migration_list import BrickSQLMigrationList
from ...sql import BrickSQL from ...sql import BrickSQL
from ..upload import upload_helper from ..upload import upload_helper
@@ -126,10 +125,6 @@ def do_drop() -> Response:
def do_upgrade() -> Response: def do_upgrade() -> Response:
BrickSQL(failsafe=True).upgrade() BrickSQL(failsafe=True).upgrade()
# Schema version just changed, so report it again rather than waiting
# for the next opportunistic collection-stats send.
BrickTelemetry.send_installation_info()
reload() reload()
return redirect(url_for('admin.admin')) return redirect(url_for('admin.admin'))
+9 -30
View File
@@ -63,9 +63,7 @@ def export_parts_rebrickable() -> Response:
year = request.args.get('year') year = request.args.get('year')
part_list = BrickPartList() part_list = BrickPartList()
part_list.filtered( part_list.all_filtered(owner_id, color_id, theme_id, year)
owner_id=owner_id, color_id=color_id, theme_id=theme_id, year=year
)
part_quantities = {} part_quantities = {}
for part in part_list.records: for part in part_list.records:
@@ -103,9 +101,7 @@ def export_parts_lego() -> Response:
year = request.args.get('year') year = request.args.get('year')
part_list = BrickPartList() part_list = BrickPartList()
part_list.filtered( part_list.all_filtered(owner_id, color_id, theme_id, year)
owner_id=owner_id, color_id=color_id, theme_id=theme_id, year=year
)
element_quantities = {} element_quantities = {}
for part in part_list.records: for part in part_list.records:
@@ -144,9 +140,7 @@ def export_parts_bricklink() -> Response:
year = request.args.get('year') year = request.args.get('year')
part_list = BrickPartList() part_list = BrickPartList()
part_list.filtered( part_list.all_filtered(owner_id, color_id, theme_id, year)
owner_id=owner_id, color_id=color_id, theme_id=theme_id, year=year
)
part_quantities = {} part_quantities = {}
for part in part_list.records: for part in part_list.records:
@@ -189,10 +183,7 @@ def export_problems_rebrickable() -> Response:
year = request.args.get('year') year = request.args.get('year')
part_list = BrickPartList() part_list = BrickPartList()
part_list.filtered( part_list.problem_filtered(owner_id, color_id, theme_id, year)
problem_only=True,
owner_id=owner_id, color_id=color_id, theme_id=theme_id, year=year
)
part_quantities = {} part_quantities = {}
for part in part_list.records: for part in part_list.records:
@@ -232,10 +223,7 @@ def export_problems_lego() -> Response:
year = request.args.get('year') year = request.args.get('year')
part_list = BrickPartList() part_list = BrickPartList()
part_list.filtered( part_list.problem_filtered(owner_id, color_id, theme_id, year)
problem_only=True,
owner_id=owner_id, color_id=color_id, theme_id=theme_id, year=year
)
element_quantities = {} element_quantities = {}
for part in part_list.records: for part in part_list.records:
@@ -275,10 +263,7 @@ def export_problems_bricklink() -> Response:
year = request.args.get('year') year = request.args.get('year')
part_list = BrickPartList() part_list = BrickPartList()
part_list.filtered( part_list.problem_filtered(owner_id, color_id, theme_id, year)
problem_only=True,
owner_id=owner_id, color_id=color_id, theme_id=theme_id, year=year
)
part_quantities = {} part_quantities = {}
for part in part_list.records: for part in part_list.records:
@@ -333,9 +318,7 @@ def get_combined_parts_data(owner_id, color_id, theme_id, year):
"""Get both set-based and individual parts combined.""" """Get both set-based and individual parts combined."""
# Get set-based parts # Get set-based parts
part_list = BrickPartList() part_list = BrickPartList()
part_list.filtered( part_list.all_filtered(owner_id, color_id, theme_id, year)
owner_id=owner_id, color_id=color_id, theme_id=theme_id, year=year
)
combined_quantities = {} combined_quantities = {}
for part in part_list.records: for part in part_list.records:
@@ -456,9 +439,7 @@ def export_parts_combined_lego() -> Response:
# Get set-based parts # Get set-based parts
part_list = BrickPartList() part_list = BrickPartList()
part_list.filtered( part_list.all_filtered(owner_id, color_id, theme_id, year)
owner_id=owner_id, color_id=color_id, theme_id=theme_id, year=year
)
element_quantities = {} element_quantities = {}
for part in part_list.records: for part in part_list.records:
@@ -539,9 +520,7 @@ def export_parts_combined_bricklink() -> Response:
# Get set-based parts # Get set-based parts
part_list = BrickPartList() part_list = BrickPartList()
part_list.filtered( part_list.all_filtered(owner_id, color_id, theme_id, year)
owner_id=owner_id, color_id=color_id, theme_id=theme_id, year=year
)
part_quantities = {} part_quantities = {}
for part in part_list.records: for part in part_list.records:
-3
View File
@@ -25,7 +25,6 @@ from ..set_tag_list import BrickSetTagList
from ..set_storage_list import BrickSetStorageList from ..set_storage_list import BrickSetStorageList
from ..set_purchase_location_list import BrickSetPurchaseLocationList from ..set_purchase_location_list import BrickSetPurchaseLocationList
from ..sql import BrickSQL from ..sql import BrickSQL
from ..telemetry import BrickTelemetry
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -146,8 +145,6 @@ def quick_add() -> Response:
sql.commit() sql.commit()
BrickTelemetry.track_action('QuickAddPart')
logger.info('Quick-added individual part {id}: {part}/{color} x{quantity}'.format( logger.info('Quick-added individual part {id}: {part}/{color} x{quantity}'.format(
id=part_id, id=part_id,
part=data["part"], part=data["part"],
-13
View File
@@ -205,19 +205,6 @@ def do_download() -> str:
peeron = PeeronInstructions(set) peeron = PeeronInstructions(set)
# Just check if pages exist, don't cache thumbnails yet # Just check if pages exist, don't cache thumbnails yet
if peeron.exists(): if peeron.exists():
# Peeron keeps this one as a single PDF, so there are no pages
# to pick, offer it like any other instructions file
if peeron.pdf_url:
return render_template(
'instructions.html',
download=True,
instructions=[('{set}_peeron'.format(set=set), peeron.pdf_url)],
set=set,
path=current_app.config['SOCKET_PATH'],
namespace=current_app.config['SOCKET_NAMESPACE'],
messages=MESSAGES
)
# Peeron has instructions - show loading interface # Peeron has instructions - show loading interface
return render_template( return render_template(
'peeron_select.html', 'peeron_select.html',
+167 -139
View File
@@ -6,164 +6,192 @@ from ..individual_part_lot_list import IndividualPartLotList
from ..minifigure_list import BrickMinifigureList from ..minifigure_list import BrickMinifigureList
from ..pagination_helper import get_pagination_config, build_pagination_context, get_request_params from ..pagination_helper import get_pagination_config, build_pagination_context, get_request_params
from ..part import BrickPart from ..part import BrickPart
from ..part_list import BrickPartList, known_metadata_id from ..part_list import BrickPartList
from ..set_list import BrickSetList, set_metadata_lists from ..set_list import BrickSetList, set_metadata_lists
from ..set_owner_list import BrickSetOwnerList from ..set_owner_list import BrickSetOwnerList
from ..set_status_list import BrickSetStatusList
from ..set_storage_list import BrickSetStorageList
from ..set_tag_list import BrickSetTagList
from ..sql import BrickSQL from ..sql import BrickSQL
from ..theme_list import BrickThemeList
part_page = Blueprint('part', __name__, url_prefix='/parts') part_page = Blueprint('part', __name__, url_prefix='/parts')
# Read the seven filters off the query string. owner, tag and status end up as SQL
# column names, so they are checked against the ids we know about first. The rest are
# bound as parameters, so they can go through as they are.
def filters_from_request(
owners: list,
statuses: list,
tags: list,
/,
) -> dict[str, str | None]:
return {
'owner_id': known_metadata_id(
request.args.get('owner'),
{owner.fields.id for owner in owners},
),
'color_id': request.args.get('color', 'all'),
'theme_id': request.args.get('theme', 'all'),
'year': request.args.get('year', 'all'),
'storage_id': request.args.get('storage', 'all'),
'tag_id': known_metadata_id(
request.args.get('tag'),
{tag.fields.id for tag in tags},
),
'status_id': known_metadata_id(
request.args.get('status'),
{status.fields.id for status in statuses},
),
}
# Options for the colour, theme and year dropdowns. They are derived from the parts
# on the page, so they narrow down as the owner filter narrows.
def filter_options(
owner_id: str | None,
/,
*,
problem_only: bool = False,
) -> dict[str, list]:
context = {}
if problem_only:
context['problem_only'] = True
# ponytail: a negated owner would give the options of the owner being excluded,
# so it just does not narrow the lists. Fine for a dropdown.
if owner_id and not owner_id.startswith('-'):
context['owner_id'] = owner_id
theme_list = BrickThemeList()
themes = []
for theme_data in BrickSQL().fetchall('part/themes/list', **context):
theme = theme_list.get(theme_data['theme_id'])
themes.append({
'theme_id': theme_data['theme_id'],
'theme_name': theme.name if theme else 'Theme {id}'.format(
id=theme_data['theme_id'],
),
})
return {
'colors': BrickSQL().fetchall('part/colors/list', **context),
'themes': themes,
'years': BrickSQL().fetchall('part/years/list', **context),
}
# Everything both parts pages need to render. They only differ by problem_only and
# the template, so the filter plumbing lives here once.
def parts_page_context(*, problem_only: bool) -> dict:
owners = BrickSetOwnerList.list()
statuses = BrickSetStatusList.list(all=True)
tags = BrickSetTagList.list()
storages = BrickSetStorageList.list()
filters = filters_from_request(owners, statuses, tags)
individuals_filter = request.args.get('individuals', 'all')
search_query, sort_field, sort_order, page = get_request_params()
per_page, is_mobile = get_pagination_config(
'problems' if problem_only else 'parts'
)
use_pagination = per_page > 0
parts = BrickPartList()
if use_pagination:
parts, total_count = parts.paginated_filtered(
problem_only=problem_only,
individuals_filter=individuals_filter,
search_query=search_query,
page=page,
per_page=per_page,
sort_field=sort_field,
sort_order=sort_order,
**filters
)
pagination = build_pagination_context(
page, per_page, total_count, is_mobile
)
else:
parts = parts.filtered(
problem_only=problem_only,
individuals_filter=individuals_filter,
**filters
)
pagination = None
return {
'table_collection': parts,
'pagination': pagination,
'use_pagination': use_pagination,
'search_query': search_query,
'sort_field': sort_field,
'sort_order': sort_order,
'current_sort': sort_field,
'current_order': sort_order,
'owners': owners,
'storages': storages,
'tags': tags,
'statuses': statuses,
'selected_owner': request.args.get('owner', 'all'),
'selected_color': filters['color_id'],
'selected_theme': filters['theme_id'],
'selected_year': filters['year'],
'selected_storage': filters['storage_id'],
'selected_tag': request.args.get('tag', 'all'),
'selected_status': request.args.get('status', 'all'),
'selected_individuals': individuals_filter,
**filter_options(filters['owner_id'], problem_only=problem_only),
}
# Index # Index
@part_page.route('/', methods=['GET']) @part_page.route('/', methods=['GET'])
@exception_handler(__file__) @exception_handler(__file__)
def list() -> str: def list() -> str:
return render_template( # Get filter parameters from request
'parts.html', owner_id = request.args.get('owner', 'all')
**parts_page_context(problem_only=False) color_id = request.args.get('color', 'all')
) theme_id = request.args.get('theme', 'all')
year = request.args.get('year', 'all')
individuals_filter = request.args.get('individuals', 'all')
search_query, sort_field, sort_order, page = get_request_params()
# Get pagination configuration
per_page, is_mobile = get_pagination_config('parts')
use_pagination = per_page > 0
if use_pagination:
# PAGINATION MODE - Server-side pagination with search
parts, total_count = BrickPartList().all_filtered_paginated(
owner_id=owner_id,
color_id=color_id,
theme_id=theme_id,
year=year,
individuals_filter=individuals_filter,
search_query=search_query,
page=page,
per_page=per_page,
sort_field=sort_field,
sort_order=sort_order
)
pagination_context = build_pagination_context(page, per_page, total_count, is_mobile)
else:
# ORIGINAL MODE - Single page with all data for client-side search
parts = BrickPartList().all_filtered(owner_id, color_id, theme_id, year, individuals_filter)
pagination_context = None
# Get list of owners for filter dropdown
owners = BrickSetOwnerList.list()
# Prepare context for dependent filters
filter_context = {}
if owner_id != 'all' and owner_id:
filter_context['owner_id'] = owner_id
# Get list of colors for filter dropdown
colors = BrickSQL().fetchall('part/colors/list', **filter_context)
# Get list of themes for filter dropdown
from ..theme_list import BrickThemeList
theme_list = BrickThemeList()
themes_data = BrickSQL().fetchall('part/themes/list', **filter_context)
themes = []
for theme_data in themes_data:
theme = theme_list.get(theme_data['theme_id'])
themes.append({
'theme_id': theme_data['theme_id'],
'theme_name': theme.name if theme else f"Theme {theme_data['theme_id']}"
})
# Get list of years for filter dropdown
years = BrickSQL().fetchall('part/years/list', **filter_context)
template_context = {
'table_collection': parts,
'owners': owners,
'selected_owner': owner_id,
'colors': colors,
'selected_color': color_id,
'themes': themes,
'selected_theme': theme_id,
'years': years,
'selected_year': year,
'selected_individuals': individuals_filter,
'search_query': search_query,
'use_pagination': use_pagination,
'current_sort': sort_field,
'current_order': sort_order
}
if pagination_context:
template_context['pagination'] = pagination_context
return render_template('parts.html', **template_context)
# Problem # Problem
@part_page.route('/problem', methods=['GET']) @part_page.route('/problem', methods=['GET'])
@exception_handler(__file__) @exception_handler(__file__)
def problem() -> str: def problem() -> str:
# Get filter parameters from request
owner_id = request.args.get('owner', 'all')
color_id = request.args.get('color', 'all')
theme_id = request.args.get('theme', 'all')
year = request.args.get('year', 'all')
storage_id = request.args.get('storage', 'all')
tag_id = request.args.get('tag', 'all')
search_query, sort_field, sort_order, page = get_request_params()
# Get pagination configuration
per_page, is_mobile = get_pagination_config('problems')
use_pagination = per_page > 0
if use_pagination:
# PAGINATION MODE - Server-side pagination with search and filters
parts, total_count = BrickPartList().problem_paginated(
owner_id=owner_id,
color_id=color_id,
theme_id=theme_id,
year=year,
storage_id=storage_id,
tag_id=tag_id,
search_query=search_query,
page=page,
per_page=per_page,
sort_field=sort_field,
sort_order=sort_order
)
pagination_context = build_pagination_context(page, per_page, total_count, is_mobile)
else:
# ORIGINAL MODE - Single page with all data for client-side search
parts = BrickPartList().problem_filtered(owner_id, color_id, theme_id, year, storage_id, tag_id)
pagination_context = None
# Get list of owners for filter dropdown
owners = BrickSetOwnerList.list()
# Prepare context for dependent filters
filter_context = {}
if owner_id != 'all' and owner_id:
filter_context['owner_id'] = owner_id
# Get list of colors for filter dropdown (problem parts only)
colors = BrickSQL().fetchall('part/colors/list_problem', **filter_context)
# Get list of themes for filter dropdown (problem parts only)
from ..theme_list import BrickThemeList
theme_list = BrickThemeList()
themes_data = BrickSQL().fetchall('part/themes/list_problem', **filter_context)
themes = []
for theme_data in themes_data:
theme = theme_list.get(theme_data['theme_id'])
themes.append({
'theme_id': theme_data['theme_id'],
'theme_name': theme.name if theme else f"Theme {theme_data['theme_id']}"
})
# Get list of years for filter dropdown (problem parts only)
years = BrickSQL().fetchall('part/years/list_problem', **filter_context)
# Get list of storages for filter dropdown (problem parts only)
storages = BrickSQL().fetchall('part/storages/list_problem', **filter_context)
# Get list of tags for filter dropdown (problem parts only)
tags = BrickSQL().fetchall('part/tags/list_problem', **filter_context)
return render_template( return render_template(
'problem.html', 'problem.html',
**parts_page_context(problem_only=True) table_collection=parts,
pagination=pagination_context,
search_query=search_query,
sort_field=sort_field,
sort_order=sort_order,
use_pagination=use_pagination,
owners=owners,
colors=colors,
selected_owner=owner_id,
selected_color=color_id,
themes=themes,
selected_theme=theme_id,
years=years,
selected_year=year,
storages=storages,
selected_storage=storage_id,
tags=tags,
selected_tag=tag_id
) )
+2 -382
View File
@@ -1,5 +1,4 @@
import logging import logging
import os
from flask import ( from flask import (
Blueprint, Blueprint,
@@ -14,19 +13,12 @@ from flask_login import login_required
from werkzeug.wrappers.response import Response from werkzeug.wrappers.response import Response
from .exceptions import exception_handler from .exceptions import exception_handler
from ..bag_part import update_state as update_bag_part_state
from ..exceptions import ErrorException from ..exceptions import ErrorException
from ..minifigure import BrickMinifigure from ..minifigure import BrickMinifigure
from ..pagination_helper import get_pagination_config, build_pagination_context, get_request_params from ..pagination_helper import get_pagination_config, build_pagination_context, get_request_params
from ..part import BrickPart from ..part import BrickPart
from ..rebrickable_image import RebrickableImage
from ..rebrickable_set import RebrickableSet from ..rebrickable_set import RebrickableSet
from ..set import BrickSet 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 ..metadata_list import known_metadata_filter
from ..set_list import BrickSetList, set_metadata_lists from ..set_list import BrickSetList, set_metadata_lists
from ..set_owner_list import BrickSetOwnerList from ..set_owner_list import BrickSetOwnerList
from ..set_purchase_location_list import BrickSetPurchaseLocationList from ..set_purchase_location_list import BrickSetPurchaseLocationList
@@ -34,7 +26,6 @@ from ..set_status_list import BrickSetStatusList
from ..set_storage_list import BrickSetStorageList from ..set_storage_list import BrickSetStorageList
from ..set_tag_list import BrickSetTagList from ..set_tag_list import BrickSetTagList
from ..socket import MESSAGES from ..socket import MESSAGES
from ..sql import BrickSQL
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -51,39 +42,13 @@ def list() -> str:
# Get filter parameters # Get filter parameters
status_filter = request.args.get('status') status_filter = request.args.get('status')
theme_filter = request.args.get('theme') theme_filter = request.args.get('theme')
owner_filter = request.args.get('owner')
purchase_location_filter = request.args.get('purchase_location') purchase_location_filter = request.args.get('purchase_location')
storage_filter = request.args.get('storage') storage_filter = request.args.get('storage')
tag_filter = request.args.get('tag')
year_filter = request.args.get('year') year_filter = request.args.get('year')
# owner and tag arrive as "owner-<id>" / "tag-<id>" and end up as SQL column
# names, which no quoting makes safe. Only ids we already know about get through.
owner_filter = known_metadata_filter(
request.args.get('owner'),
'owner',
BrickSetOwnerList.list(),
)
tag_filter = known_metadata_filter(
request.args.get('tag'),
'tag',
BrickSetTagList.list(),
)
duplicate_filter = request.args.get('duplicate', '').lower() == 'true' 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 # Get pagination configuration
per_page, is_mobile = get_pagination_config('sets') per_page, is_mobile = get_pagination_config('sets')
use_pagination = per_page > 0 use_pagination = per_page > 0
@@ -104,10 +69,6 @@ def list() -> str:
tag_filter=tag_filter, tag_filter=tag_filter,
year_filter=year_filter, year_filter=year_filter,
duplicate_filter=duplicate_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'] use_consolidated=current_app.config['SETS_CONSOLIDATION']
) )
@@ -144,10 +105,6 @@ def list() -> str:
'current_tag_filter': tag_filter, 'current_tag_filter': tag_filter,
'current_year_filter': year_filter, 'current_year_filter': year_filter,
'current_duplicate_filter': duplicate_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(), 'brickset_statuses': BrickSetStatusList.list(),
**set_metadata_lists(as_class=True) **set_metadata_lists(as_class=True)
} }
@@ -268,107 +225,6 @@ def update_tag(*, id: str, metadata_id: str) -> Response:
return jsonify({'value': state}) 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 # Ask for deletion of a set
@set_page.route('/<id>/delete', methods=['GET']) @set_page.route('/<id>/delete', methods=['GET'])
@login_required @login_required
@@ -418,30 +274,6 @@ def details(*, id: str) -> str:
# Load the specific set # Load the specific set
item = BrickSet().select_specific(id) 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 # Check if there are multiple instances of this set
all_instances = BrickSetList() all_instances = BrickSetList()
# Load all sets with metadata context for tags, owners, etc. # Load all sets with metadata context for tags, owners, etc.
@@ -466,10 +298,6 @@ def details(*, id: str) -> str:
all_instances=same_set_instances, all_instances=same_set_instances,
open_instructions=request.args.get('open_instructions'), open_instructions=request.args.get('open_instructions'),
brickset_statuses=BrickSetStatusList.list(all=True), 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) **set_metadata_lists(as_class=True)
) )
else: else:
@@ -479,10 +307,6 @@ def details(*, id: str) -> str:
item=item, item=item,
open_instructions=request.args.get('open_instructions'), open_instructions=request.args.get('open_instructions'),
brickset_statuses=BrickSetStatusList.list(all=True), 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) **set_metadata_lists(as_class=True)
) )
@@ -577,83 +401,6 @@ def checked_part(
return jsonify({'checked': checked}) 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 # Refresh a set
@set_page.route('/refresh/<set>/', methods=['GET']) @set_page.route('/refresh/<set>/', methods=['GET'])
@set_page.route('/<id>/refresh', methods=['GET']) @set_page.route('/<id>/refresh', methods=['GET'])
@@ -675,130 +422,3 @@ def refresh(*, id: str | None = None, set: str | None = None) -> str:
namespace=current_app.config['SOCKET_NAMESPACE'], namespace=current_app.config['SOCKET_NAMESPACE'],
messages=MESSAGES 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
@@ -1,53 +0,0 @@
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,8 +32,6 @@ def overview() -> str:
purchase_location_stats = stats.get_purchase_location_statistics() purchase_location_stats = stats.get_purchase_location_statistics()
financial_summary = stats.get_financial_summary() financial_summary = stats.get_financial_summary()
collection_summary = stats.get_collection_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() sets_by_year_stats = stats.get_sets_by_year_statistics()
purchases_by_year_stats = stats.get_purchases_by_year_statistics() purchases_by_year_stats = stats.get_purchases_by_year_statistics()
year_summary = stats.get_year_summary() year_summary = stats.get_year_summary()
@@ -59,8 +57,6 @@ def overview() -> str:
purchase_location_statistics=purchase_location_stats, purchase_location_statistics=purchase_location_stats,
financial_summary=financial_summary, financial_summary=financial_summary,
collection_summary=collection_summary, collection_summary=collection_summary,
instructions_summary=instructions_summary,
sidecar_pricing=sidecar_pricing,
sets_by_year_statistics=sets_by_year_stats, sets_by_year_statistics=sets_by_year_stats,
purchases_by_year_statistics=purchases_by_year_stats, purchases_by_year_statistics=purchases_by_year_stats,
year_summary=year_summary, year_summary=year_summary,
+1 -8
View File
@@ -11,7 +11,6 @@ from werkzeug.wrappers.response import Response
from .exceptions import exception_handler from .exceptions import exception_handler
from ..retired_list import BrickRetiredList from ..retired_list import BrickRetiredList
from ..sidecar_set import retirement_dates
from ..wish import BrickWish from ..wish import BrickWish
from ..wish_list import BrickWishList from ..wish_list import BrickWishList
from ..wish_owner_list import BrickWishOwnerList from ..wish_owner_list import BrickWishOwnerList
@@ -24,16 +23,10 @@ wish_page = Blueprint('wish', __name__, url_prefix='/wishes')
@wish_page.route('/', methods=['GET']) @wish_page.route('/', methods=['GET'])
@exception_handler(__file__) @exception_handler(__file__)
def list() -> str: def list() -> str:
table_collection = BrickWishList().all()
return render_template( return render_template(
'wishes.html', 'wishes.html',
table_collection=table_collection, table_collection=BrickWishList().all(),
retired=BrickRetiredList(), 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'), error=request.args.get('error'),
owners=BrickWishOwnerList.list(), owners=BrickWishOwnerList.list(),
) )
-7
View File
@@ -18,14 +18,7 @@ services:
# The app automatically detects and uses data/.env (no env_file needed) # The app automatically detects and uses data/.env (no env_file needed)
# Uncomment below only if you keep .env in root for backward compatibility # Uncomment below only if you keep .env in root for backward compatibility
# env_file: .env # env_file: .env
networks:
- default
- brickshare
develop: develop:
watch: watch:
- action: rebuild - action: rebuild
path: requirements.txt path: requirements.txt
networks:
brickshare:
external: true
-4
View File
@@ -11,10 +11,6 @@ document.addEventListener("DOMContentLoaded", () => {
const addContainer = document.getElementById('add-set'); const addContainer = document.getElementById('add-set');
if (!addContainer) return; if (!addContainer) return;
// On the bulk page the socket is initialized by set/socket.html include,
// so only wire it up here for the single add page to avoid a double init.
if (addContainer.dataset.bulk === 'true') return;
new BrickSetSocket( new BrickSetSocket(
'add', 'add',
addContainer.dataset.path, addContainer.dataset.path,
+4 -12
View File
@@ -63,12 +63,7 @@ function handleConfigChange(element) {
// Update badge display // Update badge display
function updateConfigBadge(varName, value) { function updateConfigBadge(varName, value) {
const defaultValue = window.DEFAULT_CONFIG[varName]; const defaultValue = window.DEFAULT_CONFIG[varName];
const savedValue = window.CURRENT_CONFIG[varName];
const isChanged = JSON.stringify(value) !== JSON.stringify(defaultValue); 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 // Remove existing badges but keep them inline
const existingBadges = document.querySelectorAll(`[data-badge-var="${varName}"]`); const existingBadges = document.querySelectorAll(`[data-badge-var="${varName}"]`);
@@ -119,14 +114,11 @@ function updateConfigBadge(varName, value) {
label.appendChild(valueBadge); label.appendChild(valueBadge);
} }
// Add changed/unsaved badge if needed. Unsaved takes priority: it means this // Add changed badge if needed
// toggle is not actually in effect yet, no matter what it differs from. if (isChanged) {
if (isUnsaved || isChanged) {
const changedBadge = document.createElement('span'); const changedBadge = document.createElement('span');
changedBadge.className = isUnsaved changedBadge.className = 'badge rounded-pill text-bg-warning ms-1';
? 'badge rounded-pill text-bg-info ms-1' changedBadge.textContent = 'Changed';
: '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-var', varName);
changedBadge.setAttribute('data-badge-type', 'changed'); changedBadge.setAttribute('data-badge-type', 'changed');
-92
View File
@@ -1,92 +0,0 @@
// 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>';
}
});
});
})();
+7 -15
View File
@@ -201,20 +201,12 @@ class BrickChanger {
} }
} }
// Helper to setup the changer. Safe to call again after inserting new // Helper to setup the changer
// 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( const setup_changers = () => document.querySelectorAll("*[data-changer-id]").forEach(
el => { el => new BrickChanger(
if (el.dataset.changerWired === "true") { el.dataset.changerPrefix,
return; el.dataset.changerId,
} el.dataset.changerUrl,
el.dataset.changerWired = "true"; el.dataset.changerParent
new BrickChanger( )
el.dataset.changerPrefix,
el.dataset.changerId,
el.dataset.changerUrl,
el.dataset.changerParent
);
}
); );
+37 -24
View File
@@ -378,36 +378,49 @@ window.updateUrlParams = function(params, resetPage = true) {
// Shared filter application (supports owner, color, theme, year, storage, tag, and problems filters) // Shared filter application (supports owner, color, theme, year, storage, tag, and problems filters)
window.applyPageFilters = function(tableId) { window.applyPageFilters = function(tableId) {
const ownerSelect = document.getElementById('filter-owner');
const colorSelect = document.getElementById('filter-color');
const themeSelect = document.getElementById('filter-theme');
const yearSelect = document.getElementById('filter-year');
const storageSelect = document.getElementById('filter-storage');
const tagSelect = document.getElementById('filter-tag');
const problemsSelect = document.getElementById('filter-problems');
const params = {}; const params = {};
// Read a select's value, including the leading "-" when its != toggle is on // Handle owner filter
const readFilter = (selectId, paramName) => { if (ownerSelect) {
const select = document.getElementById(selectId); params.owner = ownerSelect.value;
if (!select) { }
return;
}
// "all" means the filter is off, so never let the != toggle turn it into "-all" // Handle color filter
if (select.value === 'all' || select.value === '') { if (colorSelect) {
params[paramName] = select.value; params.color = colorSelect.value;
return; }
}
params[paramName] = typeof BrickFilterToggle !== 'undefined' // Handle theme filter
? BrickFilterToggle.getFilterValue(select) if (themeSelect) {
: select.value; params.theme = themeSelect.value;
}; }
readFilter('filter-owner', 'owner'); // Handle year filter
readFilter('filter-color', 'color'); if (yearSelect) {
readFilter('filter-theme', 'theme'); params.year = yearSelect.value;
readFilter('filter-year', 'year'); }
readFilter('filter-storage', 'storage');
readFilter('filter-tag', 'tag');
readFilter('filter-status', 'status');
// Minifigures page only // Handle storage filter
readFilter('filter-problems', 'problems'); if (storageSelect) {
params.storage = storageSelect.value;
}
// Handle tag filter
if (tagSelect) {
params.tag = tagSelect.value;
}
// Handle problems filter (for minifigures page)
if (problemsSelect) {
params.problems = problemsSelect.value;
}
// Check if we're in pagination mode // Check if we're in pagination mode
const isPaginationMode = window.isPaginationModeForPage(tableId); const isPaginationMode = window.isPaginationModeForPage(tableId);
-60
View File
@@ -46,18 +46,6 @@ 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) { if (this.html_theme) {
this.html_theme.addEventListener("change", ((grid) => () => { this.html_theme.addEventListener("change", ((grid) => () => {
grid.filter(); grid.filter();
@@ -77,28 +65,6 @@ class BrickGridFilter {
options.search = this.html_search.value.toLowerCase(); 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 // Build filters
for (const select of this.selects) { for (const select of this.selects) {
// Get the actual filter value (includes "-" prefix if toggle is in NOT mode) // Get the actual filter value (includes "-" prefix if toggle is in NOT mode)
@@ -174,17 +140,6 @@ class BrickGridFilter {
const isNot = filter.value.startsWith('-'); const isNot = filter.value.startsWith('-');
const actualValue = isNot ? filter.value.substring(1) : filter.value; 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 == null) {
// If attribute is missing // If attribute is missing
if (isNot) { if (isNot) {
@@ -236,21 +191,6 @@ 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 // Check all searchable fields for a match
if (options.search) { if (options.search) {
// Browse the whole dataset // Browse the whole dataset
+1 -2
View File
@@ -60,8 +60,7 @@ class BrickFilterToggle {
'grid-tag': 'tag', 'grid-tag': 'tag',
'grid-year': 'year' 'grid-year': 'year'
}; };
// The sets grid uses grid-*, the parts and problem tables use filter-* return mapping[selectId] || selectId.replace('grid-', '');
return mapping[selectId] || selectId.replace(/^(grid|filter)-/, '');
} }
handleToggleClick(toggle, targetSelect) { handleToggleClick(toggle, targetSelect) {
-431
View File
@@ -1,431 +0,0 @@
// 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);
+4 -11
View File
@@ -177,19 +177,12 @@ class PartsBulkOperations {
} }
} }
// Initialize bulk operations for all part accordions. // Initialize bulk operations for all part accordions when DOM is ready
// Idempotent: call again after inserting new tables (lazy-loaded bags). document.addEventListener('DOMContentLoaded', () => {
const initPartsBulkOperations = () => { // Find all hamburger menus and initialize bulk operations
const hamburgerMenus = document.querySelectorAll('button[id^="hamburger-"]'); const hamburgerMenus = document.querySelectorAll('button[id^="hamburger-"]');
hamburgerMenus.forEach(button => { hamburgerMenus.forEach(button => {
if (button.dataset.bulkWired === 'true') {
return;
}
button.dataset.bulkWired = 'true';
const accordionId = button.id.replace('hamburger-', ''); const accordionId = button.id.replace('hamburger-', '');
new PartsBulkOperations(accordionId); new PartsBulkOperations(accordionId);
}); });
}; });
window.initPartsBulkOperations = initPartsBulkOperations;
document.addEventListener('DOMContentLoaded', initPartsBulkOperations);
+9 -21
View File
@@ -150,18 +150,14 @@ class PartsTableFilter {
if (!this.body) { if (!this.body) {
return; return;
} }
// Render the empty-state message as a sibling of the table rather than a if (!this.emptyRow) {
// tbody row. The sortable library iterates every <tr> in the tbody and const columns = this.table.querySelectorAll('thead tr:first-child th').length || 1;
// reads row.cells[columnIndex], so a single colspan cell would make it this.emptyRow = document.createElement('tr');
// crash with "can't access property dataset" when sorting any column this.emptyRow.className = 'parts-filter-empty no-sort';
// past the first. this.emptyRow.innerHTML = `<td colspan="${columns}" class="text-center text-body-secondary py-3">No matching parts</td>`;
if (!this.emptyMessage) { this.body.appendChild(this.emptyRow);
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'; this.emptyRow.style.display = visibleCount === 0 ? '' : 'none';
} }
clear() { clear() {
@@ -183,16 +179,8 @@ class PartsTableFilter {
} }
} }
// Idempotent: call again after inserting new tables (lazy-loaded bags). document.addEventListener('DOMContentLoaded', () => {
const initPartsTableFilters = () => {
document.querySelectorAll('table[data-parts-filterable="true"]').forEach(table => { document.querySelectorAll('table[data-parts-filterable="true"]').forEach(table => {
if (table.dataset.filterWired === 'true') {
return;
}
table.dataset.filterWired = 'true';
new PartsTableFilter(table); new PartsTableFilter(table);
}); });
}; });
window.initPartsTableFilters = initPartsTableFilters;
document.addEventListener('DOMContentLoaded', initPartsTableFilters);
+1 -1
View File
@@ -84,7 +84,7 @@ document.addEventListener("DOMContentLoaded", () => {
const clearButton = document.getElementById('table-filter-clear'); const clearButton = document.getElementById('table-filter-clear');
if (clearButton) { if (clearButton) {
clearButton.addEventListener('click', () => { clearButton.addEventListener('click', () => {
window.clearPageFilters('parts', ['owner', 'color', 'theme', 'year', 'storage', 'tag', 'status', 'individuals']); window.clearPageFilters('parts', ['owner', 'color', 'theme', 'year', 'individuals']);
}); });
} }
}); });
+1 -1
View File
@@ -28,7 +28,7 @@ document.addEventListener("DOMContentLoaded", () => {
const clearButton = document.getElementById('table-filter-clear'); const clearButton = document.getElementById('table-filter-clear');
if (clearButton) { if (clearButton) {
clearButton.addEventListener('click', () => { clearButton.addEventListener('click', () => {
window.clearPageFilters('problems', ['owner', 'color', 'theme', 'year', 'storage', 'tag', 'status']); window.clearPageFilters('problems', ['owner', 'color', 'theme', 'year', 'storage', 'tag']);
}); });
} }
}); });
-16
View File
@@ -1,21 +1,5 @@
// Set details page functionality // Set details page functionality
document.addEventListener('DOMContentLoaded', function() { 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 collapseElement = document.getElementById('all-instances');
const toggleIcon = document.getElementById('copies-toggle-icon'); const toggleIcon = document.getElementById('copies-toggle-icon');
-311
View File
@@ -1,311 +0,0 @@
// 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);
})();
+1 -32
View File
@@ -348,16 +348,6 @@ 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() { function performServerFilter() {
const currentUrl = new URL(window.location); const currentUrl = new URL(window.location);
@@ -429,22 +419,6 @@ function setupPaginationFilterDropdowns() {
currentUrl.searchParams.delete('tag'); 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 // Reset to page 1 when filtering
currentUrl.searchParams.set('page', '1'); currentUrl.searchParams.set('page', '1');
window.location.href = currentUrl.toString(); window.location.href = currentUrl.toString();
@@ -770,7 +744,7 @@ function initializeClearFiltersButton() {
const currentUrl = new URL(window.location); const currentUrl = new URL(window.location);
// Remove all filter parameters // Remove all filter parameters
const filterParams = ['status', 'theme', 'year', 'owner', 'purchase_location', 'storage', 'tag', 'duplicate', 'parts_min', 'parts_max', 'year_min', 'year_max']; const filterParams = ['status', 'theme', 'year', 'owner', 'purchase_location', 'storage', 'tag', 'duplicate'];
filterParams.forEach(param => { filterParams.forEach(param => {
currentUrl.searchParams.delete(param); currentUrl.searchParams.delete(param);
}); });
@@ -801,11 +775,6 @@ 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 // Clear duplicate filter if active
const duplicateButton = document.getElementById('duplicate-filter-toggle'); const duplicateButton = document.getElementById('duplicate-filter-toggle');
if (duplicateButton && duplicateButton.classList.contains('btn-secondary')) { if (duplicateButton && duplicateButton.classList.contains('btn-secondary')) {
-8
View File
@@ -23,7 +23,6 @@ class BrickSetSocket extends BrickSocket {
this.html_storage = document.getElementById(`${id}-storage`); this.html_storage = document.getElementById(`${id}-storage`);
this.html_tags = document.getElementById(`${id}-tags`); this.html_tags = document.getElementById(`${id}-tags`);
this.html_description = document.getElementById(`${id}-description`); this.html_description = document.getElementById(`${id}-description`);
this.html_box_art = document.getElementById(`${id}-box-art`);
// Card elements // Card elements
this.html_card = document.getElementById(`${id}-card`); this.html_card = document.getElementById(`${id}-card`);
@@ -214,12 +213,6 @@ class BrickSetSocket extends BrickSocket {
description = this.html_description.value; 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, { this.socket.emit(this.messages.IMPORT_SET, {
set: (set !== undefined) ? set : this.html_input.value, set: (set !== undefined) ? set : this.html_input.value,
owners: owners, owners: owners,
@@ -230,7 +223,6 @@ class BrickSetSocket extends BrickSocket {
storage: storage, storage: storage,
tags: tags, tags: tags,
description: description, description: description,
box_art: box_art,
refresh: this.refresh refresh: this.refresh
}); });
} else { } else {
-35
View File
@@ -1,35 +0,0 @@
// 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, {});
}
}
-103
View File
@@ -12,13 +12,6 @@
backdrop-filter: blur(8px) contrast(60%); 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 { .card-img img.card-last-img {
max-height: 100px; max-height: 100px;
} }
@@ -53,18 +46,6 @@
object-fit:contain; 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 { .table-td-input {
max-width: 150px; max-width: 150px;
} }
@@ -365,87 +346,3 @@
tr.parts-filtered-out { tr.parts-filtered-out {
display: none; 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,7 +44,6 @@
<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 %}" <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-path="{{ path }}"
data-namespace="{{ namespace }}" data-namespace="{{ namespace }}"
data-bulk="{% if bulk %}true{% else %}false{% endif %}"
data-msg-complete="{{ messages['COMPLETE'] }}" data-msg-complete="{{ messages['COMPLETE'] }}"
data-msg-fail="{{ messages['FAIL'] }}" data-msg-fail="{{ messages['FAIL'] }}"
data-msg-import-set="{{ messages['IMPORT_SET'] }}" data-msg-import-set="{{ messages['IMPORT_SET'] }}"
@@ -62,66 +61,6 @@
Add without confirmation Add without confirmation
</label> </label>
</div> </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> <h6 class="border-bottom mt-2">Metadata</h6>
<div class="accordion accordion" id="metadata"> <div class="accordion accordion" id="metadata">
{% if brickset_owners | length %} {% if brickset_owners | length %}
-4
View File
@@ -20,8 +20,6 @@
{% include 'admin/owner/delete.html' %} {% include 'admin/owner/delete.html' %}
{% elif delete_purchase_location %} {% elif delete_purchase_location %}
{% include 'admin/purchase_location/delete.html' %} {% include 'admin/purchase_location/delete.html' %}
{% elif delete_custom_field %}
{% include 'admin/custom_field/delete.html' %}
{% elif delete_status %} {% elif delete_status %}
{% include 'admin/status/delete.html' %} {% include 'admin/status/delete.html' %}
{% elif delete_storage %} {% elif delete_storage %}
@@ -47,14 +45,12 @@
{% endif %} {% endif %}
{% include 'admin/theme.html' %} {% include 'admin/theme.html' %}
{% include 'admin/retired.html' %} {% include 'admin/retired.html' %}
{% include 'admin/value.html' %}
{{ accordion.header('Set metadata', 'metadata', 'admin', expanded=open_metadata, icon='profile-line', class='p-0') }} {{ accordion.header('Set metadata', 'metadata', 'admin', expanded=open_metadata, icon='profile-line', class='p-0') }}
{% include 'admin/owner.html' %} {% include 'admin/owner.html' %}
{% include 'admin/purchase_location.html' %} {% include 'admin/purchase_location.html' %}
{% include 'admin/status.html' %} {% include 'admin/status.html' %}
{% include 'admin/storage.html' %} {% include 'admin/storage.html' %}
{% include 'admin/tag.html' %} {% include 'admin/tag.html' %}
{% include 'admin/custom_field.html' %}
{{ accordion.footer() }} {{ accordion.footer() }}
{% include 'admin/refresh.html' %} {% include 'admin/refresh.html' %}
{% include 'admin/database.html' %} {% include 'admin/database.html' %}
-201
View File
@@ -543,124 +543,6 @@
</div> </div>
</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 --> <!-- Advanced Settings -->
<h6 class="fw-bold text-primary border-bottom pb-1 mb-3 mt-4">Advanced Settings</h6> <h6 class="fw-bold text-primary border-bottom pb-1 mb-3 mt-4">Advanced Settings</h6>
@@ -1129,69 +1011,6 @@
</div> </div>
</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>
<!-- Telemetry -->
<h6 class="fw-bold text-secondary border-bottom pb-1 mb-3 mt-4">Telemetry</h6>
<div class="row g-3">
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input config-static-toggle" type="checkbox" id="static-BK_TELEMETRY_ENABLED" data-var="BK_TELEMETRY_ENABLED" {{ is_locked('BK_TELEMETRY_ENABLED') }}>
<label class="form-check-label" for="static-BK_TELEMETRY_ENABLED">
BK_TELEMETRY_ENABLED {{ config_badges('BK_TELEMETRY_ENABLED') }}
<div class="text-muted small">Send anonymous usage telemetry to a self-hosted Matomo instance. Off by default. <a href="#" data-bs-toggle="modal" data-bs-target="#telemetryPrivacyModal">View Privacy Information</a>.</div>
</label>
</div>
</div>
<div class="col-md-4">
<div class="form-check form-switch">
<input class="form-check-input config-static-toggle" type="checkbox" id="static-BK_TELEMETRY_CATEGORY_INSTALLATION" data-var="BK_TELEMETRY_CATEGORY_INSTALLATION" {{ is_locked('BK_TELEMETRY_CATEGORY_INSTALLATION') }}>
<label class="form-check-label" for="static-BK_TELEMETRY_CATEGORY_INSTALLATION">
Installation Information {{ config_badges('BK_TELEMETRY_CATEGORY_INSTALLATION') }}
<div class="text-muted small">BrickTracker version + database schema version</div>
</label>
</div>
</div>
<div class="col-md-4">
<div class="form-check form-switch">
<input class="form-check-input config-static-toggle" type="checkbox" id="static-BK_TELEMETRY_CATEGORY_USAGE" data-var="BK_TELEMETRY_CATEGORY_USAGE" {{ is_locked('BK_TELEMETRY_CATEGORY_USAGE') }}>
<label class="form-check-label" for="static-BK_TELEMETRY_CATEGORY_USAGE">
Usage Statistics {{ config_badges('BK_TELEMETRY_CATEGORY_USAGE') }}
<div class="text-muted small">Which pages are visited (page names only)</div>
</label>
</div>
</div>
<div class="col-md-4">
<div class="form-check form-switch">
<input class="form-check-input config-static-toggle" type="checkbox" id="static-BK_TELEMETRY_CATEGORY_COLLECTION" data-var="BK_TELEMETRY_CATEGORY_COLLECTION" {{ is_locked('BK_TELEMETRY_CATEGORY_COLLECTION') }}>
<label class="form-check-label" for="static-BK_TELEMETRY_CATEGORY_COLLECTION">
Collection Statistics {{ config_badges('BK_TELEMETRY_CATEGORY_COLLECTION') }}
<div class="text-muted small">Aggregate counts only (totals, never names or IDs)</div>
</label>
</div>
</div>
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" disabled>
<label class="form-check-label text-muted">
Error Reporting <span class="badge bg-secondary">Coming soon</span>
<div class="text-muted small">Reserved for future use</div>
</label>
</div>
</div>
</div>
<!-- Save Button for Static Settings --> <!-- Save Button for Static Settings -->
<div class="d-flex gap-2 justify-content-end mt-4"> <div class="d-flex gap-2 justify-content-end mt-4">
<button id="config-save-static" class="btn btn-warning"> <button id="config-save-static" class="btn btn-warning">
@@ -1226,26 +1045,6 @@
</div> </div>
</div> </div>
<!-- Telemetry Privacy Information Modal -->
<div class="modal fade" id="telemetryPrivacyModal" tabindex="-1" aria-labelledby="telemetryPrivacyModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="telemetryPrivacyModalLabel">
<i class="ri-shield-check-line"></i> Telemetry Privacy Information
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{% include 'telemetry/_privacy_explainer.html' %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- Initialize Configuration Data --> <!-- Initialize Configuration Data -->
<script type="text/javascript"> <script type="text/javascript">
window.CURRENT_CONFIG = { window.CURRENT_CONFIG = {
-65
View File
@@ -1,65 +0,0 @@
{% 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() }}

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