Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b0b85f29f | ||
|
|
6a00cba647 | ||
|
|
d1ada6b3d0 | ||
|
|
8fef6f49e1 | ||
|
|
f46f13343d | ||
|
|
2a571edb57 |
+1
-1
@@ -18,7 +18,7 @@ docs/
|
|||||||
LICENSE
|
LICENSE
|
||||||
*.md
|
*.md
|
||||||
*.sample
|
*.sample
|
||||||
*.code-workspace
|
.code-workspace
|
||||||
|
|
||||||
# Temporary
|
# Temporary
|
||||||
*.csv
|
*.csv
|
||||||
|
|||||||
-23
@@ -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
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
|
||||||
|
|||||||
@@ -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
@@ -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'},
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
@@ -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),
|
||||||
|
|||||||
@@ -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'},
|
|
||||||
'zł': {'PLN'},
|
|
||||||
'₽': {'RUB'},
|
|
||||||
'₹': {'INR'},
|
|
||||||
'r$': {'BRL'},
|
|
||||||
'₩': {'KRW'},
|
|
||||||
'฿': {'THB'},
|
|
||||||
'₪': {'ILS'},
|
|
||||||
'chf': {'CHF'},
|
|
||||||
'fr': {'CHF'},
|
|
||||||
}
|
|
||||||
|
|
||||||
# Valid BrickLink image types served by the sidecar image proxy.
|
|
||||||
IMAGE_TYPES: Final[list[str]] = [
|
|
||||||
'box', 'box_large',
|
|
||||||
'set', 'set_large',
|
|
||||||
'instruction', 'instruction_large',
|
|
||||||
]
|
|
||||||
|
|
||||||
# How long (seconds) to trust a cached /health result so the UI never blocks
|
|
||||||
# on a dead sidecar.
|
|
||||||
HEALTH_CACHE_TTL: Final[int] = 30
|
|
||||||
|
|
||||||
|
|
||||||
# Best-effort client for the brickset-sidecar (BrickData) container.
|
|
||||||
#
|
|
||||||
# Design rule: a missing or broken sidecar must NEVER break an existing page.
|
|
||||||
# Every public method returns None / False on any failure and logs at debug.
|
|
||||||
# Nothing in here is allowed to raise into a request handler. Guard call sites
|
|
||||||
# with BrickSidecar.enabled().
|
|
||||||
class BrickSidecar(object):
|
|
||||||
# Cache for the health probe: (checked_at, healthy)
|
|
||||||
_health_cache: tuple[float, bool] | None = None
|
|
||||||
|
|
||||||
# --- Configuration helpers ------------------------------------------
|
|
||||||
|
|
||||||
# Normalized base URL (no trailing slash), or '' when unset.
|
|
||||||
@staticmethod
|
|
||||||
def base_url() -> str:
|
|
||||||
return str(current_app.config.get('SIDECAR_URL', '') or '').rstrip('/')
|
|
||||||
|
|
||||||
# True only when a sidecar URL is configured (the feature toggle).
|
|
||||||
@staticmethod
|
|
||||||
def enabled() -> bool:
|
|
||||||
return BrickSidecar.base_url() != ''
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def timeout() -> int:
|
|
||||||
return int(current_app.config.get('SIDECAR_TIMEOUT', 5))
|
|
||||||
|
|
||||||
# --- Low level ------------------------------------------------------
|
|
||||||
|
|
||||||
# Perform a GET and return the parsed JSON, or None on any failure.
|
|
||||||
@staticmethod
|
|
||||||
def _get_json(path: str, /, *, params: dict[str, Any] | None = None) -> Any:
|
|
||||||
if not BrickSidecar.enabled():
|
|
||||||
return None
|
|
||||||
|
|
||||||
url = '{base}{path}'.format(base=BrickSidecar.base_url(), path=path)
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = requests.get(
|
|
||||||
url,
|
|
||||||
params=params,
|
|
||||||
timeout=BrickSidecar.timeout(),
|
|
||||||
)
|
|
||||||
|
|
||||||
if not response.ok:
|
|
||||||
logger.debug(
|
|
||||||
'sidecar GET %s -> HTTP %s', url, response.status_code,
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
return response.json()
|
|
||||||
except (requests.RequestException, ValueError) as exception:
|
|
||||||
# ValueError covers JSON decode errors. Never propagate.
|
|
||||||
logger.debug('sidecar GET %s failed: %s', url, exception)
|
|
||||||
return None
|
|
||||||
|
|
||||||
# --- Health ---------------------------------------------------------
|
|
||||||
|
|
||||||
# Cached short-TTL health probe. Safe to call on every render.
|
|
||||||
@staticmethod
|
|
||||||
def healthy() -> bool:
|
|
||||||
if not BrickSidecar.enabled():
|
|
||||||
return False
|
|
||||||
|
|
||||||
now = time.monotonic()
|
|
||||||
cache = BrickSidecar._health_cache
|
|
||||||
if cache is not None and (now - cache[0]) < HEALTH_CACHE_TTL:
|
|
||||||
return cache[1]
|
|
||||||
|
|
||||||
payload = BrickSidecar._get_json('/health')
|
|
||||||
healthy = bool(payload) and payload.get('status') == 'ok'
|
|
||||||
|
|
||||||
BrickSidecar._health_cache = (now, healthy)
|
|
||||||
return healthy
|
|
||||||
|
|
||||||
# --- Sets -----------------------------------------------------------
|
|
||||||
|
|
||||||
# GET /sets/{ref}. Returns the single set dict (the sidecar wraps it in a
|
|
||||||
# {"sets": [...]} list) with the legoCom* JSON-string fields parsed into
|
|
||||||
# dicts, or None if missing. When price=True, the BrickLink price block is
|
|
||||||
# merged in under 'bricklink_price'.
|
|
||||||
#
|
|
||||||
# Metadata is read from the persistent cache first (effectively permanent);
|
|
||||||
# pass refresh=True to force a network fetch and rewrite the cache.
|
|
||||||
@staticmethod
|
|
||||||
def get_set(
|
|
||||||
ref: str,
|
|
||||||
/,
|
|
||||||
*,
|
|
||||||
price: bool = False,
|
|
||||||
refresh: bool = False,
|
|
||||||
use_cache: bool = True,
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
# The sidecar (brickdata) is the single source of truth and cache: it
|
|
||||||
# serves from its own DB and only hits Brickset on its own staleness
|
|
||||||
# rules. We always read live from it — no second cache here. (use_cache
|
|
||||||
# is kept for signature compatibility but no longer caches locally.)
|
|
||||||
params: dict[str, Any] = {}
|
|
||||||
if refresh:
|
|
||||||
params['refresh'] = 'true'
|
|
||||||
|
|
||||||
payload = BrickSidecar._get_json(
|
|
||||||
'/sets/{ref}'.format(ref=ref),
|
|
||||||
params=params or None,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not payload:
|
|
||||||
return None
|
|
||||||
|
|
||||||
sets = payload.get('sets') or []
|
|
||||||
if not sets:
|
|
||||||
return None
|
|
||||||
|
|
||||||
data = sets[0]
|
|
||||||
|
|
||||||
# The sidecar serializes legoCom* as JSON strings; parse them so call
|
|
||||||
# sites get a dict (or None) instead of a raw string.
|
|
||||||
for region in RETAIL_REGIONS:
|
|
||||||
key = 'legoCom{region}'.format(region=region)
|
|
||||||
data[key] = BrickSidecar._parse_json_field(data.get(key))
|
|
||||||
|
|
||||||
if price:
|
|
||||||
data = dict(data)
|
|
||||||
data['bricklink_price'] = BrickSidecar.get_price(ref, refresh=refresh)
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
# BrickLink market value for a set, or None. The sidecar owns price caching
|
|
||||||
# (and its own TTL): the normal path returns its cached price (fetching once
|
|
||||||
# if its cache expired), while refresh=True forces a live re-fetch via the
|
|
||||||
# dedicated /price endpoint. No local caching here.
|
|
||||||
@staticmethod
|
|
||||||
def get_price(
|
|
||||||
ref: str,
|
|
||||||
/,
|
|
||||||
*,
|
|
||||||
refresh: bool = False,
|
|
||||||
use_cache: bool = True,
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
currency = str(current_app.config.get('SIDECAR_CURRENCY', '') or '').strip()
|
|
||||||
|
|
||||||
# Forced refresh (explicit user action): the dedicated endpoint
|
|
||||||
# re-fetches from BrickLink and rewrites the sidecar's price cache.
|
|
||||||
if refresh:
|
|
||||||
params: dict[str, Any] = {'refresh': 'true'}
|
|
||||||
if currency:
|
|
||||||
params['currency'] = currency
|
|
||||||
return BrickSidecar._get_json(
|
|
||||||
'/sets/{ref}/price'.format(ref=ref),
|
|
||||||
params=params,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Normal path: the sidecar returns its cached price, or fetches once if
|
|
||||||
# its own TTL expired. The price rides along on the set payload under
|
|
||||||
# bricklink_price. No local caching — the sidecar is the cache.
|
|
||||||
params = {'price': 'true'}
|
|
||||||
if currency:
|
|
||||||
params['currency'] = currency
|
|
||||||
|
|
||||||
payload = BrickSidecar._get_json(
|
|
||||||
'/sets/{ref}'.format(ref=ref),
|
|
||||||
params=params,
|
|
||||||
)
|
|
||||||
|
|
||||||
if payload:
|
|
||||||
sets = payload.get('sets') or []
|
|
||||||
if sets:
|
|
||||||
return sets[0].get('bricklink_price') or None
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
# GET /sets/bulk -> cached metadata (+ cached price) for many sets at once,
|
|
||||||
# keyed by ref. The sidecar serves this from its own DB with no live
|
|
||||||
# Brickset/BrickLink calls, so it is cheap enough for collection-wide
|
|
||||||
# aggregation (stats) without a local cache. Requests are chunked to keep
|
|
||||||
# the query string within sane limits.
|
|
||||||
@staticmethod
|
|
||||||
def get_sets_bulk(
|
|
||||||
refs: list[str],
|
|
||||||
/,
|
|
||||||
*,
|
|
||||||
price: bool = True,
|
|
||||||
chunk_size: int = 200,
|
|
||||||
) -> dict[str, dict[str, Any]]:
|
|
||||||
wanted = [r for r in refs if r]
|
|
||||||
if not wanted:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
currency = str(current_app.config.get('SIDECAR_CURRENCY', '') or '').strip()
|
|
||||||
out: dict[str, dict[str, Any]] = {}
|
|
||||||
|
|
||||||
for start in range(0, len(wanted), chunk_size):
|
|
||||||
chunk = wanted[start:start + chunk_size]
|
|
||||||
params: dict[str, Any] = {'refs': ','.join(chunk)}
|
|
||||||
if price:
|
|
||||||
params['price'] = 'true'
|
|
||||||
if currency:
|
|
||||||
params['currency'] = currency
|
|
||||||
|
|
||||||
payload = BrickSidecar._get_json('/sets/bulk', params=params)
|
|
||||||
if not payload:
|
|
||||||
continue
|
|
||||||
|
|
||||||
for s in payload.get('sets') or []:
|
|
||||||
ref = '{n}-{v}'.format(
|
|
||||||
n=s.get('number'), v=s.get('numberVariant'),
|
|
||||||
)
|
|
||||||
for region in RETAIL_REGIONS:
|
|
||||||
key = 'legoCom{region}'.format(region=region)
|
|
||||||
s[key] = BrickSidecar._parse_json_field(s.get(key))
|
|
||||||
out[ref] = s
|
|
||||||
|
|
||||||
return out
|
|
||||||
|
|
||||||
# Convenience: MSRP for the configured retail region (SIDECAR_RETAIL_REGION),
|
|
||||||
# read from an already-fetched get_set() payload. None when unavailable.
|
|
||||||
@staticmethod
|
|
||||||
def retail_price(set_data: dict[str, Any], /) -> float | None:
|
|
||||||
region = BrickSidecar.retail_region()
|
|
||||||
block = set_data.get('legoCom{region}'.format(region=region))
|
|
||||||
|
|
||||||
if not isinstance(block, dict):
|
|
||||||
return None
|
|
||||||
|
|
||||||
value = block.get('retailPrice')
|
|
||||||
try:
|
|
||||||
return float(value) if value is not None else None
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Configured retail region (US/UK/CA/DE), defaulting to US.
|
|
||||||
@staticmethod
|
|
||||||
def retail_region() -> str:
|
|
||||||
region = str(current_app.config.get('SIDECAR_RETAIL_REGION', 'US')).upper()
|
|
||||||
return region if region in REGION_CURRENCY else 'US'
|
|
||||||
|
|
||||||
# Currency code for the configured retail region.
|
|
||||||
@staticmethod
|
|
||||||
def retail_currency() -> str:
|
|
||||||
return REGION_CURRENCY[BrickSidecar.retail_region()]
|
|
||||||
|
|
||||||
# The ISO code(s) a currency value can represent. An ISO code maps to itself;
|
|
||||||
# a known symbol maps to its candidate set (see CURRENCY_SYMBOLS); anything
|
|
||||||
# else falls back to its own uppercased form.
|
|
||||||
@staticmethod
|
|
||||||
def currency_codes(value: Any, /) -> set[str]:
|
|
||||||
text = str(value or '').strip()
|
|
||||||
if not text:
|
|
||||||
return set()
|
|
||||||
|
|
||||||
key = text.rstrip('.').lower()
|
|
||||||
if key in CURRENCY_SYMBOLS:
|
|
||||||
return set(CURRENCY_SYMBOLS[key])
|
|
||||||
|
|
||||||
return {text.upper()}
|
|
||||||
|
|
||||||
# Whether two currency values (symbol or ISO code) denote the same currency.
|
|
||||||
# Empty values are treated as "unknown" -> compatible, so we never warn just
|
|
||||||
# because one side is unset.
|
|
||||||
@staticmethod
|
|
||||||
def same_currency(a: Any, b: Any, /) -> bool:
|
|
||||||
codes_a = BrickSidecar.currency_codes(a)
|
|
||||||
codes_b = BrickSidecar.currency_codes(b)
|
|
||||||
|
|
||||||
if not codes_a or not codes_b:
|
|
||||||
return True
|
|
||||||
|
|
||||||
return bool(codes_a & codes_b)
|
|
||||||
|
|
||||||
# Read a price WITHOUT triggering a live BrickLink fetch: ask the sidecar
|
|
||||||
# for a cache-only price (cached_only=true), so a render never blocks.
|
|
||||||
# Returns (price_dict, fetched_at) or (None, None).
|
|
||||||
@staticmethod
|
|
||||||
def cached_price(ref: str, /) -> tuple[dict[str, Any] | None, Any]:
|
|
||||||
params: dict[str, Any] = {'price': 'true', 'cached_only': 'true'}
|
|
||||||
currency = str(current_app.config.get('SIDECAR_CURRENCY', '') or '').strip()
|
|
||||||
if currency:
|
|
||||||
params['currency'] = currency
|
|
||||||
|
|
||||||
payload = BrickSidecar._get_json(
|
|
||||||
'/sets/{ref}'.format(ref=ref),
|
|
||||||
params=params,
|
|
||||||
)
|
|
||||||
|
|
||||||
price: dict[str, Any] | None = None
|
|
||||||
if payload:
|
|
||||||
sets = payload.get('sets') or []
|
|
||||||
if sets:
|
|
||||||
price = sets[0].get('bricklink_price') or None
|
|
||||||
|
|
||||||
if price is None:
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
return price, price.get('fetched_at')
|
|
||||||
|
|
||||||
# --- Images ---------------------------------------------------------
|
|
||||||
|
|
||||||
# GET /sets/{ref}/images -> {'brickset': {...}, 'bricklink': {...}}, or None.
|
|
||||||
@staticmethod
|
|
||||||
def get_images(ref: str, /) -> dict[str, Any] | None:
|
|
||||||
return BrickSidecar._get_json('/sets/{ref}/images'.format(ref=ref))
|
|
||||||
|
|
||||||
# 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
|
|
||||||
@@ -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 (®, ', ...) into real characters before they reach the data
|
|
||||||
# handler, so they render correctly.
|
|
||||||
class _DescriptionSanitizer(HTMLParser):
|
|
||||||
def __init__(self) -> None:
|
|
||||||
super().__init__(convert_charrefs=True)
|
|
||||||
self.parts: list[str] = []
|
|
||||||
|
|
||||||
def handle_starttag(self, tag: str, attrs: Any) -> None:
|
|
||||||
if tag in _ALLOWED_TAGS:
|
|
||||||
self.parts.append(f'<{tag}>')
|
|
||||||
|
|
||||||
def handle_startendtag(self, tag: str, attrs: Any) -> None:
|
|
||||||
if tag in _ALLOWED_TAGS:
|
|
||||||
self.parts.append(f'<{tag}>')
|
|
||||||
|
|
||||||
def handle_endtag(self, tag: str) -> None:
|
|
||||||
if tag in _ALLOWED_TAGS and tag not in _VOID_TAGS:
|
|
||||||
self.parts.append(f'</{tag}>')
|
|
||||||
|
|
||||||
def handle_data(self, data: str) -> None:
|
|
||||||
self.parts.append(str(escape(data)))
|
|
||||||
|
|
||||||
|
|
||||||
def _description(value: Any) -> Markup | None:
|
|
||||||
text = _clean_str(value)
|
|
||||||
if text is None:
|
|
||||||
return None
|
|
||||||
parser = _DescriptionSanitizer()
|
|
||||||
parser.feed(text)
|
|
||||||
parser.close()
|
|
||||||
cleaned = ''.join(parser.parts)
|
|
||||||
# Brickset pads descriptions with empty spacer paragraphs (<p> </p>);
|
|
||||||
# drop those so the blocks sit flush instead of leaving big gaps. \s also
|
|
||||||
# matches the (\xa0) the parser leaves behind.
|
|
||||||
cleaned = re.sub(r'<p>\s*</p>', '', cleaned).strip()
|
|
||||||
return Markup(cleaned) if cleaned else None
|
|
||||||
|
|
||||||
|
|
||||||
def _to_float(value: Any) -> float | None:
|
|
||||||
if value is None or value == '':
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return float(value)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _to_int(value: Any) -> int | None:
|
|
||||||
if value is None or value == '':
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return int(value)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _dimensions(data: dict[str, Any], /) -> str | None:
|
|
||||||
height = _to_float(data.get('height'))
|
|
||||||
width = _to_float(data.get('width'))
|
|
||||||
depth = _to_float(data.get('depth'))
|
|
||||||
|
|
||||||
if height is None and width is None and depth is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
parts = [
|
|
||||||
'{0:g}'.format(round(value, 1)) if value is not None else '?'
|
|
||||||
for value in (height, width, depth)
|
|
||||||
]
|
|
||||||
return '{0} cm'.format(' × '.join(parts))
|
|
||||||
|
|
||||||
|
|
||||||
def _weight(data: dict[str, Any], /) -> str | None:
|
|
||||||
weight = _to_float(data.get('weight'))
|
|
||||||
if weight is None:
|
|
||||||
return None
|
|
||||||
return '{0:g} kg'.format(round(weight, 2))
|
|
||||||
|
|
||||||
|
|
||||||
def _tags(value: Any) -> list[str]:
|
|
||||||
if not value:
|
|
||||||
return []
|
|
||||||
return [tag.strip() for tag in str(value).split(',') if tag.strip()]
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_date(value: Any) -> datetime | None:
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
text = str(value).replace('Z', '+00:00')
|
|
||||||
try:
|
|
||||||
parsed = datetime.fromisoformat(text)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
if parsed.tzinfo is None:
|
|
||||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
||||||
return parsed
|
|
||||||
|
|
||||||
|
|
||||||
def _year(value: Any) -> int | None:
|
|
||||||
parsed = _parse_date(value)
|
|
||||||
return parsed.year if parsed is not None else None
|
|
||||||
|
|
||||||
|
|
||||||
def _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
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }}';
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
@@ -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')
|
||||||
|
|||||||
@@ -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']),
|
|
||||||
))
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.',
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -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))
|
|
||||||
@@ -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'))
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
+166
-138
@@ -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
@@ -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())
|
|
||||||
|
|||||||
@@ -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',
|
|
||||||
)
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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>';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
@@ -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") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
el.dataset.changerWired = "true";
|
|
||||||
new BrickChanger(
|
|
||||||
el.dataset.changerPrefix,
|
el.dataset.changerPrefix,
|
||||||
el.dataset.changerId,
|
el.dataset.changerId,
|
||||||
el.dataset.changerUrl,
|
el.dataset.changerUrl,
|
||||||
el.dataset.changerParent
|
el.dataset.changerParent
|
||||||
);
|
)
|
||||||
}
|
|
||||||
);
|
);
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 & 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);
|
|
||||||
@@ -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);
|
|
||||||
@@ -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);
|
|
||||||
|
|||||||
@@ -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']);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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']);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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')) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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, {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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' %}
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user