From 4e3ae491874d9d072f69c1abb78862f8063c9df0 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Mon, 3 Feb 2025 23:45:35 +0100
Subject: [PATCH] Set storage details
---
.env.sample | 16 +++++++--
CHANGELOG.md | 11 +++++-
bricktracker/app.py | 2 ++
bricktracker/config.py | 2 ++
bricktracker/metadata_list.py | 10 ++++--
bricktracker/navbar.py | 1 +
bricktracker/set.py | 12 ++++---
bricktracker/set_list.py | 12 +++++++
bricktracker/set_storage.py | 9 +++++
bricktracker/set_storage_list.py | 17 +++++++++
bricktracker/sql/set/list/using_storage.sql | 5 +++
bricktracker/sql/set/metadata/storage/all.sql | 14 ++++++++
.../sql/set/metadata/storage/base.sql | 15 ++++++--
bricktracker/views/storage.py | 36 +++++++++++++++++++
templates/macro/accordion.html | 4 +--
templates/macro/badge.html | 2 +-
templates/macro/table.html | 10 +++---
templates/storage.html | 15 ++++++++
templates/storage/card.html | 16 +++++++++
templates/storage/table.html | 16 +++++++++
templates/storages.html | 11 ++++++
21 files changed, 217 insertions(+), 19 deletions(-)
create mode 100644 bricktracker/sql/set/list/using_storage.sql
create mode 100644 bricktracker/sql/set/metadata/storage/all.sql
create mode 100644 bricktracker/views/storage.py
create mode 100644 templates/storage.html
create mode 100644 templates/storage/card.html
create mode 100644 templates/storage/table.html
create mode 100644 templates/storages.html
diff --git a/.env.sample b/.env.sample
index b93e8c6b..d14491e5 100644
--- a/.env.sample
+++ b/.env.sample
@@ -91,6 +91,11 @@
# Default: false
# 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.
# Default: false
# BK_HIDE_ALL_INSTRUCTIONS=true
@@ -107,10 +112,9 @@
# Default: false
# 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
-# Legacy name: BK_HIDE_MISSING_PARTS
-# BK_HIDE_ALL_PROBLEMS_PARTS=true
+# BK_HIDE_ALL_STORAGES=true
# Optional: Hide the 'Instructions' entry in a Set card
# Default: false
@@ -255,6 +259,12 @@
# Default: /bricksocket/
# 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
# Default: https://cdn.rebrickable.com/media/downloads/themes.csv.gz
# BK_THEMES_FILE_URL=
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5ed15af7..b50f3427 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,8 @@
- 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_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
@@ -28,7 +30,7 @@
- Deduplicate
- Compute number of parts
-Parts
+- Parts
- Damaged parts
- Sets
@@ -38,6 +40,9 @@ Parts
- Tags
- Storage
+- Storage
+ - Storage content and list
+
- Socket
- 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 sort (with configuration variable for default state)
+- Storage
+ - Storage list
+ - Storage content
+
## 1.1.1: PDF Instructions Download
### Instructions
diff --git a/bricktracker/app.py b/bricktracker/app.py
index 4b6f0d4f..af005d96 100644
--- a/bricktracker/app.py
+++ b/bricktracker/app.py
@@ -29,6 +29,7 @@ from bricktracker.views.login import login_page
from bricktracker.views.minifigure import minifigure_page
from bricktracker.views.part import part_page
from bricktracker.views.set import set_page
+from bricktracker.views.storage import storage_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(part_page)
app.register_blueprint(set_page)
+ app.register_blueprint(storage_page)
app.register_blueprint(wish_page)
# Register admin routes
diff --git a/bricktracker/config.py b/bricktracker/config.py
index cd7ef74a..5b9788fb 100644
--- a/bricktracker/config.py
+++ b/bricktracker/config.py
@@ -29,6 +29,7 @@ CONFIG: Final[list[dict[str, Any]]] = [
{'n': 'HIDE_ALL_MINIFIGURES', 'c': bool},
{'n': 'HIDE_ALL_PARTS', '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_SET_INSTRUCTIONS', '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': 'SOCKET_NAMESPACE', '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_PATH', 'd': './themes.csv'},
{'n': 'TIMEZONE', 'd': 'Etc/UTC'},
diff --git a/bricktracker/metadata_list.py b/bricktracker/metadata_list.py
index 68238e9d..60bfb5f1 100644
--- a/bricktracker/metadata_list.py
+++ b/bricktracker/metadata_list.py
@@ -43,8 +43,7 @@ class BrickMetadataList(BrickRecordList[T]):
# Records override (masking the class variables with instance ones)
if records is not None:
- self.records = []
- self.mapping = {}
+ self.override()
for metadata in records:
self.records.append(metadata)
@@ -79,6 +78,13 @@ class BrickMetadataList(BrickRecordList[T]):
def filter(self) -> list[T]:
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
@classmethod
def as_columns(cls, /, **kwargs) -> str:
diff --git a/bricktracker/navbar.py b/bricktracker/navbar.py
index 30007dee..20a2b292 100644
--- a/bricktracker/navbar.py
+++ b/bricktracker/navbar.py
@@ -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': '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': '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': 'admin.admin', 't': 'Admin', 'i': 'settings-4-line', 'f': 'HIDE_ADMIN'}, # noqa: E501
]
diff --git a/bricktracker/set.py b/bricktracker/set.py
index 90b2679b..6368d40c 100644
--- a/bricktracker/set.py
+++ b/bricktracker/set.py
@@ -214,7 +214,11 @@ class BrickSet(RebrickableSet):
# Compute the url for the refresh button
def url_for_refresh(self, /) -> str:
- return url_for(
- 'set.refresh',
- id=self.fields.id,
- )
+ return url_for('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 ''
diff --git a/bricktracker/set_list.py b/bricktracker/set_list.py
index b12f9717..6c3b9283 100644
--- a/bricktracker/set_list.py
+++ b/bricktracker/set_list.py
@@ -5,6 +5,7 @@ from flask import current_app
from .record_list import BrickRecordList
from .set_owner_list import BrickSetOwnerList
from .set_status_list import BrickSetStatusList
+from .set_storage import BrickSetStorage
from .set_tag_list import BrickSetTagList
from .set import BrickSet
@@ -24,6 +25,7 @@ class BrickSetList(BrickRecordList[BrickSet]):
select_query: str = 'set/list/all'
using_minifigure_query: str = 'set/list/using_minifigure'
using_part_query: str = 'set/list/using_part'
+ using_storage_query: str = 'set/list/using_storage'
def __init__(self, /):
super().__init__()
@@ -151,3 +153,13 @@ class BrickSetList(BrickRecordList[BrickSet]):
self.list(override_query=self.using_part_query)
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
diff --git a/bricktracker/set_storage.py b/bricktracker/set_storage.py
index 0a54262f..30c559c9 100644
--- a/bricktracker/set_storage.py
+++ b/bricktracker/set_storage.py
@@ -1,5 +1,7 @@
from .metadata import BrickMetadata
+from flask import url_for
+
# Lego set storage metadata
class BrickSetStorage(BrickMetadata):
@@ -11,3 +13,10 @@ class BrickSetStorage(BrickMetadata):
select_query: str = 'set/metadata/storage/select'
update_field_query: str = 'set/metadata/storage/update/field'
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,
+ )
diff --git a/bricktracker/set_storage_list.py b/bricktracker/set_storage_list.py
index 72efde70..8453f366 100644
--- a/bricktracker/set_storage_list.py
+++ b/bricktracker/set_storage_list.py
@@ -1,6 +1,8 @@
import logging
from typing import Self
+from flask import current_app
+
from .metadata_list import BrickMetadataList
from .set_storage import BrickSetStorage
@@ -13,10 +15,25 @@ class BrickSetStorageList(BrickMetadataList[BrickSetStorage]):
# Queries
select_query = 'set/metadata/storage/list'
+ all_query = 'set/metadata/storage/all'
# Set state endpoint
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
@classmethod
def new(cls, /, *, force: bool = False) -> Self:
diff --git a/bricktracker/sql/set/list/using_storage.sql b/bricktracker/sql/set/list/using_storage.sql
new file mode 100644
index 00000000..0dc0f14a
--- /dev/null
+++ b/bricktracker/sql/set/list/using_storage.sql
@@ -0,0 +1,5 @@
+{% extends 'set/base/full.sql' %}
+
+{% block where %}
+WHERE "bricktracker_sets"."storage" IS NOT DISTINCT FROM :storage
+{% endblock %}
diff --git a/bricktracker/sql/set/metadata/storage/all.sql b/bricktracker/sql/set/metadata/storage/all.sql
new file mode 100644
index 00000000..0bd8b8eb
--- /dev/null
+++ b/bricktracker/sql/set/metadata/storage/all.sql
@@ -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 %}
\ No newline at end of file
diff --git a/bricktracker/sql/set/metadata/storage/base.sql b/bricktracker/sql/set/metadata/storage/base.sql
index 2417aa69..bf616ed9 100644
--- a/bricktracker/sql/set/metadata/storage/base.sql
+++ b/bricktracker/sql/set/metadata/storage/base.sql
@@ -1,6 +1,17 @@
SELECT
"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"
-{% block where %}{% endblock %}
\ No newline at end of file
+{% block join %}{% endblock %}
+
+{% block where %}{% endblock %}
+
+{% block group %}{% endblock %}
+
+{% if order %}
+ORDER BY {{ order }}
+{% endif %}
diff --git a/bricktracker/views/storage.py b/bricktracker/views/storage.py
new file mode 100644
index 00000000..e41e97a4
--- /dev/null
+++ b/bricktracker/views/storage.py
@@ -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(),
+ )
diff --git a/templates/macro/accordion.html b/templates/macro/accordion.html
index 1fd91f3c..c5c89545 100644
--- a/templates/macro/accordion.html
+++ b/templates/macro/accordion.html
@@ -26,10 +26,10 @@
</div>
{% 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 %}
{% if size %}
- {{ header(title, id, parent, icon=icon) }}
+ {{ header(title, id, parent, expanded=expanded, icon=icon) }}
<div class="row">
{% for item in card_collection %}
<div class="col-md-6 col-xl-3 d-flex align-items-stretch">
diff --git a/templates/macro/badge.html b/templates/macro/badge.html
index bd683f40..e1f9071a 100644
--- a/templates/macro/badge.html
+++ b/templates/macro/badge.html
@@ -80,7 +80,7 @@
{% else %}
{% set text=storage.fields.name %}
{% 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 %}
{% endmacro %}
diff --git a/templates/macro/table.html b/templates/macro/table.html
index ebf1ded6..0638380f 100644
--- a/templates/macro/table.html
+++ b/templates/macro/table.html
@@ -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>
<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>
{% if color %}
<th scope="col"><i class="ri-palette-line fw-normal"></i> Color</th>
@@ -12,10 +14,10 @@
{% if quantity %}
<th data-table-number="true" scope="col"><i class="ri-functions fw-normal"></i> Quantity</th>
{% 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>
{% 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>
{% endif %}
{% if sets %}
diff --git a/templates/storage.html b/templates/storage.html
new file mode 100644
index 00000000..76e95497
--- /dev/null
+++ b/templates/storage.html
@@ -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 %}
diff --git a/templates/storage/card.html b/templates/storage/card.html
new file mode 100644
index 00000000..cf29de3f
--- /dev/null
+++ b/templates/storage/card.html
@@ -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>
diff --git a/templates/storage/table.html b/templates/storage/table.html
new file mode 100644
index 00000000..680c75c8
--- /dev/null
+++ b/templates/storage/table.html
@@ -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>
diff --git a/templates/storages.html b/templates/storages.html
new file mode 100644
index 00000000..14ac3548
--- /dev/null
+++ b/templates/storages.html
@@ -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 %}