diff --git a/app/migrations/versions/55013fe95bea_create_price_modified_column.py b/app/migrations/versions/55013fe95bea_create_price_modified_column.py new file mode 100644 index 0000000..d4010c1 --- /dev/null +++ b/app/migrations/versions/55013fe95bea_create_price_modified_column.py @@ -0,0 +1,21 @@ +"""Create price_modified column + +Revision ID: 55013fe95bea +Revises: 9159a6fed021 +Create Date: 2022-04-22 01:00:03.729596 + +""" + +# revision identifiers, used by Alembic. +revision = '55013fe95bea' +down_revision = '9159a6fed021' + +from alembic import op +import sqlalchemy as sa + +def upgrade(): + op.add_column('order_item', sa.Column('price_modified', sa.DateTime(), nullable=True)) + + +def downgrade(): + op.drop_column('order_item', 'price_modified') diff --git a/app/models/orderitem.py b/app/models/orderitem.py index b865b51..623d751 100644 --- a/app/models/orderitem.py +++ b/app/models/orderitem.py @@ -18,6 +18,7 @@ class OrderItem(db.Model): dish_id = db.Column(db.String(64), nullable=True) dish_name = db.Column(db.String(120), nullable=True) price = db.Column(db.Integer, nullable=True) + price_modified = db.Column(db.DateTime, nullable=True) paid = db.Column(db.Boolean, default=False, nullable=True) comment = db.Column(db.Text(), nullable=True) hlds_data_version = db.Column(db.String(40), nullable=True) @@ -73,3 +74,12 @@ class OrderItem(db.Model): if user and (user.is_admin() or user == self.order.courier): return True return False + + # pylint: disable=W0613 + def can_modify_payment(self, order_id: int, user_id: int) -> bool: + if int(self.order_id) != int(order_id): + return False + user = User.query.filter(User.id == user_id).first() + if user and (user.is_admin() or user == self.order.courier): + return True + return False diff --git a/app/templates/order.html b/app/templates/order.html index 8ac6f4e..7285ce8 100644 --- a/app/templates/order.html +++ b/app/templates/order.html @@ -267,6 +267,7 @@

Items per person

+
@@ -276,12 +277,20 @@ @@ -315,10 +326,18 @@ + {% endblock %} diff --git a/app/templates/order_prices.html b/app/templates/order_prices.html new file mode 100644 index 0000000..d5eec77 --- /dev/null +++ b/app/templates/order_prices.html @@ -0,0 +1,132 @@ +{% extends "layout.html" %} +{% set active_page = "orders" -%} + +{% import "utils.html" as util %} + +{% block metas %} + {{ super() }} + +{% endblock %} + +{% block container %} +
+

Edit prices

+
Only applied to order {{ order.id }}. To permanently change prices for {{ order.location_name }}, edit the HLDS location definition.
+
+ + +
+

Per dish

+
This functionality requires JavaScript.
+
+ +
TotalNameItems
{% set paid = order_items | map(attribute="paid") | all %} - + {% set can_modify_payment = True %} + {% for order_item in order_items %} + {% if not order_item.can_modify_payment(order.id, current_user.id) %} + {% set can_modify_payment = False %} + {% endif %} + {% endfor %} + - {{ order_items | map(attribute="price") | sum | euro }} + + {{ order_items | map(attribute="price") | sum | euro }} + - {% if paid %}paid{% endif %} + {% if paid %}{% endif %} {{ user_name }} @@ -298,13 +307,15 @@ {%- endif %} -
{{ item.price|euro }}
+
+ {{ item.price|euro }} + {% if item.price_modified %} + + {% endif %} +
{{ item.dish_name }}{{ "; " + item.comment if item.comment }}
{% endfor %} -
  • - -
  • + + + + + {% for dish_name, dish_quantity, dish_comment_groups in order.group_by_dish() -%} + {% set has_comments = dish_comment_groups | length > 1 or (dish_comment_groups | map("first") | any) -%} + {% for comment, items in dish_comment_groups -%} + + + {% if loop.first %} + + {% endif %} + + + + + + + {% endfor %} + {%- endfor %} + +
    DishPrice
    + {{ dish_quantity }} × + {{ dish_name }} + + {{ items | length }} × + {% if comment %}{{ comment }} + {% else %}No comment + {% endif %} + + {% set price = items[0].price | euro("") %} + {% set item_ids = items | map(attribute="id") %} + € +
    +
    + + +
    +

    Per person

    + + + + + + {% for user_name, order_items in order.group_by_user() -%} + + + + + + {%- endfor %} + +
    NameItems
    {{ user_name }} +
      + {% for item in order_items %} +
    • + € + {{ item.dish_name }}{{ "; " + item.comment if item.comment }} +
    • + {% endfor %} +
    +
    +
    + +
    + Cancel + +
    + + +{% endblock %} + +{% block styles %} + {{ super() }} + +{% endblock %} + +{% block scripts %} + {{ super() }} + +{% endblock %} diff --git a/app/utils.py b/app/utils.py index 91c9793..80556f2 100644 --- a/app/utils.py +++ b/app/utils.py @@ -1,16 +1,25 @@ "Script which contains several utils for Haldis" -from typing import Iterable +import re +from typing import Iterable, Optional -def euro_string(value: int) -> str: +def euro_string(value: int, unit="€ ") -> str: """ Convert cents to string formatted euro """ euro, cents = divmod(value, 100) if cents: - return f"€ {euro}.{cents:02}" - return f"€ {euro}" + return f"{unit}{euro}.{cents:02}" + return f"{unit}{euro}" + + +def parse_euro_string(value: str) -> Optional[int]: + m = re.fullmatch("(?:€ ?)?([0-9]+)(?:[.,]([0-9]+))?", value) + if not m: + return None + cents_02 = "{:0<2.2}".format(m.group(2)) if m.group(2) else "00" + return int(m.group(1)) * 100 + int(cents_02) def price_range_string(price_range, include_upper=False): diff --git a/app/views/order.py b/app/views/order.py index 49ca679..cd3847a 100644 --- a/app/views/order.py +++ b/app/views/order.py @@ -1,5 +1,6 @@ "Script to generate the order related views of Haldis" import random +import re import typing from datetime import datetime @@ -11,7 +12,7 @@ from forms import AnonOrderItemForm, OrderForm, OrderItemForm from hlds.definitions import location_definition_version, location_definitions from models import Order, OrderItem, User, db from notification import post_order_to_webhook -from utils import ignore_none +from utils import ignore_none, parse_euro_string from werkzeug.wrappers import Response order_bp = Blueprint("order_bp", "order") @@ -225,29 +226,47 @@ def order_item_create(order_id: int) -> typing.Any: return redirect(url_for("order_bp.order_from_id", order_id=order_id)) -@order_bp.route("///user_paid", methods=["POST"]) +@order_bp.route("//users_paid", methods=["POST"]) @login_required # pylint: disable=R1710 -def items_user_paid(order_id: int, user_name: str) -> typing.Optional[Response]: - "Indicate payment status for a user in an order" - user = User.query.filter(User.username == user_name).first() - items: typing.List[OrderItem] = [] - if user: - items = OrderItem.query.filter( - (OrderItem.user_id == user.id) & (OrderItem.order_id == order_id) - ).all() +def items_user_paid(order_id: int) -> typing.Optional[Response]: + user_names = request.form.getlist("user_names") + if request.form.get("action") == "mark_paid": + return set_items_paid(order_id, user_names, True) + elif request.form.get("action") == "mark_unpaid": + return set_items_paid(order_id, user_names, False) else: - items = OrderItem.query.filter( - (OrderItem.user_name == user_name) & (OrderItem.order_id == order_id) - ).all() - current_order = Order.query.filter(Order.id == order_id).first() - if current_order.courier_id == current_user.id or current_user.admin: + abort(404) + +def set_items_paid(order_id: int, user_names: typing.Iterable[str], paid: bool): + total_paid_items = 0 + total_failed_items = 0 + for user_name in user_names: + user = User.query.filter(User.username == user_name).first() + items: typing.List[OrderItem] = [] + if user: + items = OrderItem.query.filter( + (OrderItem.user_id == user.id) & (OrderItem.order_id == order_id) + ).all() + else: + items = OrderItem.query.filter( + (OrderItem.user_name == user_name) & (OrderItem.order_id == order_id) + ).all() + for item in items: - item.paid = True - db.session.commit() - flash("Paid %d items for %s" % (len(items), item.for_name), "success") - return redirect(url_for("order_bp.order_from_id", order_id=order_id)) - abort(404) + if item.can_modify_payment(order_id, current_user.id): + if item.paid != paid: + item.paid = paid + total_paid_items += 1 + else: + total_failed_items += 1 + + db.session.commit() + if total_failed_items == 0: + flash("Marked %d items as paid" % (total_paid_items,), "success") + else: + flash("Failed to mark %d items as paid (succeeded in marking %d items as paid)" % (total_failed_items, total_paid_items), "error") + return redirect(url_for("order_bp.order_from_id", order_id=order_id)) @order_bp.route("///delete", methods=["POST"]) @@ -305,6 +324,54 @@ def close_order(order_id: int) -> typing.Optional[Response]: return None +@order_bp.route("//prices", methods=["GET", "POST"]) +@login_required +def prices(order_id: int) -> typing.Optional[Response]: + order = Order.query.filter(Order.id == order_id).first() + if order is None: + abort(404) + if ( + current_user.is_anonymous() or + not (current_user.is_admin() or current_user.id == order.courier_id) + ): + flash("Only the courier can edit prices.", "error") + return redirect(url_for("order_bp.order_from_id", order_id=order_id)) + if not order.is_closed(): + flash("Cannot modify prices until the order is closed.", "error") + return redirect(url_for("order_bp.order_from_id", order_id=order_id)) + + if request.method == "GET": + return render_template( + "order_prices.html", + order=order, + ) + else: + new_prices = {} + + for key, value in request.form.items(): + m = re.fullmatch("item_([0-9]+)", key) + if not m: + continue + item_id = int(m.group(1)) + + price = parse_euro_string(value) + if not price: + flash(f"Could not recognize '{value}' as a price") + continue + + new_prices[item_id] = price + + for item in order.items: + new_price = new_prices.get(item.id) + if new_price is not None and new_price != item.price: + item.price = new_price + item.price_modified = datetime.now() + db.session.commit() + + return redirect(url_for("order_bp.order_from_id", order_id=order_id)) + + + def select_user(items) -> typing.Optional[User]: "Select a random user from those who are signed up for the order" user = None