Fixed sorting and filtering on /sets.

This commit is contained in:
Frederik Baerentsen
2025-09-21 15:58:32 +02:00
parent 9a32a3f193
commit 4b3aef577a
7 changed files with 297 additions and 40 deletions

View File

@@ -13,6 +13,8 @@ from .set_storage_list import BrickSetStorageList
from .set_tag import BrickSetTag
from .set_tag_list import BrickSetTagList
from .set import BrickSet
from .theme_list import BrickThemeList
from .instructions_list import BrickInstructionsList
# All the sets from the database
@@ -56,11 +58,31 @@ class BrickSetList(BrickRecordList[BrickSet]):
page: int = 1,
per_page: int = 50,
sort_field: str | None = None,
sort_order: str = 'asc'
sort_order: str = 'asc',
status_filter: str | None = None,
theme_filter: str | None = None,
owner_filter: str | None = None,
purchase_location_filter: str | None = None,
storage_filter: str | None = None,
tag_filter: str | None = None
) -> tuple[Self, int]:
# Convert theme name to theme ID for filtering
theme_id_filter = None
if theme_filter:
theme_id_filter = self._theme_name_to_id(theme_filter)
# Check if any filters are applied
has_filters = any([status_filter, theme_id_filter, owner_filter, purchase_location_filter, storage_filter, tag_filter])
# Prepare filter context
filter_context = {
'search_query': search_query,
'status_filter': status_filter,
'theme_filter': theme_id_filter, # Use converted theme ID
'owner_filter': owner_filter,
'purchase_location_filter': purchase_location_filter,
'storage_filter': storage_filter,
'tag_filter': tag_filter,
'owners': BrickSetOwnerList.as_columns(),
'statuses': BrickSetStatusList.as_columns(),
'tags': BrickSetTagList.as_columns(),
@@ -71,22 +93,107 @@ class BrickSetList(BrickRecordList[BrickSet]):
'set': '"rebrickable_sets"."set"',
'name': '"rebrickable_sets"."name"',
'year': '"rebrickable_sets"."year"',
'parts': '"rebrickable_sets"."number_of_parts"'
'parts': '"rebrickable_sets"."number_of_parts"',
'theme': '"rebrickable_sets"."theme_id"',
'minifigures': '"total_minifigures"', # Use the alias from the SQL query
'missing': '"total_missing"', # Use the alias from the SQL query
'damaged': '"total_damaged"', # Use the alias from the SQL query
'purchase-date': '"bricktracker_sets"."purchase_date"',
'purchase-price': '"bricktracker_sets"."purchase_price"'
}
# Choose query based on whether filters are applied
query_to_use = 'set/list/all_filtered' if has_filters else self.all_query
# Handle instructions filtering separately (post-SQL filtering)
instructions_filter = None
if status_filter in ['has-missing-instructions', '-has-missing-instructions']:
instructions_filter = status_filter
# Remove from SQL context to avoid SQL errors
filter_context['status_filter'] = None
# Recalculate has_filters without instructions
has_filters = any([theme_id_filter, owner_filter, purchase_location_filter, storage_filter, tag_filter])
query_to_use = 'set/list/all_filtered' if has_filters else self.all_query
# Use the base pagination method with custom list method
result, total_count = self.paginate(
page=page,
per_page=per_page,
sort_field=sort_field,
sort_order=sort_order,
list_query=self.all_query,
list_query=query_to_use,
field_mapping=field_mapping,
**filter_context
)
# Apply instructions filtering after SQL query
if instructions_filter:
result, total_count = self._filter_by_instructions(result, total_count, instructions_filter, page, per_page)
# Populate themes for filter dropdown (always needed)
result._populate_themes()
return result, total_count
def _populate_themes(self) -> None:
"""Populate themes list from the current records"""
themes = set()
for record in self.records:
if hasattr(record, 'theme') and hasattr(record.theme, 'name'):
themes.add(record.theme.name)
self.themes = list(themes)
self.themes.sort()
def _theme_name_to_id(self, theme_name: str) -> str | None:
"""Convert a theme name to theme ID for filtering"""
try:
theme_list = BrickThemeList()
for theme_id, theme in theme_list.themes.items():
if theme.name.lower() == theme_name.lower():
return str(theme_id)
return None
except Exception:
# If themes can't be loaded, return None to disable theme filtering
return None
def _filter_by_instructions(self, result_list: Self, total_count: int, instructions_filter: str, page: int, per_page: int) -> tuple[Self, int]:
"""Filter sets by instruction file existence (post-SQL filtering)"""
try:
# Load instructions list
instructions_list = BrickInstructionsList()
instruction_sets = set(instructions_list.sets.keys())
# Filter the records
filtered_records = []
for record in result_list.records:
set_id = record.fields.set
has_instructions = set_id in instruction_sets
if instructions_filter == 'has-missing-instructions':
# Show sets that are MISSING instructions
if not has_instructions:
filtered_records.append(record)
elif instructions_filter == '-has-missing-instructions':
# Show sets that HAVE instructions
if has_instructions:
filtered_records.append(record)
# Create new result with filtered records
new_result = BrickSetList()
new_result.records = filtered_records
# Note: This breaks proper pagination since we're filtering after SQL
# The total_count and pagination will be approximate
# For proper pagination, we'd need a database table for instructions
# This will be implemented in future versions
return new_result, len(filtered_records)
except Exception:
# If instructions can't be loaded, return original results
return result_list, total_count
# Sets with a minifigure part damaged
def damaged_minifigure(self, figure: str, /) -> Self:
# Save the parameters to the fields

View File

@@ -0,0 +1,69 @@
{% extends 'set/base/full.sql' %}
{% block where %}
WHERE 1=1
{% if search_query %}
AND (LOWER("rebrickable_sets"."name") LIKE LOWER('%{{ search_query }}%')
OR LOWER("rebrickable_sets"."set") LIKE LOWER('%{{ search_query }}%'))
{% endif %}
{% if theme_filter %}
AND "rebrickable_sets"."theme_id" = '{{ theme_filter }}'
{% endif %}
{% if storage_filter %}
AND "bricktracker_sets"."storage" = '{{ storage_filter }}'
{% endif %}
{% if purchase_location_filter %}
AND "bricktracker_sets"."purchase_location" = '{{ purchase_location_filter }}'
{% endif %}
{% if status_filter %}
{% if status_filter == 'has-missing' %}
AND IFNULL("problem_join"."total_missing", 0) > 0
{% elif status_filter == '-has-missing' %}
AND IFNULL("problem_join"."total_missing", 0) = 0
{% elif status_filter == 'has-damaged' %}
AND IFNULL("problem_join"."total_damaged", 0) > 0
{% elif status_filter == '-has-damaged' %}
AND IFNULL("problem_join"."total_damaged", 0) = 0
{% elif status_filter == 'has-storage' %}
AND "bricktracker_sets"."storage" IS NOT NULL AND "bricktracker_sets"."storage" != ''
{% elif status_filter == '-has-storage' %}
AND ("bricktracker_sets"."storage" IS NULL OR "bricktracker_sets"."storage" = '')
{% elif status_filter.startswith('status-') %}
AND EXISTS (
SELECT 1 FROM "bricktracker_set_statuses"
WHERE "bricktracker_set_statuses"."id" = "bricktracker_sets"."id"
AND "bricktracker_set_statuses"."{{ status_filter.replace('-', '_') }}" = 1
)
{% elif status_filter.startswith('-status-') %}
AND NOT EXISTS (
SELECT 1 FROM "bricktracker_set_statuses"
WHERE "bricktracker_set_statuses"."id" = "bricktracker_sets"."id"
AND "bricktracker_set_statuses"."{{ status_filter[1:].replace('-', '_') }}" = 1
)
{% endif %}
{% endif %}
{% if owner_filter %}
{% if owner_filter.startswith('owner-') %}
AND EXISTS (
SELECT 1 FROM "bricktracker_set_owners"
WHERE "bricktracker_set_owners"."id" = "bricktracker_sets"."id"
AND "bricktracker_set_owners"."{{ owner_filter.replace('-', '_') }}" = 1
)
{% endif %}
{% endif %}
{% if tag_filter %}
{% if tag_filter.startswith('tag-') %}
AND EXISTS (
SELECT 1 FROM "bricktracker_set_tags"
WHERE "bricktracker_set_tags"."id" = "bricktracker_sets"."id"
AND "bricktracker_set_tags"."{{ tag_filter.replace('-', '_') }}" = 1
)
{% endif %}
{% endif %}
{% endblock %}

View File

@@ -39,18 +39,32 @@ def list() -> str:
# Get filter parameters from request
search_query, sort_field, sort_order, page = get_request_params()
# 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')
# Get pagination configuration
per_page, is_mobile = get_pagination_config('sets')
use_pagination = per_page > 0
if use_pagination:
# PAGINATION MODE - Server-side pagination with search
# PAGINATION MODE - Server-side pagination with search and filters
sets, total_count = BrickSetList().all_filtered_paginated(
search_query=search_query,
page=page,
per_page=per_page,
sort_field=sort_field,
sort_order=sort_order
sort_order=sort_order,
status_filter=status_filter,
theme_filter=theme_filter,
owner_filter=owner_filter,
purchase_location_filter=purchase_location_filter,
storage_filter=storage_filter,
tag_filter=tag_filter
)
pagination_context = build_pagination_context(page, per_page, total_count, is_mobile)
@@ -65,6 +79,12 @@ def list() -> str:
'use_pagination': use_pagination,
'current_sort': sort_field,
'current_order': sort_order,
'current_status_filter': status_filter,
'current_theme_filter': theme_filter,
'current_owner_filter': owner_filter,
'current_purchase_location_filter': purchase_location_filter,
'current_storage_filter': storage_filter,
'current_tag_filter': tag_filter,
'brickset_statuses': BrickSetStatusList.list(),
**set_metadata_lists(as_class=True)
}

View File

@@ -57,6 +57,9 @@ document.addEventListener("DOMContentLoaded", () => {
// Setup sort buttons for pagination mode
setupPaginationSortButtons();
// Setup filter dropdowns for pagination mode
setupPaginationFilterDropdowns();
} else {
// ORIGINAL MODE - Grid search functionality is handled by existing grid scripts
// No additional setup needed here
@@ -105,4 +108,68 @@ function setupPaginationSortButtons() {
window.location.href = currentUrl.toString();
});
}
}
function setupPaginationFilterDropdowns() {
// Filter dropdown functionality for pagination mode
const filterDropdowns = document.querySelectorAll('#grid-filter select');
filterDropdowns.forEach(dropdown => {
dropdown.addEventListener('change', () => {
performServerFilter();
});
});
function performServerFilter() {
const currentUrl = new URL(window.location);
// Get all filter values
const statusFilter = document.getElementById('grid-status')?.value || '';
const themeFilter = document.getElementById('grid-theme')?.value || '';
const ownerFilter = document.getElementById('grid-owner')?.value || '';
const purchaseLocationFilter = document.getElementById('grid-purchase-location')?.value || '';
const storageFilter = document.getElementById('grid-storage')?.value || '';
const tagFilter = document.getElementById('grid-tag')?.value || '';
// Update URL parameters
if (statusFilter) {
currentUrl.searchParams.set('status', statusFilter);
} else {
currentUrl.searchParams.delete('status');
}
if (themeFilter) {
currentUrl.searchParams.set('theme', themeFilter);
} else {
currentUrl.searchParams.delete('theme');
}
if (ownerFilter) {
currentUrl.searchParams.set('owner', ownerFilter);
} else {
currentUrl.searchParams.delete('owner');
}
if (purchaseLocationFilter) {
currentUrl.searchParams.set('purchase_location', purchaseLocationFilter);
} else {
currentUrl.searchParams.delete('purchase_location');
}
if (storageFilter) {
currentUrl.searchParams.set('storage', storageFilter);
} else {
currentUrl.searchParams.delete('storage');
}
if (tagFilter) {
currentUrl.searchParams.set('tag', tagFilter);
} else {
currentUrl.searchParams.delete('tag');
}
// Reset to page 1 when filtering
currentUrl.searchParams.set('page', '1');
window.location.href = currentUrl.toString();
}
}

View File

@@ -6,26 +6,26 @@
<select id="grid-status" class="form-select"
data-filter="metadata"
autocomplete="off">
<option value="" selected>All</option>
<option value="" {% if not current_status_filter %}selected{% endif %}>All</option>
{% if not config['HIDE_TABLE_MISSING_PARTS'] %}
<option value="has-missing">Has missing pieces</option>
<option value="-has-missing">Has NO missing pieces</option>
<option value="has-missing" {% if current_status_filter == 'has-missing' %}selected{% endif %}>Has missing pieces</option>
<option value="-has-missing" {% if current_status_filter == '-has-missing' %}selected{% endif %}>Has NO missing pieces</option>
{% endif %}
{% if not config['HIDE_TABLE_DAMAGED_PARTS'] %}
<option value="has-damaged">Has damaged pieces</option>
<option value="-has-damaged">Has NO damaged pieces</option>
<option value="has-damaged" {% if current_status_filter == 'has-damaged' %}selected{% endif %}>Has damaged pieces</option>
<option value="-has-damaged" {% if current_status_filter == '-has-damaged' %}selected{% endif %}>Has NO damaged pieces</option>
{% endif %}
{% if not config['HIDE_SET_INSTRUCTIONS'] %}
<option value="-has-missing-instructions">Has instructions</option>
<option value="has-missing-instructions">Is MISSING instructions</option>
<option value="-has-missing-instructions" {% if current_status_filter == '-has-missing-instructions' %}selected{% endif %}>Has instructions</option>
<option value="has-missing-instructions" {% if current_status_filter == 'has-missing-instructions' %}selected{% endif %}>Is MISSING instructions</option>
{% endif %}
{% if brickset_storages | length %}
<option value="has-storage">Is in storage</option>
<option value="-has-storage">Is NOT in storage</option>
<option value="has-storage" {% if current_status_filter == 'has-storage' %}selected{% endif %}>Is in storage</option>
<option value="-has-storage" {% if current_status_filter == '-has-storage' %}selected{% endif %}>Is NOT in storage</option>
{% endif %}
{% for status in brickset_statuses %}
<option value="{{ status.as_dataset() }}">{{ status.fields.name }}</option>
<option value="-{{ status.as_dataset() }}">NOT: {{ status.fields.name }}</option>
<option value="{{ status.as_dataset() }}" {% if current_status_filter == status.as_dataset() %}selected{% endif %}>{{ status.fields.name }}</option>
<option value="-{{ status.as_dataset() }}" {% if current_status_filter == ('-' + status.as_dataset()) %}selected{% endif %}>NOT: {{ status.fields.name }}</option>
{% endfor %}
</select>
</div>
@@ -37,9 +37,9 @@
<select id="grid-theme" class="form-select"
data-filter="value" data-filter-attribute="theme"
autocomplete="off">
<option value="" selected>All</option>
<option value="" {% if not current_theme_filter %}selected{% endif %}>All</option>
{% for theme in collection.themes %}
<option value="{{ theme | lower }}">{{ theme }}</option>
<option value="{{ theme | lower }}" {% if current_theme_filter == (theme | lower) %}selected{% endif %}>{{ theme }}</option>
{% endfor %}
</select>
</div>
@@ -52,9 +52,9 @@
<select id="grid-owner" class="form-select"
data-filter="metadata"
autocomplete="off">
<option value="" selected>All</option>
<option value="" {% if not current_owner_filter %}selected{% endif %}>All</option>
{% for owner in brickset_owners %}
<option value="{{ owner.as_dataset() }}">{{ owner.fields.name }}</option>
<option value="{{ owner.as_dataset() }}" {% if current_owner_filter == owner.as_dataset() %}selected{% endif %}>{{ owner.fields.name }}</option>
{% endfor %}
</select>
</div>
@@ -68,9 +68,9 @@
<select id="grid-purchase-location" class="form-select"
data-filter="value" data-filter-attribute="purchase-location"
autocomplete="off">
<option value="" selected>All</option>
<option value="" {% if not current_purchase_location_filter %}selected{% endif %}>All</option>
{% for purchase_location in brickset_purchase_locations %}
<option value="{{ purchase_location.fields.id }}">{{ purchase_location.fields.name }}</option>
<option value="{{ purchase_location.fields.id }}" {% if current_purchase_location_filter == purchase_location.fields.id %}selected{% endif %}>{{ purchase_location.fields.name }}</option>
{% endfor %}
</select>
</div>
@@ -84,9 +84,9 @@
<select id="grid-storage" class="form-select"
data-filter="value" data-filter-attribute="storage"
autocomplete="off">
<option value="" selected>All</option>
<option value="" {% if not current_storage_filter %}selected{% endif %}>All</option>
{% for storage in brickset_storages %}
<option value="{{ storage.fields.id }}">{{ storage.fields.name }}</option>
<option value="{{ storage.fields.id }}" {% if current_storage_filter == storage.fields.id %}selected{% endif %}>{{ storage.fields.name }}</option>
{% endfor %}
</select>
</div>
@@ -100,9 +100,9 @@
<select id="grid-tag" class="form-select"
data-filter="metadata"
autocomplete="off">
<option value="" selected>All</option>
<option value="" {% if not current_tag_filter %}selected{% endif %}>All</option>
{% for tag in brickset_tags %}
<option value="{{ tag.as_dataset() }}">{{ tag.fields.name }}</option>
<option value="{{ tag.as_dataset() }}" {% if current_tag_filter == tag.as_dataset() %}selected{% endif %}>{{ tag.fields.name }}</option>
{% endfor %}
</select>
</div>

View File

@@ -6,19 +6,14 @@
data-sort-attribute="set" data-sort-natural="true"><i class="ri-hashtag"></i><span class="d-none d-md-inline"> Set</span></button>
<button id="sort-name" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="name"><i class="ri-pencil-line"></i><span class="d-none d-md-inline"> Name</span></button>
{% if not use_pagination %}
<button id="sort-theme" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="theme"><i class="ri-price-tag-3-line"></i><span class="d-none d-md-inline"> Theme</span></button>
{% endif %}
<button id="sort-year" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="year"><i class="ri-calendar-line"></i><span class="d-none d-md-inline"> Year</span></button>
{% if not use_pagination %}
<button id="sort-minifigure" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="minifigures" data-sort-desc="true"><i class="ri-group-line"></i><span class="d-none d-xl-inline"> Figures</span></button>
{% endif %}
<button id="sort-parts" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="parts" data-sort-desc="true"><i class="ri-shapes-line"></i><span class="d-none d-xl-inline"> Parts</span></button>
{% if not use_pagination %}
{% if not config['HIDE_TABLE_MISSING_PARTS'] %}
<button id="sort-missing" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="missing" data-sort-desc="true"><i class="ri-question-line"></i><span class="d-none d-xl-inline"> Missing</span></button>
@@ -31,7 +26,6 @@
data-sort-attribute="purchase-date" data-sort-desc="true"><i class="ri-calendar-line"></i><span class="d-none d-xl-inline"> Date</span></button>
<button id="sort-purchase-price" type="button" class="btn btn-outline-primary mb-2"
data-sort-attribute="purchase-price" data-sort-desc="true"><i class="ri-wallet-3-line"></i><span class="d-none d-xl-inline"> Price</span></button>
{% endif %}
<button id="sort-clear" type="button" class="btn btn-outline-dark mb-2"
data-sort-clear="true"><i class="ri-close-circle-line"></i><span class="d-none d-xl-inline"> Clear</span></button>
</div>

View File

@@ -3,7 +3,7 @@
{% block title %} - All sets{% endblock %}
{% block main %}
{% if collection | length %}
{% if collection | length or use_pagination %}
<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">
@@ -21,7 +21,6 @@
</button>
</div>
</div>
{% if not use_pagination %}
<div class="col-12">
<div class="input-group">
<button class="btn btn-outline-primary" type="button" data-bs-toggle="collapse" data-bs-target="#grid-filter" aria-expanded="{% if config['SHOW_GRID_FILTERS'] %}true{% else %}false{% endif %}" aria-controls="grid-filter">
@@ -29,12 +28,9 @@
</button>
</div>
</div>
{% endif %}
</div>
{% include 'set/sort.html' %}
{% if not use_pagination %}
{% include 'set/filter.html' %}
{% endif %}
{% if use_pagination %}
<!-- PAGINATION MODE -->
@@ -144,9 +140,13 @@
<!-- Results Info -->
<div class="text-center mt-3">
<small class="text-muted">
Showing {{ ((pagination.page - 1) * pagination.per_page + 1) }} to
{{ [pagination.page * pagination.per_page, pagination.total_count] | min }}
of {{ pagination.total_count }} sets
{% if pagination.total_count > 0 %}
Showing {{ ((pagination.page - 1) * pagination.per_page + 1) }} to
{{ [pagination.page * pagination.per_page, pagination.total_count] | min }}
of {{ pagination.total_count }} sets
{% else %}
No sets found
{% endif %}
</small>
</div>
</div>