Testversion 1

This commit is contained in:
2026-02-19 19:03:20 +00:00
parent 879ebd53b0
commit 6353ba4707
18 changed files with 734 additions and 58 deletions

View File

@@ -2,16 +2,12 @@ FROM python:3.12-slim
WORKDIR /app
# uv installieren
RUN pip install uv
# Nur dependency files zuerst kopieren (Docker Cache!)
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
# Rest kopieren
COPY app.py .
COPY . .
EXPOSE 5000
CMD ["uv", "run", "app.py"]
EXPOSE 8000
CMD ["uv", "run", "gunicorn", "-b", "0.0.0.0:8000", "app:app"]

Binary file not shown.

View File

@@ -1,67 +1,346 @@
import os
import sqlite3
import uuid
from datetime import datetime
from flask import Flask, request, jsonify
from functools import wraps
from pathlib import Path
from flask import (
Flask,
flash,
g,
redirect,
render_template,
request,
send_from_directory,
session,
url_for,
)
from werkzeug.utils import secure_filename
app = Flask(__name__)
base_dir = Path(__file__).resolve().parent
app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "change-me-in-production")
app.config["DB_PATH"] = os.environ.get("DB_PATH", str(base_dir / "app.sqlite3"))
app.config["UPLOAD_FOLDER"] = os.environ.get("UPLOAD_FOLDER", str(base_dir / "uploads"))
app.config["EVENT_PASSWORD"] = os.environ.get("EVENT_PASSWORD", "wedding2026")
app.config["MAX_CONTENT_LENGTH"] = int(os.environ.get("MAX_UPLOAD_BYTES", str(8 * 1024 * 1024)))
app.config["LOCATION_NAME"] = os.environ.get("LOCATION_NAME", "Schlossgarten Venue")
app.config["LOCATION_ADDRESS"] = os.environ.get(
"LOCATION_ADDRESS", "Musterstraße 12, 12345 Musterstadt"
)
app.config["LOCATION_WEBSITE_URL"] = os.environ.get(
"LOCATION_WEBSITE_URL", "https://example.com/location"
)
app.config["GOOGLE_MAPS_EMBED_URL"] = os.environ.get(
"GOOGLE_MAPS_EMBED_URL",
"https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d0",
)
DB_PATH = os.environ.get("DB_PATH", "/app/db/app.sqlite3")
ALLOWED_EXTENSIONS = {"jpg", "jpeg", "png"}
def get_db():
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
TEXTS = {
"de": {
"brand": "Svenja & Dominic",
"subtitle": "Willkommen zu unserer Hochzeits-App",
"login": "Login",
"name": "Dein Name",
"event_password": "Event-Passwort",
"login_submit": "Weiter zum Dashboard",
"dashboard": "Dashboard",
"logout": "Abmelden",
"rsvp": "RSVP",
"upload": "Upload",
"gallery": "Galerie",
"info": "Infos",
"save": "Speichern",
"attending": "Ich komme",
"not_attending": "Ich komme nicht",
"plus_one": "Ich bringe eine Begleitperson mit",
"file": "Bild auswählen",
"upload_submit": "Foto hochladen",
"schedule": "Ablauf",
"hotels": "Hotels",
"taxi": "Taxi",
"location": "Location",
"visit_location": "Zur Location-Webseite",
},
"en": {
"brand": "Svenja & Dominic",
"subtitle": "Welcome to our wedding app",
"login": "Login",
"name": "Your name",
"event_password": "Event password",
"login_submit": "Open dashboard",
"dashboard": "Dashboard",
"logout": "Logout",
"rsvp": "RSVP",
"upload": "Upload",
"gallery": "Gallery",
"info": "Info",
"save": "Save",
"attending": "I will attend",
"not_attending": "I cannot attend",
"plus_one": "I will bring a plus-one",
"file": "Select image",
"upload_submit": "Upload photo",
"schedule": "Schedule",
"hotels": "Hotels",
"taxi": "Taxi",
"location": "Location",
"visit_location": "Visit location website",
},
}
def init_db():
with get_db() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS rsvps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
attending INTEGER NOT NULL, -- 1 = ja, 0 = nein
plus_one INTEGER NOT NULL DEFAULT 0, -- 1 = ja, 0 = nein
created_at TEXT NOT NULL
);
""")
def get_lang() -> str:
lang = session.get("lang", "de")
return lang if lang in TEXTS else "de"
def t(key: str) -> str:
return TEXTS[get_lang()].get(key, key)
@app.context_processor
def inject_common() -> dict:
return {
"t": t,
"lang": get_lang(),
"guest_name": session.get("guest_name"),
"location_name": app.config["LOCATION_NAME"],
"location_address": app.config["LOCATION_ADDRESS"],
"location_website_url": app.config["LOCATION_WEBSITE_URL"],
"google_maps_embed_url": app.config["GOOGLE_MAPS_EMBED_URL"],
}
def get_db() -> sqlite3.Connection:
db_path = app.config["DB_PATH"]
os.makedirs(os.path.dirname(db_path), exist_ok=True)
if "db" not in g:
g.db = sqlite3.connect(db_path)
g.db.row_factory = sqlite3.Row
return g.db
@app.teardown_appcontext
def close_db(_error) -> None:
db = g.pop("db", None)
if db is not None:
db.close()
def init_db() -> None:
db_path = app.config["DB_PATH"]
db_dir = os.path.dirname(db_path)
if db_dir:
os.makedirs(db_dir, exist_ok=True)
with sqlite3.connect(db_path) as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS guests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
attending INTEGER,
plus_one INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS uploads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
uploaded_by INTEGER NOT NULL,
uploaded_at TEXT NOT NULL,
FOREIGN KEY(uploaded_by) REFERENCES guests(id)
)
"""
)
conn.commit()
def login_required(view):
@wraps(view)
def wrapped(*args, **kwargs):
if "guest_id" not in session:
return redirect(url_for("landing"))
return view(*args, **kwargs)
return wrapped
def is_allowed_file(filename: str) -> bool:
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
def upsert_guest(name: str) -> int:
now = datetime.utcnow().isoformat()
db = get_db()
row = db.execute("SELECT id FROM guests WHERE name = ?", (name,)).fetchone()
if row:
return int(row["id"])
cursor = db.execute(
"INSERT INTO guests (name, created_at) VALUES (?, ?)",
(name, now),
)
db.commit()
return int(cursor.lastrowid)
@app.get("/health")
def health():
return {"status": "ok"}
@app.post("/api/rsvp")
def create_rsvp():
data = request.get_json(force=True, silent=True) or {}
name = (data.get("name") or "").strip()
attending = data.get("attending")
plus_one = data.get("plus_one", 0)
@app.get("/")
def landing():
if "guest_id" in session:
return redirect(url_for("dashboard"))
return render_template("login.html")
@app.post("/login")
def login():
name = (request.form.get("name") or "").strip()
event_password = request.form.get("event_password") or ""
if not name:
return jsonify({"error": "name is required"}), 400
if attending not in (0, 1, True, False):
return jsonify({"error": "attending must be 0/1"}), 400
flash("Bitte Namen eingeben.")
return redirect(url_for("landing"))
attending_int = 1 if attending in (1, True) else 0
plus_one_int = 1 if plus_one in (1, True) else 0
if event_password != app.config["EVENT_PASSWORD"]:
flash("Ungültiges Event-Passwort.")
return redirect(url_for("landing"))
with get_db() as conn:
conn.execute(
"INSERT INTO rsvps (name, attending, plus_one, created_at) VALUES (?, ?, ?, ?)",
(name, attending_int, plus_one_int, datetime.utcnow().isoformat())
guest_id = upsert_guest(name)
session["guest_id"] = guest_id
session["guest_name"] = name
return redirect(url_for("dashboard"))
@app.post("/logout")
def logout():
session.clear()
return redirect(url_for("landing"))
@app.post("/lang/<code>")
def set_lang(code: str):
if code in TEXTS:
session["lang"] = code
return redirect(request.referrer or url_for("landing"))
@app.get("/dashboard")
@login_required
def dashboard():
return render_template("dashboard.html")
@app.route("/rsvp", methods=["GET", "POST"])
@login_required
def rsvp():
db = get_db()
if request.method == "POST":
attending_raw = request.form.get("attending")
plus_one = 1 if request.form.get("plus_one") == "on" else 0
if attending_raw not in {"yes", "no"}:
flash("Bitte eine RSVP-Auswahl treffen.")
return redirect(url_for("rsvp"))
attending = 1 if attending_raw == "yes" else 0
if not attending:
plus_one = 0
db.execute(
"UPDATE guests SET attending = ?, plus_one = ? WHERE id = ?",
(attending, plus_one, session["guest_id"]),
)
conn.commit()
db.commit()
flash("RSVP gespeichert.")
return redirect(url_for("rsvp"))
return jsonify({"ok": True})
guest = db.execute(
"SELECT attending, plus_one FROM guests WHERE id = ?",
(session["guest_id"],),
).fetchone()
@app.get("/api/rsvps")
def list_rsvps():
with get_db() as conn:
rows = conn.execute(
"SELECT id, name, attending, plus_one, created_at FROM rsvps ORDER BY id DESC"
).fetchall()
return jsonify([dict(r) for r in rows])
return render_template("rsvp.html", guest=guest)
@app.route("/upload", methods=["GET", "POST"])
@login_required
def upload():
if request.method == "POST":
file = request.files.get("photo")
if file is None or file.filename == "":
flash("Bitte eine Bilddatei auswählen.")
return redirect(url_for("upload"))
if not is_allowed_file(file.filename):
flash("Nur JPG/JPEG/PNG sind erlaubt.")
return redirect(url_for("upload"))
safe_name = secure_filename(file.filename)
ext = safe_name.rsplit(".", 1)[1].lower()
stored_name = f"{uuid.uuid4().hex}.{ext}"
upload_dir = app.config["UPLOAD_FOLDER"]
os.makedirs(upload_dir, exist_ok=True)
file.save(os.path.join(upload_dir, stored_name))
db = get_db()
db.execute(
"INSERT INTO uploads (filename, uploaded_by, uploaded_at) VALUES (?, ?, ?)",
(stored_name, session["guest_id"], datetime.utcnow().isoformat()),
)
db.commit()
flash("Upload erfolgreich.")
return redirect(url_for("gallery"))
return render_template("upload.html")
@app.get("/gallery")
@login_required
def gallery():
db = get_db()
images = db.execute(
"""
SELECT uploads.filename, uploads.uploaded_at, guests.name AS uploaded_by
FROM uploads
JOIN guests ON guests.id = uploads.uploaded_by
ORDER BY uploads.id DESC
"""
).fetchall()
return render_template("gallery.html", images=images)
@app.get("/uploads/<path:filename>")
@login_required
def serve_upload(filename: str):
return send_from_directory(app.config["UPLOAD_FOLDER"], filename)
@app.get("/info/<page>")
@login_required
def info(page: str):
allowed = {"schedule", "hotels", "taxi", "location"}
if page not in allowed:
return redirect(url_for("dashboard"))
return render_template("info.html", page=page)
init_db()
if __name__ == "__main__":
init_db()
app.run(host="0.0.0.0", port=5000)
app.run(host="0.0.0.0", port=8000)

BIN
backend/app.sqlite3 Normal file

Binary file not shown.

View File

@@ -6,4 +6,5 @@ readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"flask>=3.1.2",
"gunicorn>=23.0.0",
]

172
backend/static/styles.css Normal file
View File

@@ -0,0 +1,172 @@
:root {
--cream: #f8f2e8;
--beige: #efe3d3;
--forest: #274235;
--gold: #b8914c;
--ink: #1f1f1f;
--card: #fffdf9;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
color: var(--ink);
font-family: "Source Sans 3", sans-serif;
background: radial-gradient(circle at top, #fff, var(--cream));
}
h1,
h2,
h3 {
font-family: "Playfair Display", serif;
margin-top: 0;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid rgba(39, 66, 53, 0.12);
background: rgba(255, 255, 255, 0.82);
backdrop-filter: blur(4px);
}
.brand {
color: var(--forest);
text-decoration: none;
font-size: 1.2rem;
font-weight: 700;
}
.host {
color: rgba(31, 31, 31, 0.7);
font-size: 0.82rem;
}
.toolbar {
display: flex;
gap: 0.4rem;
}
.container {
width: min(960px, 92vw);
margin: 1.5rem auto 3rem;
}
.card {
background: var(--card);
border: 1px solid rgba(39, 66, 53, 0.1);
border-radius: 18px;
box-shadow: 0 10px 32px rgba(39, 66, 53, 0.08);
padding: 1.25rem;
margin-bottom: 1rem;
}
.hero {
background: linear-gradient(145deg, #fff, var(--beige));
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.8rem;
}
.link-card {
color: var(--forest);
text-decoration: none;
display: flex;
align-items: center;
justify-content: center;
min-height: 90px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.link-card:hover {
transform: translateY(-3px);
box-shadow: 0 14px 30px rgba(39, 66, 53, 0.16);
}
.form-card {
max-width: 560px;
}
.form-grid {
display: grid;
gap: 0.8rem;
}
input[type="text"],
input[type="password"],
input[type="file"] {
width: 100%;
margin-top: 0.25rem;
border: 1px solid #d9ceb9;
border-radius: 12px;
padding: 0.65rem 0.75rem;
background: #fff;
}
.radio-row {
display: flex;
align-items: center;
gap: 0.45rem;
}
.btn {
display: inline-block;
border: 0;
border-radius: 12px;
padding: 0.6rem 0.9rem;
cursor: pointer;
color: #fff;
background: var(--forest);
text-decoration: none;
transition: filter 0.2s ease;
}
.btn:hover {
filter: brightness(1.08);
}
.btn-ghost {
color: var(--forest);
background: transparent;
border: 1px solid rgba(39, 66, 53, 0.2);
}
.flash {
padding: 0.7rem 0.9rem;
border-radius: 10px;
background: #f2f7f3;
border: 1px solid rgba(39, 66, 53, 0.2);
}
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 0.8rem;
}
.gallery-item {
margin: 0;
}
.gallery-item img {
width: 100%;
aspect-ratio: 4 / 3;
object-fit: cover;
border-radius: 12px;
}
.map-wrap iframe {
width: 100%;
min-height: 320px;
border: 0;
border-radius: 12px;
margin: 0.8rem 0;
}

View File

@@ -0,0 +1,45 @@
<!doctype html>
<html lang="{{ lang }}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ t('brand') }}</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700&family=Source+Sans+3:wght@400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
</head>
<body>
<header class="topbar">
<div>
<a class="brand" href="{{ url_for('dashboard') if guest_name else url_for('landing') }}">{{ t('brand') }}</a>
<div class="host">{{ request.host }}</div>
</div>
<div class="toolbar">
<form method="post" action="{{ url_for('set_lang', code='de') }}">
<button class="btn btn-ghost" type="submit">DE</button>
</form>
<form method="post" action="{{ url_for('set_lang', code='en') }}">
<button class="btn btn-ghost" type="submit">EN</button>
</form>
{% if guest_name %}
<form method="post" action="{{ url_for('logout') }}">
<button class="btn btn-ghost" type="submit">{{ t('logout') }}</button>
</form>
{% endif %}
</div>
</header>
<main class="container">
{% with messages = get_flashed_messages() %}
{% if messages %}
{% for message in messages %}
<p class="flash">{{ message }}</p>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
</body>
</html>

View File

@@ -0,0 +1,17 @@
{% extends 'base.html' %}
{% block content %}
<section class="card">
<h1>{{ t('dashboard') }}</h1>
<p>Hallo {{ guest_name }}.</p>
</section>
<section class="card-grid">
<a class="card link-card" href="{{ url_for('rsvp') }}">{{ t('rsvp') }}</a>
<a class="card link-card" href="{{ url_for('upload') }}">{{ t('upload') }}</a>
<a class="card link-card" href="{{ url_for('gallery') }}">{{ t('gallery') }}</a>
<a class="card link-card" href="{{ url_for('info', page='schedule') }}">{{ t('schedule') }}</a>
<a class="card link-card" href="{{ url_for('info', page='hotels') }}">{{ t('hotels') }}</a>
<a class="card link-card" href="{{ url_for('info', page='taxi') }}">{{ t('taxi') }}</a>
<a class="card link-card" href="{{ url_for('info', page='location') }}">{{ t('location') }}</a>
</section>
{% endblock %}

View File

@@ -0,0 +1,20 @@
{% extends 'base.html' %}
{% block content %}
<section class="card">
<h1>{{ t('gallery') }}</h1>
{% if images %}
<div class="gallery-grid">
{% for image in images %}
<figure class="gallery-item">
<a href="{{ url_for('serve_upload', filename=image['filename']) }}" target="_blank" rel="noopener">
<img src="{{ url_for('serve_upload', filename=image['filename']) }}" alt="Upload von {{ image['uploaded_by'] }}" loading="lazy" />
</a>
<figcaption>von {{ image['uploaded_by'] }}</figcaption>
</figure>
{% endfor %}
</div>
{% else %}
<p>Noch keine Bilder vorhanden.</p>
{% endif %}
</section>
{% endblock %}

View File

@@ -0,0 +1,31 @@
{% extends 'base.html' %}
{% block content %}
<section class="card">
<h1>
{% if page == 'schedule' %}{{ t('schedule') }}{% endif %}
{% if page == 'hotels' %}{{ t('hotels') }}{% endif %}
{% if page == 'taxi' %}{{ t('taxi') }}{% endif %}
{% if page == 'location' %}{{ t('location') }}{% endif %}
</h1>
{% if page == 'schedule' %}
<p>15:00 Trauung, 17:00 Empfang, 19:00 Dinner.</p>
{% elif page == 'hotels' %}
<p>Empfehlungen folgen. Bitte frühzeitig buchen.</p>
{% elif page == 'taxi' %}
<p>Taxi-Service: 01234 / 567890 (24/7).</p>
{% elif page == 'location' %}
<p><strong>{{ location_name }}</strong></p>
<p>{{ location_address }}</p>
<div class="map-wrap">
<iframe
src="{{ google_maps_embed_url }}"
loading="lazy"
referrerpolicy="no-referrer-when-downgrade"
allowfullscreen
></iframe>
</div>
<a class="btn" href="{{ location_website_url }}" target="_blank" rel="noopener">{{ t('visit_location') }}</a>
{% endif %}
</section>
{% endblock %}

View File

@@ -0,0 +1,24 @@
{% extends 'base.html' %}
{% block content %}
<section class="hero card">
<h1>{{ t('subtitle') }}</h1>
<p>Passwortgeschützter Zugriff für unsere Gäste.</p>
</section>
<section class="card form-card">
<h2>{{ t('login') }}</h2>
<form method="post" action="{{ url_for('login') }}" class="form-grid">
<label>
{{ t('name') }}
<input type="text" name="name" required />
</label>
<label>
{{ t('event_password') }}
<input type="password" name="event_password" required />
</label>
<button class="btn" type="submit">{{ t('login_submit') }}</button>
</form>
</section>
{% endblock %}

View File

@@ -0,0 +1,24 @@
{% extends 'base.html' %}
{% block content %}
<section class="card form-card">
<h1>{{ t('rsvp') }}</h1>
<form method="post" class="form-grid">
<label class="radio-row">
<input type="radio" name="attending" value="yes" {% if guest and guest['attending'] == 1 %}checked{% endif %} />
{{ t('attending') }}
</label>
<label class="radio-row">
<input type="radio" name="attending" value="no" {% if guest and guest['attending'] == 0 %}checked{% endif %} />
{{ t('not_attending') }}
</label>
<label>
<input type="checkbox" name="plus_one" {% if guest and guest['plus_one'] == 1 %}checked{% endif %} />
{{ t('plus_one') }}
</label>
<button class="btn" type="submit">{{ t('save') }}</button>
</form>
</section>
{% endblock %}

View File

@@ -0,0 +1,13 @@
{% extends 'base.html' %}
{% block content %}
<section class="card form-card">
<h1>{{ t('upload') }}</h1>
<form method="post" enctype="multipart/form-data" class="form-grid">
<label>
{{ t('file') }}
<input type="file" name="photo" accept=".jpg,.jpeg,.png" required />
</label>
<button class="btn" type="submit">{{ t('upload_submit') }}</button>
</form>
</section>
{% endblock %}

27
backend/uv.lock generated
View File

@@ -8,10 +8,14 @@ version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "flask" },
{ name = "gunicorn" },
]
[package.metadata]
requires-dist = [{ name = "flask", specifier = ">=3.1.2" }]
requires-dist = [
{ name = "flask", specifier = ">=3.1.2" },
{ name = "gunicorn", specifier = ">=23.0.0" },
]
[[package]]
name = "blinker"
@@ -60,6 +64,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" },
]
[[package]]
name = "gunicorn"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/13/ef67f59f6a7896fdc2c1d62b5665c5219d6b0a9a1784938eb9a28e55e128/gunicorn-25.1.0.tar.gz", hash = "sha256:1426611d959fa77e7de89f8c0f32eed6aa03ee735f98c01efba3e281b1c47616", size = 594377, upload-time = "2026-02-13T11:09:58.989Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/73/4ad5b1f6a2e21cf1e85afdaad2b7b1a933985e2f5d679147a1953aaa192c/gunicorn-25.1.0-py3-none-any.whl", hash = "sha256:d0b1236ccf27f72cfe14bce7caadf467186f19e865094ca84221424e839b8b8b", size = 197067, upload-time = "2026-02-13T11:09:57.146Z" },
]
[[package]]
name = "itsdangerous"
version = "2.2.0"
@@ -144,6 +160,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "werkzeug"
version = "3.1.5"