forked from FrederikBaerentsen/BrickTracker
Merge branch 'fix/parts-filters' into release/1.5
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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"""
|
||||
|
||||
@@ -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
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
@@ -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' %}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user