Compare commits
6 Commits
12eece0226
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fd3f49a2e1 | |||
| a40b028a0e | |||
| 450a4b062f | |||
| a8b26a25da | |||
| 630595bce9 | |||
| e062a1e836 |
81
index.html
Normal file → Executable file
81
index.html
Normal file → Executable file
@@ -313,6 +313,62 @@
|
||||
.order-btn { width: 100%; justify-content: center; }
|
||||
}
|
||||
@media (max-width: 640px) { .grid { grid-template-columns: 1fr; } }
|
||||
|
||||
/* Toast Notifications */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
min-width: 300px;
|
||||
max-width: 400px;
|
||||
padding: 16px 20px;
|
||||
border-radius: 12px;
|
||||
background: rgba(12, 31, 51, 0.98);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.4), 0 0 1px rgba(255, 255, 255, 0.3);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
pointer-events: all;
|
||||
transform: translateX(400px);
|
||||
opacity: 0;
|
||||
transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
}
|
||||
.toast.show {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
.toast.success {
|
||||
border-left: 4px solid var(--ok);
|
||||
background: linear-gradient(90deg, rgba(123, 213, 141, 0.15), rgba(12, 31, 51, 0.98));
|
||||
}
|
||||
.toast.error {
|
||||
border-left: 4px solid var(--bad);
|
||||
background: linear-gradient(90deg, rgba(255, 107, 125, 0.15), rgba(12, 31, 51, 0.98));
|
||||
}
|
||||
.toast.info {
|
||||
border-left: 4px solid var(--accent);
|
||||
background: linear-gradient(90deg, rgba(243, 213, 42, 0.15), rgba(12, 31, 51, 0.98));
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.toast-container {
|
||||
top: auto;
|
||||
bottom: 20px;
|
||||
right: 16px;
|
||||
left: 16px;
|
||||
}
|
||||
.toast {
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -403,7 +459,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toastContainer" class="toast-container"></div>
|
||||
|
||||
<script>
|
||||
// Toast Notification System
|
||||
function showToast(message, type = 'info') {
|
||||
const container = document.getElementById('toastContainer');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.textContent = message;
|
||||
|
||||
container.appendChild(toast);
|
||||
|
||||
// Trigger animation
|
||||
setTimeout(() => toast.classList.add('show'), 10);
|
||||
|
||||
// Auto-remove after 4 seconds
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => container.removeChild(toast), 300);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
// Proxy der WaWi‑App (kein API‑Key im Browser nötig).
|
||||
const API_URL = "/wawi/proxy/bestand";
|
||||
let DATA = [];
|
||||
@@ -563,13 +640,13 @@ form.addEventListener("submit", async (e) => {
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (res.ok) {
|
||||
alert("Bestellung gesendet.");
|
||||
showToast("Bestellung erfolgreich gesendet! Wir melden uns bei dir.", "success");
|
||||
modal.classList.remove("open");
|
||||
document.getElementById("detailModal").classList.remove("open");
|
||||
form.reset();
|
||||
setField("fMenge", 1);
|
||||
} else {
|
||||
alert("Fehler beim Senden der Bestellung.");
|
||||
showToast("Fehler beim Senden der Bestellung. Bitte versuche es erneut.", "error");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
5
requirements.txt
Executable file
5
requirements.txt
Executable file
@@ -0,0 +1,5 @@
|
||||
Flask>=3.0.0
|
||||
Flask-WTF>=1.2.0
|
||||
Werkzeug>=3.0.0
|
||||
gunicorn>=21.0.0
|
||||
Pillow>=10.0.0
|
||||
131
wawi/app.py
131
wawi/app.py
@@ -14,6 +14,7 @@ import secrets
|
||||
import smtplib
|
||||
import time
|
||||
import threading
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
from email.message import EmailMessage
|
||||
from functools import wraps
|
||||
@@ -21,7 +22,15 @@ from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
HAS_PIL = True
|
||||
except ImportError:
|
||||
HAS_PIL = False
|
||||
logger.warning("Pillow nicht installiert - Bild-Optimierung deaktiviert")
|
||||
|
||||
from flask import Flask, Blueprint, g, flash, jsonify, redirect, render_template, request, session, url_for
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
@@ -30,17 +39,42 @@ DB_PATH = BASE_DIR / "hellas.db"
|
||||
UPLOAD_DIR = BASE_DIR / "static" / "uploads"
|
||||
ALLOWED_EXT = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
|
||||
|
||||
# Logging konfigurieren
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s [%(levelname)s] %(name)s: %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Optionaler Prefix (z. B. /wawi), wenn die App hinter einem Sub‑Pfad läuft.
|
||||
URL_PREFIX = os.environ.get("URL_PREFIX", "").strip().rstrip("/")
|
||||
STATIC_URL_PATH = f"{URL_PREFIX}/static" if URL_PREFIX else "/static"
|
||||
|
||||
app = Flask(__name__, static_url_path=STATIC_URL_PATH)
|
||||
|
||||
# Session‑Secret für Login‑Cookies (in Produktion unbedingt setzen).
|
||||
app.secret_key = os.environ.get("SECRET_KEY", "change-me")
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY", "change-me")
|
||||
|
||||
# Validierung: SECRET_KEY muss in Produktion gesetzt sein
|
||||
if SECRET_KEY == "change-me":
|
||||
import sys
|
||||
if not app.debug and "pytest" not in sys.modules:
|
||||
raise RuntimeError(
|
||||
"SECURITY ERROR: SECRET_KEY ist nicht gesetzt!\n"
|
||||
"Setze die Umgebungsvariable SECRET_KEY mit einem sicheren Wert.\n"
|
||||
"Beispiel: export SECRET_KEY=$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')"
|
||||
)
|
||||
|
||||
app.secret_key = SECRET_KEY
|
||||
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
||||
app.config["SESSION_COOKIE_SECURE"] = os.environ.get("COOKIE_SECURE", "1") == "1"
|
||||
app.config["SESSION_COOKIE_HTTPONLY"] = True
|
||||
app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024
|
||||
|
||||
# CSRF-Schutz aktivieren
|
||||
csrf = CSRFProtect(app)
|
||||
|
||||
bp = Blueprint("bp", __name__)
|
||||
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
@@ -133,6 +167,7 @@ def init_db() -> None:
|
||||
ensure_price_column(db)
|
||||
ensure_image_column(db)
|
||||
ensure_orders_columns(db)
|
||||
ensure_indexes(db)
|
||||
ensure_admin_user(db)
|
||||
|
||||
|
||||
@@ -173,6 +208,26 @@ def ensure_orders_columns(db: sqlite3.Connection) -> None:
|
||||
db.commit()
|
||||
|
||||
|
||||
def ensure_indexes(db: sqlite3.Connection) -> None:
|
||||
"""Erstellt Indizes für bessere Query-Performance (idempotent)."""
|
||||
# Index für items.artikel (häufig gesucht/gefiltert)
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_items_artikel ON items(artikel)")
|
||||
|
||||
# Index für items(artikel, groesse) für eindeutige Zuordnung
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_items_artikel_groesse ON items(artikel, groesse)")
|
||||
|
||||
# Index für orders.done und orders.canceled (Filter "offene Bestellungen")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_orders_done ON orders(done)")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_orders_canceled ON orders(canceled)")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(done, canceled)")
|
||||
|
||||
# Index für ausbuchungen.item_id (Foreign Key, JOINs)
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_ausbuchungen_item_id ON ausbuchungen(item_id)")
|
||||
|
||||
db.commit()
|
||||
logger.info("Datenbank-Indizes überprüft/erstellt")
|
||||
|
||||
|
||||
@app.before_request
|
||||
def ensure_db() -> None:
|
||||
"""Stellt sicher, dass DB/Schema einmal pro Worker initialisiert ist."""
|
||||
@@ -188,7 +243,7 @@ def now_iso() -> str:
|
||||
|
||||
|
||||
def save_upload(file) -> str | None:
|
||||
"""Speichert einen Bild‑Upload und gibt die öffentliche URL zurück."""
|
||||
"""Speichert einen Bild‑Upload mit automatischer Optimierung und Thumbnail-Erstellung."""
|
||||
if not file or not getattr(file, "filename", ""):
|
||||
return None
|
||||
ext = os.path.splitext(file.filename)[1].lower()
|
||||
@@ -196,11 +251,61 @@ def save_upload(file) -> str | None:
|
||||
return None
|
||||
if not (file.mimetype or "").startswith("image/"):
|
||||
return None
|
||||
|
||||
name = secure_filename(Path(file.filename).stem) or "image"
|
||||
safe_name = f"{name}-{uuid4().hex}{ext}"
|
||||
dest = UPLOAD_DIR / safe_name
|
||||
file.save(dest)
|
||||
return f"{URL_PREFIX}/static/uploads/{safe_name}" if URL_PREFIX else f"/static/uploads/{safe_name}"
|
||||
unique_id = uuid4().hex
|
||||
|
||||
# Original speichern
|
||||
original_name = f"{name}-{unique_id}{ext}"
|
||||
original_path = UPLOAD_DIR / original_name
|
||||
file.save(original_path)
|
||||
|
||||
# Bild optimieren (falls Pillow verfügbar)
|
||||
if HAS_PIL:
|
||||
try:
|
||||
with Image.open(original_path) as img:
|
||||
# EXIF-Orientierung korrigieren
|
||||
try:
|
||||
from PIL import ImageOps
|
||||
img = ImageOps.exif_transpose(img)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Konvertiere RGBA zu RGB (für JPEG)
|
||||
if img.mode in ('RGBA', 'LA', 'P'):
|
||||
background = Image.new('RGB', img.size, (255, 255, 255))
|
||||
if img.mode == 'P':
|
||||
img = img.convert('RGBA')
|
||||
background.paste(img, mask=img.split()[-1] if img.mode in ('RGBA', 'LA') else None)
|
||||
img = background
|
||||
|
||||
# Optimiertes Hauptbild (max 800x800)
|
||||
optimized_name = f"{name}-{unique_id}-opt.jpg"
|
||||
optimized_path = UPLOAD_DIR / optimized_name
|
||||
img_copy = img.copy()
|
||||
img_copy.thumbnail((800, 800), Image.Resampling.LANCZOS)
|
||||
img_copy.save(optimized_path, 'JPEG', quality=85, optimize=True)
|
||||
|
||||
# Thumbnail (max 400x400)
|
||||
thumb_name = f"{name}-{unique_id}-thumb.jpg"
|
||||
thumb_path = UPLOAD_DIR / thumb_name
|
||||
img.thumbnail((400, 400), Image.Resampling.LANCZOS)
|
||||
img.save(thumb_path, 'JPEG', quality=80, optimize=True)
|
||||
|
||||
# Original löschen (verwenden nur optimierte Versionen)
|
||||
original_path.unlink()
|
||||
|
||||
logger.info(f"Bild optimiert: {optimized_name} & {thumb_name}")
|
||||
|
||||
# Thumbnail-URL zurückgeben (wird als Standard verwendet)
|
||||
return f"{URL_PREFIX}/static/uploads/{thumb_name}" if URL_PREFIX else f"/static/uploads/{thumb_name}"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler bei Bild-Optimierung: {e}")
|
||||
# Fallback: Original verwenden
|
||||
|
||||
# Fallback wenn Pillow fehlt oder Fehler aufgetreten ist
|
||||
return f"{URL_PREFIX}/static/uploads/{original_name}" if URL_PREFIX else f"/static/uploads/{original_name}"
|
||||
|
||||
|
||||
def ensure_admin_user(db: sqlite3.Connection) -> None:
|
||||
@@ -472,8 +577,10 @@ def login():
|
||||
).fetchone()
|
||||
if row and check_password_hash(row["password_hash"], password):
|
||||
session["user"] = user
|
||||
logger.info(f"Erfolgreicher Login: {user}")
|
||||
nxt = request.args.get("next") or url_for("bp.index")
|
||||
return redirect(nxt)
|
||||
logger.warning(f"Fehlgeschlagener Login-Versuch: {user}")
|
||||
return render_template("login.html", error=True)
|
||||
return render_template("login.html", error=False)
|
||||
|
||||
@@ -597,6 +704,7 @@ def build_bestand() -> list[dict]:
|
||||
|
||||
|
||||
@bp.route("/order", methods=["POST"])
|
||||
@csrf.exempt # JSON API ohne CSRF-Schutz (nutzt API-Key stattdessen)
|
||||
def order():
|
||||
"""Erstellt eine Bestellung (optional API‑Key) und versendet Mail."""
|
||||
ip = request.headers.get("X-Forwarded-For", request.remote_addr or "unknown").split(",")[0].strip()
|
||||
@@ -634,6 +742,7 @@ def order():
|
||||
),
|
||||
)
|
||||
db.commit()
|
||||
logger.info(f"Neue Bestellung: {data.get('artikel')} ({data.get('groesse')}) x{data.get('menge')} von {data.get('name')}")
|
||||
|
||||
to_addr = os.environ.get("ORDER_TO", "bjoern@welker.me")
|
||||
smtp_host = os.environ.get("SMTP_HOST")
|
||||
@@ -664,10 +773,19 @@ def order():
|
||||
msg.set_content(body)
|
||||
|
||||
def _send():
|
||||
"""Sendet Bestellungs-Email asynchron mit Error Handling."""
|
||||
try:
|
||||
with smtplib.SMTP(smtp_host, smtp_port) as server:
|
||||
server.starttls()
|
||||
server.login(smtp_user, smtp_pass)
|
||||
server.send_message(msg, to_addrs=recipients)
|
||||
logger.info(f"Bestellungs-Email erfolgreich versendet an {', '.join(recipients)}")
|
||||
except smtplib.SMTPAuthenticationError as e:
|
||||
logger.error(f"SMTP-Authentifizierung fehlgeschlagen: {e}")
|
||||
except smtplib.SMTPException as e:
|
||||
logger.error(f"SMTP-Fehler beim Email-Versand: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Unerwarteter Fehler beim Email-Versand: {e}", exc_info=True)
|
||||
|
||||
threading.Thread(target=_send, daemon=True).start()
|
||||
|
||||
@@ -739,6 +857,7 @@ def complete_order(order_id: int):
|
||||
(user, now_iso(), order_id),
|
||||
)
|
||||
db.commit()
|
||||
logger.info(f"Bestellung #{order_id} abgeschlossen von {user}")
|
||||
return redirect(url_for("bp.orders"))
|
||||
|
||||
|
||||
|
||||
1
wawi/templates/ausbuchen.html
Normal file → Executable file
1
wawi/templates/ausbuchen.html
Normal file → Executable file
@@ -4,6 +4,7 @@
|
||||
<h2>Ausbuchen: {{ item.artikel }} ({{ item.groesse }})</h2>
|
||||
<div class="note">Aktueller Bestand: <strong>{{ item.gezaehlt }}</strong></div>
|
||||
<form method="post" onsubmit="return confirm('Wirklich ausbuchen?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
Menge
|
||||
|
||||
1
wawi/templates/edit.html
Normal file → Executable file
1
wawi/templates/edit.html
Normal file → Executable file
@@ -3,6 +3,7 @@
|
||||
<div class="card form-card">
|
||||
<h2>{{ "Artikel bearbeiten" if item else "Neuen Artikel anlegen" }}</h2>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
Artikel
|
||||
|
||||
2
wawi/templates/index.html
Normal file → Executable file
2
wawi/templates/index.html
Normal file → Executable file
@@ -67,10 +67,12 @@
|
||||
<td class="actions">
|
||||
<a class="btn icon" href="{{ url_for('bp.edit_item', item_id=r.id) }}" title="Bearbeiten" aria-label="Bearbeiten"><span>✎</span></a>
|
||||
<form method="post" action="{{ url_for('bp.verkauf', item_id=r.id) }}" onsubmit="return confirm('Wirklich 1 Stück als verkauft buchen?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<button class="btn icon" type="submit" title="Verkauf +1" aria-label="Verkauf +1"><span>🛒</span></button>
|
||||
</form>
|
||||
<a class="btn icon" href="{{ url_for('bp.ausbuchen', item_id=r.id) }}" title="Ausbuchen" aria-label="Ausbuchen"><span>⇩</span></a>
|
||||
<form method="post" action="{{ url_for('bp.delete_item', item_id=r.id) }}" onsubmit="return confirm('Wirklich löschen? Dieser Vorgang kann nicht rückgängig gemacht werden.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<button class="btn icon danger" type="submit" title="Löschen" aria-label="Löschen"><span>🗑</span></button>
|
||||
</form>
|
||||
</td>
|
||||
|
||||
1
wawi/templates/login.html
Normal file → Executable file
1
wawi/templates/login.html
Normal file → Executable file
@@ -6,6 +6,7 @@
|
||||
<div class="note">Benutzername oder Passwort ist falsch.</div>
|
||||
{% endif %}
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
Benutzer
|
||||
|
||||
2
wawi/templates/orders.html
Normal file → Executable file
2
wawi/templates/orders.html
Normal file → Executable file
@@ -45,9 +45,11 @@
|
||||
<td class="actions">
|
||||
{% if not o.done and not o.canceled %}
|
||||
<form method="post" action="{{ url_for('bp.complete_order', order_id=o.id) }}" onsubmit="return confirm('Bestellung als erledigt markieren?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<button class="btn small" type="submit">Erledigt</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('bp.cancel_order', order_id=o.id) }}" onsubmit="return confirm('Bestellung wirklich stornieren?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<button class="btn small danger" type="submit">Stornieren</button>
|
||||
</form>
|
||||
{% else %}
|
||||
|
||||
3
wawi/templates/users.html
Normal file → Executable file
3
wawi/templates/users.html
Normal file → Executable file
@@ -15,6 +15,7 @@
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
Benutzername
|
||||
@@ -47,9 +48,11 @@
|
||||
<td>{{ u.created_at }}</td>
|
||||
<td class="actions">
|
||||
<form method="post" action="{{ url_for('bp.reset_user_password', user_id=u.id) }}" onsubmit="return confirm('Passwort für diesen Benutzer wirklich zurücksetzen?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<button class="btn small" type="submit">Passwort neu</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('bp.delete_user', user_id=u.id) }}" onsubmit="return confirm('Benutzer wirklich löschen?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<button class="btn small danger" type="submit">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
|
||||
Reference in New Issue
Block a user