From 64a9e063ec72816f8955da14f9dfcd6657f5b967 Mon Sep 17 00:00:00 2001
From: Gregoo <versatile.mailbox@gmail.com>
Date: Tue, 4 Feb 2025 20:07:15 +0100
Subject: [PATCH] Wish requesters
---
CHANGELOG.md | 6 ++
bricktracker/metadata_list.py | 4 +-
bricktracker/record_list.py | 2 +
bricktracker/reload.py | 4 ++
bricktracker/sql/migrations/0013.sql | 7 ++
.../sql/set/metadata/owner/delete.sql | 4 ++
.../sql/set/metadata/owner/insert.sql | 4 ++
bricktracker/sql/wish/base/base.sql | 8 +++
bricktracker/sql/wish/delete/wish.sql | 12 +++-
.../sql/wish/metadata/owner/update/state.sql | 10 +++
bricktracker/views/wish.py | 71 ++++++++++++++++---
bricktracker/wish.py | 19 +++--
bricktracker/wish_list.py | 4 +-
bricktracker/wish_owner.py | 70 ++++++++++++++++++
bricktracker/wish_owner_list.py | 21 ++++++
templates/wish.html | 15 ++++
templates/wish/card.html | 49 +++++++++++++
templates/wish/table.html | 11 ++-
templates/wishes.html | 2 +-
19 files changed, 300 insertions(+), 23 deletions(-)
create mode 100644 bricktracker/sql/wish/metadata/owner/update/state.sql
create mode 100644 bricktracker/wish_owner.py
create mode 100644 bricktracker/wish_owner_list.py
create mode 100644 templates/wish.html
create mode 100644 templates/wish/card.html
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 46c76b54..a6f89a8d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -55,6 +55,9 @@
- Allow for advanced migration scenarios through companion python files
- Add a bunch of the requested fields into the database for future implementation
+- Wish
+ - Requester
+
### UI
- Add
@@ -101,6 +104,9 @@
- Storage list
- Storage content
+- Wish
+ - Requester
+
## 1.1.1: PDF Instructions Download
### Instructions
diff --git a/bricktracker/metadata_list.py b/bricktracker/metadata_list.py
index a7df7521..44bf18fe 100644
--- a/bricktracker/metadata_list.py
+++ b/bricktracker/metadata_list.py
@@ -11,6 +11,7 @@ from .set_purchase_location import BrickSetPurchaseLocation
from .set_status import BrickSetStatus
from .set_storage import BrickSetStorage
from .set_tag import BrickSetTag
+from .wish_owner import BrickWishOwner
logger = logging.getLogger(__name__)
@@ -20,7 +21,8 @@ T = TypeVar(
BrickSetPurchaseLocation,
BrickSetStatus,
BrickSetStorage,
- BrickSetTag
+ BrickSetTag,
+ BrickWishOwner
)
diff --git a/bricktracker/record_list.py b/bricktracker/record_list.py
index 18dcb10c..8d862ed2 100644
--- a/bricktracker/record_list.py
+++ b/bricktracker/record_list.py
@@ -14,6 +14,7 @@ if TYPE_CHECKING:
from .set_storage import BrickSetStorage
from .set_tag import BrickSetTag
from .wish import BrickWish
+ from .wish_owner import BrickWishOwner
T = TypeVar(
'T',
@@ -26,6 +27,7 @@ T = TypeVar(
'BrickSetStorage',
'BrickSetTag',
'BrickWish',
+ 'BrickWishOwner',
'RebrickableSet'
)
diff --git a/bricktracker/reload.py b/bricktracker/reload.py
index 38929f68..99d95bbf 100644
--- a/bricktracker/reload.py
+++ b/bricktracker/reload.py
@@ -6,6 +6,7 @@ from .set_status_list import BrickSetStatusList
from .set_storage_list import BrickSetStorageList
from .set_tag_list import BrickSetTagList
from .theme_list import BrickThemeList
+from .wish_owner_list import BrickWishOwnerList
# Reload everything related to a database after an operation
@@ -35,5 +36,8 @@ def reload() -> None:
# Reload themes
BrickThemeList(force=True)
+
+ # Reload the wish owners
+ BrickWishOwnerList.new(force=True)
except Exception:
pass
diff --git a/bricktracker/sql/migrations/0013.sql b/bricktracker/sql/migrations/0013.sql
index 33f8a6f0..469649d5 100644
--- a/bricktracker/sql/migrations/0013.sql
+++ b/bricktracker/sql/migrations/0013.sql
@@ -16,4 +16,11 @@ CREATE TABLE "bricktracker_set_owners" (
FOREIGN KEY("id") REFERENCES "bricktracker_sets"("id")
);
+-- Create a table for the wish owners
+CREATE TABLE "bricktracker_wish_owners" (
+ "set" TEXT NOT NULL,
+ PRIMARY KEY("set"),
+ FOREIGN KEY("set") REFERENCES "bricktracker_wishes"("set")
+);
+
COMMIT;
diff --git a/bricktracker/sql/set/metadata/owner/delete.sql b/bricktracker/sql/set/metadata/owner/delete.sql
index e9df18d8..5927bfdb 100644
--- a/bricktracker/sql/set/metadata/owner/delete.sql
+++ b/bricktracker/sql/set/metadata/owner/delete.sql
@@ -3,6 +3,10 @@ BEGIN TRANSACTION;
ALTER TABLE "bricktracker_set_owners"
DROP COLUMN "owner_{{ id }}";
+-- Also drop from wishes
+ALTER TABLE "bricktracker_wish_owners"
+DROP COLUMN "owner_{{ id }}";
+
DELETE FROM "bricktracker_metadata_owners"
WHERE "bricktracker_metadata_owners"."id" IS NOT DISTINCT FROM '{{ id }}';
diff --git a/bricktracker/sql/set/metadata/owner/insert.sql b/bricktracker/sql/set/metadata/owner/insert.sql
index cc54a2a5..6b2de775 100644
--- a/bricktracker/sql/set/metadata/owner/insert.sql
+++ b/bricktracker/sql/set/metadata/owner/insert.sql
@@ -3,6 +3,10 @@ BEGIN TRANSACTION;
ALTER TABLE "bricktracker_set_owners"
ADD COLUMN "owner_{{ id }}" BOOLEAN NOT NULL DEFAULT 0;
+-- Also inject into wishes
+ALTER TABLE "bricktracker_wish_owners"
+ADD COLUMN "owner_{{ id }}" BOOLEAN NOT NULL DEFAULT 0;
+
INSERT INTO "bricktracker_metadata_owners" (
"id",
"name"
diff --git a/bricktracker/sql/wish/base/base.sql b/bricktracker/sql/wish/base/base.sql
index b06c66fd..62f5a7cb 100644
--- a/bricktracker/sql/wish/base/base.sql
+++ b/bricktracker/sql/wish/base/base.sql
@@ -5,9 +5,17 @@ SELECT
"bricktracker_wishes"."theme_id",
"bricktracker_wishes"."number_of_parts",
"bricktracker_wishes"."image",
+ {% block owners %}
+ {% if owners %}{{ owners }},{% endif %}
+ {% endblock %}
"bricktracker_wishes"."url"
FROM "bricktracker_wishes"
+{% if owners %}
+LEFT JOIN "bricktracker_wish_owners"
+ON "bricktracker_wishes"."set" IS NOT DISTINCT FROM "bricktracker_wish_owners"."set"
+{% endif %}
+
{% block where %}{% endblock %}
{% if order %}
diff --git a/bricktracker/sql/wish/delete/wish.sql b/bricktracker/sql/wish/delete/wish.sql
index e60b2e48..1adcfc11 100644
--- a/bricktracker/sql/wish/delete/wish.sql
+++ b/bricktracker/sql/wish/delete/wish.sql
@@ -1,2 +1,12 @@
+-- A bit unsafe as it does not use a prepared statement but it
+-- should not be possible to inject anything through the {{ set }} context
+
+BEGIN TRANSACTION;
+
DELETE FROM "bricktracker_wishes"
-WHERE "bricktracker_wishes"."set" IS NOT DISTINCT FROM :set
\ No newline at end of file
+WHERE "bricktracker_wishes"."set" IS NOT DISTINCT FROM '{{ set }}';
+
+DELETE FROM "bricktracker_wish_owners"
+WHERE "bricktracker_wish_owners"."set" IS NOT DISTINCT FROM '{{ set }}';
+
+COMMIT;
\ No newline at end of file
diff --git a/bricktracker/sql/wish/metadata/owner/update/state.sql b/bricktracker/sql/wish/metadata/owner/update/state.sql
new file mode 100644
index 00000000..9191ca8d
--- /dev/null
+++ b/bricktracker/sql/wish/metadata/owner/update/state.sql
@@ -0,0 +1,10 @@
+INSERT INTO "bricktracker_wish_owners" (
+ "set",
+ "{{name}}"
+) VALUES (
+ :set,
+ :state
+)
+ON CONFLICT("set")
+DO UPDATE SET "{{name}}" = :state
+WHERE "bricktracker_wish_owners"."set" IS NOT DISTINCT FROM :set
diff --git a/bricktracker/views/wish.py b/bricktracker/views/wish.py
index b0c763b2..416b9005 100644
--- a/bricktracker/views/wish.py
+++ b/bricktracker/views/wish.py
@@ -1,4 +1,11 @@
-from flask import Blueprint, redirect, render_template, request, url_for
+from flask import (
+ Blueprint,
+ jsonify,
+ redirect,
+ render_template,
+ request,
+ url_for
+)
from flask_login import login_required
from werkzeug.wrappers.response import Response
@@ -6,8 +13,10 @@ from .exceptions import exception_handler
from ..retired_list import BrickRetiredList
from ..wish import BrickWish
from ..wish_list import BrickWishList
+from ..wish_owner_list import BrickWishOwnerList
-wish_page = Blueprint('wish', __name__, url_prefix='/wishlist')
+
+wish_page = Blueprint('wish', __name__, url_prefix='/wishes')
# Index
@@ -18,7 +27,8 @@ def list() -> str:
'wishes.html',
table_collection=BrickWishList().all(),
retired=BrickRetiredList(),
- error=request.args.get('error')
+ error=request.args.get('error'),
+ owners=BrickWishOwnerList.list(),
)
@@ -27,21 +37,60 @@ def list() -> str:
@login_required
@exception_handler(__file__, post_redirect='wish.list')
def add() -> Response:
- # Grab the set number
- number: str = request.form.get('number', '')
+ # Grab the set
+ set: str = request.form.get('set', '')
- if number != '':
- BrickWishList.add(number)
+ if set != '':
+ BrickWishList.add(set)
return redirect(url_for('wish.list'))
-# Delete a set from the wishlit
-@wish_page.route('/delete/<number>', methods=['POST'])
+# Ask for deletion of a wish
+@wish_page.route('/<set>/delete', methods=['GET'])
+@login_required
+@exception_handler(__file__)
+def delete(*, set: str) -> str:
+ return render_template(
+ 'wish.html',
+ delete=True,
+ item=BrickWish().select_specific(set),
+ error=request.args.get('error'),
+ owners=BrickWishOwnerList.list(),
+ )
+
+
+# Actually delete of a set
+@wish_page.route('/<set>/delete', methods=['POST'])
@login_required
@exception_handler(__file__, post_redirect='wish.list')
-def delete(*, number: str) -> Response:
- brickwish = BrickWish().select_specific(number)
+def do_delete(*, set: str) -> Response:
+ brickwish = BrickWish().select_specific(set)
brickwish.delete()
return redirect(url_for('wish.list'))
+
+
+# Details
+@wish_page.route('/<set>/details', methods=['GET'])
+@exception_handler(__file__)
+def details(*, set: str) -> str:
+ return render_template(
+ 'wish.html',
+ item=BrickWish().select_specific(set),
+ retired=BrickRetiredList(),
+ owners=BrickWishOwnerList.list(),
+ )
+
+
+# Change the state of a owner
+@wish_page.route('/<set>/owner/<metadata_id>', methods=['POST'])
+@login_required
+@exception_handler(__file__, json=True)
+def update_owner(*, set: str, metadata_id: str) -> Response:
+ brickwish = BrickWish().select_specific(set)
+ owner = BrickWishOwnerList.get(metadata_id)
+
+ state = owner.update_wish_state(brickwish, json=request.json)
+
+ return jsonify({'value': state})
diff --git a/bricktracker/wish.py b/bricktracker/wish.py
index def41e28..502792fe 100644
--- a/bricktracker/wish.py
+++ b/bricktracker/wish.py
@@ -5,6 +5,7 @@ from flask import url_for
from .exceptions import NotFoundException
from .rebrickable_set import RebrickableSet
from .sql import BrickSQL
+from .wish_owner_list import BrickWishOwnerList
# Lego brick wished set
@@ -16,11 +17,11 @@ class BrickWish(RebrickableSet):
select_query: str = 'wish/select'
insert_query: str = 'wish/insert'
- # Delete a wished set
+ # Delete a wish
def delete(self, /) -> None:
- BrickSQL().execute_and_commit(
+ BrickSQL().executescript(
'wish/delete/wish',
- parameters=self.sql_parameters()
+ set=self.fields.set
)
# Select a specific part (with a set and an id)
@@ -29,7 +30,7 @@ class BrickWish(RebrickableSet):
self.fields.set = set
# Load from database
- if not self.select():
+ if not self.select(owners=BrickWishOwnerList.as_columns()):
raise NotFoundException(
'Wish for set {set} was not found in the database'.format( # noqa: E501
set=self.fields.set,
@@ -38,6 +39,14 @@ class BrickWish(RebrickableSet):
return self
+ # Self url
+ def url(self, /) -> str:
+ return url_for('wish.details', set=self.fields.set)
+
# Deletion url
def url_for_delete(self, /) -> str:
- return url_for('wish.delete', number=self.fields.set)
+ return url_for('wish.delete', set=self.fields.set)
+
+ # Actual deletion url
+ def url_for_do_delete(self, /) -> str:
+ return url_for('wish.do_delete', set=self.fields.set)
diff --git a/bricktracker/wish_list.py b/bricktracker/wish_list.py
index 880021b7..d3038b8a 100644
--- a/bricktracker/wish_list.py
+++ b/bricktracker/wish_list.py
@@ -9,6 +9,7 @@ from .rebrickable import Rebrickable
from .rebrickable_image import RebrickableImage
from .record_list import BrickRecordList
from .wish import BrickWish
+from .wish_owner_list import BrickWishOwnerList
logger = logging.getLogger(__name__)
@@ -22,7 +23,8 @@ class BrickWishList(BrickRecordList[BrickWish]):
def all(self, /) -> Self:
# Load the wished sets from the database
for record in self.select(
- order=current_app.config['WISHES_DEFAULT_ORDER']
+ order=current_app.config['WISHES_DEFAULT_ORDER'],
+ owners=BrickWishOwnerList.as_columns(),
):
brickwish = BrickWish(record=record)
diff --git a/bricktracker/wish_owner.py b/bricktracker/wish_owner.py
new file mode 100644
index 00000000..c31d2c9f
--- /dev/null
+++ b/bricktracker/wish_owner.py
@@ -0,0 +1,70 @@
+import logging
+from typing import Any, TYPE_CHECKING
+
+from flask import url_for
+
+from .exceptions import DatabaseException
+from .metadata import BrickMetadata
+from .sql import BrickSQL
+if TYPE_CHECKING:
+ from .wish import BrickWish
+
+logger = logging.getLogger(__name__)
+
+
+# Lego wish owner metadata
+class BrickWishOwner(BrickMetadata):
+ kind: str = 'owner'
+
+ # Wish state endpoint
+ wish_state_endpoint: str = 'wish.update_owner'
+
+ # Queries
+ update_wish_state_query: str = 'wish/metadata/owner/update/state'
+
+ # Update the selected state of this metadata item for a wish
+ def update_wish_state(
+ self,
+ brickset: 'BrickWish',
+ /,
+ *,
+ json: Any | None = None,
+ state: Any | None = None
+ ) -> Any:
+ if state is None and json is not None:
+ state = json.get('value', False)
+
+ parameters = self.sql_parameters()
+ parameters['set'] = brickset.fields.set
+ parameters['state'] = state
+
+ rows, _ = BrickSQL().execute_and_commit(
+ self.update_wish_state_query,
+ parameters=parameters,
+ name=self.as_column(),
+ )
+
+ if rows != 1:
+ raise DatabaseException('Could not update the {kind} "{name}" state for wish {set}'.format( # noqa: E501
+ kind=self.kind,
+ name=self.fields.name,
+ set=brickset.fields.set,
+ ))
+
+ # Info
+ logger.info('{kind} "{name}" state changed to "{state}" for wish {set}'.format( # noqa: E501
+ kind=self.kind,
+ name=self.fields.name,
+ state=state,
+ set=brickset.fields.set,
+ ))
+
+ return state
+
+ # URL to change the selected state of this metadata item for a wish
+ def url_for_wish_state(self, set: str, /) -> str:
+ return url_for(
+ self.wish_state_endpoint,
+ set=set,
+ metadata_id=self.fields.id
+ )
diff --git a/bricktracker/wish_owner_list.py b/bricktracker/wish_owner_list.py
new file mode 100644
index 00000000..719fc6f5
--- /dev/null
+++ b/bricktracker/wish_owner_list.py
@@ -0,0 +1,21 @@
+from typing import Self
+
+from .metadata_list import BrickMetadataList
+from .wish_owner import BrickWishOwner
+
+
+# Lego sets owner list
+class BrickWishOwnerList(BrickMetadataList[BrickWishOwner]):
+ kind: str = 'wish owners'
+
+ # Database
+ table: str = 'bricktracker_wish_owners'
+ order: str = '"bricktracker_metadata_owners"."name"'
+
+ # Queries
+ select_query = 'set/metadata/owner/list'
+
+ # Instantiate the list with the proper class
+ @classmethod
+ def new(cls, /, *, force: bool = False) -> Self:
+ return cls(BrickWishOwner, force=force)
diff --git a/templates/wish.html b/templates/wish.html
new file mode 100644
index 00000000..6c0f3990
--- /dev/null
+++ b/templates/wish.html
@@ -0,0 +1,15 @@
+{% extends 'base.html' %}
+
+{% block title %} - {% if delete %}Delete a wish{% else %}Wish{% endif %} {{ item.fields.name}} ({{ item.fields.set }}){% endblock %}
+
+{% block main %}
+<div class="container">
+ <div class="row">
+ <div class="col-12">
+ {% with solo=true, delete=delete %}
+ {% include 'wish/card.html' %}
+ {% endwith %}
+ </div>
+ </div>
+</div>
+{% endblock %}
diff --git a/templates/wish/card.html b/templates/wish/card.html
new file mode 100644
index 00000000..fc16d9b9
--- /dev/null
+++ b/templates/wish/card.html
@@ -0,0 +1,49 @@
+{% import 'macro/accordion.html' as accordion %}
+{% import 'macro/badge.html' as badge %}
+{% import 'macro/card.html' as card %}
+{% import 'macro/form.html' as form %}
+
+<div class="card mb-3 flex-fill {% if solo %}card-solo{% endif %}">
+ {{ card.header(item, item.fields.name, solo=solo, identifier=item.fields.set) }}
+ {{ card.image(item, solo=solo, last=last, caption=item.fields.name, alt=item.fields.set) }}
+ <div class="card-body border-bottom-0 {% if not solo %}p-1{% endif %}">
+ {{ badge.theme(item.theme.name, solo=solo, last=last) }}
+ {{ badge.parts(item.fields.number_of_parts, solo=solo, last=last) }}
+ {% for owner in owners %}
+ {{ badge.owner(item, owner, solo=solo, last=last) }}
+ {% endfor %}
+ </div>
+ {% if solo and g.login.is_authenticated() %}
+ <div class="accordion accordion-flush border-top" id="wish-details">
+ {% if not delete %}
+ {{ accordion.header('Requester', 'owner', 'wish-details', icon='group-line', class='p-0') }}
+ <ul class="list-group list-group-flush">
+ {% if owners | length %}
+ {% for owner in owners %}
+ <li class="d-flex list-group-item list-group-item-action text-nowrap">{{ form.checkbox(owner.fields.name, item.fields.set, owner.as_dataset(), owner.url_for_wish_state(item.fields.set), item.fields[owner.as_column()], delete=delete) }}</li>
+ {% endfor %}
+ {% else %}
+ <li class="list-group-item list-group-item-action text-center"><i class="ri-error-warning-line"></i> No requester found.</li>
+ {% endif %}
+ </ul>
+ <div class="list-group list-group-flush border-top">
+ <a class="list-group-item list-group-item-action" href="{{ url_for('admin.admin', open_owner=true) }}"><i class="ri-settings-4-line"></i> Manage the set owners</a>
+ </div>
+ {{ accordion.footer() }}
+ {% endif %}
+ {{ accordion.header('Danger zone', 'danger-zone', 'wish-details', expanded=delete, danger=true, class='text-end') }}
+ {% if delete %}
+ <form action="{{ item.url_for_do_delete() }}" method="post">
+ {% if error %}<div class="alert alert-danger text-start" role="alert"><strong>Error:</strong> {{ error }}.</div>{% endif %}
+ <div class="alert alert-danger text-center" role="alert">You are about to delete a wish. This action is irreversible.</div>
+ <a class="btn btn-primary" href="{{ item.url() }}" role="button"><i class="ri-arrow-left-long-line"></i> Back to the wish</a>
+ <button type="submit" class="btn btn-danger"><i class="ri-close-line"></i> Delete the wish</button>
+ </form>
+ {% else %}
+ <a href="{{ item.url_for_delete() }}" class="btn btn-danger" role="button"><i class="ri-close-line"></i> Delete the wish</a>
+ {% endif %}
+ {{ accordion.footer() }}
+ </div>
+ <div class="card-footer"></div>
+ {% endif %}
+</div>
diff --git a/templates/wish/table.html b/templates/wish/table.html
index ca222427..41783ca9 100644
--- a/templates/wish/table.html
+++ b/templates/wish/table.html
@@ -12,6 +12,7 @@
<th scope="col"><i class="ri-calendar-line fw-normal"></i> Year</th>
<th scope="col"><i class="ri-shapes-line fw-normal"></i> Parts</th>
<th scope="col"><i class="ri-calendar-close-line fw-normal"></i> Retirement</th>
+ <th data-table-no-sort="true" class="no-sort" scope="col"><i class="ri-user-line fw-normal"></i> Requesters</th>
{% if g.login.is_authenticated() %}
<th data-table-no-sort-and-search="true" class="no-sort" scope="col"><i class="ri-settings-4-line fw-normal"></i> Actions</th>
{% endif %}
@@ -28,11 +29,15 @@
<td>{{ item.fields.year }}</td>
<td>{{ item.fields.number_of_parts }}</td>
<td>{% if retirement_date %}{{ retirement_date }}{% else %}<span class="badge rounded-pill text-bg-light border">Not found</span>{% endif %}</td>
+ <td>
+ {% for owner in owners %}
+ {{ badge.owner(item, owner) }}
+ {% endfor %}
+ </td>
{% if g.login.is_authenticated() %}
<td>
- <form action="{{ item.url_for_delete() }}" method="post">
- <button type="submit" class="btn btn-sm btn-danger"><i class="ri-delete-bin-2-line"></i> Delete</button>
- </form>
+ <a href="{{ item.url() }}" class="btn btn-sm btn-primary mb-1" role="button"><i class="ri-gift-line"></i> Details</a>
+ <a href="{{ item.url_for_delete() }}" class="btn btn-sm btn-danger mb-1" role="button"><i class="ri-delete-bin-2-line"></i> Delete</a>
</td>
{% endif %}
</tr>
diff --git a/templates/wishes.html b/templates/wishes.html
index eac8255f..2c2b2ab9 100644
--- a/templates/wishes.html
+++ b/templates/wishes.html
@@ -12,7 +12,7 @@
<label class="visually-hidden" for="number">Set number</label>
<div class="input-group">
<div class="input-group-text"><i class="ri-hashtag"></i></div>
- <input type="text" class="form-control" id="number" name="number" placeholder="107-1 or 1642-1 or ..." value="" autocomplete="off">
+ <input type="text" class="form-control" id="set" name="set" placeholder="107-1 or 1642-1 or ..." value="" autocomplete="off">
</div>
</div>
<div class="col-12">