From f192355ec681ccdfc32551502514f20da96891b2 Mon Sep 17 00:00:00 2001 From: cato447 Date: Fri, 20 Feb 2026 18:31:16 +0100 Subject: [PATCH] reworked structure and improved performance to handle 500 concurrent users --- app/Dockerfile | 2 +- app/__init__.py | 10 +- app/auth.py | 2 +- app/blueprints/admin/__init__.py | 5 + app/blueprints/admin/routes.py | 197 +++++ app/blueprints/grafana/__init__.py | 5 + app/blueprints/grafana/routes.py | 5 + app/blueprints/slides/__init__.py | 5 + app/blueprints/slides/routes.py | 158 +++++ app/requirements.txt | 6 + app/routes.py | 370 +--------- app/templates/admin.html | 10 +- app/templates/banner.html | 10 +- app/templates/confused.html | 8 +- app/templates/index.html | 4 +- app/utils.py | 20 +- app/wsgi.py | 5 + docker-compose.yml | 6 + pyproject.toml | 2 + test/locustfile.py | 27 + uv.lock | 1064 ++++++++++++++++++++++++++++ 21 files changed, 1535 insertions(+), 386 deletions(-) create mode 100644 app/blueprints/admin/__init__.py create mode 100644 app/blueprints/admin/routes.py create mode 100644 app/blueprints/grafana/__init__.py create mode 100644 app/blueprints/grafana/routes.py create mode 100644 app/blueprints/slides/__init__.py create mode 100644 app/blueprints/slides/routes.py create mode 100644 app/wsgi.py create mode 100644 test/locustfile.py diff --git a/app/Dockerfile b/app/Dockerfile index 2de8a87..e2393c3 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -38,4 +38,4 @@ COPY --from=css-builder /build/static/css/output.css ./app/static/css/output.css EXPOSE 5000 # Gunicorn command -CMD ["gunicorn", "--preload", "-w", "4", "-b", "0.0.0.0:5000", "app:create_app()"] +CMD ["gunicorn", "-w", "4", "--worker-class", "eventlet", "-b", "0.0.0.0:5000", "app.wsgi:app"] diff --git a/app/__init__.py b/app/__init__.py index 1918b29..cf45c66 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -9,7 +9,7 @@ from app.auth import init_auth from app.seed_db import seed_courses from app.config import Config -socketio = SocketIO(async_mode='eventlet', cors_allowed_origins="*") +socketio = SocketIO(async_mode='eventlet', cors_allowed_origins="*", message_queue='redis://redis:6379') def create_app(config_class=Config): # 1. Setup Log Directory @@ -42,5 +42,13 @@ def create_app(config_class=Config): socketio.init_app(app) from .routes import main + from .blueprints.slides import slides_bp + from .blueprints.admin import admin_bp + from .blueprints.grafana import grafana_bp + app.register_blueprint(main) + app.register_blueprint(slides_bp) + app.register_blueprint(admin_bp) + app.register_blueprint(grafana_bp) + return app diff --git a/app/auth.py b/app/auth.py index d364167..308a483 100644 --- a/app/auth.py +++ b/app/auth.py @@ -12,7 +12,7 @@ users: Dict[str, User] def init_auth(app: Flask): login_manager.init_app(app) bcrypt.init_app(app) - login_manager.login_view = "main.admin_login" # type: ignore + login_manager.login_view = "admin.admin_login" # type: ignore global users users = {"cato447": User("cato447", app.config["ADMIN_PASSWORD"], app.config["DEFAULT_COURSE"])} class User(flask_login.UserMixin): diff --git a/app/blueprints/admin/__init__.py b/app/blueprints/admin/__init__.py new file mode 100644 index 0000000..9bbde21 --- /dev/null +++ b/app/blueprints/admin/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +admin_bp = Blueprint('admin', __name__, url_prefix='/admin') + +from . import routes diff --git a/app/blueprints/admin/routes.py b/app/blueprints/admin/routes.py new file mode 100644 index 0000000..58de4be --- /dev/null +++ b/app/blueprints/admin/routes.py @@ -0,0 +1,197 @@ +from typing import cast, List +from flask import render_template, current_app, redirect, url_for, request, abort, session, flash +from app.utils import get_active_banner, get_course, generate_password, get_available_decks, get_passwords, get_all_courses, is_safe_url, get_token, create_token, retrieve_token, set_cookie +from app.models import db, InfoBanner, Password, Deck +from app.forms import InfoBannerForm, DeckForm, PasswordForm, LoginForm +from app.auth import user_loader, check_password +from datetime import datetime +import pytz +from . import admin_bp +import flask_login + + +@admin_bp.route("/login", methods=["POST", "GET"]) +def admin_login(): + form = LoginForm() + if form.validate_on_submit(): + assert isinstance(form.user_name.data, str) + assert isinstance(form.password.data, str) + user_name: str = form.user_name.data + password: str = form.password.data + user = user_loader(user_name) + if user and check_password(user, password): + flask_login.login_user(user) + current_app.logger.info(f"Admin login successful: {user_name}") + + next_page = request.args.get("next") + + if next_page and not is_safe_url(next_page): + current_app.logger.warning( + f"Blocked unsafe redirect attempt to: {next_page}" + ) + return abort(400) + + if next_page: + session["next_url"] = next_page + + if not get_token(): + token = retrieve_token(user_name) + if not token: + token = create_token(user_name) + return set_cookie(token) + + return redirect(next_page or url_for("admin.admin")) + else: + current_app.logger.warning(f"Failed admin login attempt: {user_name}") + flash("Incorrect username or password. Please try again.", "error") + + return render_template("admin_login.html", form=form) + + +@admin_bp.route("/", methods=["GET", "POST"]) +@flask_login.login_required +def admin(): + active_course = get_course(flask_login.current_user.active_course_id) + if active_course is None: + current_app.logger.error( + f"Admin user {flask_login.current_user.id} has no active_course_id." + ) + raise Exception + deck_form = DeckForm() + password_form = PasswordForm() + if request.method == "GET": + password_form.value.data = generate_password() + deck_form.deck.choices = get_available_decks(active_course) + decks = active_course.decks + password_form.decks.choices = [(deck.id, deck.name) for deck in decks] + + if request.method == "POST": + if "submit_deck" in request.form and deck_form.validate(): + deck = Deck( + name=cast(str, deck_form.name.data), + deck=deck_form.deck.data, + course=active_course, + description=cast(str, deck_form.description.data), + ) + db.session.add(deck) + db.session.commit() + current_app.logger.info(f"Admin created deck: {deck.name}") + + if "submit_password" in request.form: + if password_form.validate(): + value: str = password_form.value.data + expires_at: datetime | None = password_form.expires_at.data + if expires_at: + tz = pytz.timezone("Europe/Berlin") + utc = pytz.utc + expires_at = tz.localize(expires_at).astimezone(utc) + password = Password(value=value, expires_at=expires_at) + decks = db.session.execute( + db.select(Deck).filter( + Deck.id.in_(cast(List[Deck], password_form.decks.data)) + ) + ).scalars() + password.decks.extend(decks) + db.session.add(password) + db.session.commit() + current_app.logger.info( + f"Admin created password: {value} (Expires: {expires_at})" + ) + else: + # Form was submitted but validation failed + for field, errors in password_form.errors.items(): + for error in errors: + current_app.logger.warning( + f"Admin form validation error ({field}): {error}" + ) + return redirect(url_for("admin.admin")) + + return render_template( + "admin.html", + deck_form=deck_form, + password_form=password_form, + decks=decks, + passwords=get_passwords(active_course), + courses=get_all_courses(), + active_course_name=active_course.name, + ) + + +@admin_bp.route("/switch_course/", methods=["POST"]) +@flask_login.login_required +def switch_course_admin(new_course: int): + user = flask_login.current_user + if get_course(new_course): + user.active_course_id = new_course + current_app.logger.info(f"Admin {user.id} switched to course ID {new_course}") + return redirect(url_for("admin.admin")) + + +@admin_bp.route("/banner", methods=["POST", "GET"]) +@flask_login.login_required +def banner(): + banner_form = InfoBannerForm() + if banner_form.validate_on_submit(): + assert isinstance(banner_form.content.data, str) + content: str = banner_form.content.data + banner = InfoBanner(content=content) + db.session.add(banner) + db.session.commit() + current_app.logger.info(f"Admin added banner: {content[:30]}...") + return render_template( + "banner.html", + banner_form=banner_form, + banners=db.session.execute(db.select(InfoBanner)).scalars(), + ) + + +@admin_bp.route("/decks//delete", methods=["POST"]) +@flask_login.login_required +def delete_deck(deck_id: int): + deck = Deck.query.get_or_404(deck_id) + current_app.logger.info(f"Admin deleted deck: {deck.name}") + db.session.delete(deck) + db.session.commit() + return redirect(url_for("admin.admin")) + + +@admin_bp.route("/passwords//delete", methods=["POST"]) +@flask_login.login_required +def delete_password(password_id: int): + password = Password.query.get_or_404(password_id) + current_app.logger.info(f"Admin deleted password ID {password_id}") + db.session.delete(password) + db.session.commit() + return redirect(url_for("admin.admin")) + + +@admin_bp.route("/banner/show/", methods=["POST"]) +@flask_login.login_required +def set_active_banner(banner_id): + banner = InfoBanner.query.get(banner_id) + if banner: + # ensures that only one banner is active at all times + active_banner = get_active_banner() + if active_banner: + active_banner.is_active = False + banner.is_active = True + db.session.commit() + current_app.logger.info(f"Admin activated banner ID {banner_id}") + return redirect(url_for("admin.banner")) + + +@admin_bp.route("/banner/hide", methods=["POST"]) +@flask_login.login_required +def hide_active_banner(): + banner = get_active_banner() + if banner: + banner.is_active = False + db.session.commit() + current_app.logger.info("Admin hid active banner") + return redirect(url_for("admin.banner")) + + +@admin_bp.route("/confused") +@flask_login.login_required +def confused_notifications(): + return render_template("confused.html") diff --git a/app/blueprints/grafana/__init__.py b/app/blueprints/grafana/__init__.py new file mode 100644 index 0000000..ff69dcc --- /dev/null +++ b/app/blueprints/grafana/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +grafana_bp = Blueprint('grafana', __name__, url_prefix='/grafana') + +from . import routes diff --git a/app/blueprints/grafana/routes.py b/app/blueprints/grafana/routes.py new file mode 100644 index 0000000..d2582a8 --- /dev/null +++ b/app/blueprints/grafana/routes.py @@ -0,0 +1,5 @@ +from . import grafana_bp + +@grafana_bp.route('/health') +def health(): + return {"status": "ok"}, 200 diff --git a/app/blueprints/slides/__init__.py b/app/blueprints/slides/__init__.py new file mode 100644 index 0000000..87f109f --- /dev/null +++ b/app/blueprints/slides/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +slides_bp = Blueprint('slides', __name__, url_prefix='/slides') + +from . import routes # import routes after blueprint is created diff --git a/app/blueprints/slides/routes.py b/app/blueprints/slides/routes.py new file mode 100644 index 0000000..144852e --- /dev/null +++ b/app/blueprints/slides/routes.py @@ -0,0 +1,158 @@ +from . import slides_bp +from flask import current_app, abort, redirect, url_for, request, send_from_directory +from app.utils import get_cached_deck_info, token_required +from app.models import Deck, db, Course +import os +import flask_login +import re + + +slidev_deck_pattern = re.compile( + r"^\/slides\/[^\/]+\/[^\/]+\/(?:\d+|index\.html)(?:\?.*)?$" +) +slidev_all_pattern = re.compile( + r"^\/slides\/([^\/]+)\/([^\/]+)(?:\/presenter)?\/(\d+)(?:\?.*)?$" +) + + +def log_404(reason: str, deck: str, course: str, path: str, token): + """ + Helper function to log 404 errors with the user's identity. + Uses current_app.logger to ensure logs are handled by the app's config. + """ + user_identity = f"User: {token.name} (ID: {token.id})" + current_app.logger.warning( + f"404 Not Found [{reason}] | {user_identity} | Resource: {course}/{deck}/{path}" + ) + + +@slides_bp.after_request +def inject_confused_button(response): + if ( + (slidev_deck_pattern.match(request.path) or request.path == "/") + and response.content_type.startswith("text/html") + and not flask_login.current_user.is_authenticated + ): + if response.direct_passthrough: + response.direct_passthrough = False + body = response.get_data(as_text=True) + insert_at = body.find("") + if insert_at != -1: + button_html = """ + + + """ + body = body[:insert_at] + button_html + body[insert_at:] + response.set_data(body) + return response + + +@slides_bp.route("///") +@slides_bp.route("///") +@token_required +def serve_deck(course_folder, deck, token, path="index.html"): + # 1. Ask the cache for the deck data (0 DB queries on a cache hit!) + deck_info = get_cached_deck_info(token.id, course_folder, deck) + + # 2. Handle missing data + if not deck_info["course_exists"]: + log_404("Course not found", deck, course_folder, path, token) + abort(404) + + if not deck_info["deck_exists"]: + log_404("Deck not in database", deck, course_folder, path, token) + abort(404) + + # 3. Handle authorization + if not deck_info["auth"]: + current_app.logger.warning( + f"Access denied (locked) for user {token.name} -> {deck_info.get('deck_name', deck)}" + ) + return redirect(url_for("main.index")) + + # 4. Resolve the file path entirely using strings + slides_dir = os.path.join(current_app.config["SLIDES_DIR"], course_folder) + deck_path = os.path.join(slides_dir, deck) + + if not os.path.exists(deck_path): + log_404("Deck directory missing on disk", deck, course_folder, path, token) + return f"Deck '{deck_info['deck_name']}' not found.", 404 + + # 5. Serve the file + if slidev_all_pattern.match(request.path): + current_app.logger.info(f"User reloaded in deck {deck_info['deck_name']}") + return send_from_directory(deck_path, deck_info["index_file"]) + + requested_file = os.path.join(deck_path, path) + if os.path.isfile(requested_file): + if path == deck_info["index_file"]: + current_app.logger.info( + f"User {token.name} accessed deck {deck_info['deck_name']}" + ) + return send_from_directory(deck_path, path) + + log_404("File missing within deck", deck, course_folder, path, token) + abort(404) + + +@slides_bp.route("///export") +@token_required +def serve_export(deck, course_folder, token): + course = db.session.execute( + db.select(Course).where(Course.folder == course_folder) + ).scalar() + if not course: + log_404("Export: Course not found", deck, course_folder, "", token) + abort(404) + assert isinstance(course, Course) + + deck_obj = db.session.execute( + db.select(Deck).where(Deck.deck == deck, Deck.course_id == course.id) + ).scalar() + if not deck_obj: + log_404("Export: Deck not in database", deck, course_folder, "", token) + abort(404) + assert isinstance(deck_obj, Deck) + + if not deck_obj.export_file: + log_404("Export: Deck has no export_file set", deck, course_folder, "", token) + abort(404) + + if not token.is_unlocked(deck_obj.deck): + return redirect(url_for("main.index")) + + exports_dir = os.path.join(current_app.config["EXPORTS_DIR"], course.folder) + deck_path = os.path.join(exports_dir, deck_obj.deck) + requested_file = os.path.join(deck_path, deck_obj.export_file) + if os.path.isfile(requested_file): + current_app.logger.info(f"User {token.name} exported deck {deck_obj.name}") + return send_from_directory(deck_path, deck_obj.export_file) + + log_404("Export file missing", deck, course_folder, deck_obj.export_file, token) + abort(404) diff --git a/app/requirements.txt b/app/requirements.txt index f4c299b..8542102 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -1,5 +1,7 @@ # This file was autogenerated by uv via the following command: # uv export --format requirements-txt -o requirements.txt --no-hashes +async-timeout==5.0.1 ; python_full_version < '3.11.3' + # via redis bcrypt==5.0.0 # via flask-bcrypt bidict==0.23.1 @@ -68,6 +70,10 @@ python-socketio==5.13.0 # via flask-socketio pytz==2025.2 # via tutor-tool +redis==7.0.1 ; python_full_version < '3.10' + # via tutor-tool +redis==7.2.0 ; python_full_version >= '3.10' + # via tutor-tool simple-websocket==1.1.0 # via python-engineio sqlalchemy==2.0.43 diff --git a/app/routes.py b/app/routes.py index 668057a..60b4c93 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,57 +1,38 @@ -import os -import re from datetime import datetime -from typing import List, cast import flask_login from flask_socketio import join_room -import pytz from flask import ( Blueprint, - abort, current_app, # Used for logging flash, make_response, redirect, render_template, request, - send_from_directory, - session, url_for, ) from . import socketio -from .auth import check_password, user_loader -from .forms import DeckForm, InfoBannerForm, LoginForm, PasswordForm -from .models import Course, Deck, InfoBanner, Password, db +from .auth import user_loader +from .models import Password, db from .utils import ( create_token, - generate_password, get_active_banner, get_all_courses, - get_available_decks, - get_passwords, get_token, get_unlocked_decks, retrieve_token, - serialize_token, update_token, get_course, token_required, - get_cached_deck_info, - is_safe_url, + set_cookie, ) main = Blueprint("main", __name__) -slidev_deck_pattern = re.compile( - r"^\/slides\/[^\/]+\/[^\/]+\/(?:\d+|index\.html)(?:\?.*)?$" -) -slidev_all_pattern = re.compile( - r"^\/slides\/([^\/]+)\/([^\/]+)(?:\/presenter)?\/(\d+)(?:\?.*)?$" -) @socketio.on("connect") @@ -63,53 +44,6 @@ def on_connect(): ) -@main.after_request -def inject_confused_button(response): - if ( - (slidev_deck_pattern.match(request.path) or request.path == "/") - and response.content_type.startswith("text/html") - and not flask_login.current_user.is_authenticated - ): - if response.direct_passthrough: - response.direct_passthrough = False - body = response.get_data(as_text=True) - insert_at = body.find("") - if insert_at != -1: - button_html = """ - - - """ - body = body[:insert_at] + button_html + body[insert_at:] - response.set_data(body) - return response - - @main.route("/") @token_required def index(token): @@ -126,23 +60,6 @@ def index(token): ) -def set_cookie(token): - next_url = session.pop("next_url", None) - if not next_url: - current_app.logger.debug("next_url not present redirecting to index") - next_url = url_for("main.index") - - if not is_safe_url(next_url): - current_app.logger.warning(f"Blocked unsafe redirect attempt to: {next_url}") - return abort(400) - - resp = make_response(redirect(next_url)) - resp.set_cookie( - "access_token", serialize_token(token), httponly=True, samesite="Lax", path="/", max_age=31536000 - ) - return resp - - @main.route("/set_token", methods=["POST", "GET"]) def set_token(): """ @@ -163,7 +80,7 @@ def set_token(): current_app.logger.info( f"Admin username '{username}' entered in set_token. Redirecting to admin login." ) - return redirect(url_for("main.admin_login")) + return redirect(url_for("admin.admin_login")) token = retrieve_token(username) @@ -253,44 +170,6 @@ def switch_course(new_course: int, token): return redirect(url_for("main.index")) -@main.route("/admin/login", methods=["POST", "GET"]) -def admin_login(): - form = LoginForm() - if form.validate_on_submit(): - assert isinstance(form.user_name.data, str) - assert isinstance(form.password.data, str) - user_name: str = form.user_name.data - password: str = form.password.data - user = user_loader(user_name) - if user and check_password(user, password): - flask_login.login_user(user) - current_app.logger.info(f"Admin login successful: {user_name}") - - next_page = request.args.get("next") - - if next_page and not is_safe_url(next_page): - current_app.logger.warning( - f"Blocked unsafe redirect attempt to: {next_page}" - ) - return abort(400) - - if next_page: - session["next_url"] = next_page - - if not get_token(): - token = retrieve_token(user_name) - if not token: - token = create_token(user_name) - return set_cookie(token) - - return redirect(next_page or url_for("main.admin")) - else: - current_app.logger.warning(f"Failed admin login attempt: {user_name}") - flash("Incorrect username or password. Please try again.", "error") - - return render_template("admin_login.html", form=form) - - def process_access_code(access_code: str, token): password = db.session.execute( db.select(Password).where(Password.value == access_code) @@ -333,244 +212,3 @@ def access_get(access_code: str, token): def confused(): socketio.emit("confused", {"time": datetime.utcnow().isoformat()}, to="instructor") return ("", 204) - - -def log_404(reason: str, deck: str, course: str, path: str, token): - """ - Helper function to log 404 errors with the user's identity. - Uses current_app.logger to ensure logs are handled by the app's config. - """ - user_identity = f"User: {token.name} (ID: {token.id})" - current_app.logger.warning( - f"404 Not Found [{reason}] | {user_identity} | Resource: {course}/{deck}/{path}" - ) - - -@main.route("/slides///") -@main.route("/slides///") -@token_required -def serve_deck(course_folder, deck, token, path="index.html"): - # 1. Ask the cache for the deck data (0 DB queries on a cache hit!) - deck_info = get_cached_deck_info(token.id, course_folder, deck) - - # 2. Handle missing data - if not deck_info["course_exists"]: - log_404("Course not found", deck, course_folder, path, token) - abort(404) - - if not deck_info["deck_exists"]: - log_404("Deck not in database", deck, course_folder, path, token) - abort(404) - - # 3. Handle authorization - if not deck_info["auth"]: - current_app.logger.warning( - f"Access denied (locked) for user {token.name} -> {deck_info.get('deck_name', deck)}" - ) - return redirect(url_for("main.index")) - - # 4. Resolve the file path entirely using strings - slides_dir = os.path.join(current_app.config["SLIDES_DIR"], course_folder) - deck_path = os.path.join(slides_dir, deck) - - if not os.path.exists(deck_path): - log_404("Deck directory missing on disk", deck, course_folder, path, token) - return f"Deck '{deck_info['deck_name']}' not found.", 404 - - # 5. Serve the file - if slidev_all_pattern.match(request.path): - current_app.logger.info(f"User reloaded in deck {deck_info['deck_name']}") - return send_from_directory(deck_path, deck_info["index_file"]) - - requested_file = os.path.join(deck_path, path) - if os.path.isfile(requested_file): - if path == deck_info["index_file"]: - current_app.logger.info( - f"User {token.name} accessed deck {deck_info['deck_name']}" - ) - return send_from_directory(deck_path, path) - - log_404("File missing within deck", deck, course_folder, path, token) - abort(404) - - -@main.route("/exports///") -@token_required -def serve_export(deck, course_folder, path, token): - course = db.session.execute( - db.select(Course).where(Course.folder == course_folder) - ).scalar() - if not course: - log_404("Export: Course not found", deck, course_folder, path, token) - abort(404) - assert isinstance(course, Course) - - deck_obj = db.session.execute( - db.select(Deck).where(Deck.deck == deck, Deck.course_id == course.id) - ).scalar() - if not deck_obj: - log_404("Export: Deck not in database", deck, course_folder, path, token) - abort(404) - assert isinstance(deck_obj, Deck) - - if not token.is_unlocked(deck_obj.deck): - return redirect(url_for("main.index")) - - exports_dir = os.path.join(current_app.config["EXPORTS_DIR"], course.folder) - deck_path = os.path.join(exports_dir, deck_obj.deck) - requested_file = os.path.join(deck_path, path) - if os.path.isfile(requested_file): - current_app.logger.info(f"User {token.name} exported deck {deck_obj.name}") - return send_from_directory(deck_path, path) - - log_404("Export file missing", deck, course_folder, path, token) - abort(404) - - -@main.route("/admin", methods=["GET", "POST"]) -@flask_login.login_required -def admin(): - active_course = get_course(flask_login.current_user.active_course_id) - if active_course is None: - current_app.logger.error( - f"Admin user {flask_login.current_user.id} has no active_course_id." - ) - raise Exception - deck_form = DeckForm() - password_form = PasswordForm() - if request.method == "GET": - password_form.value.data = generate_password() - deck_form.deck.choices = get_available_decks(active_course) - decks = active_course.decks - password_form.decks.choices = [(deck.id, deck.name) for deck in decks] - - if request.method == "POST": - if "submit_deck" in request.form and deck_form.validate(): - deck = Deck( - name=cast(str, deck_form.name.data), - deck=deck_form.deck.data, - course=active_course, - description=cast(str, deck_form.description.data), - ) - db.session.add(deck) - db.session.commit() - current_app.logger.info(f"Admin created deck: {deck.name}") - - if "submit_password" in request.form: - if password_form.validate(): - value: str = password_form.value.data - expires_at: datetime | None = password_form.expires_at.data - if expires_at: - tz = pytz.timezone("Europe/Berlin") - utc = pytz.utc - expires_at = tz.localize(expires_at).astimezone(utc) - password = Password(value=value, expires_at=expires_at) - decks = db.session.execute( - db.select(Deck).filter( - Deck.id.in_(cast(List[Deck], password_form.decks.data)) - ) - ).scalars() - password.decks.extend(decks) - db.session.add(password) - db.session.commit() - current_app.logger.info( - f"Admin created password: {value} (Expires: {expires_at})" - ) - else: - # Form was submitted but validation failed - for field, errors in password_form.errors.items(): - for error in errors: - current_app.logger.warning( - f"Admin form validation error ({field}): {error}" - ) - return redirect(url_for("main.admin")) - - return render_template( - "admin.html", - deck_form=deck_form, - password_form=password_form, - decks=decks, - passwords=get_passwords(active_course), - courses=get_all_courses(), - active_course_name=active_course.name, - ) - - -@main.route("/admin/switch_course/", methods=["POST"]) -@flask_login.login_required -def switch_course_admin(new_course: int): - user = flask_login.current_user - if get_course(new_course): - user.active_course_id = new_course - current_app.logger.info(f"Admin {user.id} switched to course ID {new_course}") - return redirect(url_for("main.admin")) - - -@main.route("/admin/banner", methods=["POST", "GET"]) -@flask_login.login_required -def banner(): - banner_form = InfoBannerForm() - if banner_form.validate_on_submit(): - assert isinstance(banner_form.content.data, str) - content: str = banner_form.content.data - banner = InfoBanner(content=content) - db.session.add(banner) - db.session.commit() - current_app.logger.info(f"Admin added banner: {content[:30]}...") - return render_template( - "banner.html", - banner_form=banner_form, - banners=db.session.execute(db.select(InfoBanner)).scalars(), - ) - - -@main.route("/admin/decks//delete", methods=["POST"]) -@flask_login.login_required -def delete_deck(deck_id: int): - deck = Deck.query.get_or_404(deck_id) - current_app.logger.info(f"Admin deleted deck: {deck.name}") - db.session.delete(deck) - db.session.commit() - return redirect(url_for("main.admin")) - - -@main.route("/admin/passwords//delete", methods=["POST"]) -@flask_login.login_required -def delete_password(password_id: int): - password = Password.query.get_or_404(password_id) - current_app.logger.info(f"Admin deleted password ID {password_id}") - db.session.delete(password) - db.session.commit() - return redirect(url_for("main.admin")) - - -@main.route("/admin/banner/show/", methods=["POST"]) -@flask_login.login_required -def set_active_banner(banner_id): - banner = InfoBanner.query.get(banner_id) - if banner: - # ensures that only one banner is active at all times - active_banner = get_active_banner() - if active_banner: - active_banner.is_active = False - banner.is_active = True - db.session.commit() - current_app.logger.info(f"Admin activated banner ID {banner_id}") - return redirect(url_for("main.banner")) - - -@main.route("/admin/banner/hide", methods=["POST"]) -@flask_login.login_required -def hide_active_banner(): - banner = get_active_banner() - if banner: - banner.is_active = False - db.session.commit() - current_app.logger.info("Admin hid active banner") - return redirect(url_for("main.banner")) - - -@main.route("/admin/confused") -@flask_login.login_required -def confused_notifications(): - return render_template("confused.html") diff --git a/app/templates/admin.html b/app/templates/admin.html index 046add0..a52663d 100644 --- a/app/templates/admin.html +++ b/app/templates/admin.html @@ -44,13 +44,13 @@ @@ -114,7 +114,7 @@ {% endif %} -
+
@@ -187,7 +187,7 @@ {% endif %} -
+
diff --git a/app/templates/banner.html b/app/templates/banner.html index d20c373..4f81567 100644 --- a/app/templates/banner.html +++ b/app/templates/banner.html @@ -21,20 +21,20 @@