Initialisierung: Übertragung aus altem Repository

This commit is contained in:
2021-07-12 00:49:07 +02:00
commit c7f0c6a1c4
80 changed files with 12063 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
# NateMan Nachschreibtermin-Manager
# blueprints/__init__.py
# Copyright © 2020 Niklas Elsbrock
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
Index-Blueprint
"""
from flask import Blueprint, g, redirect, url_for, render_template
bp = Blueprint("index", __name__)
@bp.route("/")
def index():
""" Startseite (Pfad ``/``, leitet bei angemeldetem Lehrer auf Klausurseite um) """
if g.lehrer is not None:
return redirect(url_for("klausuren.mine"), code=303)
else:
return render_template("index/index.html.j2")
+69
View File
@@ -0,0 +1,69 @@
# NateMan Nachschreibtermin-Manager
# blueprints/admin/__init__.py
# Copyright © 2020 Niklas Elsbrock
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
Administrations-Blueprint
"""
from flask import Blueprint, flash, g, redirect, render_template, request, url_for, current_app
from sqlalchemy.exc import StatementError
from ..auth import admin_required
from ...models import db
bp = Blueprint("admin", __name__, url_prefix="/admin")
@bp.route("/")
@admin_required
def index():
""" enthält Links zur Administration (Seite *Administration*)"""
return render_template("admin/index.html.j2")
@bp.route("/sql-access", methods=("GET", "POST"))
@admin_required
def sql_access():
""" SQL-Zugriffsseite """
reload_response = redirect(url_for(".sql_access"), code=303)
if request.method != "POST":
return render_template("admin/sql_access.html.j2", query="", result=None)
# ELSE
query = request.form["query"].strip()
if not query:
flash("Bitte geben Sie eine SQL-Abfrage ein.", "error")
return reload_response
# Query ausführen
try:
result = db.session.execute(query)
except StatementError as exc:
flash(f"Die SQL-Abfrage konnte nicht ausgeführt werden.\n\nFehlerbeschreibung:\n{exc.orig}", "error")
return render_template("admin/sql_access.html.j2", query=query, result=None)
if not result.returns_rows:
db.session.commit()
logged_query = query.replace('\r\n', ' ').replace('\n', ' ')
current_app.logger.info(f"{g.lehrer} hat eine SQL-Abfrage ausgeführt: {logged_query}")
flash("Die SQL-Abfrage wurde erfolgreich ausgeführt.", "success")
return render_template("admin/sql_access.html.j2", query=query, result=result)
+206
View File
@@ -0,0 +1,206 @@
# NateMan Nachschreibtermin-Manager
# blueprints/admin/lehrer.py
# Copyright © 2020 Niklas Elsbrock
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
Lehrerkonten-Administrations-Blueprint
"""
from flask import Blueprint, abort, current_app, flash, g, redirect, render_template, request, url_for
from ..auth import admin_required
from ... import util
from ...models import Lehrer, Stufe, db
bp = Blueprint("lehrer", __name__, url_prefix="/admin/lehrer")
@bp.route("/")
@admin_required
def index():
""" Lehrerliste """
return render_template("admin/lehrer/index.html.j2")
@bp.route("/<int:lehrer_id>", methods=("GET", "POST"))
@admin_required
def edit(lehrer_id):
""" Lehrerbearbeitung """
lehrer: Lehrer = Lehrer.query.filter_by(id=lehrer_id).first()
# HTTP 404 Fehler erzeugen, wenn kein Lehrer unter der ID gefunden wurde
if not lehrer:
abort(404)
return
if request.method != "POST":
return render_template("admin/lehrer/edit.html.j2", lehrer=lehrer)
# ELSE
action = request.form["action"]
# Benutzerdaten ändern
if action == "change-credentials":
new_kuerzel = request.form["new-kuerzel"].strip()
new_email = request.form["new-email"].strip() or None
new_password = request.form["new-password"]
new_beraet_name = request.form["new-beraet"]
new_is_admin = ("new-admin" in request.form)
error_msg = None
change_kuerzel = False
change_email = False
change_password = False
change_beraet = False
change_is_admin = False
# Neues Kürzel, falls angegeben und nicht das aktuelle Kürzel
if new_kuerzel and new_kuerzel != lehrer.kuerzel:
change_kuerzel = True
# existiert das Kürzel bereits?
if Lehrer.query.filter_by(kuerzel=new_kuerzel).count() != 0:
error_msg = "Das angegebene Kürzel ist bereits registriert."
# Neue E-Mail-Adresse, falls angegeben und nicht die aktuelle Adresse
if new_email != lehrer.email:
change_email = True
# ist die neue E-Mail-Adresse syntaktisch ungültig?
if new_email is not None and not util.EMAIL_ADDRESS_REGEX.match(new_email):
error_msg = "Bitte geben Sie eine gültige E-Mail-Adresse ein."
# Neues Passwort, falls angegeben
if new_password:
change_password = True
# ist das neue Passwort zu groß?
if not util.validate_bcrypt_password(new_password):
error_msg = "Das neue Passwort darf höchstens 72 Zeichen lang sein."
if new_beraet_name == "":
new_beraet = None
else:
new_beraet = Stufe.query.filter_by(name=new_beraet_name).first()
if new_beraet is None:
abort(400)
return
if new_beraet != lehrer.beraet:
change_beraet = True
# Administratorstatus ändern
if new_is_admin != lehrer.is_admin:
change_is_admin = True
# Änderungen vornehmen, falls kein Fehler aufgetreten ist
if error_msg is not None:
# Es ist ein Fehler aufgetreten
flash(error_msg, "error")
return redirect(url_for(".edit", lehrer_id=lehrer_id), code=303)
if not (change_kuerzel or change_email or change_password or change_beraet or change_is_admin):
flash("Es wurde nichts geändert.", "error")
return redirect(url_for(".edit", lehrer_id=lehrer_id), code=303)
if change_kuerzel:
old_kuerzel = lehrer.kuerzel
lehrer.kuerzel = new_kuerzel
current_app.logger.info(f"{g.lehrer} hat das Lehrerkürzel des Kontos mit der ID {lehrer.id}"
f" von {old_kuerzel} auf {new_kuerzel} geändert.")
if change_email:
# E-Mail-Adresse wird als confirmed markiert, wenn ein Admin sie ändert
lehrer.email = new_email
lehrer.confirmation_token = None
lehrer.is_confirmed = True
current_app.logger.info(f"{g.lehrer} hat die E-Mail-Adresse von {lehrer} auf {new_email} gesetzt.")
if change_password:
lehrer.set_password(new_password)
current_app.logger.info(f"{g.lehrer} hat das Passwort von {lehrer} geändert.")
if change_beraet:
lehrer.beraet = new_beraet
if new_beraet:
current_app.logger.info(f"{g.lehrer} hat {lehrer} als Beratungslehrer(in) für die {new_beraet.name} "
f"festgelegt.")
else:
current_app.logger.info(f"{g.lehrer} hat den Beratungslehrerstatus von {lehrer} entfernt.")
if change_is_admin:
lehrer.is_admin = new_is_admin
if new_is_admin:
current_app.logger.info(f"{g.lehrer} hat {lehrer} den Administratorstatus vergeben.")
else:
current_app.logger.info(f"{g.lehrer} hat {lehrer} den Administratorstatus entfernt.")
db.session.commit()
flash("Benutzerdaten erfolgreich geändert.", "success")
return redirect(url_for(".edit", lehrer_id=lehrer_id), code=303)
# Account löschen
elif action == "delete-account":
current_app.logger.info(f"{g.lehrer} hat das Konto {lehrer} gelöscht.")
db.session.delete(lehrer)
db.session.commit()
flash("Benutzerkonto erfolgreich gelöscht.", "success")
return redirect(url_for(".index"), code=303)
abort(400) # Bad Request, wenn ungültige action
@bp.route("/add", methods=("GET", "POST"))
@admin_required
def add():
""" Lehrerhinzufügung """
if request.method != "POST":
return render_template("admin/lehrer/add.html.j2")
# ELSE
kuerzel = request.form["kuerzel"]
password = request.form["password"]
force_pwd_change = ("force-password-change" in request.form)
error_msg = None
# existiert das Kürzel bereits?
if Lehrer.query.filter_by(kuerzel=kuerzel).count() != 0:
error_msg = "Ein(e) Lehrer(in) mit diesem Kürzel existiert bereits."
# ist das neue Passwort zu groß?
elif not util.validate_bcrypt_password(password):
error_msg = "Das neue Passwort darf höchstens 72 Zeichen lang sein."
if error_msg:
flash(error_msg, "error")
return redirect(url_for(".add"), code=303)
new_lehrer = Lehrer(kuerzel=kuerzel)
new_lehrer.set_password(password, set_pwd_changed=not force_pwd_change)
db.session.add(new_lehrer)
db.session.commit()
flash("Lehrer(in) erfolgreich hinzugefügt.", "success")
current_app.logger.info(f"{g.lehrer} hat den/die Lehrer(in) {new_lehrer} hinzugefügt")
return redirect(url_for(".edit", lehrer_id=new_lehrer.id), code=303)
+477
View File
@@ -0,0 +1,477 @@
# NateMan Nachschreibtermin-Manager
# blueprints/auth.py
# Copyright © 2020 Niklas Elsbrock
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
Authentifizierungs-Blueprint
"""
import functools
import time
from datetime import datetime, timedelta
from smtplib import SMTPRecipientsRefused
from flask import Blueprint, abort, current_app, flash, g, redirect, render_template, request, url_for, make_response
from .. import emails, util
from ..config_manager import config
from ..models import Lehrer, db, Session
bp = Blueprint("auth", __name__)
SESSION_COOKIE_NAME = "login_session"
@bp.before_app_request
def load_logged_in_lehrer():
"""
Lädt den angemeldeten Lehrer vor jeder HTTP-Anfrage in die g.lehrer-Variable.
"""
session_key = request.cookies.get(SESSION_COOKIE_NAME)
session = Session.get(session_key)
g.session = session
if g.session is None:
g.lehrer = None
else:
g.lehrer = g.session.lehrer
@bp.after_app_request
def clear_rotten_session_cookie(response):
"""
Löscht veraltete Session-Cookies.
"""
if request.cookies.get(SESSION_COOKIE_NAME) and not g.lehrer:
response.delete_cookie(SESSION_COOKIE_NAME)
return response
def login_required(view):
"""
Dieser Decorator kann an Views angehängt werden, die einen angemeldeten Lehrer erfordern.
"""
@functools.wraps(view)
def wrapped_view(**kwargs):
# Kein Lehrer angemeldet?
if g.lehrer is None:
flash("Bitte melden Sie sich an.", "error")
return redirect(url_for("auth.login", r=request.path), code=303)
# muss der Lehrer sein Passwort ändern?
if g.lehrer and not g.lehrer.pwd_changed:
return redirect(url_for("auth.password_change"), code=303)
return view(**kwargs)
return wrapped_view
def beratungslehrer_required(view):
"""
Dieser Decorator kann an Views angehängt werden,
die einen angemeldeten Beratungslehrer oder Administrator erfordern.
"""
@functools.wraps(view)
def wrapped_view(**kwargs):
# Kein Lehrer angemeldet?
if g.lehrer is None:
flash("Bitte melden Sie sich an.", "error")
return redirect(url_for("auth.login", r=request.path), code=303)
# Lehrer kein Beratungslehrer oder Administrator?
if not g.lehrer.can_access():
abort(403)
return
# muss der Lehrer sein Passwort ändern?
if g.lehrer and not g.lehrer.pwd_changed:
return redirect(url_for("auth.password_change"), code=303)
return view(**kwargs)
return wrapped_view
def admin_required(view):
"""
Dieser Decorator kann an Views angehängt werden, die einen angemeldeten Administrator erfordern.
"""
@functools.wraps(view)
def wrapped_view(**kwargs):
# Kein Lehrer angemeldet?
if g.lehrer is None:
flash("Bitte melden Sie sich an.", "error")
return redirect(url_for("auth.login", r=request.path), code=303)
# Lehrer kein Administrator?
if not g.lehrer.is_admin:
abort(403)
return
# muss der Lehrer sein Passwort ändern?
if g.lehrer and not g.lehrer.pwd_changed:
return redirect(url_for("auth.password_change"), code=303)
return view(**kwargs)
return wrapped_view
@bp.route("/login", methods=("GET", "POST"))
def login():
""" Anmeldung (Seite *Anmelden*)"""
if request.method != "POST":
return render_template("auth/login.html.j2")
# ELSE
request_start_time = time.process_time()
kuerzel = request.form["kuerzel"].strip()
password = request.form["password"]
remember_me = ("remember-me" in request.form)
error = False
lehrer = Lehrer.query.filter_by(kuerzel=kuerzel).first()
# existiert Lehrer nicht?
if lehrer is None:
error = True
current_app.logger.info(f"Fehlgeschlagener Anmeldeversuch von {request.remote_addr} "
f"(Ungültiges Kürzel '{kuerzel}')")
# ist das Passwort falsch?
elif not lehrer.check_password(password):
error = True
current_app.logger.info(f"Fehlgeschlagener Anmeldeversuch von {request.remote_addr} "
f"(Falsches Passwort für {lehrer.kuerzel})")
if error:
# Bei fehlgeschlagenem Anmeldeversuch 2 Sekunden vor Antwort warten
time.sleep(request_start_time - time.process_time() + 2)
flash("Ungültige Anmeldedaten.", "error")
return redirect(url_for(".login", r=request.args.get("r", None)), code=303)
# Kein Fehler -> Lehrer anmelden
current_app.logger.info(f"{lehrer} hat sich angemeldet.")
session_expiry_delta = timedelta(days=12) if remember_me else timedelta(hours=1)
session = Session.create(lehrer, session_expiry_delta)
if not lehrer.pwd_changed:
red = redirect(url_for(".password_change"), code=303)
else:
redirect_path = request.args.get("r", None)
# Wenn ein Weiterleitungspfad als GET-Parameter übergeben wurde
# (wie es z.B. der login_required-Wrapper macht, wenn kein Benutzer
# angemeldet ist), auf diesen Pfad weiterleiten, falls er mit einem
# Schrägstrich beginnt (also kein externer Link ist).
if redirect_path and len(redirect_path) > 0 and redirect_path[0] == "/":
red = redirect(redirect_path, code=303)
else:
red = redirect(url_for("klausuren.mine"), code=303)
db.session.commit()
resp = make_response(red)
resp.set_cookie(SESSION_COOKIE_NAME, session.key,
max_age=session_expiry_delta.total_seconds() if remember_me else None,
expires=session.expiry if remember_me else None, secure=config["uses-ssl"], httponly=True,
samesite="Strict")
return resp
@bp.route("/logout")
def logout():
""" Abmeldung (Seite *Abmelden*)"""
resp = make_response(redirect(url_for("auth.login"), code=303))
if g.lehrer:
current_app.logger.info(f"{g.lehrer} hat sich abgemeldet.")
resp.delete_cookie("login_session")
db.session.delete(g.session)
db.session.commit()
return resp
@bp.route("/confirm-email")
def confirm_email():
""" Emailbestätigung """
red = redirect(url_for(".login" if g.lehrer is None else "klausuren.mine"), code=303)
token = request.args.get("token", None)
if token is None:
flash("Ungültiger/veralteter Bestätigungslink.", "alert")
return red
lehrer = Lehrer.query.filter_by(confirmation_token=token).first()
if lehrer is None:
flash("Ungültiger/veralteter Bestätigungslink.", "alert")
return red
# ELSE
lehrer.confirmation_token = None
lehrer.is_confirmed = True
db.session.commit()
current_app.logger.info(f"{lehrer} hat die eigene E-Mail-Adresse ({lehrer.email}) bestätigt.")
flash("Die E-Mail-Adresse wurde erfolgreich bestätigt.", "success")
return red
@bp.route("/password-change", methods=("GET", "POST"))
def password_change():
""" Passwortänderung nach erstem Login """
# login_required kann hier nicht verwendet werden,
# da es ein verändertes Passwort erfordert
if not g.lehrer:
return redirect(url_for("auth.login"), code=303)
if g.lehrer.pwd_changed:
return redirect(url_for(".account"), code=303)
if request.method != "POST":
return render_template("auth/password-change.html.j2")
# ELSE
new_pass = request.form["new-password"]
new_pass_confirm = request.form["new-password-confirm"]
error_msg = None
if not new_pass or not new_pass_confirm:
error_msg = "Bitte geben Sie ein neues Passwort ein."
# ist das neue Passwort kürzer als 6 Zeichen?
if len(new_pass) < 6:
error_msg = "Das neue Passwort muss mindestens 6 Zeichen lang sein."
# ist das neue Passwort zu groß?
elif not util.validate_bcrypt_password(new_pass):
error_msg = "Das neue Passwort darf höchstens 72 Zeichen lang sein."
# stimmen die neuen Passwörter nicht überein?
elif new_pass != new_pass_confirm:
error_msg = "Die neuen Passwörter stimmen nicht überein."
# Änderungen vornehmen, falls kein Fehler aufgetreten ist
if error_msg:
# Es ist ein Fehler aufgetreten
flash(error_msg, "error")
return redirect(url_for(".account"), code=303)
g.lehrer.set_password(new_pass)
db.session.commit()
current_app.logger.info(f"{g.lehrer} hat das eigene Passwort geändert.")
return redirect(url_for("klausuren.mine"), code=303)
@bp.route("/password-reset", methods=("GET", "POST"))
def password_reset():
""" Passwortzurücksetzung (Seite *Passwort vergessen*)"""
red = redirect(url_for(".login" if g.lehrer is None else "klausuren.mine"), code=303)
token = request.args.get("token", None)
t_lehrer = None
if token is not None:
t_lehrer = Lehrer.query.filter_by(password_reset_token=token).first()
expired = False
if t_lehrer is not None:
expired = t_lehrer.password_reset_expiry < datetime.utcnow()
if expired:
t_lehrer.password_reset_token = None
t_lehrer.password_reset_expiry = None
db.session.commit()
if t_lehrer is None or expired:
flash("Ungültiger/veralteter Zurücksetzungslink.", "alert")
return red
if request.method == "POST":
if token is None:
# --- Antragstellung ---
lehrer_kuerzel = request.form["lehrer-kuerzel"]
email_address = request.form["email-address"]
lehrer = Lehrer.query.filter_by(kuerzel=lehrer_kuerzel, email=email_address).first()
if lehrer is None:
flash("Es gibt keine(n) Lehrer(in) mit den angegebenen Kriterien.", "error")
return redirect(url_for(".password_reset"), code=303)
if not lehrer.is_confirmed:
flash("Es gibt eine(n) Lehrer(in) mit den angegebenen Kriterien, allerdings ist dessen/deren "
"E-Mail-Adresse nicht bestätigt.\nWenden Sie sich an eine(n) Administrator(in).", "error")
return redirect(url_for(".password_reset"), code=303)
new_token = util.random_uri_safe_string(64)
lehrer.password_reset_token = new_token
lehrer.password_reset_expiry = datetime.utcnow() + timedelta(hours=1)
# Zurücksetzungsemail senden
try:
emails.send_password_reset_mail(lehrer.email, new_token)
except SMTPRecipientsRefused:
flash(f"Es konnte keine E-Mail an {lehrer.email} gesendet werden.\n"
f"Wahrscheinlich existiert diese Adresse nicht mehr.\n"
f"Wenden Sie sich an eine(n) Administrator(in).", "error")
return redirect(url_for(".password_reset"), code=303)
db.session.commit()
flash("Sie müssten nun eine E-Mail bekommen mit einem Link, über den Sie Ihr Passwort ändern können.\n"
"Der Link ist eine Stunde lang gültig.", "success")
current_app.logger.info(f"Für {lehrer} wurde eine Passwortzurücksetzung beantragt.")
else:
# --- Passwortänderung ---
new_pass = request.form["new-password"]
new_pass_confirm = request.form["new-password-confirm"]
error_msg = None
# ist das neue Passwort kürzer als 6 Zeichen?
if len(new_pass) < 6:
error_msg = "Das neue Passwort muss mindestens 6 Zeichen lang sein."
# ist das neue Passwort zu groß?
elif not util.validate_bcrypt_password(new_pass):
error_msg = "Das neue Passwort darf höchstens 72 Zeichen lang sein."
# stimmen die neuen Passwörter nicht überein?
elif new_pass != new_pass_confirm:
error_msg = "Die neuen Passwörter stimmen nicht überein."
if error_msg:
flash(error_msg, "error")
return redirect(url_for(".password_reset", token=token), code=303)
# ELSE
t_lehrer.password_reset_token = None
t_lehrer.set_password(new_pass)
db.session.commit()
current_app.logger.info(f"{t_lehrer} hat das eigene Passwort zurückgesetzt.")
flash("Das Passwort wurde erfolgreich zurückgesetzt.", "success")
return red
else:
if token is None:
# --- Formular zur Antragstellung ---
return render_template("auth/password-reset-send.html.j2")
else:
# --- Formular zur Passwortänderung ---
return render_template("auth/password-reset-do.html.j2")
@bp.route("/account", methods=("GET", "POST"))
@login_required
def account():
""" Kontoeinstellungen (Seite *Mein Konto*) """
if request.method != "POST":
return render_template("auth/account.html.j2")
# ELSE
current_password = request.form["current-password"]
new_email = request.form["new-email"].strip() or None
new_pass = request.form["new-password"]
new_pass_confirm = request.form["new-password-confirm"]
error_msg = None
change_kuerzel = False
change_email = False
change_password = False
# ist das aktuelle Passwort falsch?
if not g.lehrer.check_password(current_password):
error_msg = "Falsches aktuelles Passwort."
else:
# Neue E-Mail-Adresse, falls angegeben und nicht die aktuelle Adresse
if new_email != g.lehrer.email:
change_email = True
# ist die neue E-Mail-Adresse syntaktisch ungültig?
if new_email is not None and not util.EMAIL_ADDRESS_REGEX.match(new_email):
error_msg = "Bitte geben Sie eine gültige E-Mail-Adresse ein."
# Neues Passwort, falls angegeben
if new_pass:
change_password = True
# ist das neue Passwort kürzer als 6 Zeichen?
if len(new_pass) < 6:
error_msg = "Das neue Passwort muss mindestens 6 Zeichen lang sein."
# ist das neue Passwort zu groß?
elif not util.validate_bcrypt_password(new_pass):
error_msg = "Das neue Passwort darf höchstens 72 Zeichen lang sein."
# stimmen die neuen Passwörter nicht überein?
elif new_pass != new_pass_confirm:
error_msg = "Die neuen Passwörter stimmen nicht überein."
# Änderungen vornehmen, falls kein Fehler aufgetreten ist
if error_msg:
# Es ist ein Fehler aufgetreten
flash(error_msg, "error")
return redirect(url_for(".account"), code=303)
# ELSE
if not change_kuerzel and not change_email and not change_password:
return redirect(url_for(".account"), code=303)
if change_email:
if new_email is not None:
token = util.random_uri_safe_string(32)
# Bestätigungsemail senden
try:
emails.send_confirmation_link_mail(new_email, token)
except SMTPRecipientsRefused:
flash(f"Wir konnten keine Bestätigungsemail an {new_email} senden. "
f"Wahrscheinlich existiert diese Adresse nicht.", "error")
return redirect(url_for(".account"), code=303)
g.lehrer.confirmation_token = token
old_email = g.lehrer.email
g.lehrer.email = new_email
g.lehrer.is_confirmed = new_email is None
current_app.logger.info(f"{g.lehrer} hat die eigene E-Mail-Adresse von {old_email} "
f"auf {g.lehrer.email} geändert.")
if change_password:
g.lehrer.set_password(new_pass)
current_app.logger.info(f"{g.lehrer} hat das eigene Passwort geändert.")
db.session.commit()
flash("Benutzerdaten erfolgreich geändert.", "success")
return redirect(url_for(".account"), code=303)
+48
View File
@@ -0,0 +1,48 @@
# NateMan Nachschreibtermin-Manager
# blueprints/error.py
# Copyright © 2020 Niklas Elsbrock
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
Enthält ausschließlich Errorhandler.
"""
from flask import Blueprint, render_template
bp = Blueprint("error", __name__)
@bp.app_errorhandler(400)
def error_400(error):
"""400 - Ungültige Anfrage"""
return render_template("error/400.html.j2"), 400
@bp.app_errorhandler(403)
def error_403(error):
"""403 - Verboten"""
return render_template("error/403.html.j2"), 403
@bp.app_errorhandler(404)
def error_404(error):
"""404 - Seite nicht gefunden"""
return render_template("error/404.html.j2"), 404
@bp.app_errorhandler(500)
def error_500(error):
"""500 - serverseitiger Fehler"""
return render_template("error/500.html.j2"), 500
+163
View File
@@ -0,0 +1,163 @@
# NateMan Nachschreibtermin-Manager
# blueprints/fileio.py
# Copyright © 2020 Niklas Elsbrock
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
Export- und Import-Blueprint
"""
import os
import tempfile
from datetime import datetime
from flask import abort, Blueprint, current_app, g, redirect, url_for, flash, request, render_template
from openpyxl.utils.exceptions import InvalidFileException
from pyexpat import ExpatError
from .auth import beratungslehrer_required, admin_required
from .. import exporter, util
from ..importer import KlausurplanImportError, import_plan, excelimport, KoopSchuelerImportError
from ..models import db, Stufe, Klausur, Schueler
bp = Blueprint("fileio", __name__)
@bp.route("/export")
@beratungslehrer_required
def export():
""" Export (Seite *Nachschreibplan exportieren*)"""
with tempfile.NamedTemporaryFile(prefix="nateman_export_") as fp:
exporter.excelexport(fp.name, g.lehrer.accessible_stufen())
# XLSX-Datei wird in ``filebytes`` geladen, damit die temporäre Datei gelöscht werden kann
fp.seek(0)
filebytes = fp.read()
download_name = "NateMan-Export " + datetime.now().strftime("%Y-%m-%d") + ".xlsx"
xlsx_mimetype = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
response = current_app.response_class(filebytes, mimetype=xlsx_mimetype)
response.headers.set("Content-Disposition", "attachment", filename=download_name)
return response
@bp.route("/import", methods=("GET", "POST"), endpoint="import")
@admin_required
def import_():
""" Import (Seite *Klausurpläne importieren*) """
if request.method != "POST":
return render_template("fileio/import.html.j2")
# ELSE
all_stufen = Stufe.query.all()
for stufe in all_stufen:
if "del_" + stufe.name in request.form:
if not g.lehrer.can_access(stufe):
abort(400)
return
_del_plan(stufe)
db.session.commit()
flash("Klausurplan erfolgreich gelöscht.", "success")
current_app.logger.info(f"{g.lehrer} hat den Klausurplan für die {stufe.name} gelöscht.")
return redirect(url_for(".import"), code=303)
new_lehrer_password = request.form["new-lehrer-password"]
error_msg = None
if not new_lehrer_password:
error_msg = "Bitte geben Sie das Passwort für neu registrierte Lehrer(innen) an."
elif not util.validate_bcrypt_password(new_lehrer_password):
error_msg = "Das Passwort für neue Lehrer(innen) darf nicht mehr als 72 Zeichen enthalten."
if error_msg is not None:
flash(error_msg, "error")
return redirect(url_for(".import"), code=303)
plan_given = []
for stufe in all_stufen:
plan = request.files.get("plan_" + stufe.name, None)
# Klausurplan importieren
if plan and plan.filename:
plan_given.append(stufe.name)
_del_plan(stufe)
try:
import_plan(plan, stufe, new_lehrer_password)
except (KeyError, ValueError, ExpatError, KlausurplanImportError) as exc:
db.session.rollback()
flash(f"Beim Importieren des Klausurplans für die {stufe.name} ist ein Fehler aufgetreten.\n"
f"Wahrscheinlich ist die Klausurplandatei ungültig oder es gibt einen Konflikt mit den "
f"bisherigen Daten.\n\nFehlerbeschreibung: {type(exc).__name__}\n{exc}", "error")
current_app.logger.warning(f"Beim Versuch von {g.lehrer}, einen Klausurplan zu importieren, "
f"ist ein Fehler aufgetreten.", exc_info=exc)
return redirect(url_for(".import"), code=303)
ks_file = request.files.get("koopschueler", None)
ks_file_given = False
# Koopschülerdatei importieren
ks_import_failures = []
if ks_file and ks_file.filename:
ks_file_given = True
_del_koopschueler()
filename_ext = os.path.splitext(ks_file.filename)[1]
with tempfile.NamedTemporaryFile(prefix="nateman_ks_import_", suffix=filename_ext) as fp:
ks_file.save(fp.name)
try:
ks_import_failures = excelimport(fp.name)
except (InvalidFileException, KoopSchuelerImportError) as exc:
db.session.rollback()
flash(f"Beim Importieren der Koopschülerliste ist ein Fehler aufgetreten.\n\n"
f"Fehlerbeschreibung: {type(exc).__name__}\n{exc}", "error")
current_app.logger.warning(f"Beim Versuch von {g.lehrer}, eine Koopschülerliste zu importieren, "
f"ist ein Fehler aufgetreten.", exc_info=exc)
return redirect(url_for(".import"), code=303)
if len(plan_given) == 0 and not ks_file_given:
flash("Es wurden keine Pläne angegeben.", "error")
else:
if len(ks_import_failures) == 0:
flash("Pläne importiert.", "success")
else:
flash("Pläne importiert.\n\nKoopschülerimport: Folgende SuS konnten nicht importiert werden:\n"
+ "; ".join(f"{failure[0]}, {failure[1]}" for failure in ks_import_failures) + ".\n"
"Die restlichen KoopSuS wurden trotzdem importiert.", "warning")
current_app.logger.info(f"{g.lehrer} hat Pläne importiert.")
db.session.commit()
return redirect(url_for(".import"), code=303)
def _del_plan(stufe: Stufe):
"""Löscht alle Klausuren und Schüler der angegebenen Stufe"""
stufe.import_date = None
Klausur.query.filter_by(stufe=stufe).delete()
Schueler.query.filter_by(stufe=stufe).delete()
def _del_koopschueler():
"""Löscht alle Koopschüler"""
Schueler.query.filter(Schueler.stammschule != None).delete()
+30
View File
@@ -0,0 +1,30 @@
# NateMan Nachschreibtermin-Manager
# blueprints/info.py
# Copyright © 2020 Niklas Elsbrock
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
Informations-Blueprint
"""
from flask import Blueprint, render_template
bp = Blueprint("info", __name__, url_prefix="/info")
@bp.route("/licenses")
def licenses():
""" Lizenzen """
return render_template("info/licenses.html.j2")
+338
View File
@@ -0,0 +1,338 @@
# NateMan Nachschreibtermin-Manager
# blueprints/klausuren.py
# Copyright © 2020 Niklas Elsbrock
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
Klausurlisten-Blueprint
"""
from datetime import datetime
from flask import Blueprint, abort, current_app, flash, g, redirect, render_template, request, url_for, Response
from .auth import login_required, beratungslehrer_required
from ..config_manager import config
from ..models import Klausur, Klausurteilnahme, Koopschule, Lehrer, Schueler, db, \
get_next_new_schueler_id, Stufe
bp = Blueprint("klausuren", __name__, url_prefix="/klausuren")
@bp.route("/")
@login_required
def mine():
""" Ansicht der eigenen Klausuren (Seite *Meine Klausuren*) """
klausur_query = Klausur.query.filter(Klausur.lehrer == g.lehrer).order_by(Klausur.date.desc())
klausuren_anstehend = klausur_query.filter(Klausur.date > datetime.now().date()).all()
klausuren_vergangen = klausur_query.filter(Klausur.date <= datetime.now().date()).all()
return render_template("klausuren/mine.html.j2", klausuren_anstehend=klausuren_anstehend,
klausuren_vergangen=klausuren_vergangen)
@bp.route("/<stufe_name>/", endpoint="stufe")
def stufe_(stufe_name):
""" Ansicht der Klausuren einer Stufe (Seite *Klausuren [Stufe]*) """
stufe = Stufe.query.filter_by(name=stufe_name).first()
if stufe is None:
abort(404)
return
dates_query = db.session.query(Klausur.date).filter_by(stufe=stufe).order_by(Klausur.date.desc())
dates_anstehend = [t.date for t in dates_query.filter(Klausur.date > datetime.now().date()).distinct()]
dates_vergangen = [t.date for t in dates_query.filter(Klausur.date <= datetime.now().date()).distinct()]
return render_template("klausuren/stufe.html.j2", stufe=stufe, dates_anstehend=dates_anstehend,
dates_vergangen=dates_vergangen)
@bp.route("/<stufe_name>/add", methods=("GET", "POST"))
@beratungslehrer_required
def add(stufe_name):
""" Klausurhinzufügung """
reload_resp = redirect(url_for(".add", stufe_name=stufe_name), code=303)
stufe = Stufe.query.filter_by(name=stufe_name).first()
if stufe is None:
abort(404)
return
if not g.lehrer.can_access(stufe):
abort(403)
return
if request.method != "POST":
return render_template("klausuren/add.html.j2", stufe=stufe)
# ELSE
kursname = request.form["kursname"]
lehrer_id = request.form["lehrer"]
date_str = request.form["date"]
try:
startperiod = int(request.form["startperiod"])
endperiod = int(request.form["endperiod"])
except ValueError:
abort(400)
return
if startperiod < 0 or startperiod > endperiod or endperiod.bit_length() > 32:
flash("Ungültiger Zeitraum", "error")
return reload_resp
if not kursname:
flash("Bitte geben Sie einen Kursnamen an.", "error")
return reload_resp
try:
date = datetime.strptime(date_str, "%Y-%m-%d").date()
except ValueError:
abort(400)
return
lehrer = Lehrer.query.filter_by(id=lehrer_id).first()
if lehrer is None:
abort(400)
return
new_klausur = Klausur(kursname=kursname, date=date, startperiod=startperiod, endperiod=endperiod, stufe=stufe,
lehrer=lehrer)
db.session.add(new_klausur)
db.session.commit()
flash("Die Klausur wurde erfolgreich hinzugefügt.", "success")
current_app.logger.info(f"{g.lehrer} hat die Klausur {new_klausur} hinzugefügt.")
return redirect(url_for(".edit", klausur_id=new_klausur.id), code=303)
@bp.route("/<int:klausur_id>", methods=("GET", "POST"))
@login_required
def edit(klausur_id):
""" Klausurbearbeitung """
klausur = Klausur.query.filter_by(id=klausur_id).first()
if klausur is None:
abort(404)
return
if not g.lehrer.can_access(klausur.stufe) and klausur.lehrer != g.lehrer:
abort(403)
return
# ELSE
if request.method != "POST":
kt_list = Klausurteilnahme.query \
.filter_by(klausur=klausur) \
.join(Schueler) \
.order_by(Schueler.nachname, Schueler.vorname) \
.all()
not_in_klausur_list = Schueler.query \
.filter_by(stufe=klausur.stufe) \
.filter(Schueler.id.notin_(db.session.query(Klausurteilnahme.schueler_id).filter_by(klausur=klausur))) \
.order_by(Schueler.nachname, Schueler.vorname) \
.all()
return render_template("klausuren/edit.html.j2", klausur=klausur, kt_list=kt_list,
not_in_klausur_list=not_in_klausur_list)
# ELSE
reload_resp = redirect(url_for(".edit", klausur_id=klausur_id), code=303)
exit_resp: Response
if klausur.lehrer == g.lehrer:
exit_resp = redirect(url_for(".mine"), code=303)
else:
exit_resp = redirect(url_for(".stufe", stufe_name=klausur.stufe.name), code=303)
if klausur.edited and not g.lehrer.can_access(klausur.stufe):
abort(400)
return
action = request.form["action"]
if action == "edit":
lehrer_can_access = g.lehrer.can_access(klausur.stufe)
annotation = request.form["annotation"].strip()
flag_as_edited = ("flag-as-edited" in request.form)
klausur_laenge_str = request.form.get("laenge", None)
if lehrer_can_access and not klausur_laenge_str:
klausur_laenge = None
else:
try:
klausur_laenge = int(klausur_laenge_str)
except ValueError:
abort(400)
return
if klausur_laenge not in config["klausuren"]["klausurlaengen"]:
abort(400)
return
klausur.laenge = klausur_laenge
for kt in Klausurteilnahme.query.filter_by(klausur=klausur).all():
kt.versaeumt = (f"s_{kt.schueler.id}" in request.form)
if annotation:
if len(annotation) > config["klausuren"]["max-annotation-length"]:
abort(400)
return
klausur.annotation = annotation
else:
klausur.annotation = None
if lehrer_can_access:
klausur.edited = flag_as_edited
else:
klausur.edited = True
current_app.logger.info(f"{g.lehrer} hat die Klausur {klausur} bearbeitet.")
flash("Klausur erfolgreich bearbeitet.", "success")
db.session.commit()
return exit_resp
elif action == "edit-advanced":
if not g.lehrer.can_access(klausur.stufe):
abort(400)
return
new_lehrer_id = request.form["new-lehrer"]
new_date_str = request.form["new-date"]
try:
new_startperiod = int(request.form["new-startperiod"])
new_endperiod = int(request.form["new-endperiod"])
except ValueError:
abort(400)
return
if new_startperiod < 0 or new_startperiod > new_endperiod or new_endperiod.bit_length() > 32:
flash("Ungültiger Zeitraum", "error")
return reload_resp
try:
new_date = datetime.strptime(new_date_str, "%Y-%m-%d").date()
except ValueError:
abort(400)
return
new_lehrer = Lehrer.query.filter_by(id=new_lehrer_id).first()
if new_lehrer is None:
abort(400)
return
klausur.lehrer = new_lehrer
klausur.date = new_date
klausur.startperiod = new_startperiod
klausur.endperiod = new_endperiod
current_app.logger.info(f"{g.lehrer} hat die Klausur {klausur} erweitert bearbeitet.")
flash("Klausur erfolgreich bearbeitet.", "success")
db.session.commit()
return reload_resp
elif action == "delete":
if not g.lehrer.can_access(klausur.stufe):
abort(400)
return
current_app.logger.info(f"{g.lehrer} hat die Klausur {klausur} gelöscht.")
flash("Die Klausur wurde erfolgreich gelöscht.", "success")
db.session.delete(klausur)
db.session.commit()
return exit_resp
elif action == "add-schueler":
schueler_id = request.form.get("added-schueler", None)
add_to = request.form["add-to"]
if schueler_id is None:
flash("Bitte wählen Sie eine(n) Schüler(in) zum Hinzufügen aus.", "error")
return reload_resp
is_new = False
if schueler_id == "new-schueler":
is_new = True
new_schueler_nachname = request.form["new-schueler-nachname"].strip()
new_schueler_vorname = request.form["new-schueler-vorname"].strip()
new_schueler_stammschule_kuerzel = request.form.get("new-schueler-stammschule", None)
stammschule = None
if new_schueler_stammschule_kuerzel:
stammschule = Koopschule.query.filter_by(kuerzel=new_schueler_stammschule_kuerzel).first()
if stammschule is None:
abort(400)
return
if not new_schueler_nachname or not new_schueler_vorname:
flash("Bitte geben Sie einen vollständigen Namen für den/die neue(n) Schüler(in) an.", "error")
return reload_resp
schueler = Schueler(id=get_next_new_schueler_id(), nachname=new_schueler_nachname,
vorname=new_schueler_vorname, stufe=klausur.stufe,
stammschule=stammschule)
db.session.add(schueler)
else:
schueler = Schueler.query.filter_by(id=schueler_id).first()
if schueler is None:
abort(400)
return
if Klausurteilnahme.query.filter_by(klausur=klausur, schueler=schueler).first() is not None:
abort(400)
return
if add_to == "klausur":
klausuren = [klausur]
elif add_to == "kurs":
klausuren = Klausur.query.filter_by(stufe=klausur.stufe).filter_by(kursname=klausur.kursname).all()
else:
abort(400)
return
for k in klausuren:
kt_entity = Klausurteilnahme(klausur=k, schueler=schueler)
db.session.add(kt_entity)
current_app.logger.info(f"{g.lehrer} hat den/die{' neue(n)' if is_new else ''} Schüler(in) {schueler} "
f"zur Klausur {klausur} hinzugefügt.")
db.session.commit()
return reload_resp
elif action == "remove-schueler":
for kt in Klausurteilnahme.query.join(Klausur)\
.filter_by(stufe=klausur.stufe)\
.filter_by(kursname=klausur.kursname)\
.all():
if f"r_{kt.schueler.id}" in request.form:
db.session.delete(kt)
current_app.logger.info(f"{g.lehrer} hat den/die Schüler(in) {kt.schueler} aus der Klausur "
f"{kt.klausur} entfernt.")
db.session.commit()
return reload_resp
else:
# Bad Request, wenn ungültige action
abort(400)
+61
View File
@@ -0,0 +1,61 @@
# NateMan Nachschreibtermin-Manager
# blueprints/schueler.py
# Copyright © 2020 Niklas Elsbrock
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
Schüler-Blueprint
"""
from typing import Dict, List, Tuple
from flask import Blueprint, current_app, flash, g, redirect, render_template, request, url_for
from .auth import beratungslehrer_required
from ..models import Klausurteilnahme, db, Schueler, Klausur, Stufe
bp = Blueprint("schueler", __name__, url_prefix="/schueler")
@bp.route("/versaeumnisse/", methods=("GET", "POST"))
@beratungslehrer_required
def versaeumnisse():
""" Versäumnisliste (Seite *Versäumnisse*) """
if request.method != "POST":
versaeumt_dict: Dict[Stufe, Tuple[List[Klausurteilnahme], List[Klausurteilnahme]]] = {}
for stufe in g.lehrer.accessible_stufen():
base_query = Klausurteilnahme.query.join(Schueler).join(Klausur) \
.filter(Klausurteilnahme.versaeumt) \
.filter(Klausur.stufe == stufe) \
.order_by(Schueler.nachname, Schueler.vorname)
versaeumt_dict[stufe] = (
base_query.filter(~Klausurteilnahme.nachgeschrieben).all(),
base_query.filter(Klausurteilnahme.nachgeschrieben).all(),
)
return render_template("schueler/versaeumnisse.html.j2", versaeumt_dict=versaeumt_dict)
# ELSE
for kt in Klausurteilnahme.query.filter_by(versaeumt=True).all():
if g.lehrer.can_access(kt.klausur.stufe):
kt.attestiert = (f"a_{kt.klausur.id}:{kt.schueler.id}" in request.form)
kt.nachgeschrieben = (f"n_{kt.klausur.id}:{kt.schueler.id}" in request.form)
current_app.logger.info(f"{g.lehrer} hat die Versäumnisliste bearbeitet.")
flash("Versäumnisliste erfolgreich bearbeitet.", "success")
db.session.commit()
return redirect(url_for(".versaeumnisse"), code=303)