majorly overhauled the design to now use docker-compose and postgres
This commit is contained in:
41
app/Dockerfile
Normal file
41
app/Dockerfile
Normal file
@@ -0,0 +1,41 @@
|
||||
# ==========================================
|
||||
# Stage 1: Build the CSS using Node.js
|
||||
# ==========================================
|
||||
FROM node:20-slim AS css-builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Copy package files and install dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
# Copy your source code (needed if your CSS framework scans HTML/JS for classes, like Tailwind)
|
||||
COPY . .
|
||||
|
||||
# Run your build script
|
||||
RUN npm run build:css
|
||||
|
||||
# ==========================================
|
||||
# Stage 2: Final Python Image
|
||||
# ==========================================
|
||||
FROM python:3.13-slim
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy the entire project from your host machine
|
||||
COPY . ./app/
|
||||
|
||||
# Overwrite the empty CSS directory with the compiled CSS from Stage 1
|
||||
# IMPORTANT: Change the paths below to match where your CSS actually gets generated!
|
||||
# Example: COPY --from=css-builder /build/static/css/style.css ./app/static/css/style.css
|
||||
COPY --from=css-builder /build/static/css/output.css ./app/static/css/output.css
|
||||
|
||||
# Expose Gunicorn port
|
||||
EXPOSE 5000
|
||||
|
||||
# Gunicorn command
|
||||
CMD ["gunicorn", "--preload", "-w", "4", "-b", "0.0.0.0:5000", "app:create_app()"]
|
||||
@@ -3,35 +3,37 @@ import logging
|
||||
from logging.handlers import WatchedFileHandler
|
||||
from flask import Flask
|
||||
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
|
||||
from app.models import db
|
||||
from app.utils import init_password_generator, init_serializer
|
||||
from app.auth import init_auth
|
||||
from app.seed_db import seed_courses
|
||||
from app.config import Config
|
||||
|
||||
socketio = SocketIO(async_mode='eventlet', cors_allowed_origins="*")
|
||||
|
||||
def create_app():
|
||||
def create_app(config_class=Config):
|
||||
# 1. Setup Log Directory
|
||||
log_dir = os.path.join(os.getcwd(), 'logs')
|
||||
if not os.path.exists(log_dir):
|
||||
os.makedirs(log_dir)
|
||||
|
||||
app = Flask(__name__, static_folder='static', static_url_path='/static')
|
||||
app.config.from_object('config.Config')
|
||||
app.config.from_object(config_class)
|
||||
|
||||
file_handler = WatchedFileHandler(log_dir + '/app.log', mode='a', encoding='UTF-8')
|
||||
file_handler.setFormatter(logging.Formatter(
|
||||
'%(asctime)s %(levelname)s [%(module)s:%(lineno)d]: %(message)s'
|
||||
))
|
||||
file_handler.setLevel(logging.INFO)
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
app.logger.addHandler(file_handler)
|
||||
app.logger.setLevel(logging.INFO)
|
||||
app.logger.setLevel(logging.DEBUG)
|
||||
|
||||
db.init_app(app)
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
seed_courses()
|
||||
db.engine.dispose()
|
||||
|
||||
init_password_generator()
|
||||
init_serializer(app)
|
||||
|
||||
15
app/config.py
Normal file
15
app/config.py
Normal file
@@ -0,0 +1,15 @@
|
||||
import os
|
||||
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY")
|
||||
SLIDES_DIR = os.environ.get("SLIDES_DIR")
|
||||
EXPORTS_DIR = os.environ.get("EXPORTS_DIR")
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL")
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD")
|
||||
COURSE_MAP = {
|
||||
"GRNVS25": "GRNVS Tutorium SS 25",
|
||||
"IT-Sec25": "IT-Sec Tutorium WS 25/26",
|
||||
}
|
||||
DEFAULT_COURSE = 2
|
||||
1858
app/package-lock.json
generated
Normal file
1858
app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
app/package.json
Normal file
23
app/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "tutor",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"build:css": "npx tailwindcss -i static/css/input.css -o static/css/output.css --minify"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "ssh://git@gitea.cato447.de:222/cato447/tutor-tool.git"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"devDependencies": {
|
||||
"autoprefixer": "^10.4.21",
|
||||
"flowbite": "^3.1.2",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17"
|
||||
}
|
||||
}
|
||||
6
app/postcss.config.js
Normal file
6
app/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
83
app/requirements.txt
Normal file
83
app/requirements.txt
Normal file
@@ -0,0 +1,83 @@
|
||||
# This file was autogenerated by uv via the following command:
|
||||
# uv export --format requirements-txt -o requirements.txt --no-hashes
|
||||
bidict==0.23.1
|
||||
# via python-socketio
|
||||
blinker==1.9.0
|
||||
# via flask
|
||||
click==8.1.8 ; python_full_version < '3.10'
|
||||
# via flask
|
||||
click==8.2.1 ; python_full_version >= '3.10'
|
||||
# via flask
|
||||
colorama==0.4.6 ; sys_platform == 'win32'
|
||||
# via click
|
||||
dnspython==2.7.0 ; python_full_version < '3.10'
|
||||
# via eventlet
|
||||
dnspython==2.8.0 ; python_full_version >= '3.10'
|
||||
# via eventlet
|
||||
eventlet==0.40.3
|
||||
# via tutor-tool
|
||||
flask==3.1.2
|
||||
# via
|
||||
# flask-login
|
||||
# flask-socketio
|
||||
# flask-sqlalchemy
|
||||
# flask-wtf
|
||||
# tutor-tool
|
||||
flask-login==0.6.3
|
||||
# via tutor-tool
|
||||
flask-socketio==5.5.1
|
||||
# via tutor-tool
|
||||
flask-sqlalchemy==3.1.1
|
||||
# via tutor-tool
|
||||
flask-wtf==1.2.2
|
||||
# via tutor-tool
|
||||
greenlet==3.2.4
|
||||
# via
|
||||
# eventlet
|
||||
# sqlalchemy
|
||||
gunicorn==23.0.0
|
||||
# via tutor-tool
|
||||
h11==0.16.0
|
||||
# via wsproto
|
||||
importlib-metadata==8.7.0 ; python_full_version < '3.10'
|
||||
# via flask
|
||||
itsdangerous==2.2.0
|
||||
# via
|
||||
# flask
|
||||
# flask-wtf
|
||||
jinja2==3.1.6
|
||||
# via flask
|
||||
markupsafe==3.0.2
|
||||
# via
|
||||
# flask
|
||||
# jinja2
|
||||
# werkzeug
|
||||
# wtforms
|
||||
packaging==25.0
|
||||
# via gunicorn
|
||||
psycopg2-binary==2.9.11
|
||||
# via tutor-tool
|
||||
python-engineio==4.12.2
|
||||
# via python-socketio
|
||||
python-socketio==5.13.0
|
||||
# via flask-socketio
|
||||
pytz==2025.2
|
||||
# via tutor-tool
|
||||
simple-websocket==1.1.0
|
||||
# via python-engineio
|
||||
sqlalchemy==2.0.43
|
||||
# via flask-sqlalchemy
|
||||
typing-extensions==4.15.0
|
||||
# via sqlalchemy
|
||||
werkzeug==3.1.3
|
||||
# via
|
||||
# flask
|
||||
# flask-login
|
||||
wsproto==1.2.0
|
||||
# via simple-websocket
|
||||
wtforms==3.2.1
|
||||
# via flask-wtf
|
||||
xkcdpass==1.20.0
|
||||
# via tutor-tool
|
||||
zipp==3.23.0 ; python_full_version < '3.10'
|
||||
# via importlib-metadata
|
||||
419
app/routes.py
419
app/routes.py
@@ -10,7 +10,7 @@ import pytz
|
||||
from flask import (
|
||||
Blueprint,
|
||||
abort,
|
||||
current_app, # Used for logging
|
||||
current_app, # Used for logging
|
||||
flash,
|
||||
make_response,
|
||||
redirect,
|
||||
@@ -35,31 +35,44 @@ from .utils import (
|
||||
get_passwords,
|
||||
get_token,
|
||||
get_unlocked_decks,
|
||||
is_deck_unlocked,
|
||||
retrieve_token,
|
||||
serialize_token,
|
||||
update_token,
|
||||
get_course
|
||||
get_course,
|
||||
token_required,
|
||||
get_cached_deck_info,
|
||||
)
|
||||
|
||||
|
||||
main = Blueprint('main', __name__)
|
||||
slidev_deck_pattern = re.compile(r"^\/slides\/[^\/]+\/[^\/]+\/(?:\d+|index\.html)(?:\?.*)?$")
|
||||
slidev_all_pattern = re.compile(r"^\/slides\/([^\/]+)\/([^\/]+)(?:\/presenter)?\/(\d+)(?:\?.*)?$")
|
||||
main = Blueprint("main", __name__)
|
||||
slidev_deck_pattern = re.compile(
|
||||
r"^\/slides\/[^\/]+\/[^\/]+\/(?:\d+|index\.html)(?:\?.*)?$"
|
||||
)
|
||||
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")
|
||||
current_app.logger.info(f"Instructor connected to socket: {flask_login.current_user.id}")
|
||||
current_app.logger.info(
|
||||
f"Instructor connected to socket: {flask_login.current_user.id}"
|
||||
)
|
||||
|
||||
|
||||
@main.after_request
|
||||
def inject_confused_button(response):
|
||||
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 (
|
||||
(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>')
|
||||
insert_at = body.find("</body>")
|
||||
if insert_at != -1:
|
||||
button_html = """
|
||||
<button id="confused-button" style="
|
||||
@@ -96,129 +109,143 @@ def inject_confused_button(response):
|
||||
return response
|
||||
|
||||
|
||||
@main.route('/')
|
||||
def index():
|
||||
token = get_token()
|
||||
if not token:
|
||||
current_app.logger.debug("User has no token. Redirecting to set_token.")
|
||||
return redirect(url_for("main.set_token"))
|
||||
else:
|
||||
current_app.logger.debug(f"User {token.name} is viewing index with {len(token.decks)} decks unlocked.")
|
||||
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())
|
||||
@main.route("/")
|
||||
@token_required
|
||||
def index(token):
|
||||
current_app.logger.debug(
|
||||
f"User {token.name} is viewing index with {len(token.decks)} decks unlocked."
|
||||
)
|
||||
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)
|
||||
next_url = session.pop("next_url", None)
|
||||
if not next_url:
|
||||
current_app.logger.debug("next_url not present redirecting to index")
|
||||
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='/'
|
||||
)
|
||||
"access_token", serialize_token(token), httponly=True, samesite="Lax", path="/"
|
||||
)
|
||||
return resp
|
||||
|
||||
@main.route('/set_token', methods=['POST', 'GET'])
|
||||
|
||||
@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':
|
||||
if request.method == "POST":
|
||||
# Get form data
|
||||
username = request.form.get('username')
|
||||
username = request.form.get("username")
|
||||
|
||||
# Simple validation
|
||||
if not username:
|
||||
current_app.logger.debug("Login failed: No username supplied")
|
||||
flash('Username is required.', 'error')
|
||||
return render_template('set_token.html')
|
||||
flash("Username is required.", "error")
|
||||
return render_template("set_token.html")
|
||||
|
||||
if user_loader(username):
|
||||
current_app.logger.info(
|
||||
f"Admin username '{username}' entered in set_token. Redirecting to admin login."
|
||||
)
|
||||
return redirect(url_for("main.admin_login", prefill_user=username))
|
||||
|
||||
token = retrieve_token(username)
|
||||
|
||||
|
||||
if not token:
|
||||
flash(f'The username "{username}" does not exist.', 'error')
|
||||
current_app.logger.warning(f"Login failed: Username '{username}' not found.")
|
||||
return render_template('set_token.html')
|
||||
flash(f'The username "{username}" does not exist.', "error")
|
||||
current_app.logger.warning(
|
||||
f"Login failed: Username '{username}' not found."
|
||||
)
|
||||
return render_template("set_token.html")
|
||||
|
||||
current_app.logger.info(f"User logged in: {username}")
|
||||
return set_cookie(token)
|
||||
|
||||
return render_template('set_token.html')
|
||||
return render_template("set_token.html")
|
||||
|
||||
|
||||
@main.route('/register', methods=['POST', 'GET'])
|
||||
@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':
|
||||
if request.method == "POST":
|
||||
# Get form data
|
||||
user_name = request.form.get('username')
|
||||
acknowledge = request.form.get('acknowledge')
|
||||
user_name = request.form.get("username")
|
||||
acknowledge = request.form.get("acknowledge")
|
||||
|
||||
# Simple validation
|
||||
if not user_name:
|
||||
flash('Username is required.', 'error')
|
||||
flash("Username is required.", "error")
|
||||
current_app.logger.debug("Username is required for registering")
|
||||
return render_template('register.html')
|
||||
|
||||
return render_template("register.html")
|
||||
|
||||
if retrieve_token(user_name):
|
||||
flash(f'The username "{user_name}" is already taken.', 'error')
|
||||
flash(f'The username "{user_name}" is already taken.', "error")
|
||||
current_app.logger.debug(f"Username {user_name} is already registered")
|
||||
return render_template('register.html')
|
||||
|
||||
return render_template("register.html")
|
||||
|
||||
if not acknowledge:
|
||||
flash('You must acknowledge the important information to proceed.', 'error')
|
||||
flash("You must acknowledge the important information to proceed.", "error")
|
||||
current_app.logger.debug("User did not acknowledge register information")
|
||||
return render_template('register.html')
|
||||
return render_template("register.html")
|
||||
|
||||
# If all validation passes, "register" the user
|
||||
token = create_token(user_name)
|
||||
token = create_token(user_name)
|
||||
current_app.logger.info(f"New user registered: {user_name}")
|
||||
|
||||
return set_cookie(token)
|
||||
|
||||
# If GET request, render the form
|
||||
return render_template('register.html')
|
||||
return render_template("register.html")
|
||||
|
||||
@main.route('/logout', methods=['POST'])
|
||||
|
||||
@main.route("/logout", methods=["POST"])
|
||||
def logout():
|
||||
token = get_token()
|
||||
user_name = token.name if token else "Unknown"
|
||||
current_app.logger.info(f"User logout: {user_name}")
|
||||
|
||||
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
|
||||
|
||||
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'))
|
||||
|
||||
@main.route("/switch_course/<int:new_course>", methods=["POST"])
|
||||
@token_required
|
||||
def switch_course(new_course: int, token):
|
||||
switched = get_course(new_course)
|
||||
|
||||
if switched:
|
||||
token.active_course = switched
|
||||
token.active_course = switched
|
||||
db.session.commit()
|
||||
current_app.logger.info(f"User {token.name} switched to course: {switched.name}")
|
||||
current_app.logger.info(
|
||||
f"User {token.name} switched to course: {switched.name}"
|
||||
)
|
||||
else:
|
||||
current_app.logger.warning(f"User {token.name} attempted to switch to invalid course ID: {new_course}")
|
||||
current_app.logger.warning(
|
||||
f"User {token.name} attempted to switch to invalid course ID: {new_course}"
|
||||
)
|
||||
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
|
||||
@main.route('/admin/login', methods=['POST', 'GET'])
|
||||
@main.route("/admin/login", methods=["POST", "GET"])
|
||||
def admin_login():
|
||||
form = LoginForm()
|
||||
if form.validate_on_submit():
|
||||
@@ -230,32 +257,27 @@ def admin_login():
|
||||
if user and check_password(user, password):
|
||||
flask_login.login_user(user)
|
||||
current_app.logger.info(f"Admin login successful: {user_name}")
|
||||
next = request.args.get('next')
|
||||
if next is None:
|
||||
return redirect(url_for('main.index'))
|
||||
|
||||
if not next.startswith('/admin'):
|
||||
return abort(400)
|
||||
token = retrieve_token(user_name)
|
||||
if not token:
|
||||
token = create_token(user_name)
|
||||
|
||||
return redirect(next)
|
||||
next_url = request.args.get("next")
|
||||
if next_url and next_url.startswith("/admin"):
|
||||
session["next_url"] = next_url
|
||||
else:
|
||||
session["next_url"] = url_for("main.index")
|
||||
|
||||
return set_cookie(token)
|
||||
else:
|
||||
current_app.logger.warning(f"Failed admin login attempt: {user_name}")
|
||||
flash('Incorrect username or password. Please try again.', 'error')
|
||||
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()
|
||||
|
||||
if not token:
|
||||
# remember where the user wanted to go
|
||||
session['next_url'] = url_for('main.access_get', access_code=access_code)
|
||||
current_app.logger.debug("Access code used without session. Redirecting to login.")
|
||||
return redirect(url_for('main.set_token'))
|
||||
|
||||
def process_access_code(access_code: str, token):
|
||||
password = db.session.execute(
|
||||
db.select(Password)
|
||||
.where(Password.value == access_code)
|
||||
db.select(Password).where(Password.value == access_code)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if password is not None:
|
||||
@@ -264,141 +286,152 @@ def process_access_code(access_code: str):
|
||||
current_app.logger.info(f"User {token.name} used valid access code.")
|
||||
else:
|
||||
current_app.logger.info(f"User {token.name} used expired access code.")
|
||||
flash('Password has expired. Please obtain a new one.', 'expired')
|
||||
flash("Password has expired. Please obtain a new one.", "expired")
|
||||
else:
|
||||
current_app.logger.warning(f"User {token.name} used invalid access code: {access_code}")
|
||||
flash('Password is not valid. Please check the code and try again.', 'error')
|
||||
return redirect(url_for('main.index'))
|
||||
current_app.logger.warning(
|
||||
f"User {token.name} used invalid access code: {access_code}"
|
||||
)
|
||||
flash("Password is not valid. Please check the code and try again.", "error")
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
|
||||
@main.route("/access", methods=["POST"])
|
||||
@token_required
|
||||
def access_post(token):
|
||||
access_code = request.form.get("password")
|
||||
|
||||
@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)
|
||||
|
||||
return process_access_code(access_code, token)
|
||||
|
||||
|
||||
# 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("/access/<access_code>", methods=["GET"])
|
||||
@token_required
|
||||
def access_get(access_code: str, token):
|
||||
return process_access_code(access_code, token)
|
||||
|
||||
|
||||
@main.route('/confused', methods=['POST'])
|
||||
@main.route("/confused", methods=["POST"])
|
||||
def confused():
|
||||
socketio.emit(
|
||||
"confused",
|
||||
{
|
||||
"time": datetime.utcnow().isoformat()
|
||||
},
|
||||
to="instructor"
|
||||
)
|
||||
socketio.emit("confused", {"time": datetime.utcnow().isoformat()}, to="instructor")
|
||||
return ("", 204)
|
||||
|
||||
def log_404(reason: str, deck: str, course: str, path: str):
|
||||
|
||||
def log_404(reason: str, deck: str, course: str, path: str, token):
|
||||
"""
|
||||
Helper function to log 404 errors with the user's identity.
|
||||
Uses current_app.logger to ensure logs are handled by the app's config.
|
||||
"""
|
||||
token = get_token()
|
||||
user_identity = f"User: {token.name} (ID: {token.id})" if token else "User: Anonymous"
|
||||
current_app.logger.warning(f"404 Not Found [{reason}] | {user_identity} | Resource: {course}/{deck}/{path}")
|
||||
user_identity = f"User: {token.name} (ID: {token.id})"
|
||||
current_app.logger.warning(
|
||||
f"404 Not Found [{reason}] | {user_identity} | Resource: {course}/{deck}/{path}"
|
||||
)
|
||||
|
||||
@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:
|
||||
log_404("Course not found", deck, course_folder, path)
|
||||
|
||||
@main.route("/slides/<course_folder>/<deck>/")
|
||||
@main.route("/slides/<course_folder>/<deck>/<path:path>")
|
||||
@token_required
|
||||
def serve_deck(course_folder, deck, token, path="index.html"):
|
||||
# 1. Ask the cache for the deck data (0 DB queries on a cache hit!)
|
||||
deck_info = get_cached_deck_info(token.id, course_folder, deck)
|
||||
|
||||
# 2. Handle missing data
|
||||
if not deck_info["course_exists"]:
|
||||
log_404("Course not found", deck, course_folder, path, token)
|
||||
abort(404)
|
||||
assert isinstance(course, Course)
|
||||
|
||||
deck_obj = db.session.execute(db.select(Deck).where(Deck.deck == deck, Deck.course_id == course.id)).scalar()
|
||||
if not deck_obj:
|
||||
log_404("Deck not in database", deck, course_folder, path)
|
||||
|
||||
if not deck_info["deck_exists"]:
|
||||
log_404("Deck not in database", deck, course_folder, path, token)
|
||||
abort(404)
|
||||
assert isinstance(deck_obj, Deck)
|
||||
|
||||
if not is_deck_unlocked(deck_obj.deck):
|
||||
# We generally do not log redirects for locked content as 404s,
|
||||
# but you could log a warning here if desired.
|
||||
current_app.logger.info(f"Access denied (locked) for user {get_token().name if get_token() else 'Anon'} -> {deck_obj.name}")
|
||||
return redirect(url_for('main.index'))
|
||||
# 3. Handle authorization
|
||||
if not deck_info["auth"]:
|
||||
current_app.logger.warning(
|
||||
f"Access denied (locked) for user {token.name} -> {deck_info.get('deck_name', 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_obj.deck)
|
||||
# 4. Resolve the file path entirely using strings
|
||||
slides_dir = os.path.join(current_app.config["SLIDES_DIR"], course_folder)
|
||||
deck_path = os.path.join(slides_dir, deck)
|
||||
|
||||
if not os.path.exists(deck_path):
|
||||
log_404("Deck directory missing on disk", deck, course_folder, path)
|
||||
return f"Deck '{deck_obj.name}' not found.", 404
|
||||
log_404("Deck directory missing on disk", deck, course_folder, path, token)
|
||||
return f"Deck '{deck_info['deck_name']}' not found.", 404
|
||||
|
||||
# 5. Serve the file
|
||||
if slidev_all_pattern.match(request.path):
|
||||
current_app.logger.info(f"User reloaded in deck {deck_obj.name}")
|
||||
return send_from_directory(deck_path, deck_obj.index_file)
|
||||
current_app.logger.info(f"User reloaded in deck {deck_info['deck_name']}")
|
||||
return send_from_directory(deck_path, deck_info["index_file"])
|
||||
|
||||
requested_file = os.path.join(deck_path, path)
|
||||
if os.path.isfile(requested_file):
|
||||
if path == deck_obj.index_file:
|
||||
current_app.logger.info(f"User {get_token().name if get_token() else 'Anon'} accessed deck {deck_obj.name}")
|
||||
if path == deck_info["index_file"]:
|
||||
current_app.logger.info(f"User {token.name} accessed deck {deck_info['deck_name']}")
|
||||
return send_from_directory(deck_path, path)
|
||||
|
||||
log_404("File missing within deck", deck, course_folder, path)
|
||||
log_404("File missing within deck", deck, course_folder, path, token)
|
||||
abort(404)
|
||||
|
||||
|
||||
@main.route('/exports/<course_folder>/<deck>/<path>')
|
||||
def serve_export(deck, course_folder, path):
|
||||
course = db.session.execute(db.select(Course).where(Course.folder==course_folder)).scalar()
|
||||
@main.route("/exports/<course_folder>/<deck>/<path>")
|
||||
@token_required
|
||||
def serve_export(deck, course_folder, path, token):
|
||||
course = db.session.execute(
|
||||
db.select(Course).where(Course.folder == course_folder)
|
||||
).scalar()
|
||||
if not course:
|
||||
log_404("Export: Course not found", deck, course_folder, path)
|
||||
log_404("Export: Course not found", deck, course_folder, path, token)
|
||||
abort(404)
|
||||
assert isinstance(course, Course)
|
||||
|
||||
deck_obj = db.session.execute(db.select(Deck).where(Deck.deck==deck, Deck.course_id == course.id)).scalar()
|
||||
|
||||
deck_obj = db.session.execute(
|
||||
db.select(Deck).where(Deck.deck == deck, Deck.course_id == course.id)
|
||||
).scalar()
|
||||
if not deck_obj:
|
||||
log_404("Export: Deck not in database", deck, course_folder, path)
|
||||
log_404("Export: Deck not in database", deck, course_folder, path, token)
|
||||
abort(404)
|
||||
assert isinstance(deck_obj, Deck)
|
||||
|
||||
if not is_deck_unlocked(deck_obj.deck):
|
||||
return redirect(url_for('main.index'))
|
||||
|
||||
exports_dir = os.path.join(current_app.config['EXPORTS_DIR'], course.folder)
|
||||
if not token.is_unlocked(deck_obj.deck):
|
||||
return redirect(url_for("main.index"))
|
||||
|
||||
exports_dir = os.path.join(current_app.config["EXPORTS_DIR"], course.folder)
|
||||
deck_path = os.path.join(exports_dir, deck_obj.deck)
|
||||
requested_file = os.path.join(deck_path, path)
|
||||
if os.path.isfile(requested_file):
|
||||
current_app.logger.info(f"User {get_token().name if get_token() else 'Anon'} exported deck {deck_obj.name}")
|
||||
current_app.logger.info(f"User {token.name} exported deck {deck_obj.name}")
|
||||
return send_from_directory(deck_path, path)
|
||||
|
||||
log_404("Export file missing", deck, course_folder, path)
|
||||
|
||||
log_404("Export file missing", deck, course_folder, path, token)
|
||||
abort(404)
|
||||
|
||||
|
||||
@main.route('/admin', methods=['GET', 'POST'])
|
||||
@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:
|
||||
current_app.logger.error(f"Admin user {flask_login.current_user.id} has no active_course_id.")
|
||||
current_app.logger.error(
|
||||
f"Admin user {flask_login.current_user.id} has no active_course_id."
|
||||
)
|
||||
raise Exception
|
||||
deck_form = DeckForm()
|
||||
password_form = PasswordForm()
|
||||
if request.method == 'GET':
|
||||
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
|
||||
decks = active_course.decks
|
||||
password_form.decks.choices = [(deck.id, deck.name) for deck in decks]
|
||||
|
||||
|
||||
if request.method == 'POST':
|
||||
if request.method == "POST":
|
||||
if "submit_deck" in request.form and deck_form.validate():
|
||||
deck = Deck(
|
||||
name=cast(str,deck_form.name.data),
|
||||
name=cast(str, deck_form.name.data),
|
||||
deck=deck_form.deck.data,
|
||||
course=active_course,
|
||||
description=cast(str,deck_form.description.data),
|
||||
description=cast(str, deck_form.description.data),
|
||||
)
|
||||
db.session.add(deck)
|
||||
db.session.commit()
|
||||
@@ -409,28 +442,42 @@ def admin():
|
||||
value: str = password_form.value.data
|
||||
expires_at: datetime | None = password_form.expires_at.data
|
||||
if expires_at:
|
||||
tz = pytz.timezone('Europe/Berlin')
|
||||
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 = 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()
|
||||
current_app.logger.info(f"Admin created password: {value} (Expires: {expires_at})")
|
||||
current_app.logger.info(
|
||||
f"Admin created password: {value} (Expires: {expires_at})"
|
||||
)
|
||||
else:
|
||||
# Form was submitted but validation failed
|
||||
for field, errors in password_form.errors.items():
|
||||
for error in errors:
|
||||
current_app.logger.warning(f"Admin form validation error ({field}): {error}")
|
||||
return redirect(url_for('main.admin'))
|
||||
current_app.logger.warning(
|
||||
f"Admin form validation error ({field}): {error}"
|
||||
)
|
||||
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)
|
||||
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'])
|
||||
|
||||
@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
|
||||
@@ -439,38 +486,46 @@ def switch_course_admin(new_course: int):
|
||||
current_app.logger.info(f"Admin {user.id} switched to course ID {new_course}")
|
||||
return redirect(url_for("main.admin"))
|
||||
|
||||
@main.route('/admin/banner', methods=['POST', 'GET'])
|
||||
|
||||
@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
|
||||
content: str = banner_form.content.data
|
||||
banner = InfoBanner(content=content)
|
||||
db.session.add(banner)
|
||||
db.session.commit()
|
||||
current_app.logger.info(f"Admin added banner: {content[:30]}...")
|
||||
return render_template("banner.html", banner_form=banner_form, banners=db.session.execute(db.select(InfoBanner)).scalars())
|
||||
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'])
|
||||
|
||||
@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)
|
||||
current_app.logger.info(f"Admin deleted deck: {deck.name}")
|
||||
db.session.delete(deck)
|
||||
db.session.commit()
|
||||
return redirect(url_for('main.admin'))
|
||||
return redirect(url_for("main.admin"))
|
||||
|
||||
@main.route('/admin/passwords/<int:password_id>/delete', methods=['POST'])
|
||||
|
||||
@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)
|
||||
current_app.logger.info(f"Admin deleted password ID {password_id}")
|
||||
db.session.delete(password)
|
||||
db.session.commit()
|
||||
return redirect(url_for('main.admin'))
|
||||
return redirect(url_for("main.admin"))
|
||||
|
||||
@main.route('/admin/banner/show/<int:banner_id>', methods=['POST'])
|
||||
|
||||
@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)
|
||||
@@ -482,19 +537,21 @@ def set_active_banner(banner_id):
|
||||
banner.is_active = True
|
||||
db.session.commit()
|
||||
current_app.logger.info(f"Admin activated banner ID {banner_id}")
|
||||
return redirect(url_for('main.banner'))
|
||||
return redirect(url_for("main.banner"))
|
||||
|
||||
@main.route('/admin/banner/hide', methods=['POST'])
|
||||
|
||||
@main.route("/admin/banner/hide", methods=["POST"])
|
||||
@flask_login.login_required
|
||||
def hide_active_banner():
|
||||
banner = get_active_banner()
|
||||
banner = get_active_banner()
|
||||
if banner:
|
||||
banner.is_active = False
|
||||
db.session.commit()
|
||||
current_app.logger.info("Admin hid active banner")
|
||||
return redirect(url_for('main.banner'))
|
||||
return redirect(url_for("main.banner"))
|
||||
|
||||
@main.route('/admin/confused')
|
||||
|
||||
@main.route("/admin/confused")
|
||||
@flask_login.login_required
|
||||
def confused_notifications():
|
||||
return render_template("confused.html")
|
||||
|
||||
20
app/tailwind.config.js
Normal file
20
app/tailwind.config.js
Normal file
@@ -0,0 +1,20 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["templates/**/*.html"], // <-- MUST BE CORRECT
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: '#121418',
|
||||
surface: '#1e2127',
|
||||
border: '#2a2d34',
|
||||
primary: '#4f8cff',
|
||||
text: '#e4e4e7',
|
||||
muted: '#a0a0ad',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require('flowbite/plugin')
|
||||
],
|
||||
}
|
||||
151
app/utils.py
151
app/utils.py
@@ -1,101 +1,131 @@
|
||||
import os
|
||||
import logging
|
||||
|
||||
from flask import Flask, request, session
|
||||
from flask import request, session, redirect, url_for, current_app
|
||||
from typing import cast
|
||||
from itsdangerous import BadSignature, URLSafeSerializer
|
||||
from xkcdpass import xkcd_password
|
||||
from functools import wraps, lru_cache
|
||||
|
||||
from .models import Deck, InfoBanner, Token, db, Course
|
||||
|
||||
# Create a module-level logger per Flask/Python standards
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def init_password_generator():
|
||||
wordfile = xkcd_password.locate_wordfile()
|
||||
global words
|
||||
words = xkcd_password.generate_wordlist(wordfile=wordfile, min_length=3, max_length=6)
|
||||
words = xkcd_password.generate_wordlist(
|
||||
wordfile=wordfile, min_length=3, max_length=6
|
||||
)
|
||||
logger.info("Password generator initialized.")
|
||||
|
||||
def init_serializer(app: Flask):
|
||||
|
||||
def init_serializer(app):
|
||||
global serializer
|
||||
serializer = URLSafeSerializer(app.config['SECRET_KEY'])
|
||||
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()
|
||||
return db.session.execute(
|
||||
db.select(InfoBanner).where(InfoBanner.is_active)
|
||||
).scalar_one_or_none()
|
||||
|
||||
def get_available_decks(app: Flask, course: Course):
|
||||
slides_dir = os.path.join(app.config['SLIDES_DIR'], course.folder)
|
||||
|
||||
def get_available_decks(course: Course):
|
||||
slides_dir = os.path.join(current_app.config["SLIDES_DIR"], course.folder)
|
||||
try:
|
||||
deck_names = [name for name in os.listdir(slides_dir) if os.path.isdir(os.path.join(slides_dir, name))]
|
||||
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
|
||||
except FileNotFoundError:
|
||||
# Log this error as it indicates a configuration or filesystem issue
|
||||
logger.error(f"Slides directory not found for course {course.folder}: {slides_dir}")
|
||||
logger.error(
|
||||
f"Slides directory not found for course {course.folder}: {slides_dir}"
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
def generate_username() -> str:
|
||||
return cast(str, xkcd_password.generate_xkcdpassword(wordlist=words, numwords=3, delimiter="-"))
|
||||
return cast(
|
||||
str,
|
||||
xkcd_password.generate_xkcdpassword(wordlist=words, numwords=3, delimiter="-"),
|
||||
)
|
||||
|
||||
|
||||
def generate_password() -> str:
|
||||
return cast(str, xkcd_password.generate_xkcdpassword(wordlist=words, numwords=2, delimiter="-"))
|
||||
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]
|
||||
return [password for deck in course.decks for password in deck.passwords]
|
||||
|
||||
|
||||
def generate_token() -> Token:
|
||||
max_tries = 100
|
||||
for _ in range(max_tries):
|
||||
user_name = generate_username()
|
||||
|
||||
if not db.session.execute(db.select(Token).where(Token.name == user_name)).scalar():
|
||||
if not db.session.execute(
|
||||
db.select(Token).where(Token.name == user_name)
|
||||
).scalar():
|
||||
break
|
||||
else:
|
||||
# Critical error: Application cannot register new users
|
||||
logger.critical(f"Failed to generate unique username after {max_tries} tries.")
|
||||
raise RuntimeError(f"Unique username could not be generated after {max_tries} tries")
|
||||
raise RuntimeError(
|
||||
f"Unique username could not be generated after {max_tries} tries"
|
||||
)
|
||||
return create_token(user_name)
|
||||
|
||||
def create_token(user_name : str) -> Token:
|
||||
|
||||
def create_token(user_name: str) -> Token:
|
||||
token = Token(user_name)
|
||||
db.session.add(token)
|
||||
db.session.commit()
|
||||
logger.info(f"Created new token for user: {user_name}")
|
||||
return token
|
||||
|
||||
|
||||
def serialize_token(token: Token) -> str:
|
||||
return serializer.dumps(token.id)
|
||||
|
||||
def get_token() -> (Token | None):
|
||||
token_cookie = request.cookies.get('access_token')
|
||||
|
||||
def get_token() -> Token | None:
|
||||
token_cookie = request.cookies.get("access_token")
|
||||
if not token_cookie:
|
||||
return None
|
||||
try:
|
||||
id = serializer.loads(token_cookie)
|
||||
token = db.session.get(Token, id)
|
||||
return token
|
||||
return token
|
||||
except BadSignature:
|
||||
# Warning: Client sent a tampered or invalid cookie
|
||||
logger.warning("Invalid signature for token cookie encountered.")
|
||||
return None
|
||||
|
||||
def retrieve_token(username : str):
|
||||
return db.session.execute(db.select(Token).filter_by(name=username)).scalar_one_or_none()
|
||||
|
||||
def get_unlocked_decks(token: (Token|None)):
|
||||
def retrieve_token(username: str):
|
||||
return db.session.execute(
|
||||
db.select(Token).filter_by(name=username)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_unlocked_decks(token: (Token | None)):
|
||||
if token is None:
|
||||
return []
|
||||
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()
|
||||
if token:
|
||||
return token.is_unlocked(deck)
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
def update_token(decks: list[Deck]):
|
||||
"""
|
||||
@@ -111,8 +141,71 @@ def update_token(decks: list[Deck]):
|
||||
token.decks = list(set(decks + token.decks))
|
||||
db.session.commit()
|
||||
|
||||
def get_course(id: int) -> (Course | None):
|
||||
|
||||
def get_course(id: int) -> Course | None:
|
||||
return db.session.get(Course, id)
|
||||
|
||||
|
||||
def get_all_courses() -> list[Course]:
|
||||
return db.session.execute(db.select(Course).order_by(Course.id)).scalars().all()
|
||||
|
||||
|
||||
def token_required(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
token = get_token()
|
||||
|
||||
if not token:
|
||||
logger.debug("User has no token. Redirecting to set_token.")
|
||||
|
||||
# --- Capture the target URL ---
|
||||
# request.url captures the full URL including query parameters
|
||||
# (e.g., /dashboard?section=homework)
|
||||
session["next_url"] = request.url
|
||||
|
||||
return redirect(url_for("main.set_token"))
|
||||
kwargs["token"] = token
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
# Cache the last 1000 lookups per worker.
|
||||
@lru_cache(maxsize=1000)
|
||||
def get_cached_deck_info(token_id, course_folder, deck_slug):
|
||||
"""
|
||||
Returns a dictionary of primitive types for route authorization and routing.
|
||||
Never return raw SQLAlchemy models from an lru_cache!
|
||||
"""
|
||||
# 1. Fetch Token
|
||||
token = db.session.get(Token, token_id)
|
||||
if not token:
|
||||
return {"auth": False, "course_exists": False, "deck_exists": False}
|
||||
|
||||
# 2. Fetch Course
|
||||
course = db.session.execute(
|
||||
db.select(Course).where(Course.folder == course_folder)
|
||||
).scalar()
|
||||
|
||||
if not course:
|
||||
return {"auth": False, "course_exists": False, "deck_exists": False}
|
||||
|
||||
# 3. Fetch Deck
|
||||
deck_obj = db.session.execute(
|
||||
db.select(Deck).where(Deck.deck == deck_slug, Deck.course_id == course.id)
|
||||
).scalar()
|
||||
|
||||
if not deck_obj:
|
||||
return {"auth": False, "course_exists": True, "deck_exists": False}
|
||||
|
||||
# 4. Check Authorization
|
||||
is_unlocked = deck_slug in [d.deck for d in token.decks]
|
||||
|
||||
# Return only primitive data (bools, strings) so the cache is thread-safe!
|
||||
return {
|
||||
"auth": is_unlocked,
|
||||
"course_exists": True,
|
||||
"deck_exists": True,
|
||||
"deck_name": deck_obj.name,
|
||||
"index_file": deck_obj.index_file
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user