Merge branch 'payment'

This commit is contained in:
Midgard 2022-05-02 21:23:33 +02:00
commit c35d107502
Signed by: midgard
GPG key ID: 511C112F1331BBB4
6 changed files with 293 additions and 35 deletions

View file

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

View file

@ -18,6 +18,7 @@ class OrderItem(db.Model):
dish_id = db.Column(db.String(64), nullable=True) dish_id = db.Column(db.String(64), nullable=True)
dish_name = db.Column(db.String(120), nullable=True) dish_name = db.Column(db.String(120), nullable=True)
price = db.Column(db.Integer, 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) paid = db.Column(db.Boolean, default=False, nullable=True)
comment = db.Column(db.Text(), nullable=True) comment = db.Column(db.Text(), nullable=True)
hlds_data_version = db.Column(db.String(40), 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): if user and (user.is_admin() or user == self.order.courier):
return True return True
return False 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

View file

@ -267,6 +267,7 @@
<section class="single_column"> <section class="single_column">
<div class="box" id="per_person"> <div class="box" id="per_person">
<h3>Items per person</h3> <h3>Items per person</h3>
<form action="{{ url_for('order_bp.items_user_paid', order_id=order.id) }}" method="post">
<table class="table table-condensed"> <table class="table table-condensed">
<thead> <thead>
<tr><th>Total</th><th>Name</th><th>Items</th></tr> <tr><th>Total</th><th>Name</th><th>Items</th></tr>
@ -276,12 +277,20 @@
<tr> <tr>
<td> <td>
{% set paid = order_items | map(attribute="paid") | all %} {% set paid = order_items | map(attribute="paid") | all %}
<input type="checkbox" name="{{ user_name }}" {% set can_modify_payment = True %}
{{ "disabled" if paid }} style="{{ 'opacity: 0.5' if paid }}"> {% 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 %}
<input type="checkbox" name="user_names" value="{{ user_name }}"
{{ "disabled" if not can_modify_payment }}>
<span class="price">{{ order_items | map(attribute="price") | sum | euro }}</span> <span class="price" style="{{ 'opacity: 0.5' if paid }}">
{{ order_items | map(attribute="price") | sum | euro }}
</span>
{% if paid %}paid{% endif %} {% if paid %}<span class="glyphicon glyphicon-ok"></span>{% endif %}
</td> </td>
<td>{{ user_name }}</td> <td>{{ user_name }}</td>
<td class="items"> <td class="items">
@ -298,13 +307,15 @@
{%- endif %} {%- endif %}
</div> </div>
<div class="price_aligned">{{ item.price|euro }}</div> <div class="price_aligned">
{{ item.price|euro }}
{% if item.price_modified %}
<span class="glyphicon glyphicon-pencil" style="opacity: 0.5" title="Edited"></span>
{% endif %}
</div>
<div class="item_description">{{ item.dish_name }}{{ "; " + item.comment if item.comment }}</div> <div class="item_description">{{ item.dish_name }}{{ "; " + item.comment if item.comment }}</div>
</li> </li>
{% endfor %} {% endfor %}
<li>
<button class="btn btn-link btn-sm" onclick="alert('TODO')" style="color: green; padding: 0 0.5em;"><span class="glyphicon glyphicon-plus"></span></button>
</li>
</ul> </ul>
</td> </td>
@ -315,10 +326,18 @@
<div class="footer"> <div class="footer">
On selected: On selected:
<button class="btn btn-sm"><span class="glyphicon glyphicon-ok"></span> Mark paid (TODO)</button> <button name="action" value="mark_paid" class="btn btn-sm"><span class="glyphicon glyphicon-ok"></span> Mark paid</button>
<button class="btn btn-sm"><span class="glyphicon glyphicon-piggy-bank"></span> Tab (TODO)</button> <button name="action" value="mark_unpaid" class="btn btn-sm">Mark unpaid</button>
<button class="btn btn-sm"><span class="glyphicon glyphicon-qrcode"></span> QR code (TODO)</button>
{% if order.is_closed() %}
&nbsp; <span style="border-left: 1px solid var(--gray0); display: inline-block;">&nbsp;</span>&nbsp;
<a href="{{ url_for('order_bp.prices', order_id=order.id) }}" class="btn btn-sm">
<span class="glyphicon glyphicon-pencil"></span> Edit prices
</a>
{% endif %}
</div> </div>
</form>
</div> </div>
</section> </section>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,132 @@
{% extends "layout.html" %}
{% set active_page = "orders" -%}
{% import "utils.html" as util %}
{% block metas %}
{{ super() }}
<meta name="robots" content="noindex, nofollow">
{% endblock %}
{% block container %}
<header>
<h2 id="order-title">Edit prices</h2>
<div>Only applied to <a href="{{ url_for('order_bp.order_from_id', order_id=order.id) }}">order {{ order.id }}</a>. To permanently change prices for {{ order.location_name }}, edit the <a href="https://git.zeus.gent/haldis/menus/-/blob/master/{{order.location_id}}.hlds">HLDS location definition</a>.</div>
</header>
<form action="{{ url_for('order_bp.prices', order_id=order.id) }}" method="post">
<div class="col-md-6" id="per_dish">
<h3>Per dish</h3>
<div class="noscript">This functionality requires JavaScript.</div>
<div class="script">
<table class="table table-condensed">
<thead>
<tr><th colspan="2">Dish</th><th>Price</th></tr>
</thead>
<tbody>
{% 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 -%}
<tr>
{% if loop.first %}
<td rowspan="{{dish_comment_groups | length }}">
<span class="quantity">{{ dish_quantity }}</span> ×
{{ dish_name }}
</td>
{% endif %}
<td>
<span class="quantity">{{ items | length }}</span> ×
{% if comment %}{{ comment }}
{% else %}<i>No comment</i>
{% endif %}
</td>
<td>
{% set price = items[0].price | euro("") %}
{% set item_ids = items | map(attribute="id") %}
<input type="text" data-for-items="{{ item_ids | join(",") }}" value="{{ price }}">
</td>
</tr>
{% endfor %}
{%- endfor %}
</tbody>
</table>
</div>
</div>
<div class="col-md-6" id="per_person">
<h3>Per person</h3>
<table class="table table-condensed">
<thead>
<tr><th>Name</th><th>Items</th></tr>
</thead>
<tbody>
{% for user_name, order_items in order.group_by_user() -%}
<tr>
<td>{{ user_name }}</td>
<td class="items">
<ul>
{% for item in order_items %}
<li class="{{ 'paid' if item.paid }}">
<input type="text" value="{{ item.price|euro("") }}" name="item_{{ item.id }}" id="item_{{ item.id }}">
<span class="item_description">{{ item.dish_name }}{{ "; " + item.comment if item.comment }}</span>
</li>
{% endfor %}
</ul>
</td>
</tr>
{%- endfor %}
</tbody>
</table>
</div>
<div>
<a href="{{ url_for('order_bp.order_from_id', order_id=order.id) }}" class="btn btn-sm">Cancel</a>
<button class="btn btn-sm btn-primary">Apply</button>
</div>
</form>
{% endblock %}
{% block styles %}
{{ super() }}
<style>
.script {
display: none;
}
#per_dish ul, #per_person ul {
list-style-type: none;
padding: 0;
}
#per_dish input, #per_person input {
width: 3em;
}
</style>
{% endblock %}
{% block scripts %}
{{ super() }}
<script type="text/javascript">
"use strict";
$(window).on("load", () => {
$(".noscript").css("display", "none");
$(".script").css("display", "unset");
function updatePerPersonPrices(e) {
console.log(e.target);
for (let item_id of e.target.dataset.forItems.split(",")) {
$("#item_" + item_id).val(e.target.value);
}
};
$("#per_dish input").on("change", updatePerPersonPrices);
$("#per_dish input").on("keyup", updatePerPersonPrices);
});
</script>
{% endblock %}

View file

@ -1,16 +1,25 @@
"Script which contains several utils for Haldis" "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 Convert cents to string formatted euro
""" """
euro, cents = divmod(value, 100) euro, cents = divmod(value, 100)
if cents: if cents:
return f"{euro}.{cents:02}" return f"{unit}{euro}.{cents:02}"
return f"{euro}" 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): def price_range_string(price_range, include_upper=False):

View file

@ -1,5 +1,6 @@
"Script to generate the order related views of Haldis" "Script to generate the order related views of Haldis"
import random import random
import re
import typing import typing
from datetime import datetime from datetime import datetime
@ -11,7 +12,7 @@ from forms import AnonOrderItemForm, OrderForm, OrderItemForm
from hlds.definitions import location_definition_version, location_definitions from hlds.definitions import location_definition_version, location_definitions
from models import Order, OrderItem, User, db from models import Order, OrderItem, User, db
from notification import post_order_to_webhook 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 from werkzeug.wrappers import Response
order_bp = Blueprint("order_bp", "order") order_bp = Blueprint("order_bp", "order")
@ -225,11 +226,22 @@ def order_item_create(order_id: int) -> typing.Any:
return redirect(url_for("order_bp.order_from_id", order_id=order_id)) return redirect(url_for("order_bp.order_from_id", order_id=order_id))
@order_bp.route("/<order_id>/<user_name>/user_paid", methods=["POST"]) @order_bp.route("/<order_id>/users_paid", methods=["POST"])
@login_required @login_required
# pylint: disable=R1710 # pylint: disable=R1710
def items_user_paid(order_id: int, user_name: str) -> typing.Optional[Response]: def items_user_paid(order_id: int) -> typing.Optional[Response]:
"Indicate payment status for a user in an order" 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:
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() user = User.query.filter(User.username == user_name).first()
items: typing.List[OrderItem] = [] items: typing.List[OrderItem] = []
if user: if user:
@ -240,14 +252,21 @@ def items_user_paid(order_id: int, user_name: str) -> typing.Optional[Response]:
items = OrderItem.query.filter( items = OrderItem.query.filter(
(OrderItem.user_name == user_name) & (OrderItem.order_id == order_id) (OrderItem.user_name == user_name) & (OrderItem.order_id == order_id)
).all() ).all()
current_order = Order.query.filter(Order.id == order_id).first()
if current_order.courier_id == current_user.id or current_user.admin:
for item in items: for item in items:
item.paid = True 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() db.session.commit()
flash("Paid %d items for %s" % (len(items), item.for_name), "success") 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)) return redirect(url_for("order_bp.order_from_id", order_id=order_id))
abort(404)
@order_bp.route("/<order_id>/<item_id>/delete", methods=["POST"]) @order_bp.route("/<order_id>/<item_id>/delete", methods=["POST"])
@ -305,6 +324,54 @@ def close_order(order_id: int) -> typing.Optional[Response]:
return None return None
@order_bp.route("/<order_id>/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]: def select_user(items) -> typing.Optional[User]:
"Select a random user from those who are signed up for the order" "Select a random user from those who are signed up for the order"
user = None user = None