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 WORKDIR /app
# uv installieren
RUN pip install uv RUN pip install uv
# Nur dependency files zuerst kopieren (Docker Cache!)
COPY pyproject.toml uv.lock ./ COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev RUN uv sync --frozen --no-dev
# Rest kopieren COPY . .
COPY app.py .
EXPOSE 5000 EXPOSE 8000
CMD ["uv", "run", "app.py"] 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 os
import sqlite3 import sqlite3
import uuid
from datetime import datetime 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__) 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(): TEXTS = {
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) "de": {
conn = sqlite3.connect(DB_PATH) "brand": "Svenja & Dominic",
conn.row_factory = sqlite3.Row "subtitle": "Willkommen zu unserer Hochzeits-App",
return conn "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: def get_lang() -> str:
conn.execute(""" lang = session.get("lang", "de")
CREATE TABLE IF NOT EXISTS rsvps ( 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, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, name TEXT NOT NULL UNIQUE,
attending INTEGER NOT NULL, -- 1 = ja, 0 = nein attending INTEGER,
plus_one INTEGER NOT NULL DEFAULT 0, -- 1 = ja, 0 = nein plus_one INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL 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() 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") @app.get("/health")
def health(): def health():
return {"status": "ok"} return {"status": "ok"}
@app.post("/api/rsvp")
def create_rsvp(): @app.get("/")
data = request.get_json(force=True, silent=True) or {} def landing():
name = (data.get("name") or "").strip() if "guest_id" in session:
attending = data.get("attending") return redirect(url_for("dashboard"))
plus_one = data.get("plus_one", 0) 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: if not name:
return jsonify({"error": "name is required"}), 400 flash("Bitte Namen eingeben.")
if attending not in (0, 1, True, False): return redirect(url_for("landing"))
return jsonify({"error": "attending must be 0/1"}), 400
attending_int = 1 if attending in (1, True) else 0 if event_password != app.config["EVENT_PASSWORD"]:
plus_one_int = 1 if plus_one in (1, True) else 0 flash("Ungültiges Event-Passwort.")
return redirect(url_for("landing"))
with get_db() as conn: guest_id = upsert_guest(name)
conn.execute( session["guest_id"] = guest_id
"INSERT INTO rsvps (name, attending, plus_one, created_at) VALUES (?, ?, ?, ?)", session["guest_name"] = name
(name, attending_int, plus_one_int, datetime.utcnow().isoformat()) 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") return render_template("rsvp.html", guest=guest)
def list_rsvps():
with get_db() as conn:
rows = conn.execute( @app.route("/upload", methods=["GET", "POST"])
"SELECT id, name, attending, plus_one, created_at FROM rsvps ORDER BY id DESC" @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() ).fetchall()
return jsonify([dict(r) for r in rows]) 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__": if __name__ == "__main__":
init_db() app.run(host="0.0.0.0", port=8000)
app.run(host="0.0.0.0", port=5000)

BIN
backend/app.sqlite3 Normal file

Binary file not shown.

View File

@@ -6,4 +6,5 @@ readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"flask>=3.1.2", "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 = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "flask" }, { name = "flask" },
{ name = "gunicorn" },
] ]
[package.metadata] [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]] [[package]]
name = "blinker" 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" }, { 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]] [[package]]
name = "itsdangerous" name = "itsdangerous"
version = "2.2.0" 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" }, { 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]] [[package]]
name = "werkzeug" name = "werkzeug"
version = "3.1.5" version = "3.1.5"

Binary file not shown.

View File

@@ -6,16 +6,21 @@ services:
ports: ports:
- "127.0.0.1:8080:80" - "127.0.0.1:8080:80"
volumes: volumes:
- ./frontend/public:/usr/share/nginx/html:ro - ./frontend/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- backend
backend: backend:
build: ./backend build: ./backend
container_name: wedding-backend container_name: wedding-backend
restart: unless-stopped restart: unless-stopped
ports: ports:
- "127.0.0.1:5000:5000" - "127.0.0.1:5000:8000"
volumes: volumes:
- ./data/uploads:/app/uploads - ./data/uploads:/app/uploads
- ./data/db:/app/db - ./data/db:/app/db
environment: environment:
- DB_PATH=/app/db/app.sqlite3 - DB_PATH=/app/db/app.sqlite3
- UPLOAD_FOLDER=/app/uploads
- EVENT_PASSWORD=${EVENT_PASSWORD:-wedding2026}
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}

View File

@@ -0,0 +1,15 @@
server {
listen 80;
server_name _;
client_max_body_size 10M;
location / {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

View File

@@ -2,10 +2,19 @@
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Svenja & Dominic</title> <title>Wedding App</title>
<style>
body { font-family: system-ui, sans-serif; margin: 0; min-height: 100vh; display: grid; place-items: center; background: #f8f6f2; color: #1f1f1f; }
main { text-align: center; padding: 24px; }
a { display: inline-block; margin-top: 12px; padding: 10px 14px; border-radius: 10px; text-decoration: none; background: #274235; color: #fff; }
</style>
</head> </head>
<body style="font-family: system-ui; padding: 40px;"> <body>
<h1>Hallo Welt 👋</h1> <main>
<h1>Svenja & Dominic</h1>
<p>Nginx ist erreichbar. Die App läuft im Backend.</p>
<a href="http://127.0.0.1:5000/">Zum Login</a>
</main>
</body> </body>
</html> </html>