Merge branch 'payment'
This commit is contained in:
commit
c35d107502
6 changed files with 293 additions and 35 deletions
|
@ -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')
|
|
@ -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
|
||||||
|
|
|
@ -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() %}
|
||||||
|
<span style="border-left: 1px solid var(--gray0); display: inline-block;"> </span>
|
||||||
|
<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 %}
|
||||||
|
|
132
app/templates/order_prices.html
Normal file
132
app/templates/order_prices.html
Normal 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 %}
|
17
app/utils.py
17
app/utils.py
|
@ -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):
|
||||||
|
|
|
@ -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,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))
|
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")
|
||||||
user = User.query.filter(User.username == user_name).first()
|
if request.form.get("action") == "mark_paid":
|
||||||
items: typing.List[OrderItem] = []
|
return set_items_paid(order_id, user_names, True)
|
||||||
if user:
|
elif request.form.get("action") == "mark_unpaid":
|
||||||
items = OrderItem.query.filter(
|
return set_items_paid(order_id, user_names, False)
|
||||||
(OrderItem.user_id == user.id) & (OrderItem.order_id == order_id)
|
|
||||||
).all()
|
|
||||||
else:
|
else:
|
||||||
items = OrderItem.query.filter(
|
abort(404)
|
||||||
(OrderItem.user_name == user_name) & (OrderItem.order_id == order_id)
|
|
||||||
).all()
|
def set_items_paid(order_id: int, user_names: typing.Iterable[str], paid: bool):
|
||||||
current_order = Order.query.filter(Order.id == order_id).first()
|
total_paid_items = 0
|
||||||
if current_order.courier_id == current_user.id or current_user.admin:
|
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:
|
for item in items:
|
||||||
item.paid = True
|
if item.can_modify_payment(order_id, current_user.id):
|
||||||
db.session.commit()
|
if item.paid != paid:
|
||||||
flash("Paid %d items for %s" % (len(items), item.for_name), "success")
|
item.paid = paid
|
||||||
return redirect(url_for("order_bp.order_from_id", order_id=order_id))
|
total_paid_items += 1
|
||||||
abort(404)
|
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("/<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
|
||||||
|
|
Loading…
Reference in a new issue