diff --git a/app/__init__.py b/app/__init__.py index f832b58..ac7893a 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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 diff --git a/app/auth.py b/app/auth.py index 350422c..850811b 100644 --- a/app/auth.py +++ b/app/auth.py @@ -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 - diff --git a/app/models.py b/app/models.py index 3ff3ad9..fb42f7b 100644 --- a/app/models.py +++ b/app/models.py @@ -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"" + return f"" 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"" diff --git a/app/routes.py b/app/routes.py index 1b0a0a7..ae156c5 100644 --- a/app/routes.py +++ b/app/routes.py @@ -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/', 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//') -@main.route('/slides//') -def serve_deck(deck, path='index.html'): - if not db.session.execute(db.select(Deck).where(Deck.deck==deck)).scalar(): +@main.route('/slides///') +@main.route('/slides///') +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/', 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//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/', methods=['POST']) diff --git a/app/seed_db.py b/app/seed_db.py new file mode 100644 index 0000000..e93af80 --- /dev/null +++ b/app/seed_db.py @@ -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() diff --git a/app/templates/admin.html b/app/templates/admin.html index 7af47e1..9952fec 100644 --- a/app/templates/admin.html +++ b/app/templates/admin.html @@ -34,26 +34,53 @@ - +
diff --git a/app/templates/index.html b/app/templates/index.html index 79a0427..f4767d4 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -32,6 +32,8 @@ + + @@ -41,66 +43,35 @@
-
-
- +
-
- +
+ + User: {{ user_name }} + - -
- - -
- - User: {{ user_name }} - +
+ +
-
- -
- - - - +
+
-
- - +
-

{{course_display_name}}

- - + {% if active_banner %}
{% endif %} +
+
+ + + +
+
+
@@ -130,9 +128,21 @@ Unlock - {% if error %} -

{{ error }}

- {% endif %} + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} + {% if category == 'error' %} +

{{ message }}

+ {% elif category == 'expired' %} +

{{ message }}

+ {% else %} +

{{ message }}

+ {% endif %} + {% endfor %} +
+ {% endif %} + {% endwith %}
@@ -142,7 +152,7 @@
{% for deck in decks %} - +

{{ deck.name }}

{{ deck.description }}

diff --git a/app/utils.py b/app/utils.py index abc94a6..a266c9c 100644 --- a/app/utils.py +++ b/app/utils.py @@ -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: