From 6353ba4707a1ff10ba3070e1572906db5d5b7be0 Mon Sep 17 00:00:00 2001 From: Dominic Date: Thu, 19 Feb 2026 19:03:20 +0000 Subject: [PATCH] Testversion 1 --- backend/Dockerfile | 10 +- backend/__pycache__/app.cpython-312.pyc | Bin 0 -> 15886 bytes backend/app.py | 367 +++++++++++++++++++++--- backend/app.sqlite3 | Bin 0 -> 20480 bytes backend/pyproject.toml | 1 + backend/static/styles.css | 172 +++++++++++ backend/templates/base.html | 45 +++ backend/templates/dashboard.html | 17 ++ backend/templates/gallery.html | 20 ++ backend/templates/info.html | 31 ++ backend/templates/login.html | 24 ++ backend/templates/rsvp.html | 24 ++ backend/templates/upload.html | 13 + backend/uv.lock | 27 +- data/db/app.sqlite3 | Bin 12288 -> 24576 bytes docker-compose.yml | 9 +- frontend/nginx/default.conf | 15 + frontend/public/index.html | 17 +- 18 files changed, 734 insertions(+), 58 deletions(-) create mode 100644 backend/__pycache__/app.cpython-312.pyc create mode 100644 backend/app.sqlite3 create mode 100644 backend/static/styles.css create mode 100644 backend/templates/base.html create mode 100644 backend/templates/dashboard.html create mode 100644 backend/templates/gallery.html create mode 100644 backend/templates/info.html create mode 100644 backend/templates/login.html create mode 100644 backend/templates/rsvp.html create mode 100644 backend/templates/upload.html create mode 100644 frontend/nginx/default.conf diff --git a/backend/Dockerfile b/backend/Dockerfile index c4e6519..23f7c10 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/__pycache__/app.cpython-312.pyc b/backend/__pycache__/app.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..934e241aa417dfe88829dfbddbd7e04b70e4449f GIT binary patch literal 15886 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 literal 0 HcmV?d00001 diff --git a/backend/app.py b/backend/app.py index 6e8530c..25819d1 100644 --- a/backend/app.py +++ b/backend/app.py @@ -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/") +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/") +@login_required +def serve_upload(filename: str): + return send_from_directory(app.config["UPLOAD_FOLDER"], filename) + + +@app.get("/info/") +@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) diff --git a/backend/app.sqlite3 b/backend/app.sqlite3 new file mode 100644 index 0000000000000000000000000000000000000000..711cf4181eddb79d3804551cd2b15f749db045da GIT binary patch literal 20480 zcmeI(zi!$<90%|_AS56Wx>*RRx~(9R2tz~LEUlXy5vpSn$Cko`9DIo^ft|#rYS$=B zU!||o2k4Vj>ew@es9>q>(4j@&pM3ai?vMTX$-{wsex`eoU{}H2#EDp&JRy{lH;fTN za& zpuqipZd0jN>4O$Imt!%#83&FV&UYJ!Cf6*^EbXn%+59A{71>I7F6$Ww3CfGOuulmmeRe`GeMSx%Zoa zyM2@Qj*a+QYd=j=XD07*lN*QpoLx^v7=<;@tuI}F{WP0b8Vx#_sfD*=xyT%b;&v+h zkyts*Eh(;iS&IdBWY7BjQ@W{CD)d2_jhRfGd}fwLNY4GWG^A6&Y@>H(^K>ShC=$Nw z`PYj)q?_D~r{OU0#iGSyUfB`vYPN2%X1depPB@WF@M_AI$s3ux^14Ea=ey#=oY9#^ z!R&aLs0`I)pOuf=R{Xe?WTEA+L~aNOKmY;|fB*y_009U<00Izz00jOofoC*Ns)a4> zD42M@H)=Ioo!w?@SAAuvFAv%;4%E)xeyiP4pBLqQ(egJUHv|MA009U<00Izz00bZa z0SG_<0_!AD%vKBYUj~x@|FQmGr}W}pAOHafKmY;|fB*y_009U<00J|C0?k$m$%_E- g`d_~EkAMIKAOHafKmY;|fB*y_009U=3.1.2", + "gunicorn>=23.0.0", ] diff --git a/backend/static/styles.css b/backend/static/styles.css new file mode 100644 index 0000000..7fc2036 --- /dev/null +++ b/backend/static/styles.css @@ -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; +} diff --git a/backend/templates/base.html b/backend/templates/base.html new file mode 100644 index 0000000..cadf4c0 --- /dev/null +++ b/backend/templates/base.html @@ -0,0 +1,45 @@ + + + + + + {{ t('brand') }} + + + + + + +
+
+ {{ t('brand') }} +
{{ request.host }}
+
+
+
+ +
+
+ +
+ {% if guest_name %} +
+ +
+ {% endif %} +
+
+ +
+ {% with messages = get_flashed_messages() %} + {% if messages %} + {% for message in messages %} +

{{ message }}

+ {% endfor %} + {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+ + diff --git a/backend/templates/dashboard.html b/backend/templates/dashboard.html new file mode 100644 index 0000000..df95904 --- /dev/null +++ b/backend/templates/dashboard.html @@ -0,0 +1,17 @@ +{% extends 'base.html' %} +{% block content %} +
+

{{ t('dashboard') }}

+

Hallo {{ guest_name }}.

+
+ +
+ {{ t('rsvp') }} + {{ t('upload') }} + {{ t('gallery') }} + {{ t('schedule') }} + {{ t('hotels') }} + {{ t('taxi') }} + {{ t('location') }} +
+{% endblock %} diff --git a/backend/templates/gallery.html b/backend/templates/gallery.html new file mode 100644 index 0000000..8ea1148 --- /dev/null +++ b/backend/templates/gallery.html @@ -0,0 +1,20 @@ +{% extends 'base.html' %} +{% block content %} +
+

{{ t('gallery') }}

+ {% if images %} + + {% else %} +

Noch keine Bilder vorhanden.

+ {% endif %} +
+{% endblock %} diff --git a/backend/templates/info.html b/backend/templates/info.html new file mode 100644 index 0000000..52afbbd --- /dev/null +++ b/backend/templates/info.html @@ -0,0 +1,31 @@ +{% extends 'base.html' %} +{% block content %} +
+

+ {% if page == 'schedule' %}{{ t('schedule') }}{% endif %} + {% if page == 'hotels' %}{{ t('hotels') }}{% endif %} + {% if page == 'taxi' %}{{ t('taxi') }}{% endif %} + {% if page == 'location' %}{{ t('location') }}{% endif %} +

+ + {% if page == 'schedule' %} +

15:00 Trauung, 17:00 Empfang, 19:00 Dinner.

+ {% elif page == 'hotels' %} +

Empfehlungen folgen. Bitte frühzeitig buchen.

+ {% elif page == 'taxi' %} +

Taxi-Service: 01234 / 567890 (24/7).

+ {% elif page == 'location' %} +

{{ location_name }}

+

{{ location_address }}

+
+ +
+ {{ t('visit_location') }} + {% endif %} +
+{% endblock %} diff --git a/backend/templates/login.html b/backend/templates/login.html new file mode 100644 index 0000000..b666b32 --- /dev/null +++ b/backend/templates/login.html @@ -0,0 +1,24 @@ +{% extends 'base.html' %} +{% block content %} +
+

{{ t('subtitle') }}

+

Passwortgeschützter Zugriff für unsere Gäste.

+
+ +
+

{{ t('login') }}

+
+ + + + + +
+
+{% endblock %} diff --git a/backend/templates/rsvp.html b/backend/templates/rsvp.html new file mode 100644 index 0000000..efa8acd --- /dev/null +++ b/backend/templates/rsvp.html @@ -0,0 +1,24 @@ +{% extends 'base.html' %} +{% block content %} +
+

{{ t('rsvp') }}

+
+ + + + + + + +
+
+{% endblock %} diff --git a/backend/templates/upload.html b/backend/templates/upload.html new file mode 100644 index 0000000..38376aa --- /dev/null +++ b/backend/templates/upload.html @@ -0,0 +1,13 @@ +{% extends 'base.html' %} +{% block content %} +
+

{{ t('upload') }}

+
+ + +
+
+{% endblock %} diff --git a/backend/uv.lock b/backend/uv.lock index 08b774e..6294d82 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -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" diff --git a/data/db/app.sqlite3 b/data/db/app.sqlite3 index 13a70234c5975102524c85de538f05c769ae433e..746c42526b2898ce4ac4d71278aec9a713fd917b 100644 GIT binary patch delta 583 zcmZojXgI()L0XWNfq{Vyh+%+vqK>gRD}!EDAunGp11tA)2EOBbrM&Wd4&2W-78Y__ zHkq=ri%Uy0wm6m~Cgr4-7Ubk7rW8XdHs>H$#}HSA5Jx8;R|Tk`f(DlY5KR8YXU73z zq-Ex$PB!6J5C?G}YEo0;lPVQF{X$&bU4tgu@{9AM$R(Ccw&0hk2Z_1)2f2E>`zd(4 zMrt7G)l>*_b#o1J^>cO&R!A>REiNh6$V|}$TMW_$0-8;7% - - Svenja & Dominic + + Wedding App + - -

Hallo Welt 👋

+ +
+

Svenja & Dominic

+

Nginx ist erreichbar. Die App läuft im Backend.

+ Zum Login +