Wish requesters

This commit is contained in:
Gregoo 2025-02-04 20:07:15 +01:00
parent ad24506ab7
commit 64a9e063ec
19 changed files with 300 additions and 23 deletions

View File

@ -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

View File

@ -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
)

View File

@ -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'
)

View File

@ -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

View File

@ -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;

View File

@ -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 }}';

View File

@ -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"

View File

@ -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 %}

View File

@ -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
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;

View File

@ -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

View File

@ -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})

View File

@ -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)

View File

@ -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)

View File

@ -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
)

View File

@ -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)

15
templates/wish.html Normal file
View File

@ -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 %}

49
templates/wish/card.html Normal file
View File

@ -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>

View File

@ -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>

View File

@ -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">