Merge branch 'master' into feature/microsoft-auth

# Conflicts:
#	app/app.py
#	app/create_database.py
#	app/models/user.py
#	requirements.in
#	requirements.txt
This commit is contained in:
Maxim De Clercq 2023-04-19 20:55:35 +02:00
commit aab522eef9
No known key found for this signature in database
GPG key ID: D35643779C52BA70
33 changed files with 653 additions and 208 deletions

View file

@ -26,7 +26,7 @@ Afterwards upgrade the database to the latest version using
cd app cd app
python3 app.py db upgrade python3 app.py db upgrade
You can now still seed the database by running, note that you might want to put your name in the `HALDIS_ADMIN_USERS` in `app/config.py` You can now still seed the database by running, note that you might want to put your name in the `HALDIS_ADMINS` in `app/config.py`
./populate-db.sh ./populate-db.sh

View file

@ -10,5 +10,5 @@ def add() -> None:
"""Add users as admin.""" """Add users as admin."""
for username in Configuration.HALDIS_ADMINS: for username in Configuration.HALDIS_ADMINS:
user = User() user = User()
user.configure(username, True, 0) user.configure(username, True, 0, associations=["zeus"])
db.session.add(user) db.session.add(user)

View file

@ -28,11 +28,12 @@ class OrderAdminModel(ModelBaseView):
"Class for the model of a OrderAdmin" "Class for the model of a OrderAdmin"
# pylint: disable=too-few-public-methods # pylint: disable=too-few-public-methods
column_default_sort = ("starttime", True) column_default_sort = ("starttime", True)
column_list = ["starttime", "stoptime", "location_name", "location_id", "courier"] column_list = ["starttime", "stoptime", "location_name", "location_id", "courier", "association"]
column_labels = { column_labels = {
"starttime": "Start Time", "starttime": "Start Time",
"stoptime": "Closing Time", "stoptime": "Closing Time",
"location_id": "HLDS Location ID", "location_id": "HLDS Location ID",
"association": "Association",
} }
form_excluded_columns = ["items", "courier_id"] form_excluded_columns = ["items", "courier_id"]
can_delete = False can_delete = False
@ -44,6 +45,7 @@ class OrderItemAdminModel(ModelBaseView):
column_default_sort = ("order_id", True) column_default_sort = ("order_id", True)
column_list = [ column_list = [
"order_id", "order_id",
"slug",
"order.location_name", "order.location_name",
"user_name", "user_name",
"user", "user",

View file

@ -3,6 +3,7 @@
"""Main Haldis script""" """Main Haldis script"""
import logging import logging
import sentry_sdk
import typing import typing
from datetime import datetime from datetime import datetime
from logging.handlers import TimedRotatingFileHandler from logging.handlers import TimedRotatingFileHandler
@ -20,7 +21,8 @@ from markupsafe import Markup
from config import Configuration from config import Configuration
from models import db from models import db
from models.anonymous_user import AnonymouseUser from models.anonymous_user import AnonymouseUser
from utils import euro_string, price_range_string from sentry_sdk.integrations.flask import FlaskIntegration
from utils import euro_string, price_range_string, ignore_none
from auth.zeus import init_oauth from auth.zeus import init_oauth
@ -156,6 +158,7 @@ def add_template_filters(app: Flask) -> None:
app.template_filter("price_range")(price_range_string) app.template_filter("price_range")(price_range_string)
app.template_filter("any")(any) app.template_filter("any")(any)
app.template_filter("all")(all) app.template_filter("all")(all)
app.template_filter("ignore_none")(ignore_none)
def create_app(): def create_app():
@ -174,10 +177,16 @@ def create_app():
def inject_config(): def inject_config():
return dict(configuration=Configuration) return dict(configuration=Configuration)
return app_manager return app, app_manager
# For usage when you directly call the script with python # For usage when you directly call the script with python
if __name__ == "__main__": if __name__ == "__main__":
app_mgr = create_app() if Configuration.SENTRY_DSN:
sentry_sdk.init(
dsn=Configuration.SENTRY_DSN,
integrations=[FlaskIntegration()]
)
app, app_mgr = create_app()
app_mgr.run() app_mgr.run()

View file

@ -83,7 +83,7 @@ def login_and_redirect_user(user) -> Response:
def create_user(username) -> User: def create_user(username) -> User:
"""Create a temporary user if it is needed""" """Create a temporary user if it is needed"""
user = User() user = User()
user.configure(username, False, 1) user.configure(username, False, 1, associations=["zeus"])
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
return user return user

View file

@ -8,10 +8,11 @@ class Configuration:
SQLALCHEMY_DATABASE_URI = "sqlite:///haldis.db" SQLALCHEMY_DATABASE_URI = "sqlite:///haldis.db"
SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_TRACK_MODIFICATIONS = False
DEBUG = True DEBUG = True
HALDIS_ADMIN_USERS = [] HALDIS_ADMINS = []
SECRET_KEY = "<change>" SECRET_KEY = "<change>"
SLACK_WEBHOOK = None SLACK_WEBHOOK = None
LOGFILE = "haldis.log" LOGFILE = "haldis.log"
SENTRY_DSN = None
ZEUS_KEY = "tomtest" ZEUS_KEY = "tomtest"
ZEUS_SECRET = "blargh" ZEUS_SECRET = "blargh"

View file

@ -4,7 +4,7 @@ import add_admins
from app import create_app, db from app import create_app, db
app_manager = create_app() app, app_manager = create_app()
entry_sets = { entry_sets = {
"admins": add_admins.add, "admins": add_admins.add,

View file

@ -9,6 +9,7 @@ user
order order
id id
slug secret used in URL
courier_id courier_id
location_id HLDS identifier location_id HLDS identifier
location_name this allows historical orders to keep the same location name location_name this allows historical orders to keep the same location name

View file

@ -24,13 +24,17 @@ class OrderForm(Form):
"Starttime", default=datetime.now, format="%d-%m-%Y %H:%M" "Starttime", default=datetime.now, format="%d-%m-%Y %H:%M"
) )
stoptime = DateTimeField("Stoptime", format="%d-%m-%Y %H:%M") stoptime = DateTimeField("Stoptime", format="%d-%m-%Y %H:%M")
association = SelectField("Association", coerce=str, validators=[validators.required()])
submit_button = SubmitField("Submit") submit_button = SubmitField("Submit")
def populate(self) -> None: def populate(self) -> None:
"Fill in the options for courier for an Order" "Fill in the options for courier for an Order"
if current_user.is_admin(): if current_user.is_admin():
self.courier_id.choices = [(0, None)] + [ self.courier_id.choices = [
(u.id, u.username) for u in User.query.order_by("username") (0, None),
(current_user.id, current_user.username),
] + [
(u.id, u.username) for u in User.query.order_by("username") if u.id != current_user.id
] ]
else: else:
self.courier_id.choices = [ self.courier_id.choices = [
@ -38,6 +42,7 @@ class OrderForm(Form):
(current_user.id, current_user.username), (current_user.id, current_user.username),
] ]
self.location_id.choices = [(l.id, l.name) for l in location_definitions] self.location_id.choices = [(l.id, l.name) for l in location_definitions]
self.association.choices = current_user.association_list()
if self.stoptime.data is None: if self.stoptime.data is None:
self.stoptime.data = datetime.now() + timedelta(hours=1) self.stoptime.data = datetime.now() + timedelta(hours=1)

View file

@ -0,0 +1,30 @@
"""add slug
Revision ID: 29ccbe077c57
Revises: 55013fe95bea
Create Date: 2022-05-20 19:46:11.924218
"""
# revision identifiers, used by Alembic.
revision = '29ccbe077c57'
down_revision = '55013fe95bea'
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import text
def upgrade():
op.add_column('order', sa.Column(
'slug',
sa.String(length=8),
nullable=False,
# Default: random alphanumerical string
server_default=text('SUBSTRING(MD5(RAND()) FROM 1 FOR 7)')
))
op.create_unique_constraint('order_slug_unique', 'order', ['slug'])
def downgrade():
op.drop_constraint('order_slug_unique', 'order', type_='unique')
op.drop_column('order', 'slug')

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

@ -0,0 +1,22 @@
"""empty message
Revision ID: 9eac0f3d7b1e
Revises: ('f6a6004bf4b9', '29ccbe077c57')
Create Date: 2022-05-30 18:35:43.918797
"""
# revision identifiers, used by Alembic.
revision = '9eac0f3d7b1e'
down_revision = ('f6a6004bf4b9', '29ccbe077c57')
from alembic import op
import sqlalchemy as sa
def upgrade():
pass
def downgrade():
pass

View file

@ -0,0 +1,28 @@
"""Add user associations
Revision ID: f6a6004bf4b9
Revises: 55013fe95bea
Create Date: 2022-05-24 21:23:27.770365
"""
# revision identifiers, used by Alembic.
revision = 'f6a6004bf4b9'
down_revision = '55013fe95bea'
from alembic import op
import sqlalchemy as sa
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('order', sa.Column('association', sa.String(length=120), server_default='', nullable=False))
op.add_column('user', sa.Column('associations', sa.String(length=255), server_default='', nullable=False))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('user', 'associations')
op.drop_column('order', 'association')
# ### end Alembic commands ###

View file

@ -1,10 +1,14 @@
"AnonymouseUser for people who are not logged in the normal way" "AnonymouseUser for people who are not logged in the normal way"
from typing import List
# pylint: disable=R0201,C0111 # pylint: disable=R0201,C0111
class AnonymouseUser: class AnonymouseUser:
id = None id = None
def association_list(self) -> List[str]:
return []
def is_active(self) -> bool: def is_active(self) -> bool:
return False return False

View file

@ -1,7 +1,9 @@
"Script for everything Order related in the database" """Script for everything Order related in the database"""
import typing import typing
from collections import defaultdict from collections import defaultdict
from datetime import datetime from datetime import datetime
import secrets
import string
from hlds.definitions import location_definitions from hlds.definitions import location_definitions
from utils import first from utils import first
@ -9,9 +11,16 @@ from utils import first
from .database import db from .database import db
from .user import User from .user import User
BASE31_ALPHABET = '23456789abcdefghjkmnpqrstuvwxyz'
def generate_slug():
secret = ''.join(secrets.choice(BASE31_ALPHABET) for i in range(8))
while Order.query.filter(Order.slug == secret).first() is not None:
secret = ''.join(secrets.choice(BASE31_ALPHABET) for i in range(8))
return secret
class Order(db.Model): class Order(db.Model):
"Class used for configuring the Order model in the database" """Class used for configuring the Order model in the database"""
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
courier_id = db.Column(db.Integer, nullable=True) courier_id = db.Column(db.Integer, nullable=True)
location_id = db.Column(db.String(64)) location_id = db.Column(db.String(64))
@ -19,6 +28,8 @@ class Order(db.Model):
starttime = db.Column(db.DateTime) starttime = db.Column(db.DateTime)
stoptime = db.Column(db.DateTime) stoptime = db.Column(db.DateTime)
public = db.Column(db.Boolean, default=True) public = db.Column(db.Boolean, default=True)
slug = db.Column(db.String(8), default=generate_slug, unique=True)
association = db.Column(db.String(120), nullable=False, server_default="")
items = db.relationship("OrderItem", backref="order", lazy="dynamic") items = db.relationship("OrderItem", backref="order", lazy="dynamic")
@ -47,7 +58,7 @@ class Order(db.Model):
self.location_name = self.location.name self.location_name = self.location.name
def for_user(self, anon=None, user=None) -> typing.List: def for_user(self, anon=None, user=None) -> typing.List:
"Get the items for a certain user" """Get the items for a certain user"""
return list( return list(
filter( filter(
(lambda i: i.user == user) (lambda i: i.user == user)
@ -58,7 +69,7 @@ class Order(db.Model):
) )
def group_by_user(self) -> typing.List[typing.Tuple[str, typing.List]]: def group_by_user(self) -> typing.List[typing.Tuple[str, typing.List]]:
"Group items of an Order by user" """Group items of an Order by user"""
group: typing.Dict[str, typing.List] = {} group: typing.Dict[str, typing.List] = {}
# pylint: disable=E1133 # pylint: disable=E1133
@ -78,7 +89,7 @@ class Order(db.Model):
) -> typing.List[ ) -> typing.List[
typing.Tuple[str, int, typing.List[typing.Tuple[str, typing.List]]] typing.Tuple[str, int, typing.List[typing.Tuple[str, typing.List]]]
]: ]:
"Group items of an Order by dish" """Group items of an Order by dish"""
group: typing.Dict[str, typing.Dict[str, typing.List]] = defaultdict( group: typing.Dict[str, typing.Dict[str, typing.List]] = defaultdict(
lambda: defaultdict(list) lambda: defaultdict(list)
) )
@ -101,11 +112,11 @@ class Order(db.Model):
) )
def is_closed(self) -> bool: def is_closed(self) -> bool:
"Return whether or not the order is closed" """Return whether the order is closed"""
return self.stoptime and datetime.now() > self.stoptime return self.stoptime and datetime.now() > self.stoptime
def can_close(self, user_id: int) -> bool: def can_close(self, user_id: int) -> bool:
"Check if a user can close the Order" """Check if a user can close the Order"""
if self.stoptime and self.stoptime < datetime.now(): if self.stoptime and self.stoptime < datetime.now():
return False return False
user = None user = None
@ -114,3 +125,13 @@ class Order(db.Model):
if self.courier_id == user_id or (user and user.is_admin()): if self.courier_id == user_id or (user and user.is_admin()):
return True return True
return False return False
def can_modify_prices(self, user_id: int) -> bool:
if not self.is_closed():
return False
user = User.query.filter_by(id=user_id).first()
return user and (user.is_admin() or user == self.courier)
def can_modify_payment(self, user_id: int) -> bool:
user = User.query.filter_by(id=user_id).first()
return user and (user.is_admin() or user == self.courier)

View file

@ -10,7 +10,7 @@ from .user import User
class OrderItem(db.Model): class OrderItem(db.Model):
"Class used for configuring the OrderItem model in the database" """Class used for configuring the OrderItem model in the database"""
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
order_id = db.Column(db.Integer, db.ForeignKey("order.id"), nullable=False) order_id = db.Column(db.Integer, db.ForeignKey("order.id"), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey("user.id")) user_id = db.Column(db.Integer, db.ForeignKey("user.id"))
@ -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)
@ -60,7 +61,7 @@ class OrderItem(db.Model):
# pylint: disable=W0613 # pylint: disable=W0613
def can_delete(self, order_id: int, user_id: int, name: str) -> bool: def can_delete(self, order_id: int, user_id: int, name: str) -> bool:
"Check if a user can delete an item" """Check if a user can delete an item"""
if int(self.order_id) != int(order_id): if int(self.order_id) != int(order_id):
return False return False
if self.order.is_closed(): if self.order.is_closed():
@ -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

@ -1,4 +1,6 @@
"Script for everything User related in the database" "Script for everything User related in the database"
from typing import List, Optional
from models import db from models import db
@ -11,6 +13,9 @@ class User(db.Model):
# Microsoft OAUTH info # Microsoft OAUTH info
microsoft_uuid = db.Column(db.String(120), unique=True) microsoft_uuid = db.Column(db.String(120), unique=True)
ugent_username = db.Column(db.String(80), unique=True) ugent_username = db.Column(db.String(80), unique=True)
# Association logic
associations = db.Column(db.String(255), nullable=False, server_default="")
# Relations # Relations
runs = db.relation( runs = db.relation(
"Order", "Order",
@ -20,12 +25,18 @@ class User(db.Model):
) )
orderItems = db.relationship("OrderItem", backref="user", lazy="dynamic") orderItems = db.relationship("OrderItem", backref="user", lazy="dynamic")
def configure(self, username: str, admin: bool, bias: int, microsoft_uuid: str = None) -> None: def association_list(self) -> List[str]:
return self.associations.split(",")
def configure(self, username: str, admin: bool, bias: int, microsoft_uuid: str = None, associations: Optional[List[str]] = None) -> None:
"""Configure the User""" """Configure the User"""
if associations is None:
associations = []
self.username = username self.username = username
self.admin = admin self.admin = admin
self.bias = bias self.bias = bias
self.microsoft_uuid = microsoft_uuid self.microsoft_uuid = microsoft_uuid
self.associations = ",".join(associations)
# pylint: disable=C0111, R0201 # pylint: disable=C0111, R0201
def is_authenticated(self) -> bool: def is_authenticated(self) -> bool:

View file

@ -11,29 +11,29 @@ from models.order import Order
def webhook_text(order: Order) -> typing.Optional[str]: def webhook_text(order: Order) -> typing.Optional[str]:
"Function that makes the text for the notification" """Function that makes the text for the notification"""
if order.location_id == "test": if order.location_id == "test":
return None return None
if order.courier is not None: if order.courier is not None:
# pylint: disable=C0301, C0209 # pylint: disable=C0301, C0209
return "<!channel|@channel> {3} is going to {1}, order <{0}|here>! Deadline in {2} minutes!".format( return "<!channel|@channel> {3} is going to {1}, order <{0}|here>! Deadline in {2} minutes!".format(
url_for("order_bp.order_from_id", order_id=order.id, _external=True), url_for("order_bp.order_from_slug", order_slug=order.slug, _external=True),
order.location_name, order.location_name,
remaining_minutes(order.stoptime), remaining_minutes(order.stoptime),
order.courier.username.title(), order.courier.username,
) )
# pylint: disable=C0209 # pylint: disable=C0209
return "<!channel|@channel> New order for {}. Deadline in {} minutes. <{}|Open here.>".format( return "<!channel|@channel> New order for {}. Deadline in {} minutes. <{}|Open here.>".format(
order.location_name, order.location_name,
remaining_minutes(order.stoptime), remaining_minutes(order.stoptime),
url_for("order_bp.order_from_id", order_id=order.id, _external=True), url_for("order_bp.order_from_slug", order_slug=order.slug, _external=True),
) )
def post_order_to_webhook(order: Order) -> None: def post_order_to_webhook(order: Order) -> None:
"Function that sends the notification for the order" """Function that sends the notification for the order"""
message = webhook_text(order) message = webhook_text(order)
if message: if message:
webhookthread = WebhookSenderThread(message, app.config["SLACK_WEBHOOK"]) webhookthread = WebhookSenderThread(message, app.config["SLACK_WEBHOOK"])
@ -41,7 +41,7 @@ def post_order_to_webhook(order: Order) -> None:
class WebhookSenderThread(Thread): class WebhookSenderThread(Thread):
"Extension of the Thread class, which sends a webhook for the notification" """Extension of the Thread class, which sends a webhook for the notification"""
def __init__(self, message: str, url: str) -> None: def __init__(self, message: str, url: str) -> None:
super().__init__() super().__init__()
@ -52,7 +52,7 @@ class WebhookSenderThread(Thread):
self.slack_webhook() self.slack_webhook()
def slack_webhook(self) -> None: def slack_webhook(self) -> None:
"The webhook for the specified chat platform" """The webhook for the specified chat platform"""
if self.url: if self.url:
requests.post(self.url, json={"text": self.message}) requests.post(self.url, json={"text": self.message})
else: else:
@ -60,9 +60,9 @@ class WebhookSenderThread(Thread):
def remaining_minutes(value) -> str: def remaining_minutes(value) -> str:
"Return the remaining minutes until the deadline of and order" """Return the remaining minutes until the deadline of and order"""
delta = value - datetime.now() delta = value - datetime.now()
if delta.total_seconds() < 0: if delta.total_seconds() < 0:
return "0" return "0"
minutes = delta.total_seconds() // 60 minutes = int(delta.total_seconds() // 60)
return f"{minutes:02}" return f"{minutes:02}"

View file

@ -18,7 +18,9 @@ sys.path.append(os.getcwd())
# Phusion Passenger expects this file to be called `passenger_wsgi.py` # Phusion Passenger expects this file to be called `passenger_wsgi.py`
# and the WSGI object to be called `application` # and the WSGI object to be called `application`
from app import app as application from app import create_app
application, appmgr = create_app()
# For running on the server with passenger etc # For running on the server with passenger etc
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -243,9 +243,9 @@ details summary {
} }
details summary:before { details summary:before {
font-style: normal; font-style: normal;
content: ""; content: "";
padding-right: 0.4em; padding-right: 0.4em;
} }
details[open] summary:before { details[open] summary:before {
content: ""; content: "";
} }

2
app/static/js/jquery.min.js vendored Normal file

File diff suppressed because one or more lines are too long

1
app/static/js/qrcode.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -12,10 +12,27 @@
{% block metas %} {% block metas %}
{{ super() }} {{ super() }}
<meta name="robots" content="noindex, nofollow"> <meta name="robots" content="noindex, nofollow">
<script type="text/javascript" src="{{ url_for('static', filename='js/jquery.min.js') }}"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/qrcode.min.js') }}"></script>
{% endblock %} {% endblock %}
{% block container %} {% block container %}
<header> <header class="row">
<div class="col-md-2" style="padding-top: 2em">
<div id="qrcode"></div>
<script type="text/javascript">
var qrcode = new QRCode(document.getElementById("qrcode"), {
text: "{{ url_for("order_bp.order_from_slug", order_slug=order.slug, _external=True) }}",
width: 128,
height: 128,
colorDark : "#000000",
colorLight : "#ffffff",
correctLevel : QRCode.CorrectLevel.H
});
</script>
</div>
<div class="col-md-10">
<h2 id="order-title">Order {{ order.id }}</h2> <h2 id="order-title">Order {{ order.id }}</h2>
<div class="location"> <div class="location">
@ -25,6 +42,10 @@
{{ order.location_name }} {{ order.location_name }}
{% endif %} {% endif %}
</div> </div>
<div>
Unique order link: <code>{{ url_for("order_bp.order_from_slug", order_slug=order.slug, _external=True) }}</code>
</div>
</div>
</header> </header>
<section> <section>
@ -36,7 +57,7 @@
{% for item in my_items %} {% for item in my_items %}
<li class="spacecake"> <li class="spacecake">
{% if item.can_delete(order.id, current_user.id, session.get('anon_name', '')) -%} {% if item.can_delete(order.id, current_user.id, session.get('anon_name', '')) -%}
<form action="{{ url_for('order_bp.delete_item', order_id=order.id, item_id=item.id) }}" method="post" style="display:inline"> <form action="{{ url_for('order_bp.delete_item', order_slug=order.slug, item_id=item.id) }}" method="post" style="display:inline">
<button class="btn btn-link btn-sm" type="submit" style="padding: 0 0.5em;"><span class="glyphicon glyphicon-remove"></span></button> <button class="btn btn-link btn-sm" type="submit" style="padding: 0 0.5em;"><span class="glyphicon glyphicon-remove"></span></button>
</form> </form>
{%- endif %} {%- endif %}
@ -65,7 +86,7 @@
<h3>Add item to order</h3> <h3>Add item to order</h3>
{% for dish in order.location.dishes %} {% for dish in order.location.dishes %}
<form method="post" action="{{ url_for('order_bp.order_item_create', order_id=order.id) }}" id="dish_{{ dish.id }}"> <form method="post" action="{{ url_for('order_bp.order_item_create', order_slug=order.slug) }}" id="dish_{{ dish.id }}">
{{ form.csrf_token }} {{ form.csrf_token }}
<input type="hidden" name="dish_id" value="{{ dish.id }}" /> <input type="hidden" name="dish_id" value="{{ dish.id }}" />
@ -134,7 +155,8 @@
<div class="box" id="order_info"> <div class="box" id="order_info">
<h3>Order information</h3> <h3>Order information</h3>
<dl> <div class="row">
<dl class="col-md-10 col-lg-8">
<div> <div>
<dt>Order opens</dt> <dt>Order opens</dt>
<dd>{{ order.starttime.strftime("%Y-%m-%d, %H:%M") }}</dd> <dd>{{ order.starttime.strftime("%Y-%m-%d, %H:%M") }}</dd>
@ -167,7 +189,7 @@
<dd> <dd>
{% if order.courier == None %} {% if order.courier == None %}
{% if not current_user.is_anonymous() %} {% if not current_user.is_anonymous() %}
<form action="{{ url_for('order_bp.volunteer', order_id=order.id) }}" method="post" style="display:inline"> <form action="{{ url_for('order_bp.volunteer', order_slug=order.slug) }}" method="post" style="display:inline">
<input type="submit" class="btn btn-primary btn-sm" value="Volunteer"></input> <input type="submit" class="btn btn-primary btn-sm" value="Volunteer"></input>
</form> </form>
{% else %}No-one yet{% endif %} {% else %}No-one yet{% endif %}
@ -176,18 +198,23 @@
{% endif %} {% endif %}
</dd> </dd>
</div> </div>
</dl> </dl>
<div> <div class="col-md-2 col-lg-4">
<img src="https://dsa.ugent.be/api/verenigingen/{{ order.association }}/logo" class="img-responsive align-top" style="max-width:200px;width:100%">
</div>
</div>
{% if order.can_close(current_user.id) -%} {% if order.can_close(current_user.id) -%}
<form action="{{ url_for('order_bp.close_order', order_id=order.id) }}" method="post" style="display:inline"> <form action="{{ url_for('order_bp.close_order', order_slug=order.slug) }}" method="post" style="display:inline">
<input type="submit" class="btn btn-danger" value="Close"></input> <input type="submit" class="btn btn-danger" value="Close"></input>
</form> </form>
{% endif %} {% endif %}
{% if courier_or_admin %} {% if courier_or_admin %}
<a class="btn" href="{{ url_for('order_bp.order_edit', order_id=order.id) }}">Edit</a> <a class="btn" href="{{ url_for('order_bp.order_edit', order_slug=order.slug) }}">Edit</a>
{%- endif %} {%- endif %}
</div>
</div> </div>
<div class="box" id="how_to_order"> <div class="box" id="how_to_order">
@ -258,7 +285,7 @@
<div class="footer"> <div class="footer">
Total {{ order.items.count() }} items — {{ total_price|euro }} Total {{ order.items.count() }} items — {{ total_price|euro }}
&nbsp; &nbsp;
<a class="btn btn-sm" href="{{ url_for('order_bp.items_shop_view', order_id=order.id) }}">Shop view</a> <a class="btn btn-sm" href="{{ url_for('order_bp.items_shop_view', order_slug=order.slug) }}">Shop view</a>
</div> </div>
</div> </div>
</div> </div>
@ -267,6 +294,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.modify_items', order_slug=order.slug) }}" 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,35 +304,37 @@
<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 }}" <input type="checkbox" name="user_names" value="{{ user_name }}"
{{ "disabled" if paid }} style="{{ 'opacity: 0.5' if paid }}"> {{ "disabled" if not order.can_modify_payment(current_user.id) }}>
<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") | ignore_none | sum | euro }}
</span>
{% if paid %}paid{% endif %} {% if paid %}<span class="glyphicon glyphicon-ok" style="opacity: 0.5"></span>{% endif %}
</td> </td>
<td>{{ user_name }}</td> <td style="{{ 'opacity: 0.5' if paid }}">{{ user_name }}</td>
<td class="items"> <td class="items">
<ul> <ul>
{% for item in order_items %} {% for item in order_items %}
<li class="{{ 'paid' if item.paid }}"> <li class="{{ 'paid' if item.paid }}">
<div class="actions"> <div class="actions">
{% if item.can_delete(order.id, current_user.id, session.get('anon_name', '')) -%} {% if item.can_delete(order.id, current_user.id, session.get('anon_name', '')) -%}
<form action="{{ url_for('order_bp.delete_item', order_id=order.id, item_id=item.id) }}" method="post" style="display:inline"> <button class="btn btn-link btn-sm" type="submit" name="delete_item" value="{{ item.id }}" style="padding: 0 0.5em;"><span class="glyphicon glyphicon-remove"></span></button>
<button class="btn btn-link btn-sm" type="submit" style="padding: 0 0.5em;"><span class="glyphicon glyphicon-remove"></span></button>
</form>
{% else %} {% else %}
<span class="glyphicon glyphicon-remove" style="color: var(--gray3); padding: 0 0.5em"></span> <span class="glyphicon glyphicon-remove" style="color: var(--gray3); padding: 0 0.5em; cursor: not-allowed"></span>
{%- 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>
@ -314,11 +344,21 @@
</table> </table>
<div class="footer"> <div class="footer">
{% if order.can_modify_payment(current_user.id) %}
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> {% endif %}
{% if order.can_modify_prices(current_user.id) %}
&nbsp; <span style="border-left: 1px solid var(--gray0); display: inline-block;">&nbsp;</span>&nbsp;
<a href="{{ url_for('order_bp.prices', order_slug=order.slug) }}" 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

@ -11,7 +11,7 @@
<h3>Edit order</h3> <h3>Edit order</h3>
<div class="row darker"> <div class="row darker">
<div class="col-sm-12"> <div class="col-sm-12">
<form method="post" action="{{ url_for('.order_edit', order_id=order_id) }}"> <form method="post" action="{{ url_for('.order_edit', order_slug=order_slug) }}">
{{ form.csrf_token }} {{ form.csrf_token }}
<div class="form-group select2 {{ 'has-errors' if form.courier_id.errors else ''}}"> <div class="form-group select2 {{ 'has-errors' if form.courier_id.errors else ''}}">
{{ form.courier_id.label(class='control-label') }}<br> {{ form.courier_id.label(class='control-label') }}<br>

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_slug', order_slug=order.slug) }}">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_slug=order.slug) }}" 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_slug', order_slug=order.slug) }}" 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

@ -38,6 +38,11 @@
{{ form.location_id(class='form-control select') }} {{ form.location_id(class='form-control select') }}
{{ util.render_form_field_errors(form.location_id) }} {{ util.render_form_field_errors(form.location_id) }}
</div> </div>
<div class="form-group select2 {{ 'has-errors' if form.association.errors else ''}}{{ ' required' if form.association.flags.required }}">
{{ form.association.label(class='control-label') }}
{{ form.association(class='form-control select') }}
{{ util.render_form_field_errors(form.association) }}
</div>
{% if current_user.is_admin() %} {% if current_user.is_admin() %}
<div class="form-group{{ ' has-error' if form.starttime.errors }}{{ ' required' if form.starttime.flags.required }}{{ ' hidden' if not current_user.is_admin() }}"> <div class="form-group{{ ' has-error' if form.starttime.errors }}{{ ' required' if form.starttime.flags.required }}{{ ' hidden' if not current_user.is_admin() }}">
{{ form.starttime.label(class='control-label') }} {{ form.starttime.label(class='control-label') }}

View file

@ -1,15 +1,18 @@
{% macro render_order(order) -%} {% macro render_order(order) -%}
<div class="row order_row"> <div class="row order_row">
<div class="col-md-8 col-lg-9 order_data"> <div class="col-md-6 order_data">
<h5>{{ order.location_name }}</h5> <h5>{{ order.location_name }}</h5>
<b class="amount_of_orders">{{ order.items.count() }} orders</b></p> <b class="amount_of_orders">{{ order.items.count() }} items ordered for {{ order.association }}</b></p>
<p class="time_data"> <p class="time_data">
{% if order.stoptime %} {% if order.stoptime %}
<span><b>Closes </b>{{ order.stoptime.strftime("%H:%M") }}</span>{{ order.stoptime|countdown }} <span><b>Closes </b>{{ order.stoptime.strftime("%H:%M") }}</span>{{ order.stoptime|countdown }}
{% else %}open{% endif %}<br/> {% else %}open{% endif %}<br/>
</div> </div>
<div class="col-md-4 col-lg-3 expand_button_wrapper"> <div class="col-md-3">
<a class="btn btn-primary btn-block align-bottom expand_button" href="{{ url_for('order_bp.order_from_id', order_id=order.id) }}">Expand</a> <img src="https://dsa.ugent.be/api/verenigingen/{{ order.association }}/logo" class="img-responsive align-bottom" style="max-width:200px;width:100%">
</div>
<div class="col-md-3 expand_button_wrapper">
<a class="btn btn-primary btn-block align-bottom expand_button" href="{{ url_for('order_bp.order_from_slug', order_slug=order.slug) }}">Expand</a>
</div> </div>
</div> </div>
{%- endmacro %} {%- endmacro %}

View file

@ -1,16 +1,27 @@
"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: Optional[int], unit="") -> str:
""" """
Convert cents to string formatted euro Convert cents to string formatted euro
""" """
if value is None:
return ""
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

@ -9,7 +9,7 @@ from flask import Blueprint, Flask, abort
from flask import current_app as app from flask import current_app as app
from flask import (jsonify, make_response, render_template, request, from flask import (jsonify, make_response, render_template, request,
send_from_directory, url_for) send_from_directory, url_for)
from flask_login import login_required from flask_login import current_user, login_required
from hlds.definitions import location_definitions from hlds.definitions import location_definitions
from hlds.models import Location from hlds.models import Location
from models import Order from models import Order
@ -34,7 +34,9 @@ def home() -> str:
(Order.stoptime > prev_day) & (Order.stoptime < datetime.now()) (Order.stoptime > prev_day) & (Order.stoptime < datetime.now())
) )
return render_template( return render_template(
"home.html", orders=get_orders(), recently_closed=recently_closed "home.html", orders=get_orders(
((datetime.now() > Order.starttime) & (Order.stoptime > datetime.now()) | (Order.stoptime == None))
), recently_closed=recently_closed
) )

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")
@ -19,8 +20,8 @@ order_bp = Blueprint("order_bp", "order")
@order_bp.route("/") @order_bp.route("/")
def orders(form: OrderForm = None) -> str: def orders(form: OrderForm = None) -> str:
"Generate general order view" """Generate general order view"""
if form is None and not current_user.is_anonymous(): if form is None and current_user.association_list():
form = OrderForm() form = OrderForm()
location_id = request.args.get("location_id") location_id = request.args.get("location_id")
form.location_id.default = location_id form.location_id.default = location_id
@ -32,7 +33,10 @@ def orders(form: OrderForm = None) -> str:
@order_bp.route("/create", methods=["POST"]) @order_bp.route("/create", methods=["POST"])
@login_required @login_required
def order_create() -> typing.Union[str, Response]: def order_create() -> typing.Union[str, Response]:
"Generate order create view" """Generate order create view"""
if not current_user.association_list():
flash("Not allowed to create an order.", "info")
abort(401)
orderForm = OrderForm() orderForm = OrderForm()
orderForm.populate() orderForm.populate()
if orderForm.validate_on_submit(): if orderForm.validate_on_submit():
@ -42,14 +46,14 @@ def order_create() -> typing.Union[str, Response]:
db.session.add(order) db.session.add(order)
db.session.commit() db.session.commit()
post_order_to_webhook(order) post_order_to_webhook(order)
return redirect(url_for("order_bp.order_from_id", order_id=order.id)) return redirect(url_for("order_bp.order_from_slug", order_slug=order.slug))
return orders(form=orderForm) return orders(form=orderForm)
@order_bp.route("/<order_id>") @order_bp.route("/<order_slug>")
def order_from_id(order_id: int, form: OrderForm = None, dish_id=None) -> str: def order_from_slug(order_slug: str, form: OrderForm = None, dish_id=None) -> str:
"Generate order view from id" """Generate order view from id"""
order = Order.query.filter(Order.id == order_id).first() order = Order.query.filter(Order.slug == order_slug).first()
if order is None: if order is None:
abort(404) abort(404)
if current_user.is_anonymous() and not order.public: if current_user.is_anonymous() and not order.public:
@ -61,8 +65,8 @@ def order_from_id(order_id: int, form: OrderForm = None, dish_id=None) -> str:
form.populate(order.location) form.populate(order.location)
if order.is_closed(): if order.is_closed():
form = None form = None
total_price = sum(o.price for o in order.items) total_price = sum(o.price or 0 for o in order.items)
debts = sum(o.price for o in order.items if not o.paid) debts = sum(o.price or 0 for o in order.items if not o.paid)
dish = order.location.dish_by_id(dish_id) if order.location else None dish = order.location.dish_by_id(dish_id) if order.location else None
@ -76,44 +80,44 @@ def order_from_id(order_id: int, form: OrderForm = None, dish_id=None) -> str:
) )
@order_bp.route("/<order_id>/items") @order_bp.route("/<order_slug>/items")
def items_shop_view(order_id: int) -> str: def items_shop_view(order_slug: int) -> str:
"Generate order items view from id" """Generate order items view from id"""
order = Order.query.filter(Order.id == order_id).first() order = Order.query.filter(Order.slug == order_slug).first()
if order is None: if order is None:
abort(404) abort(404)
if current_user.is_anonymous() and not order.public: if current_user.is_anonymous() and not order.public:
flash("Please login to see this order.", "info") flash("Please login to see this order.", "info")
abort(401) abort(401)
total_price = sum(o.price for o in order.items) total_price = sum(o.price or 0 for o in order.items)
return render_template("order_items.html", order=order, total_price=total_price) return render_template("order_items.html", order=order, total_price=total_price)
@order_bp.route("/<order_id>/edit", methods=["GET", "POST"]) @order_bp.route("/<order_slug>/edit", methods=["GET", "POST"])
@login_required @login_required
def order_edit(order_id: int) -> typing.Union[str, Response]: def order_edit(order_slug: str) -> typing.Union[str, Response]:
"Generate order edit view from id" """Generate order edit view from id"""
order = Order.query.filter(Order.id == order_id).first() order = Order.query.filter(Order.slug == order_slug).first()
if current_user.id is not order.courier_id and not current_user.is_admin(): if current_user.id is not order.courier_id and not current_user.is_admin():
abort(401) abort(401)
if order is None: if order is None:
abort(404) abort(404)
orderForm = OrderForm(obj=order) order_form = OrderForm(obj=order)
orderForm.populate() order_form.populate()
if orderForm.validate_on_submit(): if order_form.validate_on_submit():
orderForm.populate_obj(order) order_form.populate_obj(order)
order.update_from_hlds() order.update_from_hlds()
db.session.commit() db.session.commit()
return redirect(url_for("order_bp.order_from_id", order_id=order.id)) return redirect(url_for("order_bp.order_from_slug", order_slug=order.slug))
return render_template("order_edit.html", form=orderForm, order_id=order_id) return render_template("order_edit.html", form=order_form, order_slug=order.slug)
@order_bp.route("/<order_id>/create", methods=["GET", "POST"]) @order_bp.route("/<order_slug>/create", methods=["GET", "POST"])
def order_item_create(order_id: int) -> typing.Any: def order_item_create(order_slug: str) -> typing.Any:
# type is 'typing.Union[str, Response]', but this errors due to # type is 'typing.Union[str, Response]', but this errors due to
# https://github.com/python/mypy/issues/7187 # https://github.com/python/mypy/issues/7187
"Add item to order from id" """Add item to order from slug"""
current_order = Order.query.filter(Order.id == order_id).first() current_order = Order.query.filter(Order.slug == order_slug).first()
if current_order is None: if current_order is None:
abort(404) abort(404)
if current_order.is_closed(): if current_order.is_closed():
@ -122,7 +126,7 @@ def order_item_create(order_id: int) -> typing.Any:
flash("Please login to see this order.", "info") flash("Please login to see this order.", "info")
abort(401) abort(401)
location = current_order.location location = current_order.location
# If location doesn't exist any more, adding items is nonsensical # If location doesn't exist anymore, adding items is nonsensical
if not location: if not location:
abort(404) abort(404)
form = AnonOrderItemForm() if current_user.is_anonymous() else OrderItemForm() form = AnonOrderItemForm() if current_user.is_anonymous() else OrderItemForm()
@ -170,7 +174,7 @@ def order_item_create(order_id: int) -> typing.Any:
return redirect( return redirect(
url_for( url_for(
"order_bp.order_item_create", "order_bp.order_item_create",
order_id=order_id, order_slug=current_order.slug,
dish=form.dish_id.data, dish=form.dish_id.data,
user_name=user_name, user_name=user_name,
comment=comment, comment=comment,
@ -179,14 +183,13 @@ def order_item_create(order_id: int) -> typing.Any:
# If the form was not submitted (GET request) or the form had errors: show form again # If the form was not submitted (GET request) or the form had errors: show form again
if not form.validate_on_submit(): if not form.validate_on_submit():
return order_from_id(order_id, form=form, dish_id=dish_id) return order_from_slug(current_order.slug, form=form, dish_id=dish_id)
# Form was submitted and is valid # Form was submitted and is valid
item = OrderItem() item = OrderItem()
form.populate_obj(item) form.populate_obj(item)
item.hlds_data_version = location_definition_version item.hlds_data_version = location_definition_version
item.order_id = order_id item.order_id = current_order.id
if not current_user.is_anonymous(): if not current_user.is_anonymous():
item.user_id = current_user.id item.user_id = current_user.id
else: else:
@ -221,59 +224,82 @@ def order_item_create(order_id: int) -> typing.Any:
db.session.add(item) db.session.add(item)
db.session.commit() db.session.commit()
flash("Ordered %s" % (item.dish_name), "success") flash("Ordered %s" % item.dish_name, "success")
return redirect(url_for("order_bp.order_from_id", order_id=order_id)) return redirect(url_for("order_bp.order_from_slug", order_slug=order_slug))
@order_bp.route("/<order_id>/<user_name>/user_paid", methods=["POST"]) @order_bp.route("/<order_slug>/modify_items", 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 modify_items(order_slug: str) -> typing.Optional[Response]:
"Indicate payment status for a user in an order" if "delete_item" in request.form:
return delete_item(order_slug, int(request.form["delete_item"]))
user_names = request.form.getlist("user_names")
if request.form.get("action") == "mark_paid":
return set_items_paid(order_slug, user_names, True)
elif request.form.get("action") == "mark_unpaid":
return set_items_paid(order_slug, user_names, False)
else:
abort(404)
return None
def set_items_paid(order_slug: str, user_names: typing.Iterable[str], paid: bool):
order = Order.query.filter(Order.slug == order_slug).first()
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:
items = OrderItem.query.filter( items = OrderItem.query.filter(
(OrderItem.user_id == user.id) & (OrderItem.order_id == order_id) (OrderItem.user_id == user.id) & (OrderItem.order_id == order.id)
).all() ).all()
else: else:
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:
return redirect(url_for("order_bp.order_from_id", order_id=order_id)) flash("Marked %d items as paid" % (total_paid_items,), "success")
abort(404) 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_slug", order_slug=order_slug))
@order_bp.route("/<order_id>/<item_id>/delete", methods=["POST"]) @order_bp.route("/<order_slug>/<item_id>/delete", methods=["POST"])
# pylint: disable=R1710 # pylint: disable=R1710
def delete_item(order_id: int, item_id: int) -> typing.Any: def delete_item(order_slug: str, item_id: int) -> typing.Any:
# type is 'typing.Optional[Response]', but this errors due to # type is 'typing.Optional[Response]', but this errors due to
# https://github.com/python/mypy/issues/7187 # https://github.com/python/mypy/issues/7187
"Delete an item from an order" """Delete an item from an order"""
item = OrderItem.query.filter(OrderItem.id == item_id).first() item: OrderItem = OrderItem.query.filter(OrderItem.id == item_id).first()
order: Order = Order.query.filter(Order.slug == order_slug).first()
user_id = None user_id = None
if not current_user.is_anonymous(): if not current_user.is_anonymous():
user_id = current_user.id user_id = current_user.id
if item.can_delete(order_id, user_id, session.get("anon_name", "")): if item.can_delete(order.id, user_id, session.get("anon_name", "")):
dish_name = item.dish_name dish_name = item.dish_name
db.session.delete(item) db.session.delete(item)
db.session.commit() db.session.commit()
flash("Deleted %s" % (dish_name), "success") flash("Deleted %s" % dish_name, "success")
return redirect(url_for("order_bp.order_from_id", order_id=order_id)) return redirect(url_for("order_bp.order_from_slug", order_slug=order_slug))
abort(404) abort(404)
@order_bp.route("/<order_id>/volunteer", methods=["POST"]) @order_bp.route("/<order_slug>/volunteer", methods=["POST"])
@login_required @login_required
def volunteer(order_id: int) -> Response: def volunteer(order_slug: str) -> Response:
"Add a volunteer to an order" """Add a volunteer to an order"""
order = Order.query.filter(Order.id == order_id).first() order = Order.query.filter(Order.slug == order_slug).first()
if order is None: if order is None:
abort(404) abort(404)
if order.courier_id is None or order.courier_id == 0: if order.courier_id is None or order.courier_id == 0:
@ -282,14 +308,14 @@ def volunteer(order_id: int) -> Response:
flash("Thank you for volunteering!") flash("Thank you for volunteering!")
else: else:
flash("Volunteering not possible!") flash("Volunteering not possible!")
return redirect(url_for("order_bp.order_from_id", order_id=order_id)) return redirect(url_for("order_bp.order_from_slug", order_slug=order.slug))
@order_bp.route("/<order_id>/close", methods=["POST"]) @order_bp.route("/<order_slug>/close", methods=["POST"])
@login_required @login_required
def close_order(order_id: int) -> typing.Optional[Response]: def close_order(order_slug: str) -> typing.Optional[Response]:
"Close an order" """Close an order"""
order = Order.query.filter(Order.id == order_id).first() order = Order.query.filter(Order.slug == order_slug).first()
if order is None: if order is None:
abort(404) abort(404)
if ( if (
@ -301,12 +327,54 @@ def close_order(order_id: int) -> typing.Optional[Response]:
if courier is not None: if courier is not None:
order.courier_id = courier.id order.courier_id = courier.id
db.session.commit() db.session.commit()
return redirect(url_for("order_bp.order_from_id", order_id=order_id)) return redirect(url_for("order_bp.order_from_slug", order_slug=order_slug))
return None return None
@order_bp.route("/<order_slug>/prices", methods=["GET", "POST"])
@login_required
def prices(order_slug: str) -> typing.Optional[Response]:
order = Order.query.filter(Order.slug == order_slug).first()
if order is None:
abort(404)
if not order.can_modify_prices(current_user.id):
flash("You cannot modify the prices at this time.", "error")
return redirect(url_for("order_bp.order_from_slug", order_slug=order.slug))
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_slug", order_slug=order.slug))
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
# remove non users # remove non users
items = [i for i in items if i.user_id] items = [i for i in items if i.user_id]
@ -325,19 +393,20 @@ def select_user(items) -> typing.Optional[User]:
def get_orders(expression=None) -> typing.List[Order]: def get_orders(expression=None) -> typing.List[Order]:
"Give the list of all currently open and public Orders" """Give the list of all currently open and public Orders"""
order_list: typing.List[OrderForm] = [] order_list: typing.List[OrderForm] = []
if expression is None: if expression is None:
expression = (datetime.now() > Order.starttime) & ( expression = ((datetime.now() > Order.starttime) & (
Order.stoptime Order.stoptime
> datetime.now() > datetime.now()
# pylint: disable=C0121 # pylint: disable=C0121
) | (Order.stoptime == None) ) | (Order.stoptime == None)
) & (Order.association.in_(current_user.association_list()))
if not current_user.is_anonymous(): if not current_user.is_anonymous():
order_list = Order.query.filter(expression).all() order_list = Order.query.filter(expression).all()
else: else:
order_list = Order.query.filter( order_list = Order.query.filter(
# pylint: disable=C0121 # pylint: disable=C0121
expression & (Order.public == True) expression & (Order.public == True) & (Order.association.in_(current_user.association_list()))
).all() ).all()
return order_list return order_list

View file

@ -4,3 +4,6 @@ set -euo pipefail
cd "$(dirname "$0")/app" cd "$(dirname "$0")/app"
env python create_database.py setup_database env python create_database.py setup_database
latest_revision=$(env python app.py db heads | sed "s/ (head)$//")
echo Stamping db at $latest_revision
env python app.py db stamp $latest_revision

View file

@ -13,3 +13,4 @@ pymysql
pyyaml pyyaml
tatsu<5.6 # >=5.6 needs Python >=3.8 tatsu<5.6 # >=5.6 needs Python >=3.8
microsoftgraph-python microsoftgraph-python
sentry-sdk[flask]

View file

@ -11,11 +11,15 @@ appdirs==1.4.4
black==21.6b0 black==21.6b0
# via -r requirements.in # via -r requirements.in
blinker==1.4 blinker==1.4
# via flask-debugtoolbar # via
# flask-debugtoolbar
# sentry-sdk
cachelib==0.1.1 cachelib==0.1.1
# via flask-oauthlib # via flask-oauthlib
certifi==2021.5.30 certifi==2021.5.30
# via requests # via
# requests
# sentry-sdk
chardet==4.0.0 chardet==4.0.0
# via requests # via requests
click==7.1.2 click==7.1.2
@ -36,6 +40,7 @@ flask==1.1.4
# flask-script # flask-script
# flask-sqlalchemy # flask-sqlalchemy
# flask-wtf # flask-wtf
# sentry-sdk
flask-admin==1.5.8 flask-admin==1.5.8
# via -r requirements.in # via -r requirements.in
flask-bootstrap==3.3.7.1 flask-bootstrap==3.3.7.1
@ -100,6 +105,8 @@ requests==2.25.1
# requests-oauthlib # requests-oauthlib
requests-oauthlib==1.1.0 requests-oauthlib==1.1.0
# via flask-oauthlib # via flask-oauthlib
sentry-sdk[flask]==1.10.1
# via -r requirements.in
six==1.16.0 six==1.16.0
# via python-dateutil # via python-dateutil
sqlalchemy==1.4.18 sqlalchemy==1.4.18
@ -110,8 +117,10 @@ tatsu==4.4.0
# via -r requirements.in # via -r requirements.in
toml==0.10.2 toml==0.10.2
# via black # via black
urllib3==1.26.5 urllib3==1.26.12
# via requests # via
# requests
# sentry-sdk
visitor==0.1.3 visitor==0.1.3
# via flask-bootstrap # via flask-bootstrap
werkzeug==1.0.1 werkzeug==1.0.1