Merge branch 'fix/parts-filters' into release/1.5

This commit is contained in:
2026-08-01 20:48:31 +02:00
32 changed files with 1006 additions and 990 deletions
+103
View File
@@ -0,0 +1,103 @@
# Parts filter test matrix
Click through list for the parts filter work. Run it twice, once in each pagination
mode, because the two modes take different code paths to the same query.
**Run 1, client side (the default):** leave `BK_PARTS_SERVER_SIDE_PAGINATION` and
`BK_PROBLEMS_SERVER_SIDE_PAGINATION` unset.
**Run 2, server side:** set both to `true` and restart.
There is also a script that checks the query itself, no clicking needed:
```
python3 scripts/check_part_filters.py [path/to/app.db]
```
It copies your database first and seeds a couple of rows, so nothing is written to
your real data. Needs a database with at least two storages, one owner and one status.
---
## Each filter on its own
Do this on **/parts** and again on **/parts/problem**. Both pages carry the same seven
filters now, so both lists are identical.
| Filter | What to check |
|---|---|
| Owner | Result count drops. Individual parts do not disappear. |
| Color | Result count drops. |
| Theme | Result count drops. Used to do nothing at all. |
| Year | Result count drops. Used to do nothing at all. |
| Storage | Result count drops. Used to do nothing on the problem page and did not exist on the parts page. |
| Tag | Result count drops. Used to do nothing. |
| Status | Result count drops. Brand new. |
For each one also click the `=` button next to it so it flips to `≠`, and check the
result is the opposite selection.
## Storage in particular
- [ ] Pick a storage. Only parts in that storage show.
- [ ] Pick **No storage**. Only parts with nothing assigned show.
- [ ] Storage plus No storage together should cover everything. Pick each in turn and
the two counts should add up to at least the unfiltered count.
- [ ] A part sitting in a lot that has a storage shows up under that storage, even
though the part itself has none.
- [ ] Set `≠` on a storage. Parts that only live in that storage disappear, parts that
also live elsewhere stay.
## Combinations
- [ ] Owner plus storage
- [ ] Storage plus status
- [ ] Theme plus year
- [ ] Storage with `≠` plus owner
- [ ] Every filter at once
Counts should keep shrinking, and nothing should error.
## The awkward cases
- [ ] Filter down to zero results. **The filter bar and the Clear button must still be
on screen.** This used to hide them and the only way out was editing the URL.
- [ ] From there, click Clear. All seven reset and the full list comes back.
- [ ] `/parts/?page=abc` loads instead of throwing an error.
- [ ] `/parts/?page=0` loads.
- [ ] `/parts/?page=99999` loads and you can navigate back.
- [ ] Search box still works alongside the filters.
- [ ] The Individuals button on /parts still works.
## Pagination mode only (run 2)
- [ ] The count in the footer matches the filtered result, not the unfiltered total.
- [ ] Go to page 2 with a filter on. The filter is still applied.
- [ ] Sorting a column with a filter on keeps the filter.
## Things that should look different after this work
Not bugs, these are the intended corrections:
- [ ] **/parts/problem, pick an owner.** Individual parts now show up. They used to
vanish completely.
- [ ] **/parts/problem, Figures column.** Shows real numbers. It used to always read 0.
- [ ] **/parts/problem quantities** for parts belonging to an individual minifigure you
own more than one of. They are now multiplied by how many of that minifigure you
have, matching what /parts already did.
Everything else should show the same numbers as before.
## Sets regression pass
The sets work only affects server side pagination, which is off by default, so run 2
only. Set `BK_SETS_SERVER_SIDE_PAGINATION=true`.
- [ ] Every filter on /sets still works.
- [ ] Owner and tag filters keep the grouped card view when `BK_SETS_CONSOLIDATION=true`.
They used to silently switch the page to one card per copy.
- [ ] Status **Missing instructions** combined with a year. The year is now applied.
It used to be thrown away.
- [ ] Status **Missing instructions** combined with the duplicates button. Same.
- [ ] `≠` works on owner, tag, theme, year, storage and purchase location.
- [ ] The theme dropdown lists the themes of the filtered sets, not of everything.
+30
View File
@@ -206,3 +206,33 @@ class BrickMetadataList(BrickRecordList[T]):
endpoint,
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
+13 -1
View File
@@ -28,6 +28,12 @@ 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]:
"""Build pagination context for templates"""
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_next = page < total_pages
@@ -47,6 +53,12 @@ def get_request_params() -> Tuple[str, str, str, int]:
search_query = request.args.get('search', '').strip()
sort_field = request.args.get('sort', '')
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
+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. 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
# 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
+84 -30
View File
@@ -110,9 +110,6 @@ class BrickSetList(BrickRecordList[BrickSet]):
else:
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, parts_min, parts_max, year_min, year_max])
# Prepare filter context
filter_context = {
'search_query': search_query,
@@ -169,11 +166,8 @@ class BrickSetList(BrickRecordList[BrickSet]):
'purchase-price': '"bricktracker_sets"."purchase_price"'
}
# Choose query based on consolidation preference and filter complexity
# 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):
# Choose query based on consolidation preference
if use_consolidated:
query_to_use = self.consolidated_query
else:
# Use filtered query when consolidation is disabled or complex filters applied
@@ -186,7 +180,8 @@ class BrickSetList(BrickRecordList[BrickSet]):
search_query, page, per_page, sort_field, sort_order,
status_filter, theme_id_filter, owner_filter,
purchase_location_filter, storage_filter, tag_filter,
parts_min, parts_max, year_min, year_max
parts_min, parts_max, year_min, year_max,
year_filter, duplicate_filter
)
# Handle special case for set sorting with multiple columns
@@ -336,7 +331,9 @@ class BrickSetList(BrickRecordList[BrickSet]):
parts_min: int | None = None,
parts_max: int | None = None,
year_min: int | None = None,
year_max: int | None = None
year_max: int | None = None,
year_filter: str | None = None,
duplicate_filter: str | None = None
) -> tuple[Self, int]:
"""Handle filtering when instructions filter is involved"""
try:
@@ -353,6 +350,17 @@ class BrickSetList(BrickRecordList[BrickSet]):
instructions_list = BrickInstructionsList()
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
filtered_records = []
for record in all_sets.records:
@@ -378,13 +386,23 @@ class BrickSetList(BrickRecordList[BrickSet]):
continue
if tag_filter and not self._matches_tag(record, tag_filter):
continue
if parts_min is not None and record.fields.number_of_parts < parts_min:
if year_filter and not self._matches_year(record, year_filter):
continue
if parts_max is not None and record.fields.number_of_parts > parts_max:
if duplicate_filter and record.fields.set not in duplicate_sets:
continue
if year_min is not None and record.fields.year < year_min:
# 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 year_max is not None and record.fields.year > year_max:
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)
@@ -478,7 +496,10 @@ class BrickSetList(BrickRecordList[BrickSet]):
theme_list = BrickThemeList()
themes = set()
for record in theme_records:
theme_id = record.get('theme_id')
# sqlite3.Row has no .get(). This used to raise on every call, which
# 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:
theme = theme_list.get(theme_id)
if theme and hasattr(theme, 'name'):
@@ -518,43 +539,76 @@ class BrickSetList(BrickRecordList[BrickSet]):
return (search_lower in record.fields.name.lower() or
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:
"""Check if record matches theme filter"""
return str(record.fields.theme_id) == theme_id
matched = str(record.fields.theme_id) == self._without_negation(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:
"""Check if record matches owner filter"""
if not owner_filter.startswith('owner-'):
value = self._without_negation(owner_filter)
if not value.startswith('owner-'):
return True
# Convert owner-uuid format to owner_uuid column name
owner_column = owner_filter.replace('-', '_')
owner_column = value.replace('-', '_')
matched = getattr(record.fields, owner_column, 0) == 1
# Check if record has this owner attribute set to 1
return hasattr(record.fields, owner_column) and getattr(record.fields, owner_column) == 1
return self._apply_negation(matched, owner_filter)
def _matches_purchase_location(self, record, location_filter: str) -> bool:
"""Check if record matches purchase location filter"""
if location_filter == '__none__':
return not record.fields.purchase_location
return record.fields.purchase_location == location_filter
value = self._without_negation(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:
"""Check if record matches storage filter"""
if storage_filter == '__none__':
return not record.fields.storage
return record.fields.storage == storage_filter
value = self._without_negation(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:
"""Check if record matches tag filter"""
if not tag_filter.startswith('tag-'):
value = self._without_negation(tag_filter)
if not value.startswith('tag-'):
return True
# Convert tag-uuid format to tag_uuid column name
tag_column = tag_filter.replace('-', '_')
tag_column = value.replace('-', '_')
matched = getattr(record.fields, tag_column, 0) == 1
# Check if record has this tag attribute set to 1
return hasattr(record.fields, tag_column) and getattr(record.fields, tag_column) == 1
return self._apply_negation(matched, tag_filter)
def _sort_records(self, records, sort_field: str, sort_order: str):
"""Sort records manually"""
+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 %}
+203
View File
@@ -0,0 +1,203 @@
{#
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 %}
-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
@@ -177,6 +177,50 @@ AND EXISTS (
{% 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 %}
+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)
)
+14 -2
View File
@@ -26,6 +26,7 @@ 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_owner_list import BrickSetOwnerList
from ..set_purchase_location_list import BrickSetPurchaseLocationList
@@ -50,11 +51,22 @@ def list() -> str:
# Get filter parameters
status_filter = request.args.get('status')
theme_filter = request.args.get('theme')
owner_filter = request.args.get('owner')
purchase_location_filter = request.args.get('purchase_location')
storage_filter = request.args.get('storage')
tag_filter = request.args.get('tag')
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'
# Numeric range filters (#141): blank/invalid means "no bound"
+24 -37
View File
@@ -378,49 +378,36 @@ window.updateUrlParams = function(params, resetPage = true) {
// Shared filter application (supports owner, color, theme, year, storage, tag, and problems filters)
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 = {};
// Handle owner filter
if (ownerSelect) {
params.owner = ownerSelect.value;
}
// Read a select's value, including the leading "-" when its != toggle is on
const readFilter = (selectId, paramName) => {
const select = document.getElementById(selectId);
if (!select) {
return;
}
// Handle color filter
if (colorSelect) {
params.color = colorSelect.value;
}
// "all" means the filter is off, so never let the != toggle turn it into "-all"
if (select.value === 'all' || select.value === '') {
params[paramName] = select.value;
return;
}
// Handle theme filter
if (themeSelect) {
params.theme = themeSelect.value;
}
params[paramName] = typeof BrickFilterToggle !== 'undefined'
? BrickFilterToggle.getFilterValue(select)
: select.value;
};
// Handle year filter
if (yearSelect) {
params.year = yearSelect.value;
}
readFilter('filter-owner', 'owner');
readFilter('filter-color', 'color');
readFilter('filter-theme', 'theme');
readFilter('filter-year', 'year');
readFilter('filter-storage', 'storage');
readFilter('filter-tag', 'tag');
readFilter('filter-status', 'status');
// Handle storage filter
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;
}
// Minifigures page only
readFilter('filter-problems', 'problems');
// Check if we're in pagination mode
const isPaginationMode = window.isPaginationModeForPage(tableId);
+2 -1
View File
@@ -60,7 +60,8 @@ class BrickFilterToggle {
'grid-tag': 'tag',
'grid-year': 'year'
};
return mapping[selectId] || selectId.replace('grid-', '');
// The sets grid uses grid-*, the parts and problem tables use filter-*
return mapping[selectId] || selectId.replace(/^(grid|filter)-/, '');
}
handleToggleClick(toggle, targetSelect) {
+1 -1
View File
@@ -84,7 +84,7 @@ document.addEventListener("DOMContentLoaded", () => {
const clearButton = document.getElementById('table-filter-clear');
if (clearButton) {
clearButton.addEventListener('click', () => {
window.clearPageFilters('parts', ['owner', 'color', 'theme', 'year', 'individuals']);
window.clearPageFilters('parts', ['owner', 'color', 'theme', 'year', 'storage', 'tag', 'status', 'individuals']);
});
}
});
+1 -1
View File
@@ -28,7 +28,7 @@ document.addEventListener("DOMContentLoaded", () => {
const clearButton = document.getElementById('table-filter-clear');
if (clearButton) {
clearButton.addEventListener('click', () => {
window.clearPageFilters('problems', ['owner', 'color', 'theme', 'year', 'storage', 'tag']);
window.clearPageFilters('problems', ['owner', 'color', 'theme', 'year', 'storage', 'tag', 'status']);
});
}
});
+3 -1
View File
@@ -192,7 +192,9 @@
<script src="{{ url_for('static', filename='scripts/parts.js') }}"></script>
{% endif %}
{% if request.endpoint == 'part.problem' %}
<script src="{{ url_for('static', filename='scripts/parts.js') }}"></script>
{# parts.js used to be loaded here too. It looks for a #parts table that does not
exist on this page and retries every 100ms forever, and it double wired the
Clear button. problems.js is all this page needs. #}
<script src="{{ url_for('static', filename='scripts/problems.js') }}"></script>
{% endif %}
{% if request.endpoint == 'set.list' %}
+60 -4
View File
@@ -1,3 +1,11 @@
{% import 'macro/form.html' as form %}
{#
Filter bar for /parts and /parts/problem. Both pages carry the same seven filters,
so there is one template rather than two that drift apart.
Every dropdown gets the = / != toggle, so "everything except X" works the same way
it does on the sets page.
#}
<div id="table-filter" class="collapse {% if config['SHOW_GRID_FILTERS'] %}show{% endif %} row row-cols-lg-auto g-1 justify-content-center align-items-center">
{% if owners | length %}
<div class="col-12 flex-grow-1">
@@ -6,12 +14,15 @@
<select id="filter-owner" class="form-select" onchange="applyFiltersAndKeepOpen()" autocomplete="off">
<option value="all" {% if selected_owner == 'all' %}selected{% endif %}>All owners</option>
{% for owner in owners %}
<option value="{{ owner.fields.id }}" {% if selected_owner == owner.fields.id %}selected{% endif %}>{{ owner.fields.name }}</option>
<option value="{{ owner.fields.id }}" {% if selected_owner == owner.fields.id or selected_owner == ('-' + owner.fields.id) %}selected{% endif %}>{{ owner.fields.name }}</option>
{% endfor %}
</select>
{{ form.filter_toggle('filter-owner') }}
</div>
</div>
{% endif %}
{# ponytail: colour has no != toggle. "Everything except red" is not a thing people
ask for, and it would need its own branch in the query. Add both if it comes up. #}
{% if colors | length %}
<div class="col-12 flex-grow-1">
<div class="input-group">
@@ -34,9 +45,10 @@
<select id="filter-theme" class="form-select" onchange="applyFiltersAndKeepOpen()" autocomplete="off">
<option value="all" {% if selected_theme == 'all' %}selected{% endif %}>All themes</option>
{% for theme in themes %}
<option value="{{ theme.theme_id }}" {% if selected_theme == theme.theme_id|string %}selected{% endif %}>{{ theme.theme_name }}</option>
<option value="{{ theme.theme_id }}" {% if selected_theme == theme.theme_id|string or selected_theme == ('-' + theme.theme_id|string) %}selected{% endif %}>{{ theme.theme_name }}</option>
{% endfor %}
</select>
{{ form.filter_toggle('filter-theme') }}
</div>
</div>
{% endif %}
@@ -47,9 +59,53 @@
<select id="filter-year" class="form-select" onchange="applyFiltersAndKeepOpen()" autocomplete="off">
<option value="all" {% if selected_year == 'all' %}selected{% endif %}>All years</option>
{% for year in years %}
<option value="{{ year.year }}" {% if selected_year == year.year|string %}selected{% endif %}>{{ year.year }}</option>
<option value="{{ year.year }}" {% if selected_year == year.year|string or selected_year == ('-' + year.year|string) %}selected{% endif %}>{{ year.year }}</option>
{% endfor %}
</select>
{{ form.filter_toggle('filter-year') }}
</div>
</div>
{% endif %}
{% if storages | length %}
<div class="col-12 flex-grow-1">
<div class="input-group">
<span class="input-group-text"><i class="ri-inbox-line"></i><span class="ms-1 d-none d-md-inline"> Storage</span></span>
<select id="filter-storage" class="form-select" onchange="applyFiltersAndKeepOpen()" autocomplete="off">
<option value="all" {% if selected_storage == 'all' %}selected{% endif %}>All storages</option>
<option value="__none__" {% if selected_storage == '__none__' or selected_storage == '-__none__' %}selected{% endif %}>No storage</option>
{% for storage in storages %}
<option value="{{ storage.fields.id }}" {% if selected_storage == storage.fields.id or selected_storage == ('-' + storage.fields.id) %}selected{% endif %}>{{ storage.fields.name }}</option>
{% endfor %}
</select>
{{ form.filter_toggle('filter-storage') }}
</div>
</div>
{% endif %}
{% if tags | length %}
<div class="col-12 flex-grow-1">
<div class="input-group">
<span class="input-group-text"><i class="ri-price-tag-line"></i><span class="ms-1 d-none d-md-inline"> Tag</span></span>
<select id="filter-tag" class="form-select" onchange="applyFiltersAndKeepOpen()" autocomplete="off">
<option value="all" {% if selected_tag == 'all' %}selected{% endif %}>All tags</option>
{% for tag in tags %}
<option value="{{ tag.fields.id }}" {% if selected_tag == tag.fields.id or selected_tag == ('-' + tag.fields.id) %}selected{% endif %}>{{ tag.fields.name }}</option>
{% endfor %}
</select>
{{ form.filter_toggle('filter-tag') }}
</div>
</div>
{% endif %}
{% if statuses | length %}
<div class="col-12 flex-grow-1">
<div class="input-group">
<span class="input-group-text"><i class="ri-checkbox-line"></i><span class="ms-1 d-none d-md-inline"> Status</span></span>
<select id="filter-status" class="form-select" onchange="applyFiltersAndKeepOpen()" autocomplete="off">
<option value="all" {% if selected_status == 'all' %}selected{% endif %}>All statuses</option>
{% for status in statuses %}
<option value="{{ status.fields.id }}" {% if selected_status == status.fields.id or selected_status == ('-' + status.fields.id) %}selected{% endif %}>{{ status.fields.name }}</option>
{% endfor %}
</select>
{{ form.filter_toggle('filter-status') }}
</div>
</div>
{% endif %}
@@ -58,4 +114,4 @@
<i class="ri-filter-off-line"></i> <span class="d-none d-md-inline">Clear</span>
</button>
</div>
</div>
</div>
+13 -12
View File
@@ -5,7 +5,6 @@
{% block title %} - All parts{% endblock %}
{% block main %}
{% if table_collection | length %}
<div class="container-fluid">
<div class="row row-cols-lg-auto g-1 justify-content-center align-items-center pb-2">
<div class="col-12 flex-grow-1">
@@ -35,6 +34,10 @@
{% include 'part/sort.html' %}
{% include 'part/filter.html' %}
{# The guard starts here on purpose: filtering down to nothing must not
take the filter bar away with the results. #}
{% if table_collection | length %}
{% if use_pagination %}
<!-- PAGINATION MODE -->
<div class="table-responsive-sm">
@@ -191,19 +194,17 @@
</div>
{% endif %}
</div>
{% else %}
<div class="container-fluid">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="text-center">
<i class="ri-shapes-line" style="font-size: 4rem; color: #6c757d;"></i>
<h3 class="mt-3">No parts found</h3>
<p class="text-muted">No parts are available for the selected owner.</p>
</div>
{% else %}
<div class="row justify-content-center py-5">
<div class="col-md-6">
<div class="text-center">
<i class="ri-shapes-line" style="font-size: 4rem; color: #6c757d;"></i>
<h3 class="mt-3">No parts found</h3>
<p class="text-muted">Nothing matches the current filters. Clear them to start over.</p>
</div>
</div>
</div>
{% endif %}
{% endif %}
</div>
{% endblock %}
+14 -13
View File
@@ -5,7 +5,6 @@
{% block title %} - Problematic parts{% endblock %}
{% block main %}
{% if table_collection | length %}
<div class="container-fluid">
<div class="row row-cols-lg-auto g-1 justify-content-center align-items-center pb-2">
<div class="col-12 flex-grow-1">
@@ -28,7 +27,11 @@
</div>
</div>
{% include 'problem/sort.html' %}
{% include 'problem/filter.html' %}
{% include 'part/filter.html' %}
{# The guard starts here on purpose: filtering down to nothing must not
take the filter bar away with the results. #}
{% if table_collection | length %}
{% if use_pagination %}
<!-- PAGINATION MODE -->
@@ -211,19 +214,17 @@
</div>
{% endif %}
</div>
{% else %}
<div class="container-fluid">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="text-center">
<i class="ri-error-warning-line" style="font-size: 4rem; color: #6c757d;"></i>
<h3 class="mt-3">No problematic parts found</h3>
<p class="text-muted">Great! All your parts are in perfect condition.</p>
</div>
{% else %}
<div class="row justify-content-center py-5">
<div class="col-md-6">
<div class="text-center">
<i class="ri-error-warning-line" style="font-size: 4rem; color: #6c757d;"></i>
<h3 class="mt-3">No problematic parts found</h3>
<p class="text-muted">Nothing matches the current filters. Clear them, or enjoy having no missing pieces.</p>
</div>
</div>
</div>
{% endif %}
{% endif %}
</div>
{% endblock %}
-87
View File
@@ -1,87 +0,0 @@
<div id="table-filter" class="collapse {% if config['SHOW_GRID_FILTERS'] %}show{% endif %} row row-cols-lg-auto g-1 justify-content-center align-items-center">
{% if owners | length %}
<div class="col-12 flex-grow-1">
<div class="input-group">
<span class="input-group-text"><i class="ri-user-line"></i><span class="ms-1 d-none d-md-inline"> Owner</span></span>
<select id="filter-owner" class="form-select" onchange="applyFiltersAndKeepOpen()" autocomplete="off">
<option value="all" {% if selected_owner == 'all' %}selected{% endif %}>All owners</option>
{% for owner in owners %}
<option value="{{ owner.fields.id }}" {% if selected_owner == owner.fields.id %}selected{% endif %}>{{ owner.fields.name }}</option>
{% endfor %}
</select>
</div>
</div>
{% endif %}
{% if colors | length %}
<div class="col-12 flex-grow-1">
<div class="input-group">
<span class="input-group-text"><i class="ri-palette-line"></i><span class="ms-1 d-none d-md-inline"> Color</span></span>
<select id="filter-color" class="form-select" onchange="applyFiltersAndKeepOpen()" autocomplete="off">
<option value="all" {% if selected_color == 'all' %}selected{% endif %}>All colors</option>
{% for color in colors %}
<option value="{{ color.color_id }}" {% if selected_color == color.color_id|string %}selected{% endif %} data-color-rgb="{{ color.color_rgb }}" data-color-id="{{ color.color_id }}">
{{ color.color_name }}
</option>
{% endfor %}
</select>
</div>
</div>
{% endif %}
{% if themes | length %}
<div class="col-12 flex-grow-1">
<div class="input-group">
<span class="input-group-text"><i class="ri-price-tag-3-line"></i><span class="ms-1 d-none d-md-inline"> Theme</span></span>
<select id="filter-theme" class="form-select" onchange="applyFiltersAndKeepOpen()" autocomplete="off">
<option value="all" {% if selected_theme == 'all' %}selected{% endif %}>All themes</option>
{% for theme in themes %}
<option value="{{ theme.theme_id }}" {% if selected_theme == theme.theme_id|string %}selected{% endif %}>{{ theme.theme_name }}</option>
{% endfor %}
</select>
</div>
</div>
{% endif %}
{% if years | length %}
<div class="col-12 flex-grow-1">
<div class="input-group">
<span class="input-group-text"><i class="ri-calendar-line"></i><span class="ms-1 d-none d-md-inline"> Year</span></span>
<select id="filter-year" class="form-select" onchange="applyFiltersAndKeepOpen()" autocomplete="off">
<option value="all" {% if selected_year == 'all' %}selected{% endif %}>All years</option>
{% for year in years %}
<option value="{{ year.year }}" {% if selected_year == year.year|string %}selected{% endif %}>{{ year.year }}</option>
{% endfor %}
</select>
</div>
</div>
{% endif %}
{% if storages | length %}
<div class="col-12 flex-grow-1">
<div class="input-group">
<span class="input-group-text"><i class="ri-inbox-line"></i><span class="ms-1 d-none d-md-inline"> Storage</span></span>
<select id="filter-storage" class="form-select" onchange="applyFiltersAndKeepOpen()" autocomplete="off">
<option value="all" {% if selected_storage == 'all' %}selected{% endif %}>All storages</option>
{% for storage in storages %}
<option value="{{ storage.storage_id }}" {% if selected_storage == storage.storage_id %}selected{% endif %}>{{ storage.storage_name }}</option>
{% endfor %}
</select>
</div>
</div>
{% endif %}
{% if tags | length %}
<div class="col-12 flex-grow-1">
<div class="input-group">
<span class="input-group-text"><i class="ri-price-tag-line"></i><span class="ms-1 d-none d-md-inline"> Tag</span></span>
<select id="filter-tag" class="form-select" onchange="applyFiltersAndKeepOpen()" autocomplete="off">
<option value="all" {% if selected_tag == 'all' %}selected{% endif %}>All tags</option>
{% for tag in tags %}
<option value="{{ tag.tag_id }}" {% if selected_tag == tag.tag_id %}selected{% endif %}>{{ tag.tag_name }}</option>
{% endfor %}
</select>
</div>
</div>
{% endif %}
<div class="col-12 col-lg-auto">
<button id="table-filter-clear" class="btn btn-outline-danger w-100" type="button" title="Clear all filters">
<i class="ri-filter-off-line"></i> <span class="d-none d-md-inline">Clear</span>
</button>
</div>
</div>