fix(parts): make every filter on the parts and problem pages reach the SQL

This commit is contained in:
2026-07-30 19:10:01 +02:00
parent 5693e37ece
commit 4927256696
17 changed files with 590 additions and 800 deletions
+136 -187
View File
@@ -14,6 +14,46 @@ if TYPE_CHECKING:
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
class BrickPartList(BrickRecordList[BrickPart]):
@@ -21,130 +61,139 @@ class BrickPartList(BrickRecordList[BrickPart]):
minifigure: 'BrickMinifigure | None'
individual_minifigure: 'IndividualMinifigure | None'
order: str
filter_parameters: dict[str, Any]
# Queries
all_query: str = 'part/list/all'
all_by_owner_query: str = 'part/list/all_by_owner'
all_query: str = 'part/list/filtered'
filtered_query: str = 'part/list/filtered'
different_color_query = 'part/list/with_different_color'
last_query: str = 'part/list/last'
minifigure_query: str = 'part/list/from_minifigure'
problem_query: str = 'part/list/problem'
print_query: str = 'part/list/from_print'
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, /):
super().__init__()
# Placeholders
self.brickset = None
self.minifigure = None
self.filter_parameters = {}
# Store the order for this list
self.order = current_app.config['PARTS_DEFAULT_ORDER']
# Load all parts
def all(self, /) -> Self:
self.list(override_query=self.all_query)
# Build the Jinja context for the filtered query, and stash the values that get
# bound as SQL parameters. Both pages go through here, so a filter either works
# on both or on neither.
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] = {}
return self
if problem_only:
context['problem_only'] = True
# Load all parts by owner
def all_by_owner(self, owner_id: str | None = None, /) -> Self:
# Save the owner_id parameter
self.fields.owner_id = owner_id
for name in FILTER_NAMES:
value = filters.get(name)
if value and value != 'all':
context[name] = value
# Load the parts from the database
self.list(override_query=self.all_by_owner_query)
if individuals_filter == 'only':
context['individuals_filter'] = True
return self
if search_query:
context['search_query'] = search_query
# 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)
# Everything that is data rather than a column name gets bound
self.filter_parameters = {}
for name in BOUND_FILTERS:
value = context.get(name)
if value is None:
continue
if name == 'storage_id':
if value in STORAGE_SENTINELS:
continue
value = without_negation(value)
self.filter_parameters[name] = 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
# Load parts with pagination support
def all_filtered_paginated(
# Same thing, one page at a time
def paginated_filtered(
self,
owner_id: str | None = None,
color_id: str | None = None,
theme_id: str | None = None,
year: str | None = None,
/,
*,
problem_only: bool = False,
individuals_filter: str | None = None,
search_query: str | None = None,
page: int = 1,
per_page: int = 50,
sort_field: str | None = None,
sort_order: str = 'asc'
sort_order: str = 'asc',
**filters: str | None,
) -> tuple[Self, int]:
# Prepare filter context
filter_context = {}
if owner_id and owner_id != 'all':
filter_context['owner_id'] = owner_id
list_query = self.all_by_owner_query
else:
list_query = self.all_query
context = self.filter_context(
problem_only=problem_only,
individuals_filter=individuals_filter,
search_query=search_query,
**filters
)
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(
page=page,
per_page=per_page,
sort_field=sort_field,
sort_order=sort_order,
list_query=list_query,
field_mapping=field_mapping,
**filter_context
list_query=self.filtered_query,
field_mapping=self.field_mapping,
**context
)
# Base part list
@@ -176,25 +225,14 @@ class BrickPartList(BrickRecordList[BrickPart]):
else:
individual_minifigure = None
# Prepare template context for filtering
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
# Load the sets from the database. Filters arrive in context, built by
# filter_context, rather than being read back off self.fields.
for record in super().select(
override_query=override_query,
order=order,
limit=limit,
offset=offset,
**context_vars
**context
):
part = BrickPart(
brickset=brickset,
@@ -301,102 +339,13 @@ class BrickPartList(BrickRecordList[BrickPart]):
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
def sql_parameters(self, /) -> dict[str, Any]:
parameters: dict[str, Any] = super().sql_parameters()
# Filter values that are data rather than column names
parameters.update(self.filter_parameters)
# Set id
if self.brickset is not None:
parameters['id'] = self.brickset.fields.id
+6 -1
View File
@@ -119,7 +119,12 @@ class BrickRecordList(Generic[T]):
# Wrap in COUNT(*)
wrapped_sql = f"SELECT COUNT(*) as total_count FROM ({count_sql.strip()})"
count_result = BrickSQL().raw_execute(wrapped_sql, {}).fetchone()
# Same bound values as the list query, otherwise any named placeholder
# 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
# Prepare sort order
+57 -10
View File
@@ -1,9 +1,14 @@
{# 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
"color_id",
"color_name",
"color_rgb"
FROM (
-- Colors from set-based parts
-- Set parts
SELECT DISTINCT
"rebrickable_parts"."color_id" AS "color_id",
"rebrickable_parts"."color_name" AS "color_name",
@@ -12,17 +17,24 @@ FROM (
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"
{% if owner_id %}
INNER JOIN "bricktracker_set_owners"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
WHERE "bricktracker_set_owners"."owner_{{ owner_id }}" = 1
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
{% endif %}
{% 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 %}
UNION
-- Colors from individual parts
-- Individual parts
SELECT DISTINCT
"rebrickable_parts"."color_id" AS "color_id",
"rebrickable_parts"."color_name" AS "color_name",
@@ -31,10 +43,45 @@ FROM (
INNER JOIN "bricktracker_individual_parts"
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"
{% if owner_id and owner_id != 'all' %}
{% if owner_id %}
INNER JOIN "bricktracker_set_owners"
ON "bricktracker_individual_parts"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
WHERE "bricktracker_set_owners"."owner_{{ owner_id }}" = 1
{% endif %}
{% 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 %}
)
ORDER BY "color_name" ASC
ORDER BY "color_name" ASC
@@ -1,19 +0,0 @@
SELECT DISTINCT
"rebrickable_parts"."color_id" AS "color_id",
"rebrickable_parts"."color_name" AS "color_name",
"rebrickable_parts"."color_rgb" AS "color_rgb"
FROM "rebrickable_parts"
INNER JOIN "bricktracker_parts"
ON "bricktracker_parts"."part" IS NOT DISTINCT FROM "rebrickable_parts"."part"
AND "bricktracker_parts"."color" IS NOT DISTINCT FROM "rebrickable_parts"."color_id"
{% if owner_id and owner_id != 'all' %}
INNER JOIN "bricktracker_sets"
ON "bricktracker_parts"."id" IS NOT DISTINCT FROM "bricktracker_sets"."id"
INNER JOIN "bricktracker_set_owners"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
{% endif %}
WHERE ("bricktracker_parts"."missing" > 0 OR "bricktracker_parts"."damaged" > 0)
{% if owner_id and owner_id != 'all' %}
AND "bricktracker_set_owners"."owner_{{ owner_id }}" = 1
{% endif %}
ORDER BY "rebrickable_parts"."color_name" ASC
-69
View File
@@ -1,69 +0,0 @@
{% extends 'part/base/base.sql' %}
{% block total_missing %}
SUM("combined"."missing") AS "total_missing",
{% endblock %}
{% block total_damaged %}
SUM("combined"."damaged") AS "total_damaged",
{% endblock %}
{% block total_quantity %}
SUM("combined"."quantity" * IFNULL("minifigure_quantities"."quantity", 1)) AS "total_quantity",
{% endblock %}
{% block total_sets %}
IFNULL(COUNT(DISTINCT CASE WHEN "combined"."source_type" = 'set' THEN "combined"."id" ELSE NULL END), 0) AS "total_sets",
{% endblock %}
{% block total_minifigures %}
SUM(IFNULL("minifigure_quantities"."quantity", 0)) AS "total_minifigures"
{% endblock %}
{% block join %}
-- Join to get minifigure quantities from both set-based and individual minifigures
LEFT JOIN (
SELECT
"bricktracker_minifigures"."id",
"bricktracker_minifigures"."figure",
"bricktracker_minifigures"."quantity"
FROM "bricktracker_minifigures"
UNION ALL
SELECT
"bricktracker_individual_minifigures"."id",
"bricktracker_individual_minifigures"."figure",
"bricktracker_individual_minifigures"."quantity"
FROM "bricktracker_individual_minifigures"
) AS "minifigure_quantities"
ON "combined"."id" IS NOT DISTINCT FROM "minifigure_quantities"."id"
AND "combined"."figure" IS NOT DISTINCT FROM "minifigure_quantities"."figure"
{% endblock %}
{% block where %}
{% set conditions = [] %}
{% if color_id and color_id != 'all' %}
{% set _ = conditions.append('"combined"."color" = ' ~ color_id) %}
{% endif %}
{% if search_query %}
{% set search_condition = '(LOWER("rebrickable_parts"."name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("rebrickable_parts"."color_name") LIKE LOWER(\'%' ~ search_query ~ '%\') OR LOWER("combined"."part") LIKE LOWER(\'%' ~ search_query ~ '%\'))' %}
{% set _ = conditions.append(search_condition) %}
{% endif %}
{% if skip_spare_parts %}
{% set _ = conditions.append('"combined"."spare" = 0') %}
{% endif %}
{% if individuals_filter %}
{% set _ = conditions.append('"combined"."source_type" = \'individual_part\'') %}
{% endif %}
{% if conditions %}
WHERE {{ conditions | join(' AND ') }}
{% endif %}
{% endblock %}
{% block group %}
GROUP BY
"combined"."part",
"combined"."color",
"combined"."spare"
{% endblock %}
-146
View File
@@ -1,146 +0,0 @@
{% 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 %}
+193
View File
@@ -0,0 +1,193 @@
{#
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. #}
{% if theme_id %}
{% set _ = conditions.append('"rebrickable_sets"."theme_id" = :theme_id') %}
{% endif %}
{% if year %}
{% set _ = conditions.append('"rebrickable_sets"."year" = :year') %}
{% 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 %}
-119
View File
@@ -1,119 +0,0 @@
{% 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 %}
@@ -7,7 +7,7 @@
{# Compute per part+color set/minifigure counts so each sub-card shows its own
totals (fixes #159: they used to inherit the parent part's counts). Mirrors
part/list/all.sql. #}
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 %}
@@ -1,21 +0,0 @@
-- 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
@@ -1,7 +0,0 @@
-- 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
+14 -3
View File
@@ -1,4 +1,6 @@
-- Get distinct themes from parts' sets
{# Themes present among the parts. Only set sourced parts have a theme, so individual
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
"rebrickable_sets"."theme_id",
COUNT(DISTINCT "bricktracker_parts"."part") as "part_count"
@@ -7,10 +9,19 @@ 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' %}
{% if owner_id %}
INNER JOIN "bricktracker_set_owners"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
WHERE "bricktracker_set_owners"."owner_{{ owner_id }}" = 1
{% endif %}
{% 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 %}
GROUP BY "rebrickable_sets"."theme_id"
ORDER BY "rebrickable_sets"."theme_id" ASC
@@ -1,19 +0,0 @@
-- 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
+14 -3
View File
@@ -1,4 +1,6 @@
-- Get distinct years from parts' sets
{# Years present among the parts. Only set sourced parts have a year, so individual
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
"rebrickable_sets"."year",
COUNT(DISTINCT "bricktracker_parts"."part") as "part_count"
@@ -7,10 +9,19 @@ 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' %}
{% if owner_id %}
INNER JOIN "bricktracker_set_owners"
ON "bricktracker_sets"."id" IS NOT DISTINCT FROM "bricktracker_set_owners"."id"
WHERE "bricktracker_set_owners"."owner_{{ owner_id }}" = 1
{% endif %}
{% 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 %}
GROUP BY "rebrickable_sets"."year"
ORDER BY "rebrickable_sets"."year" DESC
@@ -1,19 +0,0 @@
-- 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
+30 -9
View File
@@ -63,7 +63,9 @@ def export_parts_rebrickable() -> Response:
year = request.args.get('year')
part_list = BrickPartList()
part_list.all_filtered(owner_id, color_id, theme_id, year)
part_list.filtered(
owner_id=owner_id, color_id=color_id, theme_id=theme_id, year=year
)
part_quantities = {}
for part in part_list.records:
@@ -101,7 +103,9 @@ def export_parts_lego() -> Response:
year = request.args.get('year')
part_list = BrickPartList()
part_list.all_filtered(owner_id, color_id, theme_id, year)
part_list.filtered(
owner_id=owner_id, color_id=color_id, theme_id=theme_id, year=year
)
element_quantities = {}
for part in part_list.records:
@@ -140,7 +144,9 @@ def export_parts_bricklink() -> Response:
year = request.args.get('year')
part_list = BrickPartList()
part_list.all_filtered(owner_id, color_id, theme_id, year)
part_list.filtered(
owner_id=owner_id, color_id=color_id, theme_id=theme_id, year=year
)
part_quantities = {}
for part in part_list.records:
@@ -183,7 +189,10 @@ def export_problems_rebrickable() -> Response:
year = request.args.get('year')
part_list = BrickPartList()
part_list.problem_filtered(owner_id, color_id, theme_id, year)
part_list.filtered(
problem_only=True,
owner_id=owner_id, color_id=color_id, theme_id=theme_id, year=year
)
part_quantities = {}
for part in part_list.records:
@@ -223,7 +232,10 @@ def export_problems_lego() -> Response:
year = request.args.get('year')
part_list = BrickPartList()
part_list.problem_filtered(owner_id, color_id, theme_id, year)
part_list.filtered(
problem_only=True,
owner_id=owner_id, color_id=color_id, theme_id=theme_id, year=year
)
element_quantities = {}
for part in part_list.records:
@@ -263,7 +275,10 @@ def export_problems_bricklink() -> Response:
year = request.args.get('year')
part_list = BrickPartList()
part_list.problem_filtered(owner_id, color_id, theme_id, year)
part_list.filtered(
problem_only=True,
owner_id=owner_id, color_id=color_id, theme_id=theme_id, year=year
)
part_quantities = {}
for part in part_list.records:
@@ -318,7 +333,9 @@ def get_combined_parts_data(owner_id, color_id, theme_id, year):
"""Get both set-based and individual parts combined."""
# Get set-based parts
part_list = BrickPartList()
part_list.all_filtered(owner_id, color_id, theme_id, year)
part_list.filtered(
owner_id=owner_id, color_id=color_id, theme_id=theme_id, year=year
)
combined_quantities = {}
for part in part_list.records:
@@ -439,7 +456,9 @@ def export_parts_combined_lego() -> Response:
# Get set-based parts
part_list = BrickPartList()
part_list.all_filtered(owner_id, color_id, theme_id, year)
part_list.filtered(
owner_id=owner_id, color_id=color_id, theme_id=theme_id, year=year
)
element_quantities = {}
for part in part_list.records:
@@ -520,7 +539,9 @@ def export_parts_combined_bricklink() -> Response:
# Get set-based parts
part_list = BrickPartList()
part_list.all_filtered(owner_id, color_id, theme_id, year)
part_list.filtered(
owner_id=owner_id, color_id=color_id, theme_id=theme_id, year=year
)
part_quantities = {}
for part in part_list.records:
+139 -167
View File
@@ -6,192 +6,164 @@ from ..individual_part_lot_list import IndividualPartLotList
from ..minifigure_list import BrickMinifigureList
from ..pagination_helper import get_pagination_config, build_pagination_context, get_request_params
from ..part import BrickPart
from ..part_list import BrickPartList
from ..part_list import BrickPartList, known_metadata_id
from ..set_list import BrickSetList, set_metadata_lists
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 ..theme_list import BrickThemeList
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
@part_page.route('/', methods=['GET'])
@exception_handler(__file__)
def list() -> 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')
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)
return render_template(
'parts.html',
**parts_page_context(problem_only=False)
)
# Problem
@part_page.route('/problem', methods=['GET'])
@exception_handler(__file__)
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(
'problem.html',
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
**parts_page_context(problem_only=True)
)