Compare commits

..

2 commits

Author SHA1 Message Date
mcbloch
e8431fcd12 Add share slug and qr code to order page 2022-04-20 02:35:44 +02:00
mcbloch
1e0c8ed17a change url usage to unique 7char slugs 2022-04-20 02:05:10 +02:00
42 changed files with 170 additions and 722 deletions

View file

@ -1,10 +0,0 @@
# Ignore everything
*
# Include source, config and scripts
!app
!etc
!*.md
!*.sh
!*.txt
!LICENSE

View file

@ -1,26 +0,0 @@
# syntax=docker/dockerfile:1
FROM python:3.9.2-slim AS development
WORKDIR /src
RUN pip install pymysql
ADD https://git.zeus.gent/haldis/menus/-/archive/master/menus-master.tar /tmp
RUN mkdir menus && \
tar --directory=menus --extract --strip-components=1 --file=/tmp/menus-master.tar
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
WORKDIR /src/app
CMD python app.py db upgrade && \
python app.py runserver -h 0.0.0.0 -p 8000
FROM development AS production
RUN pip install waitress
CMD python app.py db upgrade && \
python waitress_wsgi.py

View file

@ -26,7 +26,7 @@ Afterwards upgrade the database to the latest version using
cd app
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_ADMINS` 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_ADMIN_USERS` in `app/config.py`
./populate-db.sh

View file

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

View file

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

View file

@ -3,29 +3,25 @@
"""Main Haldis script"""
import logging
import sentry_sdk
import typing
from datetime import datetime
from logging.handlers import TimedRotatingFileHandler
from admin import init_admin
from config import Configuration
from flask import Flask, render_template, Response
from flask import Flask, render_template
from flask_bootstrap import Bootstrap, StaticCDN
from flask_debugtoolbar import DebugToolbarExtension
from flask_login import LoginManager
from flask_migrate import Migrate, MigrateCommand
from flask_script import Manager, Server
from auth.login import init_login
from markupsafe import Markup
from admin import init_admin
from auth.login import init_login
from auth.zeus import init_oauth
from config import Configuration
from models import db
from models.anonymous_user import AnonymouseUser
from sentry_sdk.integrations.flask import FlaskIntegration
from utils import euro_string, price_range_string, ignore_none
from utils import euro_string, price_range_string
from auth.zeus import init_oauth
def register_plugins(app: Flask) -> Manager:
@ -160,19 +156,12 @@ def add_template_filters(app: Flask) -> None:
app.template_filter("price_range")(price_range_string)
app.template_filter("any")(any)
app.template_filter("all")(all)
app.template_filter("ignore_none")(ignore_none)
def create_app():
"""Initializer for the Flask app object"""
app = Flask(__name__)
@app.route('/robots.txt')
def noindex():
r = Response(response="User-Agent: *\nDisallow: /\n", status=200, mimetype="text/plain")
r.headers["Content-Type"] = "text/plain; charset=utf-8"
return r
# Load the config file
app.config.from_object("config.Configuration")
@ -185,16 +174,10 @@ def create_app():
def inject_config():
return dict(configuration=Configuration)
return app, app_manager
return app_manager
# For usage when you directly call the script with python
if __name__ == "__main__":
if Configuration.SENTRY_DSN:
sentry_sdk.init(
dsn=Configuration.SENTRY_DSN,
integrations=[FlaskIntegration()]
)
app, app_mgr = create_app()
app_mgr = create_app()
app_mgr.run()

View file

@ -11,7 +11,7 @@ auth_microsoft_bp = Blueprint("auth_microsoft_bp", __name__)
client = Client(Configuration.MICROSOFT_AUTH_ID,
Configuration.MICROSOFT_AUTH_SECRET,
account_type="ugentbe.onmicrosoft.com")
account_type='common') # by default common, thus account_type is optional parameter.
def microsoft_login():
@ -36,31 +36,31 @@ def authorized() -> typing.Any:
oauth_code = request.args['code']
resp = client.exchange_code(url_for("auth_microsoft_bp.authorized", _external=True), oauth_code)
# access_token = resp.data['access_token']
# id_token = resp.data['id_token']
# expires_in = resp.data['expires_in']
client.set_token(resp.data)
resp = client.users.get_me()
microsoft_uuid = resp.data['id']
username = resp.data['userPrincipalName']
# print(resp.data)
username = resp.data['userPrincipalName']
microsoft_uuid = resp.data['id']
user = User.query.filter_by(username=username).first()
if username and user:
return login_and_redirect_user(user)
elif username:
# TODO Save 'ugent_username' or something similar
user = create_user(username, microsoft_uuid)
return login_and_redirect_user(user)
# Fail if fields are not populated
if not microsoft_uuid or not username:
flash("You're not allowed to enter, please contact a system administrator")
return redirect(url_for("general_bp.home"))
# Find existing user by Microsoft UUID (userPrincipalName can change)
user = User.query.filter_by(microsoft_uuid=microsoft_uuid).first()
if user:
return login_and_redirect_user(user)
# Find existing user by username (pre-existing account)
user = User.query.filter_by(username=username).first()
if user:
return login_and_redirect_user(user)
# No user found, create a new one
user = create_user(username, microsoft_uuid=microsoft_uuid)
return login_and_redirect_user(user)
def login_and_redirect_user(user) -> Response:
"""Log in the user and then redirect them"""
@ -68,10 +68,10 @@ def login_and_redirect_user(user) -> Response:
return redirect(url_for("general_bp.home"))
def create_user(username, *, microsoft_uuid) -> User:
def create_user(username, microsoft_uuid) -> User:
"""Create a temporary user if it is needed"""
user = User()
user.configure(username, False, 1, microsoft_uuid=microsoft_uuid)
user.configure(username, False, 1, microsoft_uuid)
db.session.add(user)
db.session.commit()
return user

View file

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

View file

@ -1,23 +1,17 @@
"""An example for a Haldis config"""
# import os
# config
class Configuration:
"Haldis configuration object"
# pylint: disable=too-few-public-methods
SQLALCHEMY_DATABASE_URI = "sqlite:///haldis.db"
# MARIADB_HOST = os.environ.get("MARIADB_HOST")
# MARIADB_DB = os.environ.get("MARIADB_DATABASE")
# MARIADB_USER = os.environ.get("MARIADB_USER")
# MARIADB_PASS = os.environ.get("MARIADB_PASSWORD")
# SQLALCHEMY_DATABASE_URI = f"mysql+pymysql://{MARIADB_USER}:{MARIADB_PASS}@{MARIADB_HOST}/{MARIADB_DB}"
SQLALCHEMY_TRACK_MODIFICATIONS = False
DEBUG = True
HALDIS_ADMINS = []
HALDIS_ADMIN_USERS = []
SECRET_KEY = "<change>"
SLACK_WEBHOOK = None
LOGFILE = "haldis.log"
SENTRY_DSN = None
ZEUS_KEY = "tomtest"
ZEUS_SECRET = "blargh"

View file

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

View file

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

View file

@ -24,17 +24,13 @@ class OrderForm(Form):
"Starttime", default=datetime.now, 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")
def populate(self) -> None:
"Fill in the options for courier for an Order"
if current_user.is_admin():
self.courier_id.choices = [
(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
self.courier_id.choices = [(0, None)] + [
(u.id, u.username) for u in User.query.order_by("username")
]
else:
self.courier_id.choices = [
@ -42,7 +38,6 @@ class OrderForm(Form):
(current_user.id, current_user.username),
]
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:
self.stoptime.data = datetime.now() + timedelta(hours=1)

View file

@ -1,6 +1,7 @@
# Import this class to load the standard HLDS definitions
import subprocess
from pathlib import Path
from os import path
from typing import List
from .models import Location
@ -11,14 +12,10 @@ __all__ = ["location_definitions", "location_definition_version"]
# pylint: disable=invalid-name
# TODO Use proper way to get resources, see https://stackoverflow.com/a/10935674
ROOT_DIR = Path(__file__).parent.parent.parent
DATA_DIR = ROOT_DIR / "menus"
DATA_DIR = path.join(path.dirname(__file__), "..", "..", "menus")
location_definitions: List[Location] = parse_all_directory(str(DATA_DIR))
location_definitions: List[Location] = parse_all_directory(DATA_DIR)
location_definitions.sort(key=lambda l: l.name)
try:
proc = subprocess.run(["git", "rev-parse", "HEAD"], stdout=subprocess.PIPE, cwd=str(ROOT_DIR), check=True)
location_definition_version = proc.stdout.decode().strip()
except FileNotFoundError:
location_definition_version = ""
proc = subprocess.run(["git", "rev-parse", "HEAD"], stdout=subprocess.PIPE, check=True)
location_definition_version = proc.stdout.decode().strip()

View file

@ -29,9 +29,9 @@ location = >location_header items:{ block } ;
attributes =
name:/[^\n#]*?(?= +-- | | €| *\n| *#)/
name:/[^\n#]*?(?= +-- | | *\n| *#)/
[ s '--' ~ s description:/[^\n#]*?(?= | *\n| *#)/ ]
[ / +/ ~
[ / {2,}/ ~
[ {[ s ] ('{' tags+:identifier '}')} / +|$/ ]
[ price:price ]
]

View file

@ -43,7 +43,7 @@ def upgrade():
sa.Column("starttime", sa.DateTime(), nullable=True),
sa.Column("stoptime", sa.DateTime(), nullable=True),
sa.Column("public", sa.Boolean(), nullable=True),
sa.ForeignKeyConstraint(["location_id"], ["location.id"], name="order_ibfk_1"),
sa.ForeignKeyConstraint(["location_id"], ["location.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_table(
@ -65,7 +65,7 @@ def upgrade():
sa.Column("extra", sa.String(length=254), nullable=True),
sa.Column("name", sa.String(length=120), nullable=True),
sa.ForeignKeyConstraint(["order_id"], ["order.id"]),
sa.ForeignKeyConstraint(["product_id"], ["product.id"], name="order_item_ibfk_3"),
sa.ForeignKeyConstraint(["product_id"], ["product.id"]),
sa.ForeignKeyConstraint(["user_id"], ["user.id"]),
sa.PrimaryKeyConstraint("id"),
)

View file

@ -1,30 +0,0 @@
"""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

@ -1,21 +0,0 @@
"""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

@ -1,26 +0,0 @@
"""empty message
Revision ID: 89b2c980b663
Revises: 9eac0f3d7b1e
Create Date: 2023-04-20 02:01:54.558602
"""
# revision identifiers, used by Alembic.
revision = '89b2c980b663'
down_revision = '9eac0f3d7b1e'
from alembic import op
import sqlalchemy as sa
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('user', sa.Column('microsoft_uuid', sa.VARCHAR(length=120), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('user', 'microsoft_uuid')
# ### end Alembic commands ###

View file

@ -112,12 +112,14 @@ def upgrade():
)
)
# Historical product data migrated, drop obsolete column and table
op.drop_constraint("order_item_ibfk_3", "order_item", type_="foreignkey")
op.execute(text("ALTER TABLE order_item DROP FOREIGN KEY order_item_ibfk_3"))
op.drop_column("order_item", "product_id")
op.drop_table("product")
# ----------------------------------------------------------------------------------------------
# Migrate historical location data to orders
op.execute(text("ALTER TABLE `order` DROP FOREIGN KEY order_ibfk_2"))
op.alter_column(
"order",
"location_id",
@ -155,7 +157,6 @@ def upgrade():
for query in chain(new_location_id, [location_name_from_location]):
op.execute(query)
# Historical location data migrated, drop obsolete column and table
op.drop_constraint("order_ibfk_1", "order", type_="foreignkey")
op.drop_column("order", "legacy_location_id")
op.drop_table("location")

View file

@ -1,22 +0,0 @@
"""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

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

View file

@ -11,13 +11,11 @@ from utils import first
from .database import db
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
alphabet = string.ascii_letters + string.digits
return ''.join(secrets.choice(alphabet) for i in range(7))
class Order(db.Model):
"""Class used for configuring the Order model in the database"""
@ -28,8 +26,7 @@ class Order(db.Model):
starttime = db.Column(db.DateTime)
stoptime = db.Column(db.DateTime)
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="")
slug = db.Column(db.String(7), default=generate_slug, unique=True)
items = db.relationship("OrderItem", backref="order", lazy="dynamic")
@ -125,13 +122,3 @@ class Order(db.Model):
if self.courier_id == user_id or (user and user.is_admin()):
return True
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

@ -18,7 +18,6 @@ class OrderItem(db.Model):
dish_id = db.Column(db.String(64), nullable=True)
dish_name = db.Column(db.String(120), nullable=True)
price = db.Column(db.Integer, nullable=True)
price_modified = db.Column(db.DateTime, nullable=True)
paid = db.Column(db.Boolean, default=False, nullable=True)
comment = db.Column(db.Text(), nullable=True)
hlds_data_version = db.Column(db.String(40), nullable=True)
@ -74,12 +73,3 @@ class OrderItem(db.Model):
if user and (user.is_admin() or user == self.order.courier):
return True
return False
# pylint: disable=W0613
def can_modify_payment(self, order_id: int, user_id: int) -> bool:
if int(self.order_id) != int(order_id):
return False
user = User.query.filter(User.id == user_id).first()
if user and (user.is_admin() or user == self.order.courier):
return True
return False

View file

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

View file

@ -21,7 +21,7 @@ def webhook_text(order: Order) -> typing.Optional[str]:
url_for("order_bp.order_from_slug", order_slug=order.slug, _external=True),
order.location_name,
remaining_minutes(order.stoptime),
order.courier.username,
order.courier.username.title(),
)
# pylint: disable=C0209
@ -64,5 +64,5 @@ def remaining_minutes(value) -> str:
delta = value - datetime.now()
if delta.total_seconds() < 0:
return "0"
minutes = int(delta.total_seconds() // 60)
minutes = delta.total_seconds() // 60
return f"{minutes:02}"

View file

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

View file

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

View file

@ -155,8 +155,7 @@
<div class="box" id="order_info">
<h3>Order information</h3>
<div class="row">
<dl class="col-md-10 col-lg-8">
<dl>
<div>
<dt>Order opens</dt>
<dd>{{ order.starttime.strftime("%Y-%m-%d, %H:%M") }}</dd>
@ -198,14 +197,9 @@
{% endif %}
</dd>
</div>
</dl>
<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>
<div>
{% if order.can_close(current_user.id) -%}
<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>
@ -214,7 +208,7 @@
{% if courier_or_admin %}
<a class="btn" href="{{ url_for('order_bp.order_edit', order_slug=order.slug) }}">Edit</a>
{%- endif %}
</div>
</div>
<div class="box" id="how_to_order">
@ -294,7 +288,6 @@
<section class="single_column">
<div class="box" id="per_person">
<h3>Items per person</h3>
<form action="{{ url_for('order_bp.modify_items', order_slug=order.slug) }}" method="post">
<table class="table table-condensed">
<thead>
<tr><th>Total</th><th>Name</th><th>Items</th></tr>
@ -304,37 +297,35 @@
<tr>
<td>
{% set paid = order_items | map(attribute="paid") | all %}
<input type="checkbox" name="user_names" value="{{ user_name }}"
{{ "disabled" if not order.can_modify_payment(current_user.id) }}>
<input type="checkbox" name="{{ user_name }}"
{{ "disabled" if paid }} style="{{ 'opacity: 0.5' if paid }}">
<span class="price" style="{{ 'opacity: 0.5' if paid }}">
{{ order_items | map(attribute="price") | ignore_none | sum | euro }}
</span>
<span class="price">{{ order_items | map(attribute="price") | sum | euro }}</span>
{% if paid %}<span class="glyphicon glyphicon-ok" style="opacity: 0.5"></span>{% endif %}
{% if paid %}paid{% endif %}
</td>
<td style="{{ 'opacity: 0.5' if paid }}">{{ user_name }}</td>
<td>{{ user_name }}</td>
<td class="items">
<ul>
{% for item in order_items %}
<li class="{{ 'paid' if item.paid }}">
<div class="actions">
{% if item.can_delete(order.id, current_user.id, session.get('anon_name', '')) -%}
<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>
<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>
</form>
{% else %}
<span class="glyphicon glyphicon-remove" style="color: var(--gray3); padding: 0 0.5em; cursor: not-allowed"></span>
<span class="glyphicon glyphicon-remove" style="color: var(--gray3); padding: 0 0.5em"></span>
{%- endif %}
</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="price_aligned">{{ item.price|euro }}</div>
<div class="item_description">{{ item.dish_name }}{{ "; " + item.comment if item.comment }}</div>
</li>
{% 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>
</td>
@ -344,21 +335,11 @@
</table>
<div class="footer">
{% if order.can_modify_payment(current_user.id) %}
On selected:
<button name="action" value="mark_paid" class="btn btn-sm"><span class="glyphicon glyphicon-ok"></span> Mark paid</button>
<button name="action" value="mark_unpaid" class="btn btn-sm">Mark unpaid</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 %}
<button class="btn btn-sm"><span class="glyphicon glyphicon-ok"></span> Mark paid (TODO)</button>
<button class="btn btn-sm"><span class="glyphicon glyphicon-piggy-bank"></span> Tab (TODO)</button>
<button class="btn btn-sm"><span class="glyphicon glyphicon-qrcode"></span> QR code (TODO)</button>
</div>
</form>
</div>
</section>
{% endblock %}

View file

@ -1,132 +0,0 @@
{% 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,11 +38,6 @@
{{ form.location_id(class='form-control select') }}
{{ util.render_form_field_errors(form.location_id) }}
</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() %}
<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') }}

View file

@ -1,17 +1,14 @@
{% macro render_order(order) -%}
<div class="row order_row">
<div class="col-md-6 order_data">
<div class="col-md-8 col-lg-9 order_data">
<h5>{{ order.location_name }}</h5>
<b class="amount_of_orders">{{ order.items.count() }} items ordered for {{ order.association }}</b></p>
<b class="amount_of_orders">{{ order.items.count() }} orders</b></p>
<p class="time_data">
{% if order.stoptime %}
<span><b>Closes </b>{{ order.stoptime.strftime("%H:%M") }}</span>{{ order.stoptime|countdown }}
{% else %}open{% endif %}<br/>
</div>
<div class="col-md-3">
<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">
<div class="col-md-4 col-lg-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>

View file

@ -1,27 +1,16 @@
"Script which contains several utils for Haldis"
import re
from typing import Iterable, Optional
from typing import Iterable
def euro_string(value: Optional[int], unit="") -> str:
def euro_string(value: int) -> str:
"""
Convert cents to string formatted euro
"""
if value is None:
return ""
euro, cents = divmod(value, 100)
if cents:
return f"{unit}{euro}.{cents:02}"
return f"{unit}{euro}"
def parse_euro_string(value: str) -> Optional[int]:
m = re.fullmatch("(?:€ ?)?([0-9]+)(?:[.,]([0-9]+))?", value)
if not m:
return None
cents_02 = "{:0<2.2}".format(m.group(2)) if m.group(2) else "00"
return int(m.group(1)) * 100 + int(cents_02)
return f"{euro}.{cents:02}"
return f"{euro}"
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 (jsonify, make_response, render_template, request,
send_from_directory, url_for)
from flask_login import current_user, login_required
from flask_login import login_required
from hlds.definitions import location_definitions
from hlds.models import Location
from models import Order
@ -34,9 +34,7 @@ def home() -> str:
(Order.stoptime > prev_day) & (Order.stoptime < datetime.now())
)
return render_template(
"home.html", orders=get_orders(
((datetime.now() > Order.starttime) & (Order.stoptime > datetime.now()) | (Order.stoptime == None))
), recently_closed=recently_closed
"home.html", orders=get_orders(), recently_closed=recently_closed
)

View file

@ -1,6 +1,5 @@
"""Script to generate the order related views of Haldis"""
import random
import re
import typing
from datetime import datetime
@ -12,7 +11,7 @@ from forms import AnonOrderItemForm, OrderForm, OrderItemForm
from hlds.definitions import location_definition_version, location_definitions
from models import Order, OrderItem, User, db
from notification import post_order_to_webhook
from utils import ignore_none, parse_euro_string
from utils import ignore_none
from werkzeug.wrappers import Response
order_bp = Blueprint("order_bp", "order")
@ -21,7 +20,7 @@ order_bp = Blueprint("order_bp", "order")
@order_bp.route("/")
def orders(form: OrderForm = None) -> str:
"""Generate general order view"""
if form is None and current_user.association_list():
if form is None and not current_user.is_anonymous():
form = OrderForm()
location_id = request.args.get("location_id")
form.location_id.default = location_id
@ -34,9 +33,6 @@ def orders(form: OrderForm = None) -> str:
@login_required
def order_create() -> typing.Union[str, Response]:
"""Generate order create view"""
if not current_user.association_list():
flash("Not allowed to create an order.", "info")
abort(401)
orderForm = OrderForm()
orderForm.populate()
if orderForm.validate_on_submit():
@ -65,8 +61,8 @@ def order_from_slug(order_slug: str, form: OrderForm = None, dish_id=None) -> st
form.populate(order.location)
if order.is_closed():
form = None
total_price = sum(o.price or 0 for o in order.items)
debts = sum(o.price or 0 for o in order.items if not o.paid)
total_price = sum(o.price for o in order.items)
debts = sum(o.price for o in order.items if not o.paid)
dish = order.location.dish_by_id(dish_id) if order.location else None
@ -89,7 +85,7 @@ def items_shop_view(order_slug: int) -> str:
if current_user.is_anonymous() and not order.public:
flash("Please login to see this order.", "info")
abort(401)
total_price = sum(o.price or 0 for o in order.items)
total_price = sum(o.price for o in order.items)
return render_template("order_items.html", order=order, total_price=total_price)
@ -228,27 +224,13 @@ def order_item_create(order_slug: str) -> typing.Any:
return redirect(url_for("order_bp.order_from_slug", order_slug=order_slug))
@order_bp.route("/<order_slug>/modify_items", methods=["POST"])
@order_bp.route("/<order_slug>/<user_name>/user_paid", methods=["POST"])
@login_required
# pylint: disable=R1710
def modify_items(order_slug: str) -> typing.Optional[Response]:
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:
def items_user_paid(order_slug: str, user_name: str) -> typing.Optional[Response]:
"""Indicate payment status for a user in an order"""
user = User.query.filter(User.username == user_name).first()
order = Order.query.filter(Order.slug == order_slug).first()
items: typing.List[OrderItem] = []
if user:
items = OrderItem.query.filter(
@ -258,21 +240,14 @@ def set_items_paid(order_slug: str, user_names: typing.Iterable[str], paid: bool
items = OrderItem.query.filter(
(OrderItem.user_name == user_name) & (OrderItem.order_id == order.id)
).all()
current_order = Order.query.filter(Order.id == order.id).first()
if current_order.courier_id == current_user.id or current_user.admin:
for item in items:
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
item.paid = True
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")
flash("Paid %d items for %s" % (len(items), item.for_name), "success")
return redirect(url_for("order_bp.order_from_slug", order_slug=order_slug))
abort(404)
@order_bp.route("/<order_slug>/<item_id>/delete", methods=["POST"])
@ -331,48 +306,6 @@ def close_order(order_slug: str) -> typing.Optional[Response]:
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]:
"""Select a random user from those who are signed up for the order"""
user = None
@ -396,17 +329,16 @@ def get_orders(expression=None) -> typing.List[Order]:
"""Give the list of all currently open and public Orders"""
order_list: typing.List[OrderForm] = []
if expression is None:
expression = ((datetime.now() > Order.starttime) & (
expression = (datetime.now() > Order.starttime) & (
Order.stoptime
> datetime.now()
# pylint: disable=C0121
) | (Order.stoptime == None)
) & (Order.association.in_(current_user.association_list()))
if not current_user.is_anonymous():
order_list = Order.query.filter(expression).all()
else:
order_list = Order.query.filter(
# pylint: disable=C0121
expression & (Order.public == True) & (Order.association.in_(current_user.association_list()))
expression & (Order.public == True)
).all()
return order_list

View file

@ -1,16 +0,0 @@
import sentry_sdk
from sentry_sdk.integrations.flask import FlaskIntegration
from waitress import serve
from app import create_app
from config import Configuration
if __name__ == "__main__":
if Configuration.SENTRY_DSN:
sentry_sdk.init(
dsn=Configuration.SENTRY_DSN,
integrations=[FlaskIntegration()]
)
app, app_mgr = create_app()
serve(app, host="0.0.0.0", port=8000)

View file

@ -1,17 +0,0 @@
version: "3.4"
services:
app:
build:
target: "development"
environment:
- MARIADB_DATABASE=haldis
- MARIADB_USER=haldis
- MARIADB_PASSWORD=haldis
volumes: ["$PWD:/src"]
database:
environment:
- MARIADB_DATABASE=haldis
- MARIADB_ROOT_PASSWORD=mariadb
- MARIADB_USER=haldis
- MARIADB_PASSWORD=haldis

View file

@ -1,31 +0,0 @@
version: "3.4"
services:
app:
build:
context: .
target: production
restart: on-failure
depends_on: [database]
ports: ["8000:8000"]
environment:
- MARIADB_HOST=database
- MARIADB_DATABASE
- MARIADB_USER
- MARIADB_PASSWORD
networks: [haldis]
database:
image: mariadb:10.8
hostname: database
restart: on-failure
environment:
- MARIADB_DATABASE
- MARIADB_ROOT_PASSWORD
- MARIADB_USER
- MARIADB_PASSWORD
networks: [haldis]
volumes: [haldis_data:/var/lib/mysql]
networks:
haldis:
volumes:
haldis_data:

View file

@ -25,7 +25,7 @@ syn keyword hldsChoiceType single_choice multi_choice nextgroup=hldsBlockIdAf
syn match hldsBlockId "^[a-z0-9_-]\+: "
syn match hldsBlockIdAftrKywrd "[a-z0-9_-]\+: " contained
syn match _space " \+" nextgroup=hldsTag,hldsPrice
syn match _doubleSpace " \+" nextgroup=hldsTag,hldsPrice
syn match hldsTag "{[a-z0-9_-]\+}\( \|$\)" contained nextgroup=hldsTag,hldsPrice
syn match hldsPrice "€ *[0-9]\+\(\.[0-9]\+\|\)\( \|$\)" contained

View file

@ -4,6 +4,3 @@ set -euo pipefail
cd "$(dirname "$0")/app"
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,4 +13,3 @@ pymysql
pyyaml
tatsu<5.6 # >=5.6 needs Python >=3.8
microsoftgraph-python
sentry-sdk[flask]

View file

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