mirror of
https://github.com/nelsbrock/NateMan.git
synced 2026-08-14 21:36:49 +02:00
Initialisierung: Übertragung aus altem Repository
This commit is contained in:
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user