Compare commits

..

105 commits

Author SHA1 Message Date
3cde7764c4
Update syntax for lifted double space requirement 2024-06-29 11:56:12 +02:00
redfast00
73671bd8f1
Merge pull request #213 from ZeusWPI/feat/docker
Dockerize the application
2023-06-28 22:09:06 +02:00
Maxim De Clercq
45b4913657
Add menus provided by Zeus WPI by default 2023-06-25 17:18:50 +02:00
Maxim De Clercq
7b78e7d8ff
Add .dockerignore 2023-06-25 17:18:39 +02:00
Maxim De Clercq
a29d3a33be
Use correct app when running with waitress 2023-06-25 17:18:31 +02:00
Maxim De Clercq
7fad75fc08
Dockerize the application 2023-06-25 17:18:20 +02:00
redfast00
5a82354b78
Merge pull request #214 from ZeusWPI/microsoft-auth-wip
Microsoft auth
2023-06-25 16:49:13 +02:00
Maxim De Clercq
fbb69c843a
Merge branch 'master' into microsoft-auth-wip 2023-06-25 16:18:32 +02:00
redfast00
30626e457a
Merge pull request #217 from ZeusWPI/no-double-space
Remove double space requirement
2023-06-13 23:31:56 +02:00
redfast00
0aea3f6d34
Remove double space requirement 2023-06-13 21:39:19 +02:00
redfast00
cdca5646ef
Merge pull request #204 from Happilands/patch-1
Add robots.txt file
2023-04-22 17:52:17 +02:00
Maxim De Clercq
8b1b3f482a
Add migration for Microsoft Auth 2023-04-20 02:10:59 +02:00
Maxim De Clercq
2d6aea10fb
Change Microsoft account type to ugentbe.onmicrosoft.com 2023-04-19 23:17:51 +02:00
Maxim De Clercq
6e79fc50ed
Merge pull request #186 from ZeusWPI/feature/microsoft-auth
Add working microsoft login flow
2023-04-19 22:55:32 +02:00
Maxim De Clercq
ba29ecbc73
Check for user with matching Microsoft UUID first 2023-04-19 22:38:30 +02:00
Maxim De Clercq
1ffcdc3ec1
Do not store UGent username since it is not exposed through Graph API 2023-04-19 22:03:40 +02:00
Maxim De Clercq
6bb11e49a3
Add support for when cwd is outside the project
E.g. Pycharm runs `/.../haldis/venv/bin/python /.../haldis/app/app.py` when no working directory is set.
2023-04-19 21:18:53 +02:00
Maxim De Clercq
aab522eef9
Merge branch 'master' into feature/microsoft-auth
# Conflicts:
#	app/app.py
#	app/create_database.py
#	app/models/user.py
#	requirements.in
#	requirements.txt
2023-04-19 20:55:35 +02:00
Tibo
e86fce0a7e
Merge branch 'master' into patch-1 2023-04-19 19:14:54 +02:00
Charlotte Van Petegem
02afba70a9
Merge pull request #212 from ZeusWPI/fix/admin-order-current-top
Order current user to the top for admins when choosing courier
2023-01-24 19:11:21 +01:00
Charlotte Van Petegem
1bc6a5931e
Order current user to the top for admins 2023-01-24 19:06:10 +01:00
Jasper
c991cd7882
Merge pull request #207 from JasperJanin/master
Add user group reference to orders
2022-10-27 22:15:37 +02:00
Jasper Janin
a29ade4773 Remove redundant class attributes 2022-10-27 22:12:03 +02:00
Jasper
6f7aff15cc
Merge branch 'ZeusWPI:master' into master 2022-10-27 21:35:28 +02:00
Jasper Janin
7b12c266b3 Add user group reference to orders 2022-10-27 21:32:22 +02:00
Tibo
7d122cf6e9
Merge pull request #203 from ZeusWPI/addToolversions
.tool-versions toegevoegd
2022-10-27 21:32:06 +02:00
Charlotte Van Petegem
202d5d3e7a
Revert "Merge branch 'master' of github.com:ZeusWPI/Haldis"
This reverts commit 28fa1b7592, reversing
changes made to b14671413c.
2022-10-27 21:23:13 +02:00
Jasper Janin
28fa1b7592 Merge branch 'master' of github.com:ZeusWPI/Haldis 2022-10-27 20:28:11 +02:00
Jasper Janin
bf8eb94117 Add user group reference to orders 2022-10-27 20:24:35 +02:00
Maxime
b14671413c
Merge pull request #206 from ZeusWPI/sentry-local-dev
Make sure local dev still works with sentry
2022-10-27 19:49:57 +02:00
Charlotte Van Petegem
29afc8db7a
Make sure local dev still works with sentry 2022-10-27 19:47:12 +02:00
Charlotte Van Petegem
1dcd723bd4
Merge pull request #205 from ZeusWPI/add-sentry
Add glitchtip
2022-10-27 19:41:26 +02:00
Charlotte Van Petegem
c0f44ab037
Add glitchtip 2022-10-27 19:38:46 +02:00
Happilands
4e8799eca5
Add robots.txt file
From
https://www.pythonanywhere.com/forums/topic/2899/
2022-10-27 19:29:50 +02:00
AlexVDP8
e302da0335 .tool-versions toegevoegd
Co-authored-by: Francis <francisklinck@gmail.com>
2022-10-27 19:15:29 +02:00
redfast00
c839fce270
Merge pull request #199 from ZeusWPI/fix/delete-bottom-list
Fix not being able to delete an item from the list at the bottom of an order
2022-06-10 18:41:14 +02:00
Charlotte Van Petegem
687d389fa2
Fix not being able to delete an item from the list at the bottom of an order 2022-06-10 18:28:31 +02:00
redfast00
9c4361ab1b
Merge pull request #197 from ZeusWPI/remove-dot-title-from-username
Don't title username
2022-06-03 19:36:54 +02:00
redfast00
754eae4a50
Don't title username 2022-06-03 19:27:11 +02:00
Charlotte Van Petegem
f3911b377d
Merge pull request #196 from ZeusWPI/eight-character-slug
Make slug eight characters
2022-06-03 19:16:11 +02:00
Charlotte Van Petegem
3bc2ad83ea
Remove some more letters from the alphabet 2022-06-03 19:12:00 +02:00
Charlotte Van Petegem
0661016236
Make slug eight characters 2022-06-03 19:05:33 +02:00
Maxime
10327941d2
Merge pull request #195 from ZeusWPI/base58-slugs
Use base58 for slugs
2022-06-01 21:26:49 +02:00
mcbloch
5d204a4012 use a string, not bytes 2022-06-01 17:36:52 +02:00
Maxime
2bdd07c9af
Update app/models/order.py
Co-authored-by: Charlotte Van Petegem <charlotte.vanpetegem@ugent.be>
2022-06-01 17:24:27 +02:00
mcbloch
978b432d7e use base58 for slugs to remove doubt 2022-06-01 17:18:47 +02:00
Charlotte Van Petegem
426357f00d
Move filtering by association to get_orders
Fixes duplication and restores old `/orders` behaviour
2022-05-30 20:24:07 +02:00
5306561ddd
Correct word on home page 2022-05-30 20:20:51 +02:00
Charlotte Van Petegem
01b5c72e7b
Generate slug in app 2022-05-30 19:48:23 +02:00
4a353ec17e
Create a slug for old orders in the migration 2022-05-30 19:47:48 +02:00
Charlotte Van Petegem
8f3750060b
VARCHAR requires a length in mysql 2022-05-30 18:50:46 +02:00
Charlotte Van Petegem
bb49fb2795
Add merge migration 2022-05-30 18:44:18 +02:00
Charlotte Van Petegem
28a6dc5422
Merge pull request #193 from ZeusWPI/feature/association-management
Integrate associations
2022-05-30 18:29:05 +02:00
453cacebd9
Add migration to add slug DB field to order 2022-05-25 10:29:48 +02:00
44feb1a4ff
Change forgotten order_id to order_slug in a few places 2022-05-25 10:29:46 +02:00
Charlotte Van Petegem
c04d9bbd44
Fix typing of associations in user model 2022-05-25 10:07:30 +02:00
Charlotte Van Petegem
4d9d43b0f0
Don't limit length (and fix migration) 2022-05-24 21:26:56 +02:00
redfast00
0a0d13c0dc
Merge pull request #189 from ZeusWPI/fix-float-deadline
Make deadline minutes integer instead of rounded float
2022-05-24 20:42:24 +02:00
redfast00
2c4a288d4e
Merge pull request #194 from ZeusWPI/chore/stamp-db-after-init
Make sure to stamp database at latest revision after setup
2022-05-24 20:36:52 +02:00
Charlotte Van Petegem
da1a708e28
List order association in admin view 2022-05-20 23:29:05 +02:00
Charlotte Van Petegem
d6d9d61f27
Remove some unused code 2022-05-20 23:21:11 +02:00
Charlotte Van Petegem
a077a8038a
Only list orders to users of its association 2022-05-20 23:15:45 +02:00
Charlotte Van Petegem
1c0d78f2ee
Make sure only users with at least one association can create an order 2022-05-20 22:46:56 +02:00
Charlotte Van Petegem
bbb38aa825
Make sure to stamp database at latest revision after setup 2022-05-20 21:59:51 +02:00
Charlotte Van Petegem
c43efa4b10
Migration 2022-05-20 21:34:18 +02:00
mcbloch
8a2b9247e1
initial work, model works, layout doenst 2022-05-20 21:02:24 +02:00
redfast00
25e2757461
Merge pull request #192 from ZeusWPI/chore/haldis-admins-not-admin-users
Change HALDIS_ADMIN_USERS configuration key to the one actually used
2022-05-20 20:44:32 +02:00
Charlotte Van Petegem
ab47c0a882
Change HALDIS_ADMIN_USERS configuration key to the one actually used 2022-05-20 19:32:59 +02:00
Maxime
f87f3c5446
Merge pull request #187 from ZeusWPI/feature/invitelink-token
Feature/invitelink token
2022-05-20 19:15:20 +02:00
mcbloch
a33c76f84b fix modify link 2022-05-20 19:14:10 +02:00
mcbloch
98214f8b84 Add share slug and qr code to order page 2022-05-20 19:04:25 +02:00
mcbloch
7702fdecbe change url usage to unique 7char slugs 2022-05-20 19:04:22 +02:00
mcbloch
0e0771bae1 use a more common unicode arrow that can be found in more fonts 2022-05-20 18:17:34 +02:00
redfast00
749012140b
Make deadline minutes integer instead of rounded float 2022-05-11 18:33:34 +02:00
b5202a9de6
Don't crash when rendering None price 2022-05-11 02:43:08 +02:00
7b16a3b6c5
Don't crash when item.price is None 2022-05-11 02:35:45 +02:00
03f1e56161
Fix bug with form inside form 2022-05-02 22:25:06 +02:00
cf27a7de8a
For loops don't work in this template engine 2022-05-02 22:04:45 +02:00
a568103a60
You didn't see this 2022-05-02 22:00:37 +02:00
85d8892176
Hide buttons for which user has no permission 2022-05-02 21:56:38 +02:00
c35d107502
Merge branch 'payment' 2022-05-02 21:23:33 +02:00
mcbloch
dfbf1de5a1 wsgi is particular about the app object 2022-05-02 18:25:54 +02:00
mcbloch
fec9d660c3 Fix wsgi startup script for real 2022-05-02 18:21:27 +02:00
mcbloch
9c00fcc0cf Fix for wsgi startup script 2022-05-02 18:18:32 +02:00
mcbloch
2271b0427c Hotfix for create_database script 2022-05-02 14:16:20 +02:00
5a9d9c1d31
Update prices per person on key up
After user feedback
2022-04-22 01:25:01 +02:00
ced04acb2e
Remove TODO buttons 2022-04-22 01:19:11 +02:00
09e2d704cd
Add price editor 2022-04-22 01:19:07 +02:00
fc630e9061
Support marking items as paid 2022-04-20 18:09:45 +02:00
mcbloch
841c3d5fb8 add flag to disable microsoft login 2022-04-20 01:34:19 +02:00
mcbloch
da88d807d1 disable printing of user info 2022-04-20 01:28:48 +02:00
mcbloch
cc0c271a22 Add working microsoft login flow 2022-04-20 01:27:52 +02:00
Maxime
0ace54a8fd
Merge pull request #185 from ZeusWPI/fix/pylint
Fix pylint on everything
2022-04-19 23:59:47 +02:00
mcbloch
ae77adc54e Add flask factory to scope app variable 2022-04-19 23:59:23 +02:00
Jan-Pieter Baert
e93460743a
Fix pylint on everything
Except hlds related files because that's a mess
2022-04-19 23:20:03 +02:00
Maxime
fb3e7b95f1
Merge pull request #181 from ZeusWPI/feature/179
Update script to read admins from configuration file
2022-04-19 22:34:41 +02:00
Jan-Pieter Baert
d59ad9abba
Add readme documentation about the admin user 2022-04-19 22:34:16 +02:00
Jan-Pieter Baert
492d1ca91c
Update script to read admins from configuration file 2022-04-19 22:34:06 +02:00
Maxime
461664f629
Merge pull request #183 from ZeusWPI/fix/pylint
Fix pylint in haldis
2022-04-19 22:32:35 +02:00
Jan-Pieter Baert
781e4cd45b
Add requirements for running pylint 2022-04-19 22:05:38 +02:00
Jan-Pieter Baert
5e29f2a5f7
Fix formatting 2022-04-19 22:04:49 +02:00
Jan-Pieter Baert
1cdd22c1c0
Add fail-under to pylint 2022-04-19 22:03:34 +02:00
Maxime
33d2fe3b52
Merge pull request #182 from ZeusWPI/cleanup
Cleanup populate-db script
2022-04-19 21:35:38 +02:00
mcbloch
2123d7d1a3 fix the weird copying of files 2022-04-19 21:31:40 +02:00
Jan-Pieter Baert
ba1b37f5fe
Update populate-db to be less hacky 2022-04-19 20:40:06 +02:00
62 changed files with 1298 additions and 550 deletions

10
.dockerignore Normal file
View file

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

View file

@ -5,6 +5,8 @@
# run arbitrary code.
extension-pkg-whitelist=
fail-under=9.58
# Add files or directories to the blacklist. They should be base names, not
# paths.
ignore=CVS
@ -28,7 +30,7 @@ limit-inference-results=100
# List of plugins (as comma separated values of python modules names) to load,
# usually to register additional checkers.
load-plugins=
load-plugins=pylint_flask_sqlalchemy,pylint_flask
# Pickle collected data for later comparisons.
persistent=yes
@ -60,7 +62,7 @@ confidence=
# --enable=similarities". If you want to run only the classes checker, but have
# no Warning level messages displayed, use "--disable=all --enable=classes
# --disable=W".
disable=E0401,E0611,C0103,W0511,W0611
disable=E0401,E0611,C0103,W0511,W0611,C0415
# Enable the message, report, category or checker with the given id(s). You can
# either give multiple identifier separated by comma (,) or put this option

1
.tool-versions Normal file
View file

@ -0,0 +1 @@
python 3.9.2

26
Dockerfile Normal file
View file

@ -0,0 +1,26 @@
# 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
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

0
app/__init__.py Normal file
View file

14
app/add_admins.py Normal file
View file

@ -0,0 +1,14 @@
"""Script for adding users as admin to Haldis."""
from models import User
from app import db
from models import User
from config import Configuration
def add() -> None:
"""Add users as admin."""
for username in Configuration.HALDIS_ADMINS:
user = User()
user.configure(username, True, 0, associations=["zeus"])
db.session.add(user)

View file

@ -1,19 +1,22 @@
"Module for everything related to Admin users"
import flask_login as login
from flask import Flask
from flask_admin import Admin
from flask_admin.contrib.sqla import ModelView
from flask_sqlalchemy import SQLAlchemy
from models import Order, OrderItem, OrderItemChoice, User
class ModelBaseView(ModelView):
"Class for the base view of the model"
# pylint: disable=too-few-public-methods, no-self-use
def is_accessible(self) -> bool:
"Function to check if the logged in user is an admin"
return login.current_user.is_admin()
class UserAdminModel(ModelBaseView):
"Class for the model of a UserAdmin"
# pylint: disable=too-few-public-methods
column_searchable_list = ("username",)
column_editable_list = ("username",)
@ -22,27 +25,45 @@ class UserAdminModel(ModelBaseView):
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"]
column_list = ["starttime", "stoptime", "location_name", "location_id", "courier", "association"]
column_labels = {
"starttime": "Start Time", "stoptime": "Closing Time",
"location_id": "HLDS Location ID"}
"starttime": "Start Time",
"stoptime": "Closing Time",
"location_id": "HLDS Location ID",
"association": "Association",
}
form_excluded_columns = ["items", "courier_id"]
can_delete = False
class OrderItemAdminModel(ModelBaseView):
"Class for the model of a OrderItemAdmin"
# pylint: disable=too-few-public-methods
column_default_sort = ("order_id", True)
column_list = [
"order_id", "order.location_name", "user_name", "user", "dish_name", "dish_id", "comment", "price", "paid",
"hlds_data_version"
"order_id",
"slug",
"order.location_name",
"user_name",
"user",
"dish_name",
"dish_id",
"comment",
"price",
"paid",
"hlds_data_version",
]
column_labels = {
"order_id": "Order", "order.location_name": "Order's Location",
"user_name": "Anon. User", "user_id": "Registered User",
"hlds_data_version": "HLDS Data Version", "dish_id": "HLDS Dish ID"}
"order_id": "Order",
"order.location_name": "Order's Location",
"user_name": "Anon. User",
"user_id": "Registered User",
"hlds_data_version": "HLDS Data Version",
"dish_id": "HLDS Dish ID",
}
def init_admin(app: Flask, database: SQLAlchemy) -> None:

View file

@ -1,29 +1,35 @@
#!/usr/bin/env python3
"Main Haldis script"
"""Main Haldis script"""
import logging
from logging.handlers import TimedRotatingFileHandler
import sentry_sdk
import typing
from datetime import datetime
from logging.handlers import TimedRotatingFileHandler
from flask import Flask, render_template
from admin import init_admin
from config import Configuration
from flask import Flask, render_template, Response
from flask_bootstrap import Bootstrap, StaticCDN
from flask_debugtoolbar import DebugToolbarExtension
from flask_login import LoginManager
from flask_migrate import Migrate, MigrateCommand
from flask_oauthlib.client import OAuth, OAuthException
from flask_script import Manager, Server
from markupsafe import Markup
from admin import init_admin
from login import init_login
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 utils import euro_string, price_range_string
from zeus import init_oauth
from sentry_sdk.integrations.flask import FlaskIntegration
from utils import euro_string, price_range_string, ignore_none
def register_plugins(app: Flask) -> Manager:
"""Register the plugins to the app"""
# pylint: disable=W0612
if not app.debug:
timedFileHandler = TimedRotatingFileHandler(
@ -68,7 +74,8 @@ def register_plugins(app: Flask) -> Manager:
# Make cookies more secure
app.config.update(
SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Lax",
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
)
if not app.debug:
@ -78,7 +85,8 @@ def register_plugins(app: Flask) -> Manager:
def add_handlers(app: Flask) -> None:
"Add handlers for 4xx error codes"
"""Add handlers for 4xx error codes"""
# pylint: disable=W0612,W0613
@app.errorhandler(404)
def handle404(e) -> typing.Tuple[str, int]:
@ -90,29 +98,34 @@ def add_handlers(app: Flask) -> None:
def add_routes(application: Flask) -> None:
"Add all routes to Haldis"
"""Add all routes to Haldis"""
# import views # TODO convert to blueprint
# import views.stats # TODO convert to blueprint
from views.order import order_bp
from views.general import general_bp
from views.stats import stats_blueprint
from auth.login import auth_bp
from auth.microsoft import auth_microsoft_bp
from auth.zeus import auth_zeus_bp
from views.debug import debug_bp
from login import auth_bp
from zeus import oauth_bp
from views.general import general_bp
from views.order import order_bp
from views.stats import stats_blueprint
application.register_blueprint(general_bp, url_prefix="/")
application.register_blueprint(order_bp, url_prefix="/order")
application.register_blueprint(stats_blueprint, url_prefix="/stats")
application.register_blueprint(auth_bp, url_prefix="/")
application.register_blueprint(oauth_bp, url_prefix="/")
if Configuration.ENABLE_MICROSOFT_AUTH:
application.register_blueprint(auth_microsoft_bp,
url_prefix="/users/auth/microsoft_graph_auth") # "/auth/microsoft")
application.register_blueprint(auth_zeus_bp, url_prefix="/auth/zeus")
if application.debug:
application.register_blueprint(debug_bp, url_prefix="/debug")
def add_template_filters(app: Flask) -> None:
"Add functions which can be used in the templates"
"""Add functions which can be used in the templates"""
# pylint: disable=W0612
@app.template_filter("countdown")
def countdown(
@ -147,10 +160,19 @@ 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")
@ -159,7 +181,20 @@ add_handlers(app)
add_routes(app)
add_template_filters(app)
@app.context_processor
def inject_config():
return dict(configuration=Configuration)
return app, app_manager
# For usage when you directly call the script with python
if __name__ == "__main__":
app_manager.run()
if Configuration.SENTRY_DSN:
sentry_sdk.init(
dsn=Configuration.SENTRY_DSN,
integrations=[FlaskIntegration()]
)
app, app_mgr = create_app()
app_mgr.run()

View file

@ -1,32 +1,25 @@
"Script for everything related to logging in and out"
"""Script for everything related to logging in and out"""
from flask import Blueprint, abort, redirect, session, url_for
from flask_login import current_user, logout_user
from werkzeug.wrappers import Response
from models import User
from zeus import zeus_login
from werkzeug.wrappers import Response
auth_bp = Blueprint("auth_bp", __name__)
def init_login(app) -> None:
"Initialize the login"
"""Initialize the login"""
# pylint: disable=W0612
@app.login_manager.user_loader
def load_user(userid) -> User:
"Load the user"
"""Load the user"""
return User.query.filter_by(id=userid).first()
@auth_bp.route("/login")
def login():
"Function to handle a user trying to log in"
return zeus_login()
@auth_bp.route("/logout")
def logout() -> Response:
"Function to handle a user trying to log out"
"""Function to handle a user trying to log out"""
if "zeus_token" in session:
session.pop("zeus_token", None)
logout_user()
@ -34,6 +27,6 @@ def logout() -> Response:
def before_request() -> None:
"Function for what has to be done before a request"
"""Function for what has to be done before a request"""
if current_user.is_anonymous() or not current_user.is_allowed():
abort(401)

77
app/auth/microsoft.py Normal file
View file

@ -0,0 +1,77 @@
import typing
from flask import Blueprint, url_for, request, redirect, flash, Response
from flask_login import login_user
from microsoftgraph.client import Client
from config import Configuration
from models import User, db
auth_microsoft_bp = Blueprint("auth_microsoft_bp", __name__)
client = Client(Configuration.MICROSOFT_AUTH_ID,
Configuration.MICROSOFT_AUTH_SECRET,
account_type="ugentbe.onmicrosoft.com")
def microsoft_login():
"""Log in using Microsoft"""
scope = ["openid", "profile", "User.Read", "User.Read.All"]
url = client.authorization_url(url_for("auth_microsoft_bp.authorized", _external=True), scope, state=None)
return redirect(url)
@auth_microsoft_bp.route("/login")
def login():
"""Function to handle a user trying to log in"""
return microsoft_login()
@auth_microsoft_bp.route("callback") # "/authorized")
def authorized() -> typing.Any:
# type is 'typing.Union[str, Response]', but this errors due to
# https://github.com/python/mypy/issues/7187
"""Check authorized status"""
oauth_code = request.args['code']
resp = client.exchange_code(url_for("auth_microsoft_bp.authorized", _external=True), oauth_code)
client.set_token(resp.data)
resp = client.users.get_me()
microsoft_uuid = resp.data['id']
username = resp.data['userPrincipalName']
# 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"""
login_user(user)
return redirect(url_for("general_bp.home"))
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)
db.session.add(user)
db.session.commit()
return user

View file

@ -1,34 +1,37 @@
"Script containing everything specific to ZeusWPI"
import typing
from flask import Blueprint, current_app, flash, redirect, request, session, url_for
from flask import (Blueprint, current_app, flash, redirect, request, session,
url_for)
from flask_login import login_user
from flask_oauthlib.client import OAuth, OAuthException
from flask_oauthlib.client import OAuth, OAuthException, OAuthRemoteApp
from models import User, db
from werkzeug.wrappers import Response
from models import User, db
oauth_bp = Blueprint("oauth_bp", __name__)
auth_zeus_bp = Blueprint("auth_zeus_bp", __name__)
def zeus_login():
"Log in using ZeusWPI"
"""Log in using ZeusWPI"""
return current_app.zeus.authorize(
callback=url_for("oauth_bp.authorized", _external=True)
)
callback=url_for("auth_zeus_bp.authorized", _external=True))
@oauth_bp.route("/login/zeus/authorized")
@auth_zeus_bp.route("/login")
def login():
"""Function to handle a user trying to log in"""
return zeus_login()
@auth_zeus_bp.route("/authorized")
def authorized() -> typing.Any:
# type is 'typing.Union[str, Response]', but this errors due to
# https://github.com/python/mypy/issues/7187
"Check authorized status"
"""Check authorized status"""
resp = current_app.zeus.authorized_response()
if resp is None:
return "Access denied: reason=%s error=%s" % (
request.args["error"],
request.args["error_description"],
)
# pylint: disable=C0301
return f"Access denied: reason={request.args['error']} error={request.args['error_description']}"
if isinstance(resp, OAuthException):
return f"Access denied: {resp.message}<br>{resp.data}"
@ -48,8 +51,8 @@ def authorized() -> typing.Any:
return redirect(url_for("general_bp.home"))
def init_oauth(app):
"Initialize the OAuth for ZeusWPI"
def init_oauth(app) -> OAuthRemoteApp:
"""Initialize the OAuth for ZeusWPI"""
oauth = OAuth(app)
zeus = oauth.remote_app(
@ -72,15 +75,15 @@ def init_oauth(app):
def login_and_redirect_user(user) -> Response:
"Log in the user and then redirect them"
"""Log in the user and then redirect them"""
login_user(user)
return redirect(url_for("general_bp.home"))
def create_user(username) -> User:
"Create a temporary user if it is needed"
"""Create a temporary user if it is needed"""
user = User()
user.configure(username, False, 1)
user.configure(username, False, 1, associations=["zeus"])
db.session.add(user)
db.session.commit()
return user

View file

@ -1,15 +1,26 @@
"An example for a Haldis config"
# config
"""An example for a Haldis config"""
# import os
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 = []
SECRET_KEY = "<change>"
SLACK_WEBHOOK = None
LOGFILE = "haldis.log"
SENTRY_DSN = None
ZEUS_KEY = "tomtest"
ZEUS_SECRET = "blargh"
ENABLE_MICROSOFT_AUTH = False
MICROSOFT_AUTH_ID = ""
MICROSOFT_AUTH_SECRET = ""

View file

@ -1,6 +1,10 @@
"Script for interaction and changes to the database"
"""Script for interaction and changes to the database"""
import add_admins
from app import db, app_manager
from app import create_app, db
app, app_manager = create_app()
entry_sets = {
"admins": add_admins.add,
@ -11,13 +15,13 @@ no = ["no", "n"]
def commit() -> None:
"Commit all the things to the database"
"""Commit all the things to the database"""
db.session.commit()
print("Committing successful")
def check_if_overwrite() -> bool:
"Check if the user wants to overwrite the previous database"
"""Check if the user wants to overwrite the previous database"""
answer = input("Do you want to overwrite the previous database? (y/N) ")
return answer.lower() in yes
@ -25,12 +29,12 @@ def check_if_overwrite() -> bool:
def add_all() -> None:
"Add all possible entries in the entry_sets to the database"
for entry_set, function in entry_sets.items():
print("Adding {}.".format(entry_set))
print(f"Adding {entry_set}.")
function()
def recreate_from_scratch() -> None:
"Recreate a completely new database"
"""Recreate a completely new database"""
print("Resetting the database!")
db.drop_all()
db.create_all()
@ -38,19 +42,19 @@ def recreate_from_scratch() -> None:
def add_to_current() -> None:
"Add things to the current database"
available = [entry_set for entry_set in entry_sets]
"""Add things to the current database"""
available = list(entry_sets)
def add_numbers() -> str:
return " ".join(
["{}({}), ".format(loc, i) for i, loc in enumerate(available)]
[f"{loc}({i}), " for i, loc in enumerate(available)]
).rstrip(", ")
while input("Do you still want to add something? (Y/n) ").lower() not in no:
print(
"What do you want to add? (Use numbers, or A for all, or C for cancel) "
)
answer = input("Available: {} : ".format(add_numbers()))
answer = input(f"Available: {add_numbers()} : ")
if answer.lower() == "a":
add_all()
available = []
@ -58,7 +62,7 @@ def add_to_current() -> None:
pass
elif answer.isnumeric() and answer in [str(x) for x in range(len(available))]:
answer_index = int(answer)
print("Adding {}.".format(available[answer_index]))
print(f"Adding {available[answer_index]}.")
entry_sets[str(available[answer_index])]()
del available[answer_index]
else:
@ -68,7 +72,7 @@ def add_to_current() -> None:
@app_manager.command
def setup_database(): # type: None
"Start the database interaction script"
"""Start the database interaction script"""
print("Database modification script!")
print("=============================\n\n")
if (not db.engine.table_names()) or check_if_overwrite():

View file

@ -1,24 +0,0 @@
"Script for adding users as admin to Haldis."
from app import db
from models import User
def add() -> None:
"Add users as admin."
feli = User()
feli.configure("feliciaan", True, 0)
db.session.add(feli)
destro = User()
destro.configure("destro", True, 0)
db.session.add(destro)
iepoev = User()
iepoev.configure("iepoev", True, 1)
db.session.add(iepoev)
flynn = User()
flynn.configure("flynn", True, 0)
db.session.add(flynn)
# To future developers, add yourself here

View file

@ -9,6 +9,7 @@ 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

@ -1,23 +1,31 @@
"Module used for everything related to the fat versions of models"
import typing
from sqlalchemy.sql import desc, func
from hlds.definitions import location_definitions
from hlds.models import Location, Dish
from hlds.models import Dish, Location
from models import Order, OrderItem, User
from sqlalchemy.sql import desc, func
class FatModel:
"General class for the fat version of models"
@classmethod
def all(cls):
"Function to query all"
# pylint: disable=E1101
return cls.query.all()
@classmethod
def amount(cls):
"Function to query the amount"
# pylint: disable=E1101
return cls.query.count()
class FatLocation(Location, FatModel):
"Fat version of the Location model"
@classmethod
def all(cls):
return location_definitions
@ -28,6 +36,7 @@ class FatLocation(Location, FatModel):
class FatOrder(Order, FatModel):
"Fat version of the Order model"
# It's hard to add the unique user constraint,
# as DISTINCT seems to apply after a GROUP BY and aggregate
@ -35,16 +44,15 @@ class FatOrder(Order, FatModel):
# even if they get reduced by the disctinct afterwards.
@classmethod
def items_per_order(cls):
return (
Order.query.join(OrderItem)
.group_by(Order.id)
.with_entities(Order.id, func.count(OrderItem.user_id).label("total"))
)
"Function to get the total of all items per order"
return (Order.query.join(OrderItem).group_by(Order.id).with_entities(
Order.id,
func.count(OrderItem.user_id).label("total")))
class FatUser(User, FatModel):
pass
"Fat version of the User model"
class FatOrderItem(OrderItem, FatModel):
pass
"Fat version of the OrderItem model"

View file

@ -1,25 +1,16 @@
"Script for everything form related in Haldis"
from datetime import datetime, timedelta
from typing import Optional
from flask import session, request
from flask import request, session
from flask_login import current_user
from flask_wtf import FlaskForm as Form
from wtforms import (
DateTimeField,
SelectField,
SelectMultipleField,
StringField,
SubmitField,
FieldList,
validators,
)
from utils import euro_string, price_range_string
from hlds.definitions import location_definitions
from hlds.models import Location, Dish, Choice
from hlds.models import Choice, Dish, Location
from models import User
from utils import euro_string, price_range_string
from wtforms import (DateTimeField, FieldList, SelectField,
SelectMultipleField, StringField, SubmitField, validators)
class OrderForm(Form):
@ -33,13 +24,17 @@ 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)] + [
(u.id, u.username) for u in User.query.order_by("username")
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
]
else:
self.courier_id.choices = [
@ -47,6 +42,7 @@ 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)
@ -59,6 +55,7 @@ class OrderItemForm(Form):
submit_button = SubmitField("Submit")
def populate(self, location: Location) -> None:
"Populate the order item form"
self.dish_id.choices = [(dish.id, dish.name) for dish in location.dishes]
if not self.is_submitted() and self.comment.data is None:
self.comment.data = request.args.get("comment")
@ -85,7 +82,7 @@ class AnonOrderItemForm(OrderItemForm):
self.user_name.data = session.get("anon_name", None)
def validate(self) -> bool:
"Check if the provided anon_name is not already taken"
"""Check if the provided anon_name is not already taken"""
rv = OrderForm.validate(self)
if not rv:
return False

View file

@ -6,4 +6,4 @@ These are not imported in this module's init, to avoid opening the definition fi
parser on them when testing other code in this module, or when testing the parser on other files.
"""
from .models import Location, Choice, Option
from .models import Choice, Location, Option

View file

@ -1,21 +1,24 @@
# Import this class to load the standard HLDS definitions
from os import path
from typing import List
import subprocess
from .parser import parse_all_directory
from .models import Location
from pathlib import Path
from typing import List
from .models import Location
from .parser import parse_all_directory
__all__ = ["location_definitions", "location_definition_version"]
# pylint: disable=invalid-name
# TODO Use proper way to get resources, see https://stackoverflow.com/a/10935674
DATA_DIR = path.join(path.dirname(__file__), "..", "..", "menus")
ROOT_DIR = Path(__file__).parent.parent.parent
DATA_DIR = ROOT_DIR / "menus"
location_definitions: List[Location] = parse_all_directory(DATA_DIR)
location_definitions: List[Location] = parse_all_directory(str(DATA_DIR))
location_definitions.sort(key=lambda l: l.name)
proc = subprocess.run(["git", "rev-parse", "HEAD"], stdout=subprocess.PIPE, check=True)
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 = ""

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

@ -1,26 +1,28 @@
#!/usr/bin/env python3
# pylint: disable=too-few-public-methods
from typing import Iterable, List, Tuple, Mapping, Any, Optional
from typing import Any, Iterable, List, Mapping, Optional, Tuple
from utils import euro_string, first
def _format_tags(tags: Iterable[str]) -> str:
return " :: {}".format(" ".join(["{" + tag + "}" for tag in tags])) \
if tags \
else ""
# pylint: disable=consider-using-f-string
return " :: {}".format(" ".join(["{" + tag + "}"
for tag in tags])) if tags else ""
def _format_price(price: int) -> str:
return " {}".format(euro_string(price)) if price else ""
return f" {euro_string(price)}" if price else ""
def _format_type_and_choice(type_and_choice):
type_, choice = type_and_choice
return "{} {}".format(type_, choice)
return f"{type_} {choice}"
class Option:
def __init__(self, id_, *, name, description, price, tags):
self.id: str = id_
self.name: str = name
@ -29,15 +31,17 @@ class Option:
self.tags: List[str] = tags
def __str__(self):
# pylint: disable=consider-using-f-string
return "{0.id}: {0.name}{1}{2}{3}".format(
self,
" -- {}".format(self.description) if self.description else "",
f" -- {self.description}" if self.description else "",
_format_tags(self.tags),
_format_price(self.price),
)
class Choice:
def __init__(self, id_, *, name, description, options):
self.id: str = id_
self.name: str = name
@ -48,7 +52,7 @@ class Choice:
def __str__(self):
return "{0.id}: {0.name}{1}\n\t\t{2}".format(
self,
" -- {}".format(self.description) if self.description else "",
f" -- {self.description}" if self.description else "",
"\n\t\t".join(map(str, self.options)),
)
@ -57,6 +61,7 @@ class Choice:
class Dish:
def __init__(self, id_, *, name, description, price, tags, choices):
self.id: str = id_
self.name: str = name
@ -70,7 +75,7 @@ class Dish:
def __str__(self):
return "dish {0.id}: {0.name}{1}{2}{3}\n\t{4}".format(
self,
" -- {}".format(self.description) if self.description else "",
f" -- {self.description}" if self.description else "",
_format_tags(self.tags),
_format_price(self.price),
"\n\t".join(map(_format_type_and_choice, self.choices)),
@ -86,14 +91,20 @@ class Dish:
return sum(
f(option.price for option in choice.options)
for (choice_type, choice) in self.choices
if choice_type == "single_choice"
)
if choice_type == "single_choice")
class Location:
def __init__(
self, id_, *, name, dishes, osm=None, address=None, telephone=None, website=None
):
def __init__(self,
id_,
*,
name,
dishes,
osm=None,
address=None,
telephone=None,
website=None):
self.id: str = id_
self.name: str = name
self.osm: Optional[str] = osm
@ -107,24 +118,18 @@ class Location:
return first(filter(lambda d: d.id == dish_id, self.dishes))
def __str__(self):
return (
"============================\n"
return ("============================\n"
"{0.id}: {0.name}"
"{1}\n"
"============================\n"
"\n"
"{2}"
).format(
"{2}").format(
self,
"".join(
"\n\t{} {}".format(k, v)
for k, v in (
"".join(f"\n\t{k} {v}" for k, v in (
("osm", self.osm),
("address", self.address),
("telephone", self.telephone),
("website", self.website),
)
if v is not None
),
) if v is not None),
"\n".join(map(str, self.dishes)),
)

View file

@ -1,16 +1,17 @@
#!/usr/bin/env python3
from glob import glob
from os import path
import itertools
from copy import deepcopy
from typing import Iterable, List, Union, Tuple
from glob import glob
from os import path
from typing import Iterable, List, Tuple, Union
from tatsu import parse as tatsu_parse
from tatsu.ast import AST
from tatsu.exceptions import SemanticError
from .models import Location, Choice, Option, Dish
from utils import first
from .models import Choice, Dish, Location, Option
# TODO Use proper way to get resources, see https://stackoverflow.com/a/10935674
with open(path.join(path.dirname(__file__), "hlds.tatsu")) as fh:
@ -58,14 +59,16 @@ class HldsSemanticActions:
option.price += dish.price
dish.price = 0
dishes = list(dishes)
dishes.append(Dish(
dishes.append(
Dish(
"custom",
name="Vrije keuze",
description="Zet wat je wil in comment",
price=0,
tags=[],
choices=[],
))
)
)
attributes = {att["key"]: att["value"] for att in ast["attributes"]}
@ -145,7 +148,7 @@ def parse(menu: str) -> List[Location]:
def parse_file(filename: str) -> List[Location]:
with open(filename, "r") as file_handle:
with open(filename) as file_handle:
return parse(file_handle.read())

View file

@ -1,10 +1,8 @@
"Script that runs migrations online or offline"
from __future__ import with_statement
from logging.config import fileConfig
from alembic import context
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel

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"]),
sa.ForeignKeyConstraint(["location_id"], ["location.id"], name="order_ibfk_1"),
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"]),
sa.ForeignKeyConstraint(["product_id"], ["product.id"], name="order_item_ibfk_3"),
sa.ForeignKeyConstraint(["user_id"], ["user.id"]),
sa.PrimaryKeyConstraint("id"),
)

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,26 @@
"""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

@ -12,11 +12,11 @@ revision = "9159a6fed021"
down_revision = "150252c1cdb1"
from itertools import chain
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import table, column, text
import sqlalchemy as sa
from alembic import op
from hlds.definitions import location_definitions
from sqlalchemy.sql import column, table, text
LOCATION_LEGACY_TO_HLDS = {
2: "blauw_kotje",
@ -50,62 +50,94 @@ LOCATION_LEGACY_TO_HLDS = {
def upgrade():
# First the simple actions
op.create_table("order_item_choice",
op.create_table(
"order_item_choice",
sa.Column("id", sa.Integer, nullable=False),
sa.Column("choice_id", sa.String(length=64), nullable=True),
sa.Column("order_item_id", sa.Integer, nullable=False),
sa.Column("kind", sa.String(length=1), nullable=False),
sa.Column("name", sa.String(length=120), nullable=True),
sa.Column("value", sa.String(length=120), nullable=True),
sa.ForeignKeyConstraint(["order_item_id"], ["order_item.id"], ),
sa.PrimaryKeyConstraint("id")
sa.ForeignKeyConstraint(
["order_item_id"],
["order_item.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.add_column(
"order_item",
sa.Column("hlds_data_version", sa.String(length=40), nullable=True),
)
op.alter_column(
"order", "courrier_id", new_column_name="courier_id", type_=sa.Integer
)
op.alter_column(
"order_item",
"extra",
new_column_name="comment",
existing_type=sa.String(254),
type_=sa.Text,
)
op.alter_column(
"order_item", "name", new_column_name="user_name", type_=sa.String(120)
)
op.add_column("order_item", sa.Column("hlds_data_version", sa.String(length=40), nullable=True))
op.alter_column("order", "courrier_id", new_column_name="courier_id", type_=sa.Integer)
op.alter_column("order_item", "extra", new_column_name="comment",
existing_type=sa.String(254), type_=sa.Text)
op.alter_column("order_item", "name", new_column_name="user_name", type_=sa.String(120))
# ----------------------------------------------------------------------------------------------
# Migrate historical product data to order items
# First create the new columns we will populate
op.add_column("order_item", sa.Column("dish_id", sa.String(length=64), nullable=True))
op.add_column("order_item", sa.Column("dish_name", sa.String(length=120), nullable=True))
op.add_column(
"order_item", sa.Column("dish_id", sa.String(length=64), nullable=True)
)
op.add_column(
"order_item", sa.Column("dish_name", sa.String(length=120), nullable=True)
)
op.add_column("order_item", sa.Column("price", sa.Integer(), nullable=True))
# Brief, ad-hoc table constructs just for our UPDATE statement, see
# https://alembic.sqlalchemy.org/en/latest/ops.html#alembic.operations.Operations.execute
order_item = table("order_item",
order_item = table(
"order_item",
column("product_id", sa.Integer),
column("dish_id", sa.String),
column("dish_name", sa.String),
column("price", sa.Integer)
column("price", sa.Integer),
)
# Construct and execute queries
op.execute(text("""
op.execute(
text(
"""
UPDATE order_item
SET dish_name = (SELECT product.name FROM product WHERE product.id = order_item.product_id),
price = (SELECT product.price FROM product WHERE product.id = order_item.product_id)"""
))
)
)
# Historical product data migrated, drop obsolete column and table
op.execute(text("ALTER TABLE order_item DROP FOREIGN KEY order_item_ibfk_3"))
op.drop_constraint("order_item_ibfk_3", "order_item", type_="foreignkey")
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", new_column_name="legacy_location_id",
type_=sa.Integer, nullable=True)
op.add_column("order", sa.Column("location_id", sa.String(length=64), nullable=True))
op.add_column("order", sa.Column("location_name", sa.String(length=128), nullable=True))
op.alter_column(
"order",
"location_id",
new_column_name="legacy_location_id",
type_=sa.Integer,
nullable=True,
)
op.add_column(
"order", sa.Column("location_id", sa.String(length=64), nullable=True)
)
op.add_column(
"order", sa.Column("location_name", sa.String(length=128), nullable=True)
)
# Brief, ad-hoc table constructs just for our UPDATE statement, see
# https://alembic.sqlalchemy.org/en/latest/ops.html#alembic.operations.Operations.execute
order = table("order",
order = table(
"order",
column("legacy_location_id", sa.Integer),
column("location_id", sa.String),
column("location_name", sa.String)
column("location_name", sa.String),
)
# Construct and execute queries
new_location_id = [
@ -114,7 +146,8 @@ def upgrade():
.values(location_id=new_id)
for old_id, new_id in LOCATION_LEGACY_TO_HLDS.items()
]
location_name_from_location = text("""
location_name_from_location = text(
"""
UPDATE `order`
SET location_name = (SELECT location.name FROM location
WHERE location.id = `order`.legacy_location_id)"""
@ -122,6 +155,7 @@ 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

@ -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"
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

@ -1,18 +1,26 @@
"Script for everything Order related in the database"
"""Script for everything Order related in the database"""
import typing
from datetime import datetime
from collections import defaultdict
from datetime import datetime
import secrets
import string
from utils import first
from hlds.definitions import location_definitions
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
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)
courier_id = db.Column(db.Integer, nullable=True)
location_id = db.Column(db.String(64))
@ -20,15 +28,11 @@ 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(7))
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")
def __init__(self):
super().__init__()
alphabet = string.ascii_letters + string.digits
self.slug = ''.join(secrets.choice(alphabet) for i in range(7))
def __getattr__(self, name):
if name == "location":
return first(
@ -39,9 +43,9 @@ class Order(db.Model):
def __repr__(self) -> str:
# pylint: disable=R1705
if self.location:
return "Order %d @ %s" % (self.id, self.location.name or "None")
return f"Order {self.id} @ {self.location.name or 'None'}"
else:
return "Order %d" % (self.id)
return f"Order {self.id}"
def update_from_hlds(self) -> None:
"""
@ -54,19 +58,21 @@ class Order(db.Model):
self.location_name = self.location.name
def for_user(self, anon=None, user=None) -> typing.List:
"""Get the items for a certain user"""
return list(
filter(
(lambda i: i.user == user)
if user is not None
else (lambda i: i.user_name == anon),
self.items
self.items,
)
)
def group_by_user(self) -> typing.List[typing.Tuple[str, typing.List]]:
"Group items of an Order by user"
group: typing.Dict[str, typing.List] = dict()
"""Group items of an Order by user"""
group: typing.Dict[str, typing.List] = {}
# pylint: disable=E1133
for item in self.items:
if item.for_name not in group:
group[item.for_name] = []
@ -78,12 +84,17 @@ class Order(db.Model):
return list(sorted(group.items(), key=lambda t: (t[0] or "", t[1] or "")))
def group_by_dish(self) \
-> typing.List[typing.Tuple[str, int, typing.List[typing.Tuple[str, typing.List]]]]:
"Group items of an Order by dish"
group: typing.Dict[str, typing.Dict[str, typing.List]] = \
defaultdict(lambda: defaultdict(list))
def group_by_dish(
self,
) -> typing.List[
typing.Tuple[str, int, typing.List[typing.Tuple[str, typing.List]]]
]:
"""Group items of an Order by dish"""
group: typing.Dict[str, typing.Dict[str, typing.List]] = defaultdict(
lambda: defaultdict(list)
)
# pylint: disable=E1133
for item in self.items:
group[item.dish_name][item.comment].append(item)
@ -95,16 +106,17 @@ class Order(db.Model):
sorted(
(comment, sorted(items, key=lambda x: (x.for_name or "")))
for comment, items in comment_group.items()
)
),
)
for dish_name, comment_group in group.items()
)
def is_closed(self) -> bool:
"""Return whether the order is closed"""
return self.stoptime and datetime.now() > self.stoptime
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():
return False
user = None
@ -113,3 +125,13 @@ 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

@ -1,15 +1,16 @@
"Script for everything OrderItem related in the database"
from datetime import datetime
from utils import first
from hlds.definitions import location_definitions
from utils import first
from .database import db
from .order import Order
from .user import User
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)
order_id = db.Column(db.Integer, db.ForeignKey("order.id"), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey("user.id"))
@ -17,24 +18,25 @@ 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)
choices = db.relationship("OrderItemChoice", backref="order_item", lazy="dynamic")
choices = db.relationship("OrderItemChoice",
backref="order_item",
lazy="dynamic")
def __getattr__(self, name):
if name == "dish":
location_id = (
Order.query.filter(Order.id == self.order_id).first().location_id
)
location_id = (Order.query.filter(
Order.id == self.order_id).first().location_id)
location = first(
filter(lambda l: l.id == location_id, location_definitions)
)
filter(lambda l: l.id == location_id, location_definitions))
if location:
return first(filter(lambda d: d.id == self.dish_id, location.dishes))
else:
raise ValueError("No Location found with id: " + location_id)
return first(
filter(lambda d: d.id == self.dish_id, location.dishes))
raise ValueError(f"No Location found with id: {location_id}")
raise AttributeError()
@property
@ -45,11 +47,7 @@ class OrderItem(db.Model):
return self.user_name
def __repr__(self) -> str:
return "Order %d: %s wants %s" % (
self.order_id or 0,
self.for_name,
self.dish_name or "None",
)
return "Order {self.order_id or 0}: {self.for_name} wants {self.dish_name or 'None'}"
def update_from_hlds(self) -> None:
"""
@ -63,7 +61,7 @@ class OrderItem(db.Model):
# pylint: disable=W0613
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):
return False
if self.order.is_closed():
@ -76,3 +74,12 @@ class OrderItem(db.Model):
if user and (user.is_admin() or user == self.order.courier):
return True
return False
# pylint: disable=W0613
def can_modify_payment(self, order_id: int, user_id: int) -> bool:
if int(self.order_id) != int(order_id):
return False
user = User.query.filter(User.id == user_id).first()
if user and (user.is_admin() or user == self.order.courier):
return True
return False

View file

@ -1,3 +1,4 @@
"Script for everything OrderItemChoice related in the database"
from datetime import datetime
from .database import db
@ -5,6 +6,7 @@ from .orderitem import OrderItem
class OrderItemChoice(db.Model):
"Class used for configuring the OrderItemChoice model in the database"
id = db.Column(db.Integer, primary_key=True)
choice_id = db.Column(db.String(64), nullable=True)
order_item_id = db.Column(
@ -16,7 +18,8 @@ class OrderItemChoice(db.Model):
# pylint: disable=attribute-defined-outside-init
def configure(self, order: OrderItem) -> None:
"Set the orderitem"
self.order = order
def __repr__(self) -> str:
return "{}: {}".format(self.name, self.value)
return f"{self.name}: {self.value}"

View file

@ -1,13 +1,21 @@
"Script for everything User related in the database"
from typing import List, Optional
from models import db
class User(db.Model):
"Class used for configuring the User model in the database"
"""Class used for configuring the User model in the database"""
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
admin = db.Column(db.Boolean)
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="")
# Relations
runs = db.relation(
"Order",
backref="courier",
@ -16,11 +24,18 @@ class User(db.Model):
)
orderItems = db.relationship("OrderItem", backref="user", lazy="dynamic")
def configure(self, username: str, admin: bool, bias: int) -> None:
"Configure the User"
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"""
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:
@ -39,4 +54,4 @@ class User(db.Model):
return str(self.id)
def __repr__(self) -> str:
return "%s" % self.username
return f"{self.username}"

View file

@ -11,28 +11,29 @@ from models.order import Order
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":
return None
if order.courier is not None:
# pylint: disable=C0301
# pylint: disable=C0301, C0209
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,
remaining_minutes(order.stoptime),
order.courier.username.title(),
order.courier.username,
)
# pylint: disable=C0209
return "<!channel|@channel> New order for {}. Deadline in {} minutes. <{}|Open here.>".format(
order.location_name,
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:
"Function that sends the notification for the order"
"""Function that sends the notification for the order"""
message = webhook_text(order)
if message:
webhookthread = WebhookSenderThread(message, app.config["SLACK_WEBHOOK"])
@ -40,10 +41,10 @@ def post_order_to_webhook(order: Order) -> None:
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:
super(WebhookSenderThread, self).__init__()
super().__init__()
self.message = message
self.url = url
@ -51,7 +52,7 @@ class WebhookSenderThread(Thread):
self.slack_webhook()
def slack_webhook(self) -> None:
"The webhook for the specified chat platform"
"""The webhook for the specified chat platform"""
if self.url:
requests.post(self.url, json={"text": self.message})
else:
@ -59,9 +60,9 @@ class WebhookSenderThread(Thread):
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()
if delta.total_seconds() < 0:
return "0"
minutes = delta.total_seconds() // 60
return "%02d" % minutes
minutes = int(delta.total_seconds() // 60)
return f"{minutes:02}"

View file

@ -1,9 +1,8 @@
#!/usr/bin/env python3
"Module used for parsing the HLDS files"
from tatsu.util import asjson
from hlds.parser import parse_files
USAGE = """{0} [filename]...
Parse HLDS files, print as JSON

View file

@ -18,7 +18,9 @@ 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 app as application
from app import create_app
application, appmgr = create_app()
# 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: "";
}

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

@ -63,7 +63,8 @@
<nav class="navbar navbar-default navbar-fixed-top">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar" aria-expanded="false" aria-controls="navbar">
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar"
aria-expanded="false" aria-controls="navbar">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
@ -81,7 +82,10 @@
</ul>
<ul class="nav navbar-nav navbar-right">
{% if current_user.is_anonymous() %}
<li><a href="{{ url_for('auth_bp.login') }}">Login</a></li>
{% if configuration.ENABLE_MICROSOFT_AUTH %}
<li><a href="{{ url_for('auth_microsoft_bp.login') }}">Login with Microsoft</a></li>
{% endif %}
<li><a href="{{ url_for('auth_zeus_bp.login') }}">Login with Zeus</a></li>
{% else %}
<li><a href="{{ url_for('general_bp.profile') }}">{{ current_user.username }}</a></li>
<li><a href="{{ url_for('auth_bp.logout') }}">Logout</a></li>

View file

@ -12,10 +12,27 @@
{% block metas %}
{{ super() }}
<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 %}
{% 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>
<div class="location">
@ -25,6 +42,10 @@
{{ order.location_name }}
{% endif %}
</div>
<div>
Unique order link: <code>{{ url_for("order_bp.order_from_slug", order_slug=order.slug, _external=True) }}</code>
</div>
</div>
</header>
<section>
@ -36,7 +57,7 @@
{% for item in my_items %}
<li class="spacecake">
{% 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>
</form>
{%- endif %}
@ -65,7 +86,7 @@
<h3>Add item to order</h3>
{% 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 }}
<input type="hidden" name="dish_id" value="{{ dish.id }}" />
@ -134,7 +155,8 @@
<div class="box" id="order_info">
<h3>Order information</h3>
<dl>
<div class="row">
<dl class="col-md-10 col-lg-8">
<div>
<dt>Order opens</dt>
<dd>{{ order.starttime.strftime("%Y-%m-%d, %H:%M") }}</dd>
@ -167,7 +189,7 @@
<dd>
{% if order.courier == None %}
{% 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>
</form>
{% else %}No-one yet{% endif %}
@ -176,18 +198,23 @@
{% endif %}
</dd>
</div>
</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) -%}
<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>
</form>
{% endif %}
{% 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 %}
</div>
</div>
<div class="box" id="how_to_order">
@ -258,7 +285,7 @@
<div class="footer">
Total {{ order.items.count() }} items — {{ total_price|euro }}
&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>
@ -267,6 +294,7 @@
<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>
@ -276,35 +304,37 @@
<tr>
<td>
{% set paid = order_items | map(attribute="paid") | all %}
<input type="checkbox" name="{{ user_name }}"
{{ "disabled" if paid }} style="{{ 'opacity: 0.5' if paid }}">
<input type="checkbox" name="user_names" value="{{ user_name }}"
{{ "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>{{ user_name }}</td>
<td style="{{ 'opacity: 0.5' if paid }}">{{ 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', '')) -%}
<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" style="padding: 0 0.5em;"><span class="glyphicon glyphicon-remove"></span></button>
</form>
<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>
{% 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 %}
</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>
</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>
@ -314,11 +344,21 @@
</table>
<div class="footer">
{% if order.can_modify_payment(current_user.id) %}
On selected:
<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>
<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 %}
</div>
</form>
</div>
</section>
{% endblock %}

View file

@ -11,7 +11,7 @@
<h3>Edit order</h3>
<div class="row darker">
<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 }}
<div class="form-group select2 {{ 'has-errors' if form.courier_id.errors else ''}}">
{{ 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') }}
{{ 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,15 +1,18 @@
{% macro render_order(order) -%}
<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>
<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">
{% 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-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_id', order_id=order.id) }}">Expand</a>
<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">
<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>
{%- endmacro %}

View file

@ -1,20 +1,31 @@
"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
"""
if value is None:
return ""
euro, cents = divmod(value, 100)
if cents:
return "{}.{:02}".format(euro, cents)
else:
return "{}".format(euro)
return f"{unit}{euro}.{cents:02}"
return f"{unit}{euro}"
def parse_euro_string(value: str) -> Optional[int]:
m = re.fullmatch("(?:€ ?)?([0-9]+)(?:[.,]([0-9]+))?", value)
if not m:
return None
cents_02 = "{:0<2.2}".format(m.group(2)) if m.group(2) else "00"
return int(m.group(1)) * 100 + int(cents_02)
def price_range_string(price_range, include_upper=False):
"Convert a price range to a string formatted euro"
if price_range[0] == price_range[1]:
return euro_string(price_range[0])
return ("{}{}" if include_upper else "from {}").format(
@ -33,4 +44,5 @@ def first(iterable: Iterable, default=None):
def ignore_none(iterable: Iterable):
"Filter to ignore None objects"
return filter(lambda x: x is not None, iterable)

View file

@ -17,12 +17,12 @@ def list_routes() -> str:
for rule in app.url_map.iter_rules():
options = {}
for arg in rule.arguments:
options[arg] = "[{0}]".format(arg)
options[arg] = f"[{arg}]"
print(rule.endpoint)
methods = ",".join(rule.methods)
url = url_for(rule.endpoint, **options)
line = urllib.parse.unquote(
"{:50s} {:20s} {}".format(rule.endpoint, methods, url)
f"{rule.endpoint:50s} {methods:20s} {url}"
)
output.append(line)

View file

@ -1,32 +1,26 @@
"Script to generate the general views of Haldis"
import json
import os
from datetime import datetime, timedelta
import yaml
from typing import Optional
from flask import Flask, render_template, make_response
from flask import request, jsonify
from flask import Blueprint, abort
import yaml
from flask import Blueprint, Flask, abort
from flask import current_app as app
from flask import send_from_directory, url_for
from flask_login import login_required
from utils import first
from flask import (jsonify, make_response, render_template, request,
send_from_directory, url_for)
from flask_login import current_user, login_required
from hlds.definitions import location_definitions
from hlds.models import Location
from models import Order
from utils import first
# import views
from views.order import get_orders
import json
from flask import jsonify
general_bp = Blueprint("general_bp", __name__)
with open(os.path.join(os.path.dirname(__file__), "themes.yml"), "r") as _stream:
with open(os.path.join(os.path.dirname(__file__), "themes.yml")) as _stream:
_theme_data = yaml.safe_load(_stream)
THEME_OPTIONS = _theme_data["options"]
THEMES = _theme_data["themes"]
@ -37,10 +31,12 @@ def home() -> str:
"Generate the home view"
prev_day = datetime.now() - timedelta(days=1)
recently_closed = get_orders(
((Order.stoptime > prev_day) & (Order.stoptime < datetime.now()))
(Order.stoptime > prev_day) & (Order.stoptime < datetime.now())
)
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
)
@ -60,7 +56,7 @@ def is_theme_active(theme, now):
return start_datetime <= now <= end_datetime
raise Exception("Unknown theme type {}".format(theme_type))
raise Exception(f"Unknown theme type {theme_type}")
def get_theme_css(theme, options):
@ -71,13 +67,18 @@ def get_theme_css(theme, options):
for option in theme.get("options", []):
theme_name = theme["name"]
assert option in THEME_OPTIONS, f"Theme `{theme_name}` uses undefined option `{option}`"
assert (
option in THEME_OPTIONS
), f"Theme `{theme_name}` uses undefined option `{option}`"
chosen_value = options[option]
possible_values = list(THEME_OPTIONS[option].keys())
value = chosen_value if chosen_value in possible_values \
value = (
chosen_value
if chosen_value in possible_values
else THEME_OPTIONS[option]["_default"]
)
filename += "_" + value
@ -119,13 +120,15 @@ def current_theme_js():
themes = get_active_themes()
selected_theme_name = request.cookies.get("theme", None)
matching_theme = first((t for t in themes if t["file"] == selected_theme_name))
matching_theme = first(t for t in themes if t["file"] == selected_theme_name)
cur_theme = matching_theme or themes[-1]
response = make_response(rf'''
response = make_response(
rf"""
var currentTheme = {json.dumps(cur_theme['file'])};
var currentThemeOptions = {json.dumps(cur_theme.get('options', []))};
''')
"""
)
response.headers["Content-Type"] = "text/javascript"
# Theme name that is not valid at this moment: delete cookie
@ -166,7 +169,8 @@ def location_dish(location_id, dish_id) -> str:
dish = loc.dish_by_id(dish_id)
if dish is None:
abort(404)
return jsonify([
return jsonify(
[
{
"type": c[0],
"id": c[1].id,
@ -184,7 +188,8 @@ def location_dish(location_id, dish_id) -> str:
],
}
for c in dish.choices
])
]
)
@general_bp.route("/about/")
@ -204,7 +209,7 @@ def profile() -> str:
def favicon() -> str:
"Generate the favicon"
# pylint: disable=R1705
if not get_orders((Order.stoptime > datetime.now())):
if not get_orders(Order.stoptime > datetime.now()):
return send_from_directory(
os.path.join(app.root_path, "static"),
"favicon.ico",

View file

@ -1,37 +1,27 @@
"Script to generate the order related views of Haldis"
"""Script to generate the order related views of Haldis"""
import random
import re
import typing
from datetime import datetime
from werkzeug.wrappers import Response
# from flask import current_app as app
from flask import (
Blueprint,
abort,
flash,
redirect,
render_template,
request,
session,
url_for,
wrappers,
)
from flask import (Blueprint, abort, flash, redirect, render_template, request,
session, url_for, wrappers)
from flask_login import current_user, login_required
from forms import AnonOrderItemForm, OrderForm, OrderItemForm
from hlds.definitions import location_definition_version, location_definitions
from models import Order, OrderItem, User, db
from hlds.definitions import location_definitions, location_definition_version
from notification import post_order_to_webhook
from utils import ignore_none
from utils import ignore_none, parse_euro_string
from werkzeug.wrappers import Response
order_bp = Blueprint("order_bp", "order")
@order_bp.route("/")
def orders(form: OrderForm = None) -> str:
"Generate general order view"
if form is None and not current_user.is_anonymous():
"""Generate general order view"""
if form is None and current_user.association_list():
form = OrderForm()
location_id = request.args.get("location_id")
form.location_id.default = location_id
@ -43,7 +33,10 @@ def orders(form: OrderForm = None) -> str:
@order_bp.route("/create", methods=["POST"])
@login_required
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.populate()
if orderForm.validate_on_submit():
@ -53,14 +46,14 @@ def order_create() -> typing.Union[str, Response]:
db.session.add(order)
db.session.commit()
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)
@order_bp.route("/<order_id>")
def order_from_id(order_id: int, form: OrderForm = None, dish_id=None) -> str:
"Generate order view from id"
order = Order.query.filter(Order.id == order_id).first()
@order_bp.route("/<order_slug>")
def order_from_slug(order_slug: str, form: OrderForm = None, dish_id=None) -> str:
"""Generate order view from id"""
order = Order.query.filter(Order.slug == order_slug).first()
if order is None:
abort(404)
if current_user.is_anonymous() and not order.public:
@ -72,8 +65,8 @@ def order_from_id(order_id: int, form: OrderForm = None, dish_id=None) -> str:
form.populate(order.location)
if order.is_closed():
form = None
total_price = sum([o.price for o in order.items])
debts = sum([o.price for o in order.items if not o.paid])
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)
dish = order.location.dish_by_id(dish_id) if order.location else None
@ -87,44 +80,44 @@ def order_from_id(order_id: int, form: OrderForm = None, dish_id=None) -> str:
)
@order_bp.route("/<order_id>/items")
def items_shop_view(order_id: int) -> str:
"Generate order items view from id"
order = Order.query.filter(Order.id == order_id).first()
@order_bp.route("/<order_slug>/items")
def items_shop_view(order_slug: int) -> str:
"""Generate order items view from id"""
order = Order.query.filter(Order.slug == order_slug).first()
if order is None:
abort(404)
if current_user.is_anonymous() and not order.public:
flash("Please login to see this order.", "info")
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)
@order_bp.route("/<order_id>/edit", methods=["GET", "POST"])
@order_bp.route("/<order_slug>/edit", methods=["GET", "POST"])
@login_required
def order_edit(order_id: int) -> typing.Union[str, Response]:
"Generate order edit view from id"
order = Order.query.filter(Order.id == order_id).first()
def order_edit(order_slug: str) -> typing.Union[str, Response]:
"""Generate order edit view from id"""
order = Order.query.filter(Order.slug == order_slug).first()
if current_user.id is not order.courier_id and not current_user.is_admin():
abort(401)
if order is None:
abort(404)
orderForm = OrderForm(obj=order)
orderForm.populate()
if orderForm.validate_on_submit():
orderForm.populate_obj(order)
order_form = OrderForm(obj=order)
order_form.populate()
if order_form.validate_on_submit():
order_form.populate_obj(order)
order.update_from_hlds()
db.session.commit()
return redirect(url_for("order_bp.order_from_id", order_id=order.id))
return render_template("order_edit.html", form=orderForm, order_id=order_id)
return redirect(url_for("order_bp.order_from_slug", order_slug=order.slug))
return render_template("order_edit.html", form=order_form, order_slug=order.slug)
@order_bp.route("/<order_id>/create", methods=["GET", "POST"])
def order_item_create(order_id: int) -> typing.Any:
@order_bp.route("/<order_slug>/create", methods=["GET", "POST"])
def order_item_create(order_slug: str) -> typing.Any:
# type is 'typing.Union[str, Response]', but this errors due to
# https://github.com/python/mypy/issues/7187
"Add item to order from id"
current_order = Order.query.filter(Order.id == order_id).first()
"""Add item to order from slug"""
current_order = Order.query.filter(Order.slug == order_slug).first()
if current_order is None:
abort(404)
if current_order.is_closed():
@ -138,7 +131,9 @@ def order_item_create(order_id: int) -> typing.Any:
abort(404)
form = AnonOrderItemForm() if current_user.is_anonymous() else OrderItemForm()
dish_id = request.form["dish_id"] if form.is_submitted() else request.args.get("dish")
dish_id = (
request.form["dish_id"] if form.is_submitted() else request.args.get("dish")
)
if dish_id and not location.dish_by_id(dish_id):
abort(404)
if not form.is_submitted():
@ -179,7 +174,7 @@ def order_item_create(order_id: int) -> typing.Any:
return redirect(
url_for(
"order_bp.order_item_create",
order_id=order_id,
order_slug=current_order.slug,
dish=form.dish_id.data,
user_name=user_name,
comment=comment,
@ -188,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 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
item = OrderItem()
form.populate_obj(item)
item.hlds_data_version = location_definition_version
item.order_id = order_id
item.order_id = current_order.id
if not current_user.is_anonymous():
item.user_id = current_user.id
else:
@ -230,59 +224,82 @@ def order_item_create(order_id: int) -> typing.Any:
db.session.add(item)
db.session.commit()
flash("Ordered %s" % (item.dish_name), "success")
return redirect(url_for("order_bp.order_from_id", order_id=order_id))
flash("Ordered %s" % item.dish_name, "success")
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
# pylint: disable=R1710
def items_user_paid(order_id: int, user_name: str) -> typing.Optional[Response]:
"Indicate payment status for a user in an order"
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:
user = User.query.filter(User.username == user_name).first()
items: typing.List[OrderItem] = []
if user:
items = OrderItem.query.filter(
(OrderItem.user_id == user.id) & (OrderItem.order_id == order_id)
(OrderItem.user_id == user.id) & (OrderItem.order_id == order.id)
).all()
else:
items = OrderItem.query.filter(
(OrderItem.user_name == user_name) & (OrderItem.order_id == order_id)
(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:
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()
flash("Paid %d items for %s" % (len(items), item.for_name), "success")
return redirect(url_for("order_bp.order_from_id", order_id=order_id))
abort(404)
if total_failed_items == 0:
flash("Marked %d items as paid" % (total_paid_items,), "success")
else:
flash("Failed to mark %d items as paid (succeeded in marking %d items as paid)" % (total_failed_items, total_paid_items), "error")
return redirect(url_for("order_bp.order_from_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
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
# https://github.com/python/mypy/issues/7187
"Delete an item from an order"
item = OrderItem.query.filter(OrderItem.id == item_id).first()
"""Delete an item from an order"""
item: OrderItem = OrderItem.query.filter(OrderItem.id == item_id).first()
order: Order = Order.query.filter(Order.slug == order_slug).first()
user_id = None
if not current_user.is_anonymous():
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
db.session.delete(item)
db.session.commit()
flash("Deleted %s" % (dish_name), "success")
return redirect(url_for("order_bp.order_from_id", order_id=order_id))
flash("Deleted %s" % dish_name, "success")
return redirect(url_for("order_bp.order_from_slug", order_slug=order_slug))
abort(404)
@order_bp.route("/<order_id>/volunteer", methods=["POST"])
@order_bp.route("/<order_slug>/volunteer", methods=["POST"])
@login_required
def volunteer(order_id: int) -> Response:
"Add a volunteer to an order"
order = Order.query.filter(Order.id == order_id).first()
def volunteer(order_slug: str) -> Response:
"""Add a volunteer to an order"""
order = Order.query.filter(Order.slug == order_slug).first()
if order is None:
abort(404)
if order.courier_id is None or order.courier_id == 0:
@ -291,14 +308,14 @@ def volunteer(order_id: int) -> Response:
flash("Thank you for volunteering!")
else:
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
def close_order(order_id: int) -> typing.Optional[Response]:
"Close an order"
order = Order.query.filter(Order.id == order_id).first()
def close_order(order_slug: str) -> typing.Optional[Response]:
"""Close an order"""
order = Order.query.filter(Order.slug == order_slug).first()
if order is None:
abort(404)
if (
@ -310,12 +327,54 @@ def close_order(order_id: int) -> typing.Optional[Response]:
if courier is not None:
order.courier_id = courier.id
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
@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"
"""Select a random user from those who are signed up for the order"""
user = None
# remove non users
items = [i for i in items if i.user_id]
@ -334,19 +393,20 @@ def select_user(items) -> typing.Optional[User]:
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] = []
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))
expression & (Order.public == True) & (Order.association.in_(current_user.association_list()))
).all()
return order_list

View file

@ -1,10 +1,9 @@
"Script to generate the stats related views of Haldis"
from fatmodels import FatLocation, FatOrder, FatOrderItem, FatUser
from flask import Blueprint
from flask import current_app as app
from flask import render_template
from fatmodels import FatLocation, FatOrder, FatOrderItem, FatUser
stats_blueprint = Blueprint("stats_blueprint", __name__)

16
app/waitress_wsgi.py Normal file
View file

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

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

31
docker-compose.yml Normal file
View file

@ -0,0 +1,31 @@
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 _doubleSpace " \+" nextgroup=hldsTag,hldsPrice
syn match _space " \+" nextgroup=hldsTag,hldsPrice
syn match hldsTag "{[a-z0-9_-]\+}\( \|$\)" contained nextgroup=hldsTag,hldsPrice
syn match hldsPrice "€ *[0-9]\+\(\.[0-9]\+\|\)\( \|$\)" contained

View file

@ -12,7 +12,7 @@ E="${normal}"
if [ ! -d "venv" ]; then
PYTHON_VERSION=$(cat .python-version)
echo -e "${B} No venv found, creating a new one with version ${PYTHON_VERSION} ${E}"
python3 -m virtualenv -p $PYTHON_VERSION venv
python3 -m virtualenv -p "$PYTHON_VERSION" venv
fi
source venv/bin/activate

View file

@ -2,6 +2,8 @@
set -euo pipefail
cd "$(dirname "$0")/app"
cp database/* .
../venv/bin/python create_database.py setup_database
rm -f add_* create_database.py muhscheme.txt
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

2
pylint-requirement.txt Normal file
View file

@ -0,0 +1,2 @@
pylint-flask
pylint-flask-sqlalchemy

View file

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

View file

@ -1,5 +1,5 @@
#
# This file is autogenerated by pip-compile
# This file is autogenerated by pip-compile with python 3.9
# To update, run:
#
# pip-compile
@ -11,11 +11,15 @@ appdirs==1.4.4
black==21.6b0
# via -r requirements.in
blinker==1.4
# via flask-debugtoolbar
# via
# flask-debugtoolbar
# sentry-sdk
cachelib==0.1.1
# via flask-oauthlib
certifi==2021.5.30
# via requests
# via
# requests
# sentry-sdk
chardet==4.0.0
# via requests
click==7.1.2
@ -24,6 +28,19 @@ click==7.1.2
# flask
dominate==2.6.0
# via flask-bootstrap
flask==1.1.4
# via
# -r requirements.in
# flask-admin
# flask-bootstrap
# flask-debugtoolbar
# flask-login
# flask-migrate
# flask-oauthlib
# flask-script
# flask-sqlalchemy
# flask-wtf
# sentry-sdk
flask-admin==1.5.8
# via -r requirements.in
flask-bootstrap==3.3.7.1
@ -44,18 +61,6 @@ flask-sqlalchemy==2.5.1
# flask-migrate
flask-wtf==0.15.1
# via -r requirements.in
flask==1.1.4
# via
# -r requirements.in
# flask-admin
# flask-bootstrap
# flask-debugtoolbar
# flask-login
# flask-migrate
# flask-oauthlib
# flask-script
# flask-sqlalchemy
# flask-wtf
greenlet==1.1.0
# via sqlalchemy
idna==2.10
@ -74,6 +79,8 @@ markupsafe==2.0.1
# jinja2
# mako
# wtforms
microsoftgraph-python==1.1.3
# via -r requirements.in
mypy-extensions==0.4.3
# via black
oauthlib==2.1.0
@ -92,10 +99,14 @@ pyyaml==5.4.1
# via -r requirements.in
regex==2021.4.4
# via black
requests==2.25.1
# via
# microsoftgraph-python
# requests-oauthlib
requests-oauthlib==1.1.0
# via flask-oauthlib
requests==2.25.1
# via requests-oauthlib
sentry-sdk[flask]==1.10.1
# via -r requirements.in
six==1.16.0
# via python-dateutil
sqlalchemy==1.4.18
@ -106,8 +117,10 @@ tatsu==4.4.0
# via -r requirements.in
toml==0.10.2
# via black
urllib3==1.26.5
# via requests
urllib3==1.26.12
# via
# requests
# sentry-sdk
visitor==0.1.3
# via flask-bootstrap
werkzeug==1.0.1