Set storage details

This commit is contained in:
Gregoo 2025-02-03 23:45:35 +01:00
parent f9e9edd506
commit 4e3ae49187
21 changed files with 217 additions and 19 deletions

View File

@ -91,6 +91,11 @@
# Default: false # Default: false
# BK_HIDE_ADMIN=true # BK_HIDE_ADMIN=true
# Optional: Hide the 'Problems' entry from the menu. Does not disable the route.
# Default: false
# Legacy name: BK_HIDE_MISSING_PARTS
# BK_HIDE_ALL_PROBLEMS_PARTS=true
# Optional: Hide the 'Instructions' entry from the menu. Does not disable the route. # Optional: Hide the 'Instructions' entry from the menu. Does not disable the route.
# Default: false # Default: false
# BK_HIDE_ALL_INSTRUCTIONS=true # BK_HIDE_ALL_INSTRUCTIONS=true
@ -107,10 +112,9 @@
# Default: false # Default: false
# BK_HIDE_ALL_SETS=true # BK_HIDE_ALL_SETS=true
# Optional: Hide the 'Problems' entry from the menu. Does not disable the route. # Optional: Hide the 'Storages' entry from the menu. Does not disable the route.
# Default: false # Default: false
# Legacy name: BK_HIDE_MISSING_PARTS # BK_HIDE_ALL_STORAGES=true
# BK_HIDE_ALL_PROBLEMS_PARTS=true
# Optional: Hide the 'Instructions' entry in a Set card # Optional: Hide the 'Instructions' entry in a Set card
# Default: false # Default: false
@ -255,6 +259,12 @@
# Default: /bricksocket/ # Default: /bricksocket/
# BK_SOCKET_PATH=custompath # BK_SOCKET_PATH=custompath
# Optional: Change the default order of storages. By default ordered by insertion order.
# Useful column names for this option are:
# - "bricktracker_metadata_storages"."name" ASC: storage name
# Default: "bricktracker_metadata_storages"."name" ASC
# BK_MINIFIGURES_DEFAULT_ORDER="bricktracker_metadata_storages"."name" ASC
# Optional: URL to the themes.csv.gz on Rebrickable # Optional: URL to the themes.csv.gz on Rebrickable
# Default: https://cdn.rebrickable.com/media/downloads/themes.csv.gz # Default: https://cdn.rebrickable.com/media/downloads/themes.csv.gz
# BK_THEMES_FILE_URL= # BK_THEMES_FILE_URL=

View File

@ -13,6 +13,8 @@
- Added: `BK_HIDE_TABLE_DAMAGED_PARTS`, hide the Damaged column in all tables - Added: `BK_HIDE_TABLE_DAMAGED_PARTS`, hide the Damaged column in all tables
- Added: `BK_SHOW_GRID_SORT`, show the sort options on the grid by default - Added: `BK_SHOW_GRID_SORT`, show the sort options on the grid by default
- Added: `BK_SHOW_GRID_FILTERS`, show the filter options on the grid by default - Added: `BK_SHOW_GRID_FILTERS`, show the filter options on the grid by default
- Added: `BK_HIDE_ALL_STORAGES`, hide the "Storages" menu entry
- Added: `BK_MINIFIGURES_DEFAULT_ORDER`, ordering of storages
### Code ### Code
@ -28,7 +30,7 @@
- Deduplicate - Deduplicate
- Compute number of parts - Compute number of parts
Parts - Parts
- Damaged parts - Damaged parts
- Sets - Sets
@ -38,6 +40,9 @@ Parts
- Tags - Tags
- Storage - Storage
- Storage
- Storage content and list
- Socket - Socket
- Add decorator for rebrickable, authenticated and threaded socket actions - Add decorator for rebrickable, authenticated and threaded socket actions
@ -86,6 +91,10 @@ Parts
- Manually collapsible filters (with configuration variable for default state) - Manually collapsible filters (with configuration variable for default state)
- Manually collapsible sort (with configuration variable for default state) - Manually collapsible sort (with configuration variable for default state)
- Storage
- Storage list
- Storage content
## 1.1.1: PDF Instructions Download ## 1.1.1: PDF Instructions Download
### Instructions ### Instructions

View File

@ -29,6 +29,7 @@ from bricktracker.views.login import login_page
from bricktracker.views.minifigure import minifigure_page from bricktracker.views.minifigure import minifigure_page
from bricktracker.views.part import part_page from bricktracker.views.part import part_page
from bricktracker.views.set import set_page from bricktracker.views.set import set_page
from bricktracker.views.storage import storage_page
from bricktracker.views.wish import wish_page from bricktracker.views.wish import wish_page
@ -77,6 +78,7 @@ def setup_app(app: Flask) -> None:
app.register_blueprint(minifigure_page) app.register_blueprint(minifigure_page)
app.register_blueprint(part_page) app.register_blueprint(part_page)
app.register_blueprint(set_page) app.register_blueprint(set_page)
app.register_blueprint(storage_page)
app.register_blueprint(wish_page) app.register_blueprint(wish_page)
# Register admin routes # Register admin routes

View File

@ -29,6 +29,7 @@ CONFIG: Final[list[dict[str, Any]]] = [
{'n': 'HIDE_ALL_MINIFIGURES', 'c': bool}, {'n': 'HIDE_ALL_MINIFIGURES', 'c': bool},
{'n': 'HIDE_ALL_PARTS', 'c': bool}, {'n': 'HIDE_ALL_PARTS', 'c': bool},
{'n': 'HIDE_ALL_SETS', 'c': bool}, {'n': 'HIDE_ALL_SETS', 'c': bool},
{'n': 'HIDE_ALL_STORAGES', 'c': bool},
{'n': 'HIDE_ALL_PROBLEMS_PARTS', 'e': 'BK_HIDE_MISSING_PARTS', 'c': bool}, {'n': 'HIDE_ALL_PROBLEMS_PARTS', 'e': 'BK_HIDE_MISSING_PARTS', 'c': bool},
{'n': 'HIDE_SET_INSTRUCTIONS', 'c': bool}, {'n': 'HIDE_SET_INSTRUCTIONS', 'c': bool},
{'n': 'HIDE_TABLE_DAMAGED_PARTS', 'c': bool}, {'n': 'HIDE_TABLE_DAMAGED_PARTS', 'c': bool},
@ -59,6 +60,7 @@ CONFIG: Final[list[dict[str, Any]]] = [
{'n': 'SKIP_SPARE_PARTS', 'c': bool}, {'n': 'SKIP_SPARE_PARTS', 'c': bool},
{'n': 'SOCKET_NAMESPACE', 'd': 'bricksocket'}, {'n': 'SOCKET_NAMESPACE', 'd': 'bricksocket'},
{'n': 'SOCKET_PATH', 'd': '/bricksocket/'}, {'n': 'SOCKET_PATH', 'd': '/bricksocket/'},
{'n': 'STORAGE_DEFAULT_ORDER', 'd': '"bricktracker_metadata_storages"."name" ASC'}, # noqa: E501
{'n': 'THEMES_FILE_URL', 'd': 'https://cdn.rebrickable.com/media/downloads/themes.csv.gz'}, # noqa: E501 {'n': 'THEMES_FILE_URL', 'd': 'https://cdn.rebrickable.com/media/downloads/themes.csv.gz'}, # noqa: E501
{'n': 'THEMES_PATH', 'd': './themes.csv'}, {'n': 'THEMES_PATH', 'd': './themes.csv'},
{'n': 'TIMEZONE', 'd': 'Etc/UTC'}, {'n': 'TIMEZONE', 'd': 'Etc/UTC'},

View File

@ -43,8 +43,7 @@ class BrickMetadataList(BrickRecordList[T]):
# Records override (masking the class variables with instance ones) # Records override (masking the class variables with instance ones)
if records is not None: if records is not None:
self.records = [] self.override()
self.mapping = {}
for metadata in records: for metadata in records:
self.records.append(metadata) self.records.append(metadata)
@ -79,6 +78,13 @@ class BrickMetadataList(BrickRecordList[T]):
def filter(self) -> list[T]: def filter(self) -> list[T]:
return self.records return self.records
# Add a layer of override data
def override(self) -> None:
self.fields = BrickRecordFields()
self.records = []
self.mapping = {}
# Return the items as columns for a select # Return the items as columns for a select
@classmethod @classmethod
def as_columns(cls, /, **kwargs) -> str: def as_columns(cls, /, **kwargs) -> str:

View File

@ -14,6 +14,7 @@ NAVBAR: Final[list[dict[str, Any]]] = [
{'e': 'part.problem', 't': 'Problems', 'i': 'error-warning-line', 'f': 'HIDE_ALL_PROBLEMS_PARTS'}, # noqa: E501 {'e': 'part.problem', 't': 'Problems', 'i': 'error-warning-line', 'f': 'HIDE_ALL_PROBLEMS_PARTS'}, # noqa: E501
{'e': 'minifigure.list', 't': 'Minifigures', 'i': 'group-line', 'f': 'HIDE_ALL_MINIFIGURES'}, # noqa: E501 {'e': 'minifigure.list', 't': 'Minifigures', 'i': 'group-line', 'f': 'HIDE_ALL_MINIFIGURES'}, # noqa: E501
{'e': 'instructions.list', 't': 'Instructions', 'i': 'file-line', 'f': 'HIDE_ALL_INSTRUCTIONS'}, # noqa: E501 {'e': 'instructions.list', 't': 'Instructions', 'i': 'file-line', 'f': 'HIDE_ALL_INSTRUCTIONS'}, # noqa: E501
{'e': 'storage.list', 't': 'Storages', 'i': 'archive-2-line', 'f': 'HIDE_ALL_STORAGES'}, # noqa: E501
{'e': 'wish.list', 't': 'Wishlist', 'i': 'gift-line', 'f': 'HIDE_WISHES'}, {'e': 'wish.list', 't': 'Wishlist', 'i': 'gift-line', 'f': 'HIDE_WISHES'},
{'e': 'admin.admin', 't': 'Admin', 'i': 'settings-4-line', 'f': 'HIDE_ADMIN'}, # noqa: E501 {'e': 'admin.admin', 't': 'Admin', 'i': 'settings-4-line', 'f': 'HIDE_ADMIN'}, # noqa: E501
] ]

View File

@ -214,7 +214,11 @@ class BrickSet(RebrickableSet):
# Compute the url for the refresh button # Compute the url for the refresh button
def url_for_refresh(self, /) -> str: def url_for_refresh(self, /) -> str:
return url_for( return url_for('set.refresh', id=self.fields.id)
'set.refresh',
id=self.fields.id, # Compute the url for the set storage
) def url_for_storage(self, /) -> str:
if self.fields.storage is not None:
return url_for('storage.details', id=self.fields.storage)
else:
return ''

View File

@ -5,6 +5,7 @@ from flask import current_app
from .record_list import BrickRecordList from .record_list import BrickRecordList
from .set_owner_list import BrickSetOwnerList from .set_owner_list import BrickSetOwnerList
from .set_status_list import BrickSetStatusList from .set_status_list import BrickSetStatusList
from .set_storage import BrickSetStorage
from .set_tag_list import BrickSetTagList from .set_tag_list import BrickSetTagList
from .set import BrickSet from .set import BrickSet
@ -24,6 +25,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
select_query: str = 'set/list/all' select_query: str = 'set/list/all'
using_minifigure_query: str = 'set/list/using_minifigure' using_minifigure_query: str = 'set/list/using_minifigure'
using_part_query: str = 'set/list/using_part' using_part_query: str = 'set/list/using_part'
using_storage_query: str = 'set/list/using_storage'
def __init__(self, /): def __init__(self, /):
super().__init__() super().__init__()
@ -151,3 +153,13 @@ class BrickSetList(BrickRecordList[BrickSet]):
self.list(override_query=self.using_part_query) self.list(override_query=self.using_part_query)
return self return self
# Sets using a storage
def using_storage(self, storage: BrickSetStorage, /) -> Self:
# Save the parameters to the fields
self.fields.storage = storage.fields.id
# Load the sets from the database
self.list(override_query=self.using_storage_query)
return self

View File

@ -1,5 +1,7 @@
from .metadata import BrickMetadata from .metadata import BrickMetadata
from flask import url_for
# Lego set storage metadata # Lego set storage metadata
class BrickSetStorage(BrickMetadata): class BrickSetStorage(BrickMetadata):
@ -11,3 +13,10 @@ class BrickSetStorage(BrickMetadata):
select_query: str = 'set/metadata/storage/select' select_query: str = 'set/metadata/storage/select'
update_field_query: str = 'set/metadata/storage/update/field' update_field_query: str = 'set/metadata/storage/update/field'
update_set_state_query: str = 'set/metadata/storage/update/state' update_set_state_query: str = 'set/metadata/storage/update/state'
# Self url
def url(self, /) -> str:
return url_for(
'storage.details',
id=self.fields.id,
)

View File

@ -1,6 +1,8 @@
import logging import logging
from typing import Self from typing import Self
from flask import current_app
from .metadata_list import BrickMetadataList from .metadata_list import BrickMetadataList
from .set_storage import BrickSetStorage from .set_storage import BrickSetStorage
@ -13,10 +15,25 @@ class BrickSetStorageList(BrickMetadataList[BrickSetStorage]):
# Queries # Queries
select_query = 'set/metadata/storage/list' select_query = 'set/metadata/storage/list'
all_query = 'set/metadata/storage/all'
# Set state endpoint # Set state endpoint
set_state_endpoint: str = 'set.update_storage' set_state_endpoint: str = 'set.update_storage'
# Load all storages
@classmethod
def all(cls, /) -> Self:
new = cls.new()
new.override()
for record in new.select(
override_query=cls.all_query,
order=current_app.config['STORAGE_DEFAULT_ORDER']
):
new.records.append(new.model(record=record))
return new
# Instantiate the list with the proper class # Instantiate the list with the proper class
@classmethod @classmethod
def new(cls, /, *, force: bool = False) -> Self: def new(cls, /, *, force: bool = False) -> Self:

View File

@ -0,0 +1,5 @@
{% extends 'set/base/full.sql' %}
{% block where %}
WHERE "bricktracker_sets"."storage" IS NOT DISTINCT FROM :storage
{% endblock %}

View File

@ -0,0 +1,14 @@
{% extends 'set/metadata/storage/base.sql' %}
{% block total_sets %}
IFNULL(COUNT("bricktracker_sets"."id"), 0) AS "total_sets"
{% endblock %}
{% block join %}
LEFT JOIN "bricktracker_sets"
ON "bricktracker_metadata_storages"."id" IS NOT DISTINCT FROM "bricktracker_sets"."storage"
{% endblock %}
{% block group %}
GROUP BY "bricktracker_metadata_storages"."id"
{% endblock %}

View File

@ -1,6 +1,17 @@
SELECT SELECT
"bricktracker_metadata_storages"."id", "bricktracker_metadata_storages"."id",
"bricktracker_metadata_storages"."name" "bricktracker_metadata_storages"."name",
{% block total_sets %}
NULL as "total_sets" -- dummy for order: total_sets
{% endblock %}
FROM "bricktracker_metadata_storages" FROM "bricktracker_metadata_storages"
{% block where %}{% endblock %} {% block join %}{% endblock %}
{% block where %}{% endblock %}
{% block group %}{% endblock %}
{% if order %}
ORDER BY {{ order }}
{% endif %}

View File

@ -0,0 +1,36 @@
from flask import Blueprint, render_template
from .exceptions import exception_handler
from ..set_owner_list import BrickSetOwnerList
from ..set_list import BrickSetList
from ..set_storage import BrickSetStorage
from ..set_storage_list import BrickSetStorageList
from ..set_tag_list import BrickSetTagList
storage_page = Blueprint('storage', __name__, url_prefix='/storages')
# Index
@storage_page.route('/', methods=['GET'])
@exception_handler(__file__)
def list() -> str:
return render_template(
'storages.html',
table_collection=BrickSetStorageList.all(),
)
# Storage details
@storage_page.route('/<id>/details')
@exception_handler(__file__)
def details(*, id: str) -> str:
storage = BrickSetStorage().select_specific(id)
return render_template(
'storage.html',
item=storage,
sets=BrickSetList().using_storage(storage),
brickset_owners=BrickSetOwnerList.list(),
brickset_storages=BrickSetStorageList.list(as_class=True),
brickset_tags=BrickSetTagList.list(),
)

View File

@ -26,10 +26,10 @@
</div> </div>
{% endmacro %} {% endmacro %}
{% macro cards(card_collection, title, id, parent, target, icon=none) %} {% macro cards(card_collection, title, id, parent, target, expanded=false, icon=none) %}
{% set size=card_collection | length %} {% set size=card_collection | length %}
{% if size %} {% if size %}
{{ header(title, id, parent, icon=icon) }} {{ header(title, id, parent, expanded=expanded, icon=icon) }}
<div class="row"> <div class="row">
{% for item in card_collection %} {% for item in card_collection %}
<div class="col-md-6 col-xl-3 d-flex align-items-stretch"> <div class="col-md-6 col-xl-3 d-flex align-items-stretch">

View File

@ -80,7 +80,7 @@
{% else %} {% else %}
{% set text=storage.fields.name %} {% set text=storage.fields.name %}
{% endif %} {% endif %}
{{ badge(check=storage, solo=solo, last=last, color='light text-warning-emphasis bg-warning-subtle border border-warning-subtle', icon='archive-2-line', text=text, alt='Storage', tooltip=tooltip) }} {{ badge(url=item.url_for_storage(), solo=solo, last=last, color='light text-warning-emphasis bg-warning-subtle border border-warning-subtle', icon='archive-2-line', text=text, alt='Storage', tooltip=tooltip) }}
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}

View File

@ -1,7 +1,9 @@
{% macro header(color=false, parts=false, quantity=false, missing_parts=false, damaged_parts=false, sets=false, minifigures=false) %} {% macro header(image=true, color=false, parts=false, quantity=false, missing=true, missing_parts=false, damaged=true, damaged_parts=false, sets=false, minifigures=false) %}
<thead> <thead>
<tr> <tr>
<th data-table-no-sort="true" class="no-sort" scope="col"><i class="ri-image-line fw-normal"></i> Image</th> {% if image %}
<th data-table-no-sort="true" class="no-sort" scope="col"><i class="ri-image-line fw-normal"></i> Image</th>
{% endif %}
<th scope="col"><i class="ri-pencil-line fw-normal"></i> Name</th> <th scope="col"><i class="ri-pencil-line fw-normal"></i> Name</th>
{% if color %} {% if color %}
<th scope="col"><i class="ri-palette-line fw-normal"></i> Color</th> <th scope="col"><i class="ri-palette-line fw-normal"></i> Color</th>
@ -12,10 +14,10 @@
{% if quantity %} {% if quantity %}
<th data-table-number="true" scope="col"><i class="ri-functions fw-normal"></i> Quantity</th> <th data-table-number="true" scope="col"><i class="ri-functions fw-normal"></i> Quantity</th>
{% endif %} {% endif %}
{% if not config['HIDE_TABLE_MISSING_PARTS'] %} {% if missing and not config['HIDE_TABLE_MISSING_PARTS'] %}
<th data-table-number="true" scope="col"><i class="ri-question-line fw-normal"></i> Missing{% if missing_parts %} parts{% endif %}</th> <th data-table-number="true" scope="col"><i class="ri-question-line fw-normal"></i> Missing{% if missing_parts %} parts{% endif %}</th>
{% endif %} {% endif %}
{% if not config['HIDE_TABLE_DAMAGED_PARTS'] %} {% if damaged and not config['HIDE_TABLE_DAMAGED_PARTS'] %}
<th data-table-number="true" scope="col"><i class="ri-error-warning-line fw-normal"></i> Damaged{% if damaged_parts %} parts{% endif %}</th> <th data-table-number="true" scope="col"><i class="ri-error-warning-line fw-normal"></i> Damaged{% if damaged_parts %} parts{% endif %}</th>
{% endif %} {% endif %}
{% if sets %} {% if sets %}

15
templates/storage.html Normal file
View File

@ -0,0 +1,15 @@
{% extends 'base.html' %}
{% block title %} - Storage {{ item.fields.name}}{% endblock %}
{% block main %}
<div class="container">
<div class="row">
<div class="col-12">
{% with solo=true %}
{% include 'storage/card.html' %}
{% endwith %}
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,16 @@
{% import 'macro/accordion.html' as accordion with context %}
{% import 'macro/badge.html' as badge %}
{% import 'macro/card.html' as card %}
<div class="card mb-3 flex-fill {% if solo %}card-solo{% endif %}">
{{ card.header(item, item.fields.name, solo=solo, icon='archive-2-line') }}
<div class="card-body border-bottom-0 {% if not solo %}p-1{% endif %}">
{{ badge.total_sets(sets | length, solo=solo, last=last) }}
</div>
{% if solo %}
<div class="accordion accordion-flush border-top" id="storage-details">
{{ accordion.cards(sets, 'Sets', 'sets-stored', 'storage-details', 'set/card.html', expanded=true, icon='hashtag') }}
</div>
<div class="card-footer"></div>
{% endif %}
</div>

View File

@ -0,0 +1,16 @@
{% import 'macro/form.html' as form %}
{% import 'macro/table.html' as table %}
<div class="table-responsive-sm">
<table data-table="true" class="table table-striped align-middle" id="storage">
{{ table.header(image=false, missing=false, damaged=false, sets=true) }}
<tbody>
{% for item in table_collection %}
<tr>
<td data-sort="{{ item.fields.name }}"><a class="text-reset" href="{{ item.url() }}">{{ item.fields.name }}</a></td>
<td>{{ item.fields.total_sets }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>

11
templates/storages.html Normal file
View File

@ -0,0 +1,11 @@
{% extends 'base.html' %}
{% block title %} - All storages{% endblock %}
{% block main %}
<div class="container-fluid px-0">
{% with all=true %}
{% include 'storage/table.html' %}
{% endwith %}
</div>
{% endblock %}