Files
tutor-tool/app/routes.py

424 lines
14 KiB
Python

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,
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 .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,
retrieve_token,
serialize_token,
update_token,
get_course
)
main = Blueprint('main', __name__)
slidev_deck_pattern = re.compile(r"^\/slides\/[^\/]+\/\d*(?:\?.*)?$")
slidev_all_pattern = re.compile(r"^\/slides\/([^\/]+)\/([^\/]+)(?:\/presenter)?\/(\d+)(?:\?.*)?$")
@socketio.on("connect")
def on_connect():
if flask_login.current_user.is_authenticated:
join_room("instructor")
@main.after_request
def inject_confused_button(response):
if True:
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('</body>')
if insert_at != -1:
button_html = """
<button id="confused-button" style="
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
z-index: 9999;
background-color: #4f8cff;
color: white;
font-size: 1rem;
font-weight: bold;
border: none;
border-radius: 9999px;
padding: 1rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: background-color 0.3s, transform 0.2s;"
title="Didn't understand? Click!">
Ich habe das nicht verstanden
</button>
<script>
document.getElementById('confused-button').addEventListener('click', () => {
fetch('/confused', {method: 'POST'})
.then(response => {
if (response.ok) {
alert('Alles klar! Ich versuche es besser zu erklären. Melde dich, wenn du eine spezifische Frage hast oder noch etwas unklar bleibt.');
}
}).catch(error => console.error('Error:', error));
});
</script>
"""
body = body[:insert_at] + button_html + body[insert_at:]
response.set_data(body)
return response
@main.route('/')
def index():
token = get_token()
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_name=token.active_course.name, courses=get_all_courses())
def set_cookie(token):
next_url = session.pop('next_url', None)
if not next_url:
next_url = url_for("main.index")
resp = make_response(redirect(next_url))
resp.set_cookie(
'access_token',
serialize_token(token),
httponly=True,
samesite='Lax',
path='/'
)
return resp
@main.route('/set_token', methods=['POST', 'GET'])
def set_token():
"""
Handles.
GET: Renders the registration form.
POST: Processes the form submission.
"""
if request.method == 'POST':
# Get form data
user_name = request.form.get('username')
# Simple validation
if not user_name:
flash('Username is required.', 'error')
return render_template('set_token.html')
token = retrieve_token(user_name)
if not token:
flash(f'The username "{user_name}" does not exist.', 'error')
return render_template('set_token.html')
print(f"Set token {session}")
return set_cookie(token)
return render_template('set_token.html')
@main.route('/register', methods=['POST', 'GET'])
def register():
"""
Handles user registration.
GET: Renders the registration form.
POST: Processes the form submission.
"""
if request.method == 'POST':
# Get form data
user_name = request.form.get('username')
acknowledge = request.form.get('acknowledge')
# Simple validation
if not user_name:
flash('Username is required.', 'error')
return render_template('register.html')
if retrieve_token(user_name):
flash(f'The username "{user_name}" is already taken.', 'error')
return render_template('register.html')
if not acknowledge:
flash('You must acknowledge the important information to proceed.', 'error')
return render_template('register.html')
# If all validation passes, "register" the user
token = create_token(user_name)
print(f"User registered: {user_name}")
return set_cookie(token)
# If GET request, render the form
return render_template('register.html')
@main.route('/logout', methods=['POST'])
def logout():
resp = make_response(redirect(url_for('main.index'))) # Redirect to a safe page, e.g., index or login
resp.set_cookie('access_token', '', expires=0) # Clears the cookie
return resp
@main.route('/switch_course/<new_course>', methods=['POST'])
def switch_course(new_course: int):
token = get_token()
if not token:
return redirect(url_for('main.set_token'))
switched = get_course(new_course)
if switched:
token.active_course = switched
db.session.commit()
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)
next = request.args.get('next')
if next is None:
return redirect(url_for('main.index'))
if not next.startswith('/admin'):
return abort(400)
return redirect(next)
else:
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 = get_token()
print(f"Process {token}")
if not token:
# remember where the user wanted to go
session['next_url'] = url_for('main.access_get', access_code=access_code)
print(f"Process {session}")
return redirect(url_for('main.set_token'))
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_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_get(access_code: str):
return process_access_code(access_code)
@main.route('/confused', methods=['POST'])
def confused():
socketio.emit(
"confused",
{
"time": datetime.utcnow().isoformat()
},
to="instructor"
)
return ("", 204)
@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.deck):
return redirect(url_for('main.index'))
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, deck.index_file)
requested_file = os.path.join(deck_path, path)
if os.path.isfile(requested_file):
return send_from_directory(deck_path, path)
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:
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, 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()
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()
else:
# Form was submitted but validation failed
for field, errors in password_form.errors.items():
for error in errors:
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=get_passwords(active_course), courses=get_all_courses(), active_course_name=active_course.name)
@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
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()
return render_template("banner.html", banner_form=banner_form, banners=db.session.execute(db.select(InfoBanner)).scalars())
@main.route('/admin/decks/<int:deck_id>/delete', methods=['POST'])
@flask_login.login_required
def delete_deck(deck_id: int):
deck = Deck.query.get_or_404(deck_id)
db.session.delete(deck)
db.session.commit()
return redirect(url_for('main.admin'))
@main.route('/admin/passwords/<int:password_id>/delete', methods=['POST'])
@flask_login.login_required
def delete_password(password_id: int):
password = Password.query.get_or_404(password_id)
db.session.delete(password)
db.session.commit()
return redirect(url_for('main.admin'))
@main.route('/admin/banner/show/<int:banner_id>', 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()
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()
return redirect(url_for('main.banner'))
@main.route('/admin/confused')
@flask_login.login_required
def confused_notifications():
return render_template("confused.html")