diff --git a/BookingSystem/__init__.py b/BookingSystem/__init__.py
index 4632dea..1688a4f 100644
--- a/BookingSystem/__init__.py
+++ b/BookingSystem/__init__.py
@@ -18,6 +18,11 @@
API_TOKEN = os.getenv('API_TOKEN')
REGEX_ITEM = r'^(?:(?![\s])[ÆØÅæøåa-zA-Z0-9_\s\-]*[ÆØÅæøåa-zA-Z0-9_\-]+)$'
+MIN_DAYS = int(os.getenv('MIN_DAYS', 1))
+MAX_DAYS = int(os.getenv('MAX_DAYS', 14))
+MIN_LABELS = int(os.getenv('MIN_LABELS', 0))
+MAX_LABELS = int(os.getenv('MAX_LABELS', 10))
+
# Logger setup
logger = Logger(__name__)
if os.getenv('DEBUG') == 'True':
diff --git a/BookingSystem/api.py b/BookingSystem/api.py
index 16ce6b4..c3906ad 100644
--- a/BookingSystem/api.py
+++ b/BookingSystem/api.py
@@ -10,13 +10,12 @@
import flask
import requests
-import groups
import inventory
import mail
import user
-from __init__ import DATABASE, LABEL_SERVER
+from __init__ import DATABASE, LABEL_SERVER, MIN_DAYS, MAX_DAYS, MIN_LABELS, MAX_LABELS
from inventory import Item
-from sanitizer import VALIDATORS, MINMAX, sanitize, handle_api_exception
+from sanitizer import VALIDATORS, MINMAX, sanitize, handle_api_exception, APIException
from utils import login_required, next_july
api = flask.Blueprint('api', __name__)
@@ -112,6 +111,7 @@ def delete_item(item_id: str) -> flask.Response:
@api.route('/items//label//preview', methods=['GET'])
@login_required(admin_only=True)
+@handle_api_exception
def get_label_preview(item_id: str, variant: str = 'qr') -> flask.Response:
"""Get a label preview for an item."""
item = inventory.get(item_id)
@@ -126,7 +126,7 @@ def print_label(item_id: str) -> flask.Response:
# START: Validation
validation_map = {
'print_label_count': VALIDATORS.INT,
- 'print_label_count_minmax': MINMAX(0, 9),
+ 'print_label_count_minmax': MINMAX(MIN_LABELS, MAX_LABELS),
'print_label_type': VALIDATORS.LABEL_TYPE,
}
form = sanitize(validation_map, flask.request.form)
@@ -152,7 +152,7 @@ def book_equipment() -> flask.Response:
validation_map = {
'user': VALIDATORS.UNIQUE_ID,
'days': VALIDATORS.INT,
- 'days_minmax': MINMAX(1, 90),
+ 'days_minmax': MINMAX(MIN_DAYS, MAX_DAYS),
'equipment': VALIDATORS.ITEM_LIST_EXISTS,
}
form = sanitize(validation_map, flask.request.form)
@@ -179,21 +179,19 @@ def return_equipment(item_id: str) -> flask.Response:
def get_user(userid: str) -> flask.Response:
"""Get user as JSON."""
u = user.get(userid)
- if not u:
- return flask.abort(404)
return flask.jsonify(u)
@api.route('/users', methods=['POST'])
@login_required()
+@handle_api_exception
def register_student() -> flask.Response:
"""Add/update a class in the database."""
con = sqlite3.connect(DATABASE)
cur = con.cursor()
selected_classroom = flask.request.form.get('classroom')
- if selected_classroom not in groups.get_all():
- return flask.abort(418) # I'm a teapot
+ sanitize({'classroom': VALIDATORS.GROUP}, {'classroom': selected_classroom})
data = {
'name': flask.session.get('user').name,
@@ -212,68 +210,84 @@ def register_student() -> flask.Response:
return flask.redirect(flask.request.referrer)
-@api.route('/groups', methods=['POST'])
+@api.route('/groups', methods=['PUT'])
@login_required(admin_only=True)
+@handle_api_exception
def update_groups() -> flask.Response:
"""Update a class in the database."""
+ new_groups = list()
+ for group in flask.request.form.get('groups').split('\n'):
+ if not group.strip():
+ continue
+ sanitize({'group': VALIDATORS.GROUP_NAME}, {'group': group.strip()})
+ new_groups.append(group.strip())
+
con = sqlite3.connect(DATABASE)
cur = con.cursor()
# noinspection SqlWithoutWhere
cur.execute('DELETE FROM groups')
con.commit()
-
- for group in flask.request.form.get('groups').split('\n'):
- if not group.strip():
- continue
- cur.execute('INSERT INTO groups (classroom) VALUES (?)', (group.strip(),))
+ for group in new_groups:
+ cur.execute('INSERT INTO groups (classroom) VALUES (?)', (group,))
con.commit()
con.close()
- return flask.redirect(flask.request.referrer)
+ return flask.Response('Gruppene ble oppdatert.', status=200)
-@api.route('/categories', methods=['POST'])
+@api.route('/categories', methods=['PUT'])
@login_required(admin_only=True)
+@handle_api_exception
def update_categories() -> flask.Response:
"""Update every category in the database."""
+ new_categories = list()
+ for category in flask.request.form.get('categories').split('\n'):
+ if not category.strip():
+ continue
+ sanitize({'category': VALIDATORS.CATEGORY_NAME}, {'category': category.strip()})
+ new_categories.append(category.strip())
+
con = sqlite3.connect(DATABASE)
cur = con.cursor()
# noinspection SqlWithoutWhere
cur.execute('DELETE FROM categories')
con.commit()
-
- for category in flask.request.form.get('categories').split('\n'):
- if not category.strip():
- continue
- cur.execute('INSERT INTO categories (name) VALUES (?)', (category.strip(),))
+ for category in new_categories:
+ cur.execute('INSERT INTO categories (name) VALUES (?)', (category,))
con.commit()
con.close()
- return flask.redirect(flask.request.referrer)
+ return flask.Response('Kategoriene ble oppdatert.', status=200)
-@api.route('/emails', methods=['POST'])
+@api.route('/emails', methods=['PUT'])
@login_required(admin_only=True)
+@handle_api_exception
def update_emails() -> flask.Response:
"""Update every email in the database."""
+ new_emails = list()
+ for email in flask.request.form.get('emails').split('\n'):
+ if not email.strip():
+ continue
+ sanitize({'email': VALIDATORS.EMAIL}, {'email': email.strip()})
+ new_emails.append(email.strip())
+
con = sqlite3.connect(DATABASE)
cur = con.cursor()
# noinspection SqlWithoutWhere
cur.execute('DELETE FROM emails')
con.commit()
-
- for email in flask.request.form.get('emails').split('\n'):
- if not email.strip():
- continue
- cur.execute('INSERT INTO emails (email) VALUES (?)', (email.strip(),))
+ for email in new_emails:
+ cur.execute('INSERT INTO emails (email) VALUES (?)', (email,))
con.commit()
con.close()
- return flask.redirect(flask.request.referrer)
+ return flask.Response('E-postene ble oppdatert.', status=200)
@api.route('/email/report', methods=['POST'])
@login_required(admin_only=True, api=True)
+@handle_api_exception
def email_report() -> flask.Response:
"""Emails a report to all users in the emails table.
@@ -282,13 +296,18 @@ def email_report() -> flask.Response:
If the interval parameter is set, the report will only be sent
if the last report was sent more than interval days ago.
+
+ You can only send an email once every hour by default. (Handled by mail.py)
"""
interval = flask.request.args.get('interval')
+
if interval:
- last_sent = datetime.fromtimestamp(float(mail.get_last_sent())).date()
+ sanitize({'interval': VALIDATORS.INT}, flask.request.args)
current_date = datetime.now().date()
+ last_sent = datetime.fromtimestamp(float(mail.get_last_sent())).date()
if last_sent and (current_date - last_sent).days < int(interval):
- return flask.Response(f'Ikke sendt - mindre enn {interval} dager siden forrige rapport.', status=200)
+ raise APIException(f'Ikke sendt - mindre enn {interval} dager siden forrige rapport.', 200)
+
return mail.send_report()
@@ -300,6 +319,7 @@ def prune_inactive_users() -> flask.Response:
Example cronjob:
0 1 1 7 * curl -X POST "http://localhost:5000/users/prune_inactive?token="
"""
+ # TODO: run this every day as a background task here instead of an external cronjob?
user.prune_inactive()
return flask.Response('Inaktive brukere ble fjernet.', status=200)
diff --git a/BookingSystem/app.py b/BookingSystem/app.py
index 6aa7425..ba31893 100644
--- a/BookingSystem/app.py
+++ b/BookingSystem/app.py
@@ -13,7 +13,7 @@
import mail
import routes
import user
-from __init__ import logger, REGEX_ITEM
+from __init__ import logger, REGEX_ITEM, MIN_DAYS, MAX_DAYS, MIN_LABELS, MAX_LABELS
from db import init_db
from flask_session import Session
@@ -60,7 +60,11 @@ def context_processor() -> dict:
used_ids=inventory.get_all_ids(),
users=user.get_all_active_users(),
unavailable_items=inventory.get_all_unavailable(),
- overdue_items=inventory.get_all_overdue())
+ overdue_items=inventory.get_all_overdue(),
+ MIN_DAYS=MIN_DAYS,
+ MAX_DAYS=MAX_DAYS,
+ MIN_LABELS=MIN_LABELS,
+ MAX_LABELS=MAX_LABELS, )
@app.errorhandler(401)
def unauthorized(_) -> flask.Response:
diff --git a/BookingSystem/inventory.py b/BookingSystem/inventory.py
index 550466b..eeb717b 100644
--- a/BookingSystem/inventory.py
+++ b/BookingSystem/inventory.py
@@ -56,6 +56,9 @@ def api_repr(self) -> dict:
'last_seen': self.last_seen
}
+ def mail_repr(self) -> str:
+ return f'{self.id} {self.name} ({self.category})'
+
def __str__(self) -> str:
if self.order_due_date:
return f'{self.lender_name}: {self.id} - {self.name} ({self.category}, {parser.parse(self.order_due_date):%d.%m.%Y})'
@@ -79,6 +82,12 @@ def lender_association(self) -> str:
return 'Sjekk historikk'
return self.user.get('classroom') or 'Lærer'
+ @property
+ def lender_association_mail(self) -> str:
+ if not self.user.get('name'):
+ return 'Slettet bruker [Sjekk historikk]'
+ return self.user.get('classroom') or 'Ansatt'
+
@property
def classroom(self) -> str:
if '(' in self.lender_association:
@@ -161,7 +170,7 @@ def delete(item_id: str) -> None:
logger.info(f'Slettet utstyr {item_id}.')
except sqlite3.IntegrityError:
logger.error(f'{item_id} eksisterer ikke.')
- raise APIException(f'{item_id} eksisterer ikke.')
+ raise APIException(f'{item_id} eksisterer ikke.', status_code=404)
finally:
con.close()
audits.audit('ITEM_REM', f'{item_id} ble slettet.')
@@ -170,12 +179,12 @@ def delete(item_id: str) -> None:
def get(item_id: str) -> Item:
"""Return a JSON object of the item with the given ID."""
con = sqlite3.connect(DATABASE)
- item = Item(*con.execute(read_sql_query('get_item.sql'), {'id': item_id}).fetchone())
+ item = con.execute(read_sql_query('get_item.sql'), {'id': item_id}).fetchone()
con.close()
if not item:
logger.error(f'{item_id} eksisterer ikke.')
raise APIException(f'{item_id} eksisterer ikke.')
- return item
+ return Item(*item)
def get_all() -> list[Item]:
diff --git a/BookingSystem/mail.py b/BookingSystem/mail.py
index 457879c..83ed5ff 100644
--- a/BookingSystem/mail.py
+++ b/BookingSystem/mail.py
@@ -7,6 +7,7 @@
import inventory
from __init__ import DATABASE, logger
+from sanitizer import APIException
SMTP_SERVER = os.getenv('SMTP_SERVER')
SMTP_PORT = int(os.getenv('SMTP_PORT')) if os.getenv('SMTP_PORT') else 587
@@ -66,9 +67,9 @@ def formatted_overdue_items() -> str:
"""
items = [item for item in inventory.get_all_unavailable() if item.overdue]
- pairs = {item.lender_association: [item2
- for item2 in items
- if item2.lender_association == item.lender_association]
+ pairs = {item.lender_association_mail: [item2.mail_repr()
+ for item2 in items
+ if item2.lender_association_mail == item.lender_association_mail]
for item in items}
sorted_pairs = {key: pairs[key] for key in sorted(pairs) if pairs[key]}
w = '
'
@@ -84,11 +85,19 @@ def formatted_overdue_items() -> str:
def send_report() -> flask.Response:
- """Send an e-mail to all emails in the database."""
+ """Send an e-mail to all emails in the database.
+
+ Force is for debugging only.
+ """
+ # If the last sent email was sent within the past hour, raise an exception
+ if get_last_sent():
+ if datetime.now().timestamp() - float(get_last_sent()) < 3600:
+ raise APIException('Rapport ble ikke sendt: forrige rapport ble sendt for under en time siden.', 400)
+
items = [item for item in inventory.get_all_unavailable() if item.overdue]
if not items:
update_last_sent()
- return flask.Response('Ikke sendt (intet å rapportere!)', status=400)
+ raise APIException('Rapport ble ikke sendt: finner ikke overskredet utstyr.', 400)
title = f'[UtstyrServer] Rapport for overskredet utstyr {datetime.now().strftime("%d.%m.%Y")}'
recipients = get_all_emails()
@@ -120,8 +129,9 @@ def send_report() -> flask.Response:
{SMTP_FROM}
""".encode('utf-8')
server.sendmail(SMTP_USERNAME, recipients, message)
+ # TODO: Handle exceptions properly
except Exception as e:
logger.warning(f'Failed to send email: {e}')
- return flask.Response('Klarte ikke sende rapport, prøv igjen eller kontakt en administrator.', status=500)
+ raise APIException('Klarte ikke sende rapport, prøv igjen eller kontakt en administrator.', 500)
update_last_sent()
return flask.Response('Rapport sendt!', status=200)
diff --git a/BookingSystem/routes.py b/BookingSystem/routes.py
index 7f06f0d..fdb9b7c 100644
--- a/BookingSystem/routes.py
+++ b/BookingSystem/routes.py
@@ -4,6 +4,7 @@
import mail
from __init__ import KIOSK_FQDN, LABEL_SERVER
from db import add_admin
+from sanitizer import handle_api_exception
from utils import login_required
app = flask.blueprints.Blueprint('app', __name__)
@@ -72,12 +73,14 @@ def inventar_add() -> str:
@app.route('/inventar/edit/')
@login_required(admin_only=True)
+@handle_api_exception
def edit_item(item_id: str) -> str:
return flask.render_template('inventar_edit.html', item=inventory.get(item_id))
@app.route('/inventar/print/')
@login_required(admin_only=True)
+@handle_api_exception
def print_item(item_id: str) -> str:
return flask.render_template('inventar_print.html', item=inventory.get(item_id))
diff --git a/BookingSystem/sanitizer.py b/BookingSystem/sanitizer.py
index 53bfda7..f142363 100644
--- a/BookingSystem/sanitizer.py
+++ b/BookingSystem/sanitizer.py
@@ -3,8 +3,8 @@
from functools import wraps
import flask
-from werkzeug.datastructures import ImmutableMultiDict
+import groups
import inventory
from __init__ import REGEX_ITEM, logger
@@ -15,9 +15,13 @@ class VALIDATORS(Enum):
UNIQUE_OR_SAME_ID = auto()
NAME = auto()
CATEGORY = auto()
+ CATEGORY_NAME = auto()
INT = auto()
LABEL_TYPE = auto()
ITEM_LIST_EXISTS = auto()
+ EMAIL = auto()
+ GROUP = auto()
+ GROUP_NAME = auto()
class APIException(Exception):
@@ -25,6 +29,7 @@ def __init__(self, message: str, status_code: int = 400) -> None:
super().__init__(message)
self.message = message
self.status_code = status_code
+ logger.debug(f'APIException: {message}')
class MINMAX:
@@ -52,53 +57,65 @@ def unique(fkey: str) -> bool:
l_val, l_ids = form.get(fkey).lower(), [i.lower() for i in inventory.get_all_ids()]
return l_val not in l_ids
+ def email(text: str) -> bool:
+ # Check if the email is valid
+ r = re.compile(r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,7}$")
+ return bool(r.match(text))
+
+ def categoryname(text: str) -> bool:
+ # Check if the category is valid
+ r = re.compile(r'^[a-zæøåA-ZÆØÅ0-9\- ]+$')
+ return bool(r.match(text))
+
+ def groupname(text: str) -> bool:
+ # Check if the group is valid
+ r = re.compile(r'^([a-zæøåA-ZÆØÅ0-9\- ]+) \(([a-zæøåA-ZÆØÅ0-9\-& ]+)\)$')
+ return bool(r.match(text))
+
for key, sanitizer in sanitization_map.items():
match sanitizer:
case VALIDATORS.ID | VALIDATORS.NAME:
# Check if the ID/name is valid
if not item_pattern(key):
- logger.debug(f'Invalid item pattern for {key} ({form.get(key)})')
raise APIException(f'Ugyldig ID ({form.get(key)})')
case VALIDATORS.UNIQUE_ID:
# Check if the ID is unique
if not unique(key):
- logger.debug(f'Invalid unique id for {key} ({form.get(key)})')
raise APIException(f'{form.get(key)} er allerede i bruk.')
# Check if the ID is valid
if not item_pattern(key):
- logger.debug(f'Invalid unique id for {key} ({form.get(key)})')
raise APIException(f'Ugyldig ID ({form.get(key)})')
case VALIDATORS.UNIQUE_OR_SAME_ID:
# Check if the ID is unique or the same as the current ID
same_id = form.get(key).lower() == data.get(key).lower()
if not unique(key) and not same_id:
- logger.debug(f'Invalid unique or same id for {key} ({form.get(key)})')
raise APIException(f'{form.get(key)} er allerede i bruk.')
# Check if the ID is valid
if not item_pattern(key):
- logger.debug(f'Invalid unique or same id for {key} ({form.get(key)})')
raise APIException(f'Ugyldig ID ({form.get(key)})')
case VALIDATORS.CATEGORY:
# Check if the category is valid
if form.get(key) not in inventory.all_categories():
- logger.debug(f'Invalid category for {key} ({form.get(key)})')
raise APIException(f'Ugyldig kategori ({form.get(key)})')
+ case VALIDATORS.CATEGORY_NAME:
+ # Check if the category name is valid
+ if not categoryname(form.get(key)):
+ raise APIException(f'Ugyldig kategorinavn ({form.get(key)})')
+
case VALIDATORS.INT:
# Check if the value is an int
try:
int(form.get(key))
except (ValueError, TypeError):
- logger.debug(f'Invalid int for {key} ({form.get(key)})')
raise APIException(f'Ugyldig tallverdi ({form.get(key)})')
case VALIDATORS.LABEL_TYPE:
# Check if the label type is valid
if form.get(key) not in ['barcode', 'qr']:
- logger.debug(f'Invalid label type for {key} ({form.get(key)})')
raise APIException(f'Ugyldig etikett-type ({form.get(key)})')
case VALIDATORS.ITEM_LIST_EXISTS:
@@ -106,9 +123,23 @@ def unique(fkey: str) -> bool:
ids = form.getlist(key)
all_ids = [i for i in inventory.get_all_ids()]
if not all(i in all_ids for i in ids):
- logger.debug(f'Invalid item list for {key} ({form.getlist(key)})')
raise APIException(f'En eller flere gjenstander finnes ikke ({form.getlist(key)})')
+ case VALIDATORS.EMAIL:
+ # Check if the email is valid
+ if not email(form.get(key)):
+ raise APIException(f'Ugyldig e-post ({form.get(key)})')
+
+ case VALIDATORS.GROUP:
+ # Check if the group is valid (done by students, send to 418 ;))
+ if form.get(key) not in groups.get_all():
+ raise APIException(f'Ugyldig gruppe ({form.get(key)}), godt forsøk ;)', 418)
+
+ case VALIDATORS.GROUP_NAME:
+ # Check if the group name is valid
+ if not groupname(form.get(key)):
+ raise APIException(f'Ugyldig gruppenavn ({form.get(key)}), må være "Klasserom (Lærer)"')
+
if key.endswith('_minmax'):
# Check if the value is between the min and max
mn, mx = sanitizer
@@ -122,7 +153,7 @@ def unique(fkey: str) -> bool:
def sanitize(validation_map: dict[any: VALIDATORS | MINMAX],
- form: ImmutableMultiDict,
+ form: dict,
data: dict = dict) -> dict[str: any]:
"""
Validate a form based on a validation map,
diff --git a/BookingSystem/templates/404.html b/BookingSystem/templates/404.html
index 5bfb8bb..0eec001 100644
--- a/BookingSystem/templates/404.html
+++ b/BookingSystem/templates/404.html
@@ -2,7 +2,13 @@
{% block content %}
-
404 - Page not found
+
404 - Fant ikke siden
Siden du prøvde å nå finnes ikke, eller er fjernet.
+
+ {% if error %}
+
+ Feilmelding: {{ error }}
+
+ {% endif %}
{% endblock %}
\ No newline at end of file
diff --git a/BookingSystem/templates/admin_settings.html b/BookingSystem/templates/admin_settings.html
index 4183a05..faacbba 100644
--- a/BookingSystem/templates/admin_settings.html
+++ b/BookingSystem/templates/admin_settings.html
@@ -9,7 +9,7 @@
Dersom du er forvirret av denne siden, så kan du trygt forlate den :)