majorly overhauled the design to now use docker-compose and postgres
This commit is contained in:
@@ -1,7 +0,0 @@
|
||||
.venv
|
||||
__pycache__/
|
||||
*.pyc
|
||||
nohup.out
|
||||
.git
|
||||
log/
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -2,8 +2,7 @@ instance
|
||||
__pycache__
|
||||
node_modules
|
||||
.venv
|
||||
config.py
|
||||
slides
|
||||
exports
|
||||
data
|
||||
logs
|
||||
nohup.out
|
||||
.env*
|
||||
|
||||
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
|
||||
0
package-lock.json → app/package-lock.json
generated
0
package-lock.json → app/package-lock.json
generated
@@ -4,7 +4,7 @@
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"build:css": "npx tailwindcss -i app/static/css/input.css -o app/static/css/output.css --minify"
|
||||
"build:css": "npx tailwindcss -i static/css/input.css -o static/css/output.css --minify"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
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")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["./app/templates/**/*.html"], // <-- MUST BE CORRECT
|
||||
content: ["templates/**/*.html"], // <-- MUST BE CORRECT
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
class Config:
|
||||
SECRET_KEY =
|
||||
SLIDES_DIR =
|
||||
EXPORTS_DIR =
|
||||
SQLALCHEMY_DATABASE_URI =
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS =
|
||||
ADMIN_PASSWORD =
|
||||
COURSE_MAP =
|
||||
DEFAULT_COURSE =
|
||||
6
debug.py
6
debug.py
@@ -1,6 +0,0 @@
|
||||
from app import create_app, socketio
|
||||
|
||||
app = create_app()
|
||||
|
||||
if __name__ == '__main__':
|
||||
socketio.run(app, port=5000, debug=True)
|
||||
30
docker-compose.yml
Normal file
30
docker-compose.yml
Normal file
@@ -0,0 +1,30 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:17
|
||||
restart: always
|
||||
env_file: ./app/.env.db
|
||||
ports:
|
||||
- "127.0.0.1:5433:5432"
|
||||
volumes:
|
||||
- ./data/postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
tutortool:
|
||||
build:
|
||||
context: ./app
|
||||
dockerfile: Dockerfile
|
||||
env_file: ./app/.env.tutortool
|
||||
ports:
|
||||
- "127.0.0.1:5000:5000"
|
||||
volumes:
|
||||
- ./data/slides:/src/data/slides
|
||||
- ./data/exports:/src/data/exports
|
||||
- ./logs:/src/logs
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: on-failure
|
||||
@@ -12,6 +12,7 @@ dependencies = [
|
||||
"flask-sqlalchemy>=3.1.1",
|
||||
"flask-wtf>=1.2.2",
|
||||
"gunicorn>=23.0.0",
|
||||
"psycopg2-binary>=2.9.11",
|
||||
"pytz>=2025.2",
|
||||
"xkcdpass>=1.20.0",
|
||||
]
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
#/bin/bash
|
||||
|
||||
npm run build:css
|
||||
|
||||
nohup uv run gunicorn \
|
||||
--bind 127.0.0.1:5000 \
|
||||
--worker-class eventlet \
|
||||
--workers 1 \
|
||||
--access-logfile logs/traffic.log \
|
||||
run:app &
|
||||
90
uv.lock
generated
90
uv.lock
generated
@@ -187,6 +187,8 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/91/ae2eb6b7979e2f9b035a9f612cf70f1bf54aad4e1d125129bef1eae96f19/greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d", size = 584358, upload-time = "2025-08-07T13:18:23.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/85/433de0c9c0252b22b16d413c9407e6cb3b41df7389afc366ca204dbc1393/greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5", size = 1113550, upload-time = "2025-08-07T13:42:37.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/8d/88f3ebd2bc96bf7747093696f4335a0a8a4c5acfcf1b757717c0d2474ba3/greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f", size = 1137126, upload-time = "2025-08-07T13:18:20.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/29/74242b7d72385e29bcc5563fba67dad94943d7cd03552bac320d597f29b2/greenlet-3.2.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f47617f698838ba98f4ff4189aef02e7343952df3a615f847bb575c3feb177a7", size = 1544904, upload-time = "2025-11-04T12:42:04.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/e2/1572b8eeab0f77df5f6729d6ab6b141e4a84ee8eb9bc8c1e7918f94eda6d/greenlet-3.2.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af41be48a4f60429d5cad9d22175217805098a9ef7c40bfef44f7669fb9d74d8", size = 1611228, upload-time = "2025-11-04T12:42:08.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/6f/b60b0291d9623c496638c582297ead61f43c4b72eef5e9c926ef4565ec13/greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c", size = 298654, upload-time = "2025-08-07T13:50:00.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/de/f28ced0a67749cac23fecb02b694f6473f47686dff6afaa211d186e2ef9c/greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2", size = 272305, upload-time = "2025-08-07T13:15:41.288Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/16/2c3792cba130000bf2a31c5272999113f4764fd9d874fb257ff588ac779a/greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246", size = 632472, upload-time = "2025-08-07T13:42:55.044Z" },
|
||||
@@ -196,6 +198,8 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/8e/abdd3f14d735b2929290a018ecf133c901be4874b858dd1c604b9319f064/greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8", size = 587684, upload-time = "2025-08-07T13:18:25.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/65/deb2a69c3e5996439b0176f6651e0052542bb6c8f8ec2e3fba97c9768805/greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52", size = 1116647, upload-time = "2025-08-07T13:42:38.655Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/cc/b07000438a29ac5cfb2194bfc128151d52f333cee74dd7dfe3fb733fc16c/greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa", size = 1142073, upload-time = "2025-08-07T13:18:21.737Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/24/28a5b2fa42d12b3d7e5614145f0bd89714c34c08be6aabe39c14dd52db34/greenlet-3.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9c6de1940a7d828635fbd254d69db79e54619f165ee7ce32fda763a9cb6a58c", size = 1548385, upload-time = "2025-11-04T12:42:11.067Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/05/03f2f0bdd0b0ff9a4f7b99333d57b53a7709c27723ec8123056b084e69cd/greenlet-3.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03c5136e7be905045160b1b9fdca93dd6727b180feeafda6818e6496434ed8c5", size = 1613329, upload-time = "2025-11-04T12:42:12.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/0f/30aef242fcab550b0b3520b8e3561156857c94288f0332a79928c31a52cf/greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9", size = 299100, upload-time = "2025-08-07T13:44:12.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" },
|
||||
@@ -205,6 +209,8 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/45/80935968b53cfd3f33cf99ea5f08227f2646e044568c9b1555b58ffd61c2/greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0", size = 1564846, upload-time = "2025-11-04T12:42:15.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/02/b7c30e5e04752cb4db6202a3858b149c0710e5453b71a3b2aec5d78a1aab/greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d", size = 1633814, upload-time = "2025-11-04T12:42:17.175Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" },
|
||||
@@ -214,6 +220,8 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" },
|
||||
@@ -221,6 +229,8 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/c0/93885c4106d2626bf51fdec377d6aef740dfa5c4877461889a7cf8e565cc/greenlet-3.2.4-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:b6a7c19cf0d2742d0809a4c05975db036fdff50cd294a93632d6a310bf9ac02c", size = 269859, upload-time = "2025-08-07T13:16:16.003Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/f5/33f05dc3ba10a02dedb1485870cf81c109227d3d3aa280f0e48486cac248/greenlet-3.2.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:27890167f55d2387576d1f41d9487ef171849ea0359ce1510ca6e06c8bece11d", size = 627610, upload-time = "2025-08-07T13:43:01.345Z" },
|
||||
@@ -230,6 +240,8 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/4c/f3de2a8de0e840ecb0253ad0dc7e2bb3747348e798ec7e397d783a3cb380/greenlet-3.2.4-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9913f1a30e4526f432991f89ae263459b1c64d1608c0d22a5c79c287b3c70df", size = 582817, upload-time = "2025-08-07T13:18:35.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/80/7332915adc766035c8980b161c2e5d50b2f941f453af232c164cff5e0aeb/greenlet-3.2.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b90654e092f928f110e0007f572007c9727b5265f7632c2fa7415b4689351594", size = 1111985, upload-time = "2025-08-07T13:42:42.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/71/1928e2c80197353bcb9b50aa19c4d8e26ee6d7a900c564907665cf4b9a41/greenlet-3.2.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:81701fd84f26330f0d5f4944d4e92e61afe6319dcd9775e39396e39d7c3e5f98", size = 1136137, upload-time = "2025-08-07T13:18:26.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/bf/7bd33643e48ed45dcc0e22572f650767832bd4e1287f97434943cc402148/greenlet-3.2.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:28a3c6b7cd72a96f61b0e4b2a36f681025b60ae4779cc73c1535eb5f29560b10", size = 1542941, upload-time = "2025-11-04T12:42:27.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/74/4bc433f91d0d09a1c22954a371f9df928cb85e72640870158853a83415e5/greenlet-3.2.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:52206cd642670b0b320a1fd1cbfd95bca0e043179c1d8a045f2c6109dfe973be", size = 1609685, upload-time = "2025-11-04T12:42:29.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/48/a5dc74dde38aeb2b15d418cec76ed50e1dd3d620ccda84d8199703248968/greenlet-3.2.4-cp39-cp39-win32.whl", hash = "sha256:65458b409c1ed459ea899e939f0e1cdb14f58dbc803f2f93c5eab5694d32671b", size = 281400, upload-time = "2025-08-07T14:02:20.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/44/342c4591db50db1076b8bda86ed0ad59240e3e1da17806a4cf10a6d0e447/greenlet-3.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb", size = 298533, upload-time = "2025-08-07T13:56:34.168Z" },
|
||||
]
|
||||
@@ -260,7 +272,7 @@ name = "importlib-metadata"
|
||||
version = "8.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "zipp" },
|
||||
{ name = "zipp", marker = "python_full_version < '3.10'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" }
|
||||
wheels = [
|
||||
@@ -365,6 +377,80 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psycopg2-binary"
|
||||
version = "2.9.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/f2/8e377d29c2ecf99f6062d35ea606b036e8800720eccfec5fe3dd672c2b24/psycopg2_binary-2.9.11-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d6fe6b47d0b42ce1c9f1fa3e35bb365011ca22e39db37074458f27921dca40f2", size = 3756506, upload-time = "2025-10-10T11:10:30.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/cc/dc143ea88e4ec9d386106cac05023b69668bd0be20794c613446eaefafe5/psycopg2_binary-2.9.11-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a6c0e4262e089516603a09474ee13eabf09cb65c332277e39af68f6233911087", size = 3863943, upload-time = "2025-10-10T11:10:34.586Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/df/16848771155e7c419c60afeb24950b8aaa3ab09c0a091ec3ccca26a574d0/psycopg2_binary-2.9.11-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c47676e5b485393f069b4d7a811267d3168ce46f988fa602658b8bb901e9e64d", size = 4410873, upload-time = "2025-10-10T11:10:38.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/79/5ef5f32621abd5a541b89b04231fe959a9b327c874a1d41156041c75494b/psycopg2_binary-2.9.11-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:a28d8c01a7b27a1e3265b11250ba7557e5f72b5ee9e5f3a2fa8d2949c29bf5d2", size = 4468016, upload-time = "2025-10-10T11:10:43.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/9b/d7542d0f7ad78f57385971f426704776d7b310f5219ed58da5d605b1892e/psycopg2_binary-2.9.11-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f3f2732cf504a1aa9e9609d02f79bea1067d99edf844ab92c247bbca143303b", size = 4164996, upload-time = "2025-10-10T11:10:46.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/ed/e409388b537fa7414330687936917c522f6a77a13474e4238219fcfd9a84/psycopg2_binary-2.9.11-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:865f9945ed1b3950d968ec4690ce68c55019d79e4497366d36e090327ce7db14", size = 3981881, upload-time = "2025-10-30T02:54:57.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/30/50e330e63bb05efc6fa7c1447df3e08954894025ca3dcb396ecc6739bc26/psycopg2_binary-2.9.11-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:91537a8df2bde69b1c1db01d6d944c831ca793952e4f57892600e96cee95f2cd", size = 3650857, upload-time = "2025-10-10T11:10:50.112Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/e0/4026e4c12bb49dd028756c5b0bc4c572319f2d8f1c9008e0dad8cc9addd7/psycopg2_binary-2.9.11-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4dca1f356a67ecb68c81a7bc7809f1569ad9e152ce7fd02c2f2036862ca9f66b", size = 3296063, upload-time = "2025-10-10T11:10:54.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/34/eb172be293c886fef5299fe5c3fcf180a05478be89856067881007934a7c/psycopg2_binary-2.9.11-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0da4de5c1ac69d94ed4364b6cbe7190c1a70d325f112ba783d83f8440285f152", size = 3043464, upload-time = "2025-10-30T02:55:02.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/1c/532c5d2cb11986372f14b798a95f2eaafe5779334f6a80589a68b5fcf769/psycopg2_binary-2.9.11-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37d8412565a7267f7d79e29ab66876e55cb5e8e7b3bbf94f8206f6795f8f7e7e", size = 3345378, upload-time = "2025-10-10T11:11:01.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/e7/de420e1cf16f838e1fa17b1120e83afff374c7c0130d088dba6286fcf8ea/psycopg2_binary-2.9.11-cp310-cp310-win_amd64.whl", hash = "sha256:c665f01ec8ab273a61c62beeb8cce3014c214429ced8a308ca1fc410ecac3a39", size = 2713904, upload-time = "2025-10-10T11:11:04.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/ae/8d8266f6dd183ab4d48b95b9674034e1b482a3f8619b33a0d86438694577/psycopg2_binary-2.9.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0e8480afd62362d0a6a27dd09e4ca2def6fa50ed3a4e7c09165266106b2ffa10", size = 3756452, upload-time = "2025-10-10T11:11:11.583Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/34/aa03d327739c1be70e09d01182619aca8ebab5970cd0cfa50dd8b9cec2ac/psycopg2_binary-2.9.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:763c93ef1df3da6d1a90f86ea7f3f806dc06b21c198fa87c3c25504abec9404a", size = 3863957, upload-time = "2025-10-10T11:11:16.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/89/3fdb5902bdab8868bbedc1c6e6023a4e08112ceac5db97fc2012060e0c9a/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e164359396576a3cc701ba8af4751ae68a07235d7a380c631184a611220d9a4", size = 4410955, upload-time = "2025-10-10T11:11:21.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/24/e18339c407a13c72b336e0d9013fbbbde77b6fd13e853979019a1269519c/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:d57c9c387660b8893093459738b6abddbb30a7eab058b77b0d0d1c7d521ddfd7", size = 4468007, upload-time = "2025-10-10T11:11:24.831Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/7e/b8441e831a0f16c159b5381698f9f7f7ed54b77d57bc9c5f99144cc78232/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2c226ef95eb2250974bf6fa7a842082b31f68385c4f3268370e3f3870e7859ee", size = 4165012, upload-time = "2025-10-10T11:11:29.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/61/4aa89eeb6d751f05178a13da95516c036e27468c5d4d2509bb1e15341c81/psycopg2_binary-2.9.11-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a311f1edc9967723d3511ea7d2708e2c3592e3405677bf53d5c7246753591fbb", size = 3981881, upload-time = "2025-10-30T02:55:07.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/a1/2f5841cae4c635a9459fe7aca8ed771336e9383b6429e05c01267b0774cf/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ebb415404821b6d1c47353ebe9c8645967a5235e6d88f914147e7fd411419e6f", size = 3650985, upload-time = "2025-10-10T11:11:34.975Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/74/4defcac9d002bca5709951b975173c8c2fa968e1a95dc713f61b3a8d3b6a/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f07c9c4a5093258a03b28fab9b4f151aa376989e7f35f855088234e656ee6a94", size = 3296039, upload-time = "2025-10-10T11:11:40.432Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/c2/782a3c64403d8ce35b5c50e1b684412cf94f171dc18111be8c976abd2de1/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:00ce1830d971f43b667abe4a56e42c1e2d594b32da4802e44a73bacacb25535f", size = 3043477, upload-time = "2025-10-30T02:55:11.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/31/36a1d8e702aa35c38fc117c2b8be3f182613faa25d794b8aeaab948d4c03/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cffe9d7697ae7456649617e8bb8d7a45afb71cd13f7ab22af3e5c61f04840908", size = 3345842, upload-time = "2025-10-10T11:11:45.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/b4/a5375cda5b54cb95ee9b836930fea30ae5a8f14aa97da7821722323d979b/psycopg2_binary-2.9.11-cp311-cp311-win_amd64.whl", hash = "sha256:304fd7b7f97eef30e91b8f7e720b3db75fee010b520e434ea35ed1ff22501d03", size = 2713894, upload-time = "2025-10-10T11:11:48.775Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/91/f870a02f51be4a65987b45a7de4c2e1897dd0d01051e2b559a38fa634e3e/psycopg2_binary-2.9.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be9b840ac0525a283a96b556616f5b4820e0526addb8dcf6525a0fa162730be4", size = 3756603, upload-time = "2025-10-10T11:11:52.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/fa/cae40e06849b6c9a95eb5c04d419942f00d9eaac8d81626107461e268821/psycopg2_binary-2.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f090b7ddd13ca842ebfe301cd587a76a4cf0913b1e429eb92c1be5dbeb1a19bc", size = 3864509, upload-time = "2025-10-10T11:11:56.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/75/364847b879eb630b3ac8293798e380e441a957c53657995053c5ec39a316/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab8905b5dcb05bf3fb22e0cf90e10f469563486ffb6a96569e51f897c750a76a", size = 4411159, upload-time = "2025-10-10T11:12:00.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/a0/567f7ea38b6e1c62aafd58375665a547c00c608a471620c0edc364733e13/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf940cd7e7fec19181fdbc29d76911741153d51cab52e5c21165f3262125685e", size = 4468234, upload-time = "2025-10-10T11:12:04.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/da/4e42788fb811bbbfd7b7f045570c062f49e350e1d1f3df056c3fb5763353/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa0f693d3c68ae925966f0b14b8edda71696608039f4ed61b1fe9ffa468d16db", size = 4166236, upload-time = "2025-10-10T11:12:11.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/94/c1777c355bc560992af848d98216148be5f1be001af06e06fc49cbded578/psycopg2_binary-2.9.11-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1cf393f1cdaf6a9b57c0a719a1068ba1069f022a59b8b1fe44b006745b59757", size = 3983083, upload-time = "2025-10-30T02:55:15.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/42/c9a21edf0e3daa7825ed04a4a8588686c6c14904344344a039556d78aa58/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7a6beb4beaa62f88592ccc65df20328029d721db309cb3250b0aae0fa146c3", size = 3652281, upload-time = "2025-10-10T11:12:17.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/22/dedfbcfa97917982301496b6b5e5e6c5531d1f35dd2b488b08d1ebc52482/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:31b32c457a6025e74d233957cc9736742ac5a6cb196c6b68499f6bb51390bd6a", size = 3298010, upload-time = "2025-10-10T11:12:22.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/ea/d3390e6696276078bd01b2ece417deac954dfdd552d2edc3d03204416c0c/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:edcb3aeb11cb4bf13a2af3c53a15b3d612edeb6409047ea0b5d6a21a9d744b34", size = 3044641, upload-time = "2025-10-30T02:55:19.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/9a/0402ded6cbd321da0c0ba7d34dc12b29b14f5764c2fc10750daa38e825fc/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b6d93d7c0b61a1dd6197d208ab613eb7dcfdcca0a49c42ceb082257991de9d", size = 3347940, upload-time = "2025-10-10T11:12:26.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/d2/99b55e85832ccde77b211738ff3925a5d73ad183c0b37bcbbe5a8ff04978/psycopg2_binary-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:b33fabeb1fde21180479b2d4667e994de7bbf0eec22832ba5d9b5e4cf65b6c6d", size = 2714147, upload-time = "2025-10-10T11:12:29.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133, upload-time = "2025-10-30T02:55:24.329Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712, upload-time = "2025-10-30T02:55:27.975Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/41/cb36a61146b3afed03e980477f6dd29c0263f15e4b4844660501a774dc0b/psycopg2_binary-2.9.11-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:20e7fb94e20b03dcc783f76c0865f9da39559dcc0c28dd1a3fce0d01902a6b9c", size = 3756418, upload-time = "2025-10-10T11:14:00.728Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/e3/8623be505c8921727277f22753092835b559543b078e83378bb74712dbc8/psycopg2_binary-2.9.11-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4bdab48575b6f870f465b397c38f1b415520e9879fdf10a53ee4f49dcbdf8a21", size = 3863981, upload-time = "2025-10-10T11:14:05.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/86/ec3682dc3550c65eff80384f603a6a55b798e1b86ccef262d454d19f96eb/psycopg2_binary-2.9.11-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9d3a9edcfbe77a3ed4bc72836d466dfce4174beb79eda79ea155cc77237ed9e8", size = 4410882, upload-time = "2025-10-10T11:14:09.552Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/af/540ee7d56fb33408c57240d55904c95e4a30952c096f5e1542769cadc787/psycopg2_binary-2.9.11-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:44fc5c2b8fa871ce7f0023f619f1349a0aa03a0857f2c96fbc01c657dcbbdb49", size = 4468062, upload-time = "2025-10-10T11:14:15.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/d5/b95d47b2e67b2adfaba517c803a99a1ac41e84c8201d0f3b29d77b56e357/psycopg2_binary-2.9.11-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9c55460033867b4622cda1b6872edf445809535144152e5d14941ef591980edf", size = 4165036, upload-time = "2025-10-10T11:14:21.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/f9/99e39882b70d9b0cfdcbad33bea2e5823843c3a7839c1aaf89fc1337c05c/psycopg2_binary-2.9.11-cp39-cp39-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2d11098a83cca92deaeaed3d58cfd150d49b3b06ee0d0852be466bf87596899e", size = 3981901, upload-time = "2025-10-30T02:55:39.325Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/c3/8d2c97f1dfddedf5a06c6ad2eda83fba48555a7bc525c3150aedc6f2bedc/psycopg2_binary-2.9.11-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:691c807d94aecfbc76a14e1408847d59ff5b5906a04a23e12a89007672b9e819", size = 3650995, upload-time = "2025-10-10T11:14:27.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/89/afdf59b44b84ebb28111652485fab608429389f4051d22bc5a7bb43d5208/psycopg2_binary-2.9.11-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:8b81627b691f29c4c30a8f322546ad039c40c328373b11dff7490a3e1b517855", size = 3296106, upload-time = "2025-10-10T11:14:33.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/21/851c9ecf0e9a699907d1c455dbbde7ef9b11dba28e7b7b132c7bb28391f2/psycopg2_binary-2.9.11-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:b637d6d941209e8d96a072d7977238eea128046effbf37d1d8b2c0764750017d", size = 3043491, upload-time = "2025-10-30T02:55:42.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/de/50f6eced439e7a131b268276c4b68cf8800fd55d8cef7b37109c44bf957a/psycopg2_binary-2.9.11-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:41360b01c140c2a03d346cec3280cf8a71aa07d94f3b1509fa0161c366af66b4", size = 3345816, upload-time = "2025-10-10T11:14:38.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/3b/e0506f199dc8a90ff3b462f261f45d15c0703bb8c59f0da1add5f0c11a30/psycopg2_binary-2.9.11-cp39-cp39-win_amd64.whl", hash = "sha256:875039274f8a2361e5207857899706da840768e2a775bf8c65e82f60b197df02", size = 2714968, upload-time = "2025-10-10T11:14:43.24Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-engineio"
|
||||
version = "4.12.2"
|
||||
@@ -476,6 +562,7 @@ dependencies = [
|
||||
{ name = "flask-sqlalchemy" },
|
||||
{ name = "flask-wtf" },
|
||||
{ name = "gunicorn" },
|
||||
{ name = "psycopg2-binary" },
|
||||
{ name = "pytz" },
|
||||
{ name = "xkcdpass" },
|
||||
]
|
||||
@@ -489,6 +576,7 @@ requires-dist = [
|
||||
{ name = "flask-sqlalchemy", specifier = ">=3.1.1" },
|
||||
{ name = "flask-wtf", specifier = ">=1.2.2" },
|
||||
{ name = "gunicorn", specifier = ">=23.0.0" },
|
||||
{ name = "psycopg2-binary", specifier = ">=2.9.11" },
|
||||
{ name = "pytz", specifier = ">=2025.2" },
|
||||
{ name = "xkcdpass", specifier = ">=1.20.0" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user