316 lines
11 KiB
Python
316 lines
11 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,
|
|
jsonify,
|
|
make_response,
|
|
redirect,
|
|
render_template,
|
|
request,
|
|
send_from_directory,
|
|
url_for,
|
|
)
|
|
|
|
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 .utils import (
|
|
generate_password,
|
|
generate_token,
|
|
get_active_banner,
|
|
get_available_decks,
|
|
get_token,
|
|
get_unlocked_decks,
|
|
is_deck_unlocked,
|
|
retrieve_token,
|
|
serialize_token,
|
|
update_token,
|
|
)
|
|
|
|
|
|
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.before_request
|
|
def ensure_cookie():
|
|
token = get_token()
|
|
if token:
|
|
return
|
|
else:
|
|
token = generate_token()
|
|
|
|
resp = make_response(redirect(request.url))
|
|
resp.set_cookie('access_token', serialize_token(token), httponly=True)
|
|
return resp
|
|
|
|
@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:
|
|
abort(500)
|
|
else:
|
|
return render_template('index.html', active_banner=get_active_banner(), decks=get_unlocked_decks(token), user_name=token.name)
|
|
|
|
|
|
@main.route('/login', methods=['POST', 'GET'])
|
|
def 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)
|
|
flash('Login successful!', 'success')
|
|
|
|
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("login.html", form=form)
|
|
|
|
|
|
@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('/')
|
|
return render_template('index.html', error="Invalid password.", decks=get_unlocked_decks(token))
|
|
|
|
|
|
@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('/')
|
|
return render_template('index.html', error="Invalid password.", decks=get_unlocked_decks(token))
|
|
|
|
|
|
|
|
@main.route('/confused', methods=['POST'])
|
|
def confused():
|
|
|
|
socketio.emit(
|
|
"confused",
|
|
{
|
|
"time": datetime.utcnow().isoformat()
|
|
},
|
|
to="instructor"
|
|
)
|
|
return ("", 204)
|
|
|
|
|
|
@main.route('/retrieve-token', methods=['POST'])
|
|
def set_username_token():
|
|
new_username : str = request.form.get("user_name", "").strip()
|
|
resp = make_response(redirect('/'))
|
|
if new_username:
|
|
token = retrieve_token(new_username)
|
|
if token:
|
|
resp.set_cookie('access_token', serialize_token(token), httponly=True)
|
|
return resp
|
|
|
|
|
|
@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():
|
|
abort(404)
|
|
|
|
if not is_deck_unlocked(deck):
|
|
return redirect('/')
|
|
|
|
slides_dir = current_app.config['SLIDES_DIR']
|
|
deck_path = os.path.join(slides_dir, 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')
|
|
|
|
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():
|
|
deck_form = DeckForm()
|
|
password_form = PasswordForm()
|
|
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()
|
|
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,
|
|
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=passwords)
|
|
|
|
@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()
|
|
flash(f'Deck "{deck.name}" deleted.', 'success')
|
|
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()
|
|
flash(f'Password "{password.value}" deleted.', 'success')
|
|
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")
|