diff --git a/CHANGELOG.md b/CHANGELOG.md index 46c76b5..a6f89a8 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 a7df752..44bf18f 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 18dcb10..8d862ed 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 38929f6..99d95bb 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 33f8a6f..469649d 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 e9df18d..5927bfd 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 cc54a2a..6b2de77 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 b06c66f..62f5a7c 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 e60b2e4..1adcfc1 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 0000000..9191ca8 --- /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 b0c763b..416b900 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/', methods=['POST']) +# Ask for deletion of a wish +@wish_page.route('//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('//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('//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('//owner/', 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 def41e2..502792f 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 880021b..d3038b8 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 0000000..c31d2c9 --- /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 0000000..719fc6f --- /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 0000000..6c0f399 --- /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 %} +
+
+
+ {% with solo=true, delete=delete %} + {% include 'wish/card.html' %} + {% endwith %} +
+
+
+{% endblock %} diff --git a/templates/wish/card.html b/templates/wish/card.html new file mode 100644 index 0000000..fc16d9b --- /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 %} + +
+ {{ 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) }} +
+ {{ 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 %} +
+ {% if solo and g.login.is_authenticated() %} +
+ {% if not delete %} + {{ accordion.header('Requester', 'owner', 'wish-details', icon='group-line', class='p-0') }} +
    + {% if owners | length %} + {% for owner in owners %} +
  • {{ 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) }}
  • + {% endfor %} + {% else %} +
  • No requester found.
  • + {% endif %} +
+ + {{ accordion.footer() }} + {% endif %} + {{ accordion.header('Danger zone', 'danger-zone', 'wish-details', expanded=delete, danger=true, class='text-end') }} + {% if delete %} +
+ {% if error %}{% endif %} + + Back to the wish + +
+ {% else %} + Delete the wish + {% endif %} + {{ accordion.footer() }} +
+ + {% endif %} +
diff --git a/templates/wish/table.html b/templates/wish/table.html index ca22242..41783ca 100644 --- a/templates/wish/table.html +++ b/templates/wish/table.html @@ -12,6 +12,7 @@ Year Parts Retirement + Requesters {% if g.login.is_authenticated() %} Actions {% endif %} @@ -28,11 +29,15 @@ {{ item.fields.year }} {{ item.fields.number_of_parts }} {% if retirement_date %}{{ retirement_date }}{% else %}Not found{% endif %} + + {% for owner in owners %} + {{ badge.owner(item, owner) }} + {% endfor %} + {% if g.login.is_authenticated() %} -
- -
+ Details + Delete {% endif %} diff --git a/templates/wishes.html b/templates/wishes.html index eac8255..2c2b2ab 100644 --- a/templates/wishes.html +++ b/templates/wishes.html @@ -12,7 +12,7 @@
- +