diff --git a/app/forms.py b/app/forms.py index 2e2364b..98624aa 100644 --- a/app/forms.py +++ b/app/forms.py @@ -19,3 +19,8 @@ class PasswordForm(FlaskForm): class LoginForm(FlaskForm): user_name = StringField("Username", validators=[DataRequired()]) password = PasswordField("Password", validators=[DataRequired()]) + + +class InfoBannerForm(FlaskForm): + content = StringField("Content", validators=[DataRequired()]) + diff --git a/app/models.py b/app/models.py index 57a7503..698f4dc 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 Column, DateTime, String, TypeDecorator +from sqlalchemy import Boolean, Column, DateTime, String, TypeDecorator from sqlalchemy.orm import Mapped, mapped_column, relationship db : SQLAlchemy = SQLAlchemy() @@ -45,7 +45,7 @@ class Password(db.Model): value : Mapped[str] = mapped_column(String(60)) expires_at: Mapped[Optional[datetime]] = mapped_column(UTCDateTime()) # Optional expiration - decks : Mapped[List[Deck]] = relationship("Deck", secondary=deck_password, back_populates='passwords') + decks : Mapped[List[Deck]] = relationship("Deck", secondary=deck_password, back_populates='passwords', cascade="all") def __init__(self, value: str, expires_at: datetime|None) -> None: self.value = value @@ -67,8 +67,8 @@ class Deck(db.Model): deck : Mapped[str] = mapped_column(String(60), unique=True, nullable=False) description : Mapped[str] = mapped_column(String(60)) - passwords : Mapped[List[Password]] = relationship("Password", secondary=deck_password, back_populates='decks') - tokens : Mapped[List[Token]] = relationship("Token", secondary=deck_token, back_populates='decks') + passwords : Mapped[List[Password]] = relationship("Password", secondary=deck_password, back_populates='decks', cascade="all") + tokens : Mapped[List[Token]] = relationship("Token", secondary=deck_token, back_populates='decks', cascade="all") def __init__(self, name: str, deck: str, description: str = "") -> None: self.name = name @@ -82,7 +82,7 @@ class Token(db.Model): __tablename__ = "token" id : Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(String(60), unique=True, nullable=False) - decks : Mapped[List[Deck]]= relationship("Deck", secondary=deck_token, back_populates='tokens') + decks : Mapped[List[Deck]]= relationship("Deck", secondary=deck_token, back_populates='tokens', cascade="all") def __init__(self, name: str) -> None: self.name = name @@ -93,3 +93,13 @@ class Token(db.Model): def is_unlocked(self, deck: str) -> bool : return deck in [deck.deck for deck in self.decks] +class InfoBanner(db.Model): + __tablename__ = "info_banner" + id : Mapped[int] = mapped_column(primary_key=True) + content : Mapped[str] = mapped_column(String(280)) + is_active : Mapped[bool] = mapped_column(Boolean) + + def __init__(self, content : str) -> None: + self.content = content + self.is_active = False + diff --git a/app/routes.py b/app/routes.py index b434abd..f1c9a0c 100644 --- a/app/routes.py +++ b/app/routes.py @@ -1,13 +1,16 @@ -from datetime import datetime, timezone import os import re -from typing import cast, List +from datetime import datetime +from typing import List, cast +import flask_login +import pytz from flask import ( Blueprint, abort, current_app, flash, + jsonify, make_response, redirect, render_template, @@ -15,33 +18,76 @@ from flask import ( send_from_directory, url_for, ) -import flask_login -import pytz -from sqlalchemy.sql import expression from .auth import check_password, user_loader -from .forms import DeckForm, LoginForm, PasswordForm -from .models import Deck, Password, db +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, - serialize_token, is_deck_unlocked, + serialize_token, update_token, - generate_password, ) main = Blueprint('main', __name__) -slidev_slide_pattern = re.compile(r'\d+$') +slidev_deck_pattern = re.compile(r"^\/slides\/[^\/]+\/\d*(?:\?.*)?$") +slidev_all_pattern = re.compile(r"^\/slides\/[^\/]+(?:\/presenter)?\/\d+(?:\?.*)?$") + +@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'): + 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('/') def index(): token = get_token() if not token: token = generate_token() - resp = make_response(render_template('index.html', decks=get_unlocked_decks(token), user_name=token.name)) + resp = make_response(render_template('index.html', active_banner=get_active_banner(), decks=get_unlocked_decks(token), user_name=token.name)) resp.set_cookie('access_token', serialize_token(token), httponly=True) return resp @@ -81,6 +127,12 @@ def access(): return redirect('/') return render_template('index.html', error="Invalid password.", decks=get_unlocked_decks(token)) +@main.route('/confused', methods=['POST']) + +def confused(): + resp = jsonify(success=True) + return resp + @main.route('/slides//') @main.route('/slides//') @@ -96,7 +148,7 @@ def serve_deck(deck, path='index.html'): if not os.path.exists(deck_path): return f"Deck '{deck}' not found.", 404 - if slidev_slide_pattern.match(path): + if slidev_all_pattern.match(request.path): return send_from_directory(deck_path, 'index.html') requested_file = os.path.join(deck_path, path) @@ -152,6 +204,17 @@ def 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//delete', methods=['POST']) @flask_login.login_required @@ -170,3 +233,26 @@ def delete_password(password_id: int): db.session.commit() flash(f'Password "{password.value}" deleted.', 'success') 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() + 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')) + diff --git a/app/templates/banner.html b/app/templates/banner.html new file mode 100644 index 0000000..c98d34f --- /dev/null +++ b/app/templates/banner.html @@ -0,0 +1,100 @@ + + + + + Banner + + + + + + + + + + + + + + +
+
+
Info Banner
+
+
+ + +
+ + +
+
+
+

Enter new Banner

+
+ {{ banner_form.hidden_tag() }} +
+ + {{ banner_form.content(class="w-full rounded-lg p-3 bg-background border border-border text-text placeholder-muted focus:outline-none focus:ring-2 focus:ring-primary") }} +
+ + +
+ {% if error %} +

{{ error }}

+ {% endif %} +
+
+
+ + + {% if banners %} +
+
+ {% for banner in banners %} +
+

{{ banner.content }}

+ {% if banner.is_active %} +
+ +
+ {% else %} +
+ +
+ {% endif %} +
+ {% endfor %} +
+
+ {% else %} +

No banners created yet.

+ {% endif %} + +
+ + + + diff --git a/app/templates/index.html b/app/templates/index.html index 2b5a74c..f5b6217 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -48,6 +48,14 @@
+ + {% if active_banner %} +
+ +
+ {% endif %}
diff --git a/app/utils.py b/app/utils.py index fbae87e..1f0b97c 100644 --- a/app/utils.py +++ b/app/utils.py @@ -5,7 +5,7 @@ from typing import cast from itsdangerous import BadSignature, URLSafeSerializer from xkcdpass import xkcd_password -from .models import Deck, Token, db +from .models import Deck, InfoBanner, Token, db def init_password_generator(): wordfile = xkcd_password.locate_wordfile() @@ -16,6 +16,9 @@ def init_serializer(app: Flask): global serializer serializer = URLSafeSerializer(app.config['SECRET_KEY']) +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'] deck_names = [name for name in os.listdir(slides_dir) if os.path.isdir(os.path.join(slides_dir, name))]