Compare commits

..

3 commits
master ... slug

Author SHA1 Message Date
Midgard 0ebb877070
Create a slug for old orders in the migration 2022-05-25 13:57:02 +02:00
Midgard 105cf4a044
Revert "Don't crash on orders that don't have a slug"
This reverts commit ff0ea068de.

The next commit (Create a slug for old orders in the migration) will
change our strategy of writing code to handle the legacy slugless orders:
we will generate a slug for old orders.
2022-05-25 13:56:07 +02:00
Midgard ff0ea068de
Don't crash on orders that don't have a slug
Orders created before we introduced slugs don't have a slug. This commit
introduces code to work with them. Without these changes, the legacy
orders are not reachable any more, and trying to create a link for them,
crashes the page.

I wrote this commit because in my test environment I had a long-lived
order for testing purposes, and the home page crashed because the order
would show up in the list of Open Orders.
2022-05-25 10:55:33 +02:00
35 changed files with 162 additions and 507 deletions

View file

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

View file

@ -1 +0,0 @@
python 3.9.2

View file

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

View file

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

View file

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

View file

@ -3,29 +3,24 @@
"""Main Haldis script""" """Main Haldis script"""
import logging import logging
import sentry_sdk
import typing import typing
from datetime import datetime from datetime import datetime
from logging.handlers import TimedRotatingFileHandler from logging.handlers import TimedRotatingFileHandler
from admin import init_admin from admin import init_admin
from config import Configuration from flask import Flask, render_template
from flask import Flask, render_template, Response
from flask_bootstrap import Bootstrap, StaticCDN from flask_bootstrap import Bootstrap, StaticCDN
from flask_debugtoolbar import DebugToolbarExtension from flask_debugtoolbar import DebugToolbarExtension
from flask_login import LoginManager from flask_login import LoginManager
from flask_migrate import Migrate, MigrateCommand from flask_migrate import Migrate, MigrateCommand
from flask_oauthlib.client import OAuth, OAuthException
from flask_script import Manager, Server from flask_script import Manager, Server
from login import init_login
from markupsafe import Markup from markupsafe import Markup
from admin import init_admin
from auth.login import init_login
from auth.zeus import init_oauth
from config import Configuration
from models import db from models import db
from models.anonymous_user import AnonymouseUser from models.anonymous_user import AnonymouseUser
from sentry_sdk.integrations.flask import FlaskIntegration
from utils import euro_string, price_range_string, ignore_none from utils import euro_string, price_range_string, ignore_none
from zeus import init_oauth
def register_plugins(app: Flask) -> Manager: def register_plugins(app: Flask) -> Manager:
@ -102,22 +97,18 @@ def add_routes(application: Flask) -> None:
# import views # TODO convert to blueprint # import views # TODO convert to blueprint
# import views.stats # TODO convert to blueprint # import views.stats # TODO convert to blueprint
from auth.login import auth_bp from 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 views.debug import debug_bp
from views.general import general_bp from views.general import general_bp
from views.order import order_bp from views.order import order_bp
from views.stats import stats_blueprint from views.stats import stats_blueprint
from zeus import oauth_bp
application.register_blueprint(general_bp, url_prefix="/") application.register_blueprint(general_bp, url_prefix="/")
application.register_blueprint(order_bp, url_prefix="/order") application.register_blueprint(order_bp, url_prefix="/order")
application.register_blueprint(stats_blueprint, url_prefix="/stats") application.register_blueprint(stats_blueprint, url_prefix="/stats")
application.register_blueprint(auth_bp, url_prefix="/") application.register_blueprint(auth_bp, url_prefix="/")
if Configuration.ENABLE_MICROSOFT_AUTH: application.register_blueprint(oauth_bp, url_prefix="/")
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: if application.debug:
application.register_blueprint(debug_bp, url_prefix="/debug") application.register_blueprint(debug_bp, url_prefix="/debug")
@ -167,12 +158,6 @@ def create_app():
"""Initializer for the Flask app object""" """Initializer for the Flask app object"""
app = Flask(__name__) 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 # Load the config file
app.config.from_object("config.Configuration") app.config.from_object("config.Configuration")
@ -181,20 +166,10 @@ def create_app():
add_routes(app) add_routes(app)
add_template_filters(app) add_template_filters(app)
@app.context_processor
def inject_config():
return dict(configuration=Configuration)
return app, app_manager return app, app_manager
# For usage when you directly call the script with python # For usage when you directly call the script with python
if __name__ == "__main__": if __name__ == "__main__":
if Configuration.SENTRY_DSN:
sentry_sdk.init(
dsn=Configuration.SENTRY_DSN,
integrations=[FlaskIntegration()]
)
app, app_mgr = create_app() app, app_mgr = create_app()
app_mgr.run() app_mgr.run()

View file

@ -1,77 +0,0 @@
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,26 +1,16 @@
"""An example for a Haldis config""" "An example for a Haldis config"
# import os # config
class Configuration: class Configuration:
"Haldis configuration object" "Haldis configuration object"
# pylint: disable=too-few-public-methods # pylint: disable=too-few-public-methods
SQLALCHEMY_DATABASE_URI = "sqlite:///haldis.db" 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 SQLALCHEMY_TRACK_MODIFICATIONS = False
DEBUG = True DEBUG = True
HALDIS_ADMINS = [] HALDIS_ADMINS = []
SECRET_KEY = "<change>" SECRET_KEY = "<change>"
SLACK_WEBHOOK = None SLACK_WEBHOOK = None
LOGFILE = "haldis.log" LOGFILE = "haldis.log"
SENTRY_DSN = None
ZEUS_KEY = "tomtest" ZEUS_KEY = "tomtest"
ZEUS_SECRET = "blargh" ZEUS_SECRET = "blargh"
ENABLE_MICROSOFT_AUTH = False
MICROSOFT_AUTH_ID = ""
MICROSOFT_AUTH_SECRET = ""

View file

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

View file

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

View file

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

View file

@ -1,25 +1,31 @@
"""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 import Blueprint, abort, redirect, session, url_for
from flask_login import current_user, logout_user from flask_login import current_user, logout_user
from models import User from models import User
from werkzeug.wrappers import Response from werkzeug.wrappers import Response
from zeus import zeus_login
auth_bp = Blueprint("auth_bp", __name__) auth_bp = Blueprint("auth_bp", __name__)
def init_login(app) -> None: def init_login(app) -> None:
"""Initialize the login""" "Initialize the login"
# pylint: disable=W0612 # pylint: disable=W0612
@app.login_manager.user_loader @app.login_manager.user_loader
def load_user(userid) -> User: def load_user(userid) -> User:
"""Load the user""" "Load the user"
return User.query.filter_by(id=userid).first() 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") @auth_bp.route("/logout")
def logout() -> Response: 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: if "zeus_token" in session:
session.pop("zeus_token", None) session.pop("zeus_token", None)
logout_user() logout_user()
@ -27,6 +33,6 @@ def logout() -> Response:
def before_request() -> None: 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(): if current_user.is_anonymous() or not current_user.is_allowed():
abort(401) abort(401)

View file

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

View file

@ -17,14 +17,29 @@ from sqlalchemy.sql import text
def upgrade(): def upgrade():
op.add_column('order', sa.Column( op.add_column('order', sa.Column(
'slug', 'slug',
sa.String(length=8), sa.String(length=7),
nullable=False, nullable=False,
# Default: random alphanumerical string # Default: random alphanumerical string
server_default=text('SUBSTRING(MD5(RAND()) FROM 1 FOR 7)') server_default=text('SUBSTRING(MD5(RAND()) FROM 1 FOR 7)')
)) ))
op.create_unique_constraint('order_slug_unique', 'order', ['slug']) op.create_unique_constraint('order_slug_unique', 'order', ['slug'])
# Trigger to handle duplicates: generate new slug if slug already exists
op.execute(text(
"""
CREATE TRIGGER order_before_insert
BEFORE UPDATE ON `order`
FOR EACH ROW
BEGIN
WHILE (NEW.slug IS NULL OR (SELECT id FROM `order` WHERE slug = NEW.slug) IS NOT NULL) DO
SET NEW.slug = SUBSTRING(MD5(RAND()) FROM 1 FOR 7);
END WHILE;
END
"""
))
def downgrade(): def downgrade():
op.execute(text("DROP TRIGGER order_before_insert"))
op.drop_constraint('order_slug_unique', 'order', type_='unique') op.drop_constraint('order_slug_unique', 'order', type_='unique')
op.drop_column('order', 'slug') op.drop_column('order', 'slug')

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,7 +2,6 @@
import typing import typing
from collections import defaultdict from collections import defaultdict
from datetime import datetime from datetime import datetime
import secrets
import string import string
from hlds.definitions import location_definitions from hlds.definitions import location_definitions
@ -11,13 +10,6 @@ from utils import first
from .database import db from .database import db
from .user import User from .user import User
BASE31_ALPHABET = '23456789abcdefghjkmnpqrstuvwxyz'
def generate_slug():
secret = ''.join(secrets.choice(BASE31_ALPHABET) for i in range(8))
while Order.query.filter(Order.slug == secret).first() is not None:
secret = ''.join(secrets.choice(BASE31_ALPHABET) for i in range(8))
return secret
class Order(db.Model): class Order(db.Model):
"""Class used for configuring the Order model in the database""" """Class used for configuring the Order model in the database"""
@ -28,8 +20,9 @@ class Order(db.Model):
starttime = db.Column(db.DateTime) starttime = db.Column(db.DateTime)
stoptime = db.Column(db.DateTime) stoptime = db.Column(db.DateTime)
public = db.Column(db.Boolean, default=True) public = db.Column(db.Boolean, default=True)
slug = db.Column(db.String(8), default=generate_slug, unique=True) # The default value for `slug`, a random 7-character alphanumerical string,
association = db.Column(db.String(120), nullable=False, server_default="") # is created on the database side. See migrations/versions/29ccbe077c57_add_slug.py
slug = db.Column(db.String(7), unique=True)
items = db.relationship("OrderItem", backref="order", lazy="dynamic") items = db.relationship("OrderItem", backref="order", lazy="dynamic")

View file

@ -1,21 +1,13 @@
"Script for everything User related in the database" "Script for everything User related in the database"
from typing import List, Optional
from models import db from models import db
class User(db.Model): 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) id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False) username = db.Column(db.String(80), unique=True, nullable=False)
admin = db.Column(db.Boolean) admin = db.Column(db.Boolean)
bias = db.Column(db.Integer) 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( runs = db.relation(
"Order", "Order",
backref="courier", backref="courier",
@ -24,18 +16,11 @@ class User(db.Model):
) )
orderItems = db.relationship("OrderItem", backref="user", lazy="dynamic") orderItems = db.relationship("OrderItem", backref="user", lazy="dynamic")
def association_list(self) -> List[str]: def configure(self, username: str, admin: bool, bias: int) -> None:
return self.associations.split(",") "Configure the User"
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.username = username
self.admin = admin self.admin = admin
self.bias = bias self.bias = bias
self.microsoft_uuid = microsoft_uuid
self.associations = ",".join(associations)
# pylint: disable=C0111, R0201 # pylint: disable=C0111, R0201
def is_authenticated(self) -> bool: def is_authenticated(self) -> bool:

View file

@ -21,7 +21,7 @@ def webhook_text(order: Order) -> typing.Optional[str]:
url_for("order_bp.order_from_slug", order_slug=order.slug, _external=True), url_for("order_bp.order_from_slug", order_slug=order.slug, _external=True),
order.location_name, order.location_name,
remaining_minutes(order.stoptime), remaining_minutes(order.stoptime),
order.courier.username, order.courier.username.title(),
) )
# pylint: disable=C0209 # pylint: disable=C0209

View file

@ -63,8 +63,7 @@
<nav class="navbar navbar-default navbar-fixed-top"> <nav class="navbar navbar-default navbar-fixed-top">
<div class="container"> <div class="container">
<div class="navbar-header"> <div class="navbar-header">
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar" <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar" aria-expanded="false" aria-controls="navbar">
aria-expanded="false" aria-controls="navbar">
<span class="sr-only">Toggle navigation</span> <span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span> <span class="icon-bar"></span>
<span class="icon-bar"></span> <span class="icon-bar"></span>
@ -82,10 +81,7 @@
</ul> </ul>
<ul class="nav navbar-nav navbar-right"> <ul class="nav navbar-nav navbar-right">
{% if current_user.is_anonymous() %} {% if current_user.is_anonymous() %}
{% if configuration.ENABLE_MICROSOFT_AUTH %} <li><a href="{{ url_for('auth_bp.login') }}">Login</a></li>
<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 %} {% else %}
<li><a href="{{ url_for('general_bp.profile') }}">{{ current_user.username }}</a></li> <li><a href="{{ url_for('general_bp.profile') }}">{{ current_user.username }}</a></li>
<li><a href="{{ url_for('auth_bp.logout') }}">Logout</a></li> <li><a href="{{ url_for('auth_bp.logout') }}">Logout</a></li>
@ -100,8 +96,8 @@
{{ utils.flashed_messages(container=True) }} {{ utils.flashed_messages(container=True) }}
<div class="container main"> <div class="container main">
{% block container -%} {% block container -%}
{%- endblock %} {%- endblock %}
</div> </div>
<footer> <footer>

View file

@ -155,66 +155,60 @@
<div class="box" id="order_info"> <div class="box" id="order_info">
<h3>Order information</h3> <h3>Order information</h3>
<div class="row"> <dl>
<dl class="col-md-10 col-lg-8"> <div>
<div> <dt>Order opens</dt>
<dt>Order opens</dt> <dd>{{ order.starttime.strftime("%Y-%m-%d, %H:%M") }}</dd>
<dd>{{ order.starttime.strftime("%Y-%m-%d, %H:%M") }}</dd>
<dt>Order closes</dt> <dt>Order closes</dt>
<dd> <dd>
{% if order.stoptime %} {% if order.stoptime %}
{% set stoptimefmt = ( {% set stoptimefmt = (
"%H:%M" if order.stoptime.date() == order.starttime.date() "%H:%M" if order.stoptime.date() == order.starttime.date()
else "%Y-%m-%d, %H:%M" else "%Y-%m-%d, %H:%M"
) %} ) %}
{{ order.stoptime.strftime(stoptimefmt) }} ({{ order.stoptime|countdown }}) {{ order.stoptime.strftime(stoptimefmt) }} ({{ order.stoptime|countdown }})
{% else %} {% else %}
Never Never
{% endif %} {% endif %}
</dd> </dd>
</div>
<div>
<dt>Location</dt>
<dd>
{% if order.location %}
<a href="{{ url_for('general_bp.location', location_id=order.location_id) }}">{{ order.location_name }}</a>
{% else %}
{{ order.location_name }}
{% endif %}
</dd>
<dt>Courier</dt>
<dd>
{% if order.courier == None %}
{% if not current_user.is_anonymous() %}
<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 %}
{% else %}
{{ order.courier.username }}
{% endif %}
</dd>
</div>
</dl>
<div class="col-md-2 col-lg-4">
<img src="https://dsa.ugent.be/api/verenigingen/{{ order.association }}/logo" class="img-responsive align-top" style="max-width:200px;width:100%">
</div> </div>
<div>
<dt>Location</dt>
<dd>
{% if order.location %}
<a href="{{ url_for('general_bp.location', location_id=order.location_id) }}">{{ order.location_name }}</a>
{% else %}
{{ order.location_name }}
{% endif %}
</dd>
<dt>Courier</dt>
<dd>
{% if order.courier == None %}
{% if not current_user.is_anonymous() %}
<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 %}
{% else %}
{{ order.courier.username }}
{% endif %}
</dd>
</div>
</dl>
<div>
{% if order.can_close(current_user.id) -%}
<form action="{{ url_for('order_bp.close_order', order_slug=order.slug) }}" method="post" style="display:inline">
<input type="submit" class="btn btn-danger" value="Close"></input>
</form>
{% endif %}
{% if courier_or_admin %}
<a class="btn" href="{{ url_for('order_bp.order_edit', order_slug=order.slug) }}">Edit</a>
{%- endif %}
</div> </div>
{% if order.can_close(current_user.id) -%}
<form action="{{ url_for('order_bp.close_order', order_slug=order.slug) }}" method="post" style="display:inline">
<input type="submit" class="btn btn-danger" value="Close"></input>
</form>
{% endif %}
{% if courier_or_admin %}
<a class="btn" href="{{ url_for('order_bp.order_edit', order_slug=order.slug) }}">Edit</a>
{%- endif %}
</div> </div>
<div class="box" id="how_to_order"> <div class="box" id="how_to_order">
@ -320,7 +314,9 @@
<li class="{{ 'paid' if item.paid }}"> <li class="{{ 'paid' if item.paid }}">
<div class="actions"> <div class="actions">
{% if item.can_delete(order.id, current_user.id, session.get('anon_name', '')) -%} {% if item.can_delete(order.id, current_user.id, session.get('anon_name', '')) -%}
<button class="btn btn-link btn-sm" type="submit" name="delete_item" value="{{ item.id }}" style="padding: 0 0.5em;"><span class="glyphicon glyphicon-remove"></span></button> <form action="{{ url_for('order_bp.delete_item', order_slug=order.slug, item_id=item.id) }}" method="post" style="display:inline">
<button class="btn btn-link btn-sm" type="submit" style="padding: 0 0.5em;"><span class="glyphicon glyphicon-remove"></span></button>
</form>
{% else %} {% else %}
<span class="glyphicon glyphicon-remove" style="color: var(--gray3); padding: 0 0.5em; cursor: not-allowed"></span> <span class="glyphicon glyphicon-remove" style="color: var(--gray3); padding: 0 0.5em; cursor: not-allowed"></span>
{%- endif %} {%- endif %}

View file

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

View file

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

View file

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

View file

@ -21,7 +21,7 @@ order_bp = Blueprint("order_bp", "order")
@order_bp.route("/") @order_bp.route("/")
def orders(form: OrderForm = None) -> str: def orders(form: OrderForm = None) -> str:
"""Generate general order view""" """Generate general order view"""
if form is None and current_user.association_list(): if form is None and not current_user.is_anonymous():
form = OrderForm() form = OrderForm()
location_id = request.args.get("location_id") location_id = request.args.get("location_id")
form.location_id.default = location_id form.location_id.default = location_id
@ -34,9 +34,6 @@ def orders(form: OrderForm = None) -> str:
@login_required @login_required
def order_create() -> typing.Union[str, Response]: def order_create() -> typing.Union[str, Response]:
"""Generate order create view""" """Generate order create view"""
if not current_user.association_list():
flash("Not allowed to create an order.", "info")
abort(401)
orderForm = OrderForm() orderForm = OrderForm()
orderForm.populate() orderForm.populate()
if orderForm.validate_on_submit(): if orderForm.validate_on_submit():
@ -396,17 +393,16 @@ def get_orders(expression=None) -> typing.List[Order]:
"""Give the list of all currently open and public Orders""" """Give the list of all currently open and public Orders"""
order_list: typing.List[OrderForm] = [] order_list: typing.List[OrderForm] = []
if expression is None: if expression is None:
expression = ((datetime.now() > Order.starttime) & ( expression = (datetime.now() > Order.starttime) & (
Order.stoptime Order.stoptime
> datetime.now() > datetime.now()
# pylint: disable=C0121 # pylint: disable=C0121
) | (Order.stoptime == None) ) | (Order.stoptime == None)
) & (Order.association.in_(current_user.association_list()))
if not current_user.is_anonymous(): if not current_user.is_anonymous():
order_list = Order.query.filter(expression).all() order_list = Order.query.filter(expression).all()
else: else:
order_list = Order.query.filter( order_list = Order.query.filter(
# pylint: disable=C0121 # pylint: disable=C0121
expression & (Order.public == True) & (Order.association.in_(current_user.association_list())) expression & (Order.public == True)
).all() ).all()
return order_list return order_list

View file

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

View file

@ -4,30 +4,24 @@ import typing
from flask import (Blueprint, current_app, flash, redirect, request, session, from flask import (Blueprint, current_app, flash, redirect, request, session,
url_for) url_for)
from flask_login import login_user from flask_login import login_user
from flask_oauthlib.client import OAuth, OAuthException, OAuthRemoteApp from flask_oauthlib.client import OAuth, OAuthException
from models import User, db from models import User, db
from werkzeug.wrappers import Response from werkzeug.wrappers import Response
auth_zeus_bp = Blueprint("auth_zeus_bp", __name__) oauth_bp = Blueprint("oauth_bp", __name__)
def zeus_login(): def zeus_login():
"""Log in using ZeusWPI""" "Log in using ZeusWPI"
return current_app.zeus.authorize( return current_app.zeus.authorize(
callback=url_for("auth_zeus_bp.authorized", _external=True)) callback=url_for("oauth_bp.authorized", _external=True))
@auth_zeus_bp.route("/login") @oauth_bp.route("/login/zeus/authorized")
def login():
"""Function to handle a user trying to log in"""
return zeus_login()
@auth_zeus_bp.route("/authorized")
def authorized() -> typing.Any: def authorized() -> typing.Any:
# type is 'typing.Union[str, Response]', but this errors due to # type is 'typing.Union[str, Response]', but this errors due to
# https://github.com/python/mypy/issues/7187 # https://github.com/python/mypy/issues/7187
"""Check authorized status""" "Check authorized status"
resp = current_app.zeus.authorized_response() resp = current_app.zeus.authorized_response()
if resp is None: if resp is None:
# pylint: disable=C0301 # pylint: disable=C0301
@ -51,8 +45,8 @@ def authorized() -> typing.Any:
return redirect(url_for("general_bp.home")) return redirect(url_for("general_bp.home"))
def init_oauth(app) -> OAuthRemoteApp: def init_oauth(app):
"""Initialize the OAuth for ZeusWPI""" "Initialize the OAuth for ZeusWPI"
oauth = OAuth(app) oauth = OAuth(app)
zeus = oauth.remote_app( zeus = oauth.remote_app(
@ -75,15 +69,15 @@ def init_oauth(app) -> OAuthRemoteApp:
def login_and_redirect_user(user) -> Response: 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) login_user(user)
return redirect(url_for("general_bp.home")) return redirect(url_for("general_bp.home"))
def create_user(username) -> User: def create_user(username) -> User:
"""Create a temporary user if it is needed""" "Create a temporary user if it is needed"
user = User() user = User()
user.configure(username, False, 1, associations=["zeus"]) user.configure(username, False, 1)
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
return user return user

View file

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

View file

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

View file

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

View file

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