finished backend upgrades to allow multiple courses

This commit is contained in:
2025-10-19 13:50:46 +02:00
parent 02fc60151f
commit 01f5e8ab08
8 changed files with 218 additions and 118 deletions

View File

@@ -3,6 +3,7 @@ from flask_socketio import SocketIO
from .models import db
from .utils import init_password_generator, init_serializer
from .auth import init_auth
from .seed_db import seed_courses
socketio = SocketIO(async_mode='eventlet', cors_allowed_origins="*")
@@ -13,11 +14,13 @@ def create_app():
with app.app_context():
db.create_all()
seed_courses()
init_password_generator()
init_serializer(app)
init_auth(app)
socketio.init_app(app)
from .routes import main

View File

@@ -11,13 +11,12 @@ def init_auth(app: Flask):
login_manager.init_app(app)
login_manager.login_view = "main.admin_login" # type: ignore
global users
users = {"admin": User("admin", app.config["ADMIN_PASSWORD"])}
users = {"admin": User("admin", app.config["ADMIN_PASSWORD"], app.config["DEFAULT_COURSE"])}
class User(flask_login.UserMixin):
def __init__(self, id, password):
def __init__(self, id, password, active_course_id):
self.id = id
self.password = password
self.active_course_id = active_course_id
@login_manager.user_loader
def user_loader(id: str):
@@ -25,4 +24,3 @@ def user_loader(id: str):
def check_password(user: User, input: str):
return user.password == input

View File

@@ -5,7 +5,7 @@ from typing import List, Optional
import pytz
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import Boolean, Column, DateTime, String, TypeDecorator, ForeignKey
from sqlalchemy import Boolean, Column, DateTime, String, TypeDecorator, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
db : SQLAlchemy = SQLAlchemy()
@@ -61,10 +61,15 @@ class Password(db.Model):
class Deck(db.Model):
__tablename__ = "deck"
__table_args__ = (
UniqueConstraint('course_id', 'deck', name='_course_deck_uc'),
)
id : Mapped[int] = mapped_column(primary_key=True)
name : Mapped[str] = mapped_column(String(60), unique=True, nullable=False)
deck : Mapped[str] = mapped_column(String(60), unique=True, nullable=False)
deck : Mapped[str] = mapped_column(String(60), nullable=False)
description : Mapped[str] = mapped_column(String(60))
index_file: Mapped[str] = mapped_column(String(40), nullable=False)
passwords : Mapped[List[Password]] = relationship("Password", secondary=deck_password, back_populates='decks')
tokens : Mapped[List[Token]] = relationship("Token", secondary=deck_token, back_populates='decks')
@@ -72,14 +77,15 @@ class Deck(db.Model):
course: Mapped['Course'] = relationship("Course", back_populates='decks')
def __init__(self, name: str, deck: str, course: Course, description: str = "") -> None:
def __init__(self, name: str, deck: str, course: Course, description: str = "", index_file: str = "index.html") -> None:
self.name = name
self.deck = deck
self.description = description
self.course = course
self.index_file = index_file
def __repr__(self) -> str:
return f"<Deck(name={self.name}, deck={self.deck}, course={self.course})>"
return f"<Deck(name={self.name}, deck={self.deck}, course={self.course}, index={self.index_file})>"
class Token(db.Model):
__tablename__ = "token"
@@ -101,11 +107,13 @@ class Token(db.Model):
class Course(db.Model):
__tablename__ = "course"
id : Mapped[int] = mapped_column(primary_key=True)
folder: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
name: Mapped[str] = mapped_column(String(60), unique=True, nullable=False)
decks: Mapped[List['Deck']] = relationship(back_populates='course')
def __init__(self, name: str) -> None:
def __init__(self, name: str, folder: str) -> None:
self.name = name
self.folder = folder
def __repr__(self) -> str:
return f"<Course(id={self.id}, name={self.name})>"

View File

@@ -9,6 +9,7 @@ from flask_socketio import join_room
import pytz
from flask import (
Blueprint,
Response,
abort,
current_app,
flash,
@@ -24,13 +25,14 @@ from . import socketio
from .auth import check_password, user_loader
from .forms import DeckForm, InfoBannerForm, LoginForm, PasswordForm
from .models import Deck, InfoBanner, Password, db
from .models import Course, Deck, InfoBanner, 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,
is_deck_unlocked,
@@ -43,7 +45,7 @@ from .utils import (
main = Blueprint('main', __name__)
slidev_deck_pattern = re.compile(r"^\/slides\/[^\/]+\/\d*(?:\?.*)?$")
slidev_all_pattern = re.compile(r"^\/slides\/[^\/]+(?:\/presenter)?\/\d+(?:\?.*)?$")
slidev_all_pattern = re.compile(r"^\/slides\/([^\/]+)\/([^\/]+)(?:\/presenter)?\/(\d+)(?:\?.*)?$")
@socketio.on("connect")
def on_connect():
@@ -100,7 +102,7 @@ def index():
if not token:
return redirect(url_for("main.set_token"))
else:
return render_template('index.html', active_banner=get_active_banner(), decks=get_unlocked_decks(token), user_name=token.name, active_course_id=token.active_course_id, courses=get_all_courses())
return render_template('index.html', active_banner=get_active_banner(), decks=get_unlocked_decks(token), user_name=token.name, active_course=token.active_course, courses=get_all_courses())
@main.route('/set_token', methods=['POST', 'GET'])
def set_token():
@@ -190,6 +192,7 @@ def switch_course(new_course: int):
return redirect(url_for("main.index"))
@main.route('/admin/login', methods=['POST', 'GET'])
def admin_login():
form = LoginForm()
@@ -214,27 +217,34 @@ def admin_login():
return render_template("admin_login.html", form=form)
def process_access_code(access_code: str):
password = db.session.execute(
db.select(Password)
.where(Password.value == access_code)
).scalar_one_or_none()
if password is not None:
if not password.is_expired():
update_token(password.decks)
else:
flash('Password has expired. Please obtain a new one.', 'expired')
else:
flash('Password is not valid. Please check the code and try again.', 'error')
return redirect(url_for('main.index'))
@main.route('/access', methods=['POST'])
def access():
password = db.session.execute(db.select(Password).where(Password.value == request.form.get('password'))).scalar()
assert isinstance(password, Password|None)
token = get_token()
if password is not None and not password.is_expired():
update_token(password.decks)
return redirect(url_for('main.index'))
return render_template('index.html', error="Invalid password.", decks=get_unlocked_decks(token))
def access_post():
access_code = request.form.get('password')
if not access_code:
return redirect(url_for("main.index"))
return process_access_code(access_code)
# Route for direct link/QR code access (GET)
@main.route('/access/<access_code>', methods=['GET'])
def access_link(access_code: str):
password = db.session.execute(db.select(Password).where(Password.value == access_code)).scalar()
assert isinstance(password, Password|None)
token = get_token()
if password is not None and not password.is_expired():
update_token(password.decks)
return redirect(url_for('main.index'))
return render_template('index.html', error="Invalid password.", decks=get_unlocked_decks(token))
def access_get(access_code: str):
return process_access_code(access_code)
@main.route('/confused', methods=['POST'])
@@ -248,25 +258,29 @@ def confused():
)
return ("", 204)
@main.route('/slides/<deck>/')
@main.route('/slides/<deck>/<path:path>')
def serve_deck(deck, path='index.html'):
if not db.session.execute(db.select(Deck).where(Deck.deck==deck)).scalar():
@main.route('/slides/<course_folder>/<deck>/')
@main.route('/slides/<course_folder>/<deck>/<path:path>')
def serve_deck(deck, course_folder, path='index.html'):
course = db.session.execute(db.select(Course).where(Course.folder==course_folder)).scalar()
if not course:
abort(404)
assert isinstance(course, Course)
deck = db.session.execute(db.select(Deck).where(Deck.deck==deck and Deck.course_id == course.id)).scalar()
if not deck:
abort(404)
assert isinstance(deck, Deck)
if not is_deck_unlocked(deck):
if not is_deck_unlocked(deck.deck):
return redirect(url_for('main.index'))
slides_dir = current_app.config['SLIDES_DIR']
deck_path = os.path.join(slides_dir, deck)
slides_dir = os.path.join(current_app.config['SLIDES_DIR'], course.folder)
deck_path = os.path.join(slides_dir, deck.deck)
if not os.path.exists(deck_path):
return f"Deck '{deck}' not found.", 404
if slidev_all_pattern.match(request.path):
return send_from_directory(deck_path, 'index.html')
if deck in ["tut-08","tut-11"] and path == 'index.html':
return send_from_directory(deck_path, 'slides.pdf')
return send_from_directory(deck_path, deck.index_file)
requested_file = os.path.join(deck_path, path)
if os.path.isfile(requested_file):
@@ -277,13 +291,16 @@ def serve_deck(deck, path='index.html'):
@main.route('/admin', methods=['GET', 'POST'])
@flask_login.login_required
def admin():
active_course_id = flask_login.current_user.active_course_id
active_course = get_course(active_course_id)
if active_course is None:
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(current_app)
decks = db.session.scalars(db.select(Deck).order_by(Deck.name)).all()
passwords = db.session.scalars(db.select(Password)).all()
deck_form.deck.choices = get_available_decks(current_app, active_course)
decks = active_course.decks
password_form.decks.choices = [(deck.id, deck.name) for deck in decks]
@@ -292,6 +309,7 @@ def admin():
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)
@@ -320,7 +338,15 @@ def admin():
print(f"Error in {getattr(password_form, field).label.text}: {error}", flush=True)
return redirect(url_for('main.admin'))
return render_template("admin.html", deck_form=deck_form, password_form=password_form, decks=decks, passwords=passwords)
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_id=active_course_id)
@main.route('/admin/switch_course/<new_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
return redirect(url_for("main.admin"))
@main.route('/admin/banner', methods=['POST', 'GET'])
@flask_login.login_required
@@ -340,7 +366,6 @@ def delete_deck(deck_id: int):
deck = Deck.query.get_or_404(deck_id)
db.session.delete(deck)
db.session.commit()
flash(f'Deck "{deck.name}" deleted.', 'success')
return redirect(url_for('main.admin'))
@main.route('/admin/passwords/<int:password_id>/delete', methods=['POST'])
@@ -349,7 +374,6 @@ def delete_password(password_id: int):
password = Password.query.get_or_404(password_id)
db.session.delete(password)
db.session.commit()
flash(f'Password "{password.value}" deleted.', 'success')
return redirect(url_for('main.admin'))
@main.route('/admin/banner/show/<int:banner_id>', methods=['POST'])

26
app/seed_db.py Normal file
View File

@@ -0,0 +1,26 @@
# Assuming db and models (Course) are initialized/imported
from .models import db, Course
def seed_courses():
course_data = [
{'id': 1, 'folder': 'grnvs25', 'name': 'GRNVS Tutorium SS 25'},
{'id': 2, 'folder': 'itsec2526', 'name': 'IT-Sec Tutorium WS 25/26'},
]
for data in course_data:
# Check if the course already exists by ID
existing_course = db.session.get(Course, data['id'])
if existing_course is None:
# Create a new Course object if it doesn't exist
new_course = Course(name=data['name'], folder=data['folder'])
# Since you're specifying the ID, you need to set it manually
# if it's not an autoincrement sequence (SQLite handles this fine)
new_course.id = data['id']
db.session.add(new_course)
# If it exists, you might want to update the name:
elif existing_course.name != data['name']:
existing_course.name = data['name']
db.session.commit()

View File

@@ -34,26 +34,53 @@
</head>
<body class="bg-background text-text min-h-screen font-sans">
<nav class="bg-white border-gray-200 dark:bg-gray-900">
<div class="max-w-screen-xl flex flex-wrap items-center x-auto p-4">
<a href="{{url_for('main.index')}}" class="flex items-center space-x-3 rtl:space-x-reverse">
<div class="max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4">
<div class="flex items-center space-x-6 rtl:space-x-reverse">
<a href="{{ url_for('main.index') }}" class="flex items-center">
<span class="self-center text-2xl font-semibold whitespace-nowrap dark:text-white">Tutortool</span>
</a>
<div class="relative group">
<button class="flex items-center text-gray-900 dark:text-white hover:text-blue-700 dark:hover:text-blue-500 font-medium py-2 px-3 rounded-lg bg-transparent">
{% for course in courses %}
{% if course.id == active_course_id %}
{{ course.name }}
{% endif %}
{% endfor %}
<svg class="w-2.5 h-2.5 ms-2.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4"/>
</svg>
</button>
<div class="absolute z-10 hidden group-hover:block w-44 bg-white divide-y divide-gray-100 rounded-lg shadow dark:bg-gray-700">
<ul class="py-2 text-sm text-gray-700 dark:text-gray-200">
{% for course in courses %}
<li>
<form method="POST" action="/admin/switch_course/{{ course.id }}">
<button type="submit">{{ course.name }}</button>
</form>
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
<div class="w-auto" id="navbar-default">
<ul class="font-medium p-0 py-2 border border-gray-100 rounded-lg bg-gray-50 flex flex-row space-x-8 rtl:space-x-reverse mt-0 border-0 bg-white dark:bg-gray-800 dark:bg-gray-900 dark:border-gray-700 justify-between">
<ul class="flex flex-row space-x-8 rtl:space-x-reverse font-medium p-0 border-0 bg-white dark:bg-gray-900">
<li>
<a href="{{url_for('main.admin')}}" class="rounded-sm bg-transparent text-blue-700 p-0 dark:text-blue-500" aria-current="page">Deck Admin</a>
<a href="{{ url_for('main.admin') }}" class="rounded-sm text-blue-700 dark:text-blue-500 font-semibold" aria-current="page">Deck Admin</a>
</li>
<li>
<a href="{{url_for('main.banner')}}" class="text-grey-900 dark:hover:text-blue-500 hover:text-blue-700 rounded-sm bg-transparent dark:text-white">Banner</a>
<a href="{{ url_for('main.banner') }}" class="text-gray-900 dark:text-white hover:text-blue-700 dark:hover:text-blue-500">Banner</a>
</li>
<li>
<a href="{{url_for('main.confused_notifications')}}" class="text-grey-900 dark:hover:text-blue-500 hover:text-blue-700 rounded-sm bg-transparent p-0 dark:text-white">Confused</a>
<a href="{{ url_for('main.confused_notifications') }}" class="text-gray-900 dark:text-white hover:text-blue-700 dark:hover:text-blue-500">Confused</a>
</li>
</ul>
</div>
</div>
</nav>
<div class="container mx-auto px-6 py-10 space-y-12">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-10">

View File

@@ -33,6 +33,8 @@
<!-- in base.html, in <head> or just before </body> -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.1/socket.io.min.js"></script>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head>
@@ -41,35 +43,10 @@
<!-- Header -->
<header class="bg-surface border-b border-border shadow-sm">
<div
class="w-full px-6 py-4
flex items-center justify-between"
>
<div class="relative z-20">
<button id="menu-toggle" class="text-white hover:text-gray-300 focus:outline-none p-2 rounded-md">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
<div class="w-full px-6 py-4 flex items-center justify-between" >
<div class="hamburger-menu">
<button class="menu-toggle" aria-controls="course-selector">Menu</button>
<select id="course-selector" name="course_selection" onchange="this.form.submit()">
<option value="" disabled selected>Select a Course</option>
{% for course in courses %}
<option value="{{ course.id }}">
{{ course.display_name }}
</option>
{% endfor %}
</select>
</div>
<div class="flex items-center space-x-1 relative z-10">
<span class="text-white text-base font-medium whitespace-nowrap py-1">
<div class="flex items-center space-x-3">
<span class="text-gray-700 dark:text-gray-200 text-base font-medium whitespace-nowrap">
User: {{ user_name }}
</span>
@@ -82,24 +59,18 @@
</button>
</form>
<a href="/admin" class="ml-4">
</div>
<div class="relative z-10">
<a href="/admin">
<button class="bg-primary hover:bg-blue-700 text-white px-3 py-1 rounded-md transition">
Admin
</button>
</a>
</div>
</div>
</header>
<script>
document.getElementById('menu-toggle').addEventListener('click', function() {
document.getElementById('course-menu').classList.toggle('hidden');
});
</script> <!-- Main Content -->
</header> <!-- Main Content -->
<main class="max-w-5xl mx-auto px-6 py-10 space-y-12">
<h3 class="text-3xl font-bold text-center mb-8">{{course_display_name}}</h3>
{% if active_banner %}
<section class="flex justify-center">
@@ -109,6 +80,33 @@
</section>
{% endif %}
<div class="flex justify-center">
<div class="relative group">
<button class="flex items-center text-gray-900 dark:text-white hover:text-blue-700 dark:hover:text-blue-500 font-medium py-1 px-3 rounded-lg bg-transparent text-2xl">
{% for course in courses %}
{% if course.id == active_course.id %}
{{ course.name }}
{% endif %}
{% endfor %}
<svg class="w-2.5 h-2.5 ms-2.5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 10 6">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4"/>
</svg>
</button>
<div class="absolute hidden group-hover:block bg-white divide-y divide-gray-100 rounded-lg shadow dark:bg-gray-700 z-50">
<ul class="py-2 px-4 text-sm text-gray-700 dark:text-gray-200">
{% for course in courses %}
<li>
<form method="POST" action="/switch_course/{{ course.id }}">
<button type="submit"> {{ course.name }}</button>
</form>
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
<!-- Password Form -->
<section class="flex justify-center">
<div class="w-full max-w-md">
@@ -130,9 +128,21 @@
Unlock
</button>
</form>
{% if error %}
<p class="text-red-500 text-center mt-3">{{ error }}</p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flashed-messages">
{% for category, message in messages %}
{% if category == 'error' %}
<p class="text-red-500 text-center mt-3">{{ message }}</p>
{% elif category == 'expired' %}
<p class="text-orange-500 text-center mt-3">{{ message }}</p>
{% else %}
<p class="text-gray-500 text-center mt-3">{{ message }}</p>
{% endif %}
{% endfor %}
</div>
{% endif %}
{% endwith %}
</div>
</div>
</section>
@@ -142,7 +152,7 @@
<section>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{% for deck in decks %}
<a href="/slides/{{ deck.deck }}/" class="block bg-surface border border-border rounded-xl p-5 shadow-sm hover:border-primary hover:bg-background transition">
<a href="/slides/{{deck.course.folder}}/{{ deck.deck }}/{{ deck.index_file}}" class="block bg-surface border border-border rounded-xl p-5 shadow-sm hover:border-primary hover:bg-background transition">
<h3 class="text-lg font-semibold mb-1">{{ deck.name }}</h3>
<p class="text-muted text-sm">{{ deck.description }}</p>
</a>

View File

@@ -19,8 +19,8 @@ def init_serializer(app: Flask):
def get_active_banner() -> InfoBanner | None:
return db.session.execute(db.select(InfoBanner).where(InfoBanner.is_active)).scalar_one_or_none()
def get_available_decks(app: Flask):
slides_dir = app.config['SLIDES_DIR']
def get_available_decks(app: Flask, course: Course):
slides_dir = os.path.join(app.config['SLIDES_DIR'], course.folder)
deck_names = [name for name in os.listdir(slides_dir) if os.path.isdir(os.path.join(slides_dir, name))]
deck_names.sort()
return deck_names
@@ -31,6 +31,9 @@ def generate_username() -> str:
def generate_password() -> str:
return cast(str, xkcd_password.generate_xkcdpassword(wordlist=words, numwords=2, delimiter="-"))
def get_passwords(course: Course):
return [password for deck in course.decks for password in deck.passwords]
def generate_token() -> Token:
max_tries = 100
for _ in range(max_tries):
@@ -68,12 +71,13 @@ def retrieve_token(username : str):
def get_unlocked_decks(token: (Token|None)):
if token is None:
return []
decks = token.decks
decks = [deck for deck in token.decks if deck.course_id == token.active_course_id]
decks.sort(key=lambda deck: deck.deck)
return decks
def is_deck_unlocked(deck: str):
token = get_token()
print(token)
if token:
return token.is_unlocked(deck)
else: