Compare commits

...

10 Commits

Author SHA1 Message Date
fd3f49a2e1 feat: add automatic image optimization and thumbnails
Image Processing:
- Add Pillow dependency for image manipulation
- Auto-create optimized versions on upload
- Generate main image (max 800x800, 85% quality)
- Generate thumbnail (max 400x400, 80% quality)
- Delete original after optimization

Quality Improvements:
- Auto-correct EXIF orientation (photos from phones)
- Convert RGBA/transparency to RGB with white background
- Use LANCZOS resampling for high-quality downscaling
- Optimize JPEG compression

Performance:
- Smaller file sizes = faster page loads
- Thumbnails for product listings
- Optimized full-size for detail views
- Reduced storage usage

Fallback:
- Graceful degradation if Pillow not installed
- Error handling preserves original on failure
- Logging for monitoring optimization success

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-06 08:22:11 +01:00
a40b028a0e feat: replace alert() with modern toast notifications
UI Improvements:
- Add animated toast notification system
- Replace browser alert() with styled toast messages
- Support success, error, and info types
- Auto-dismiss after 4 seconds
- Smooth slide-in animation
- Mobile-responsive positioning (bottom on mobile)

User Experience:
- Success: "Bestellung erfolgreich gesendet! Wir melden uns bei dir."
- Error: "Fehler beim Senden der Bestellung. Bitte versuche es erneut."
- Non-blocking notifications (no modal interruption)
- Modern, polished look matching site design

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-06 08:20:07 +01:00
450a4b062f perf: add database indexes for better query performance
Indexes added:
- items.artikel - frequently searched/filtered
- items(artikel, groesse) - unique article/size lookup
- orders.done - order completion filter
- orders.canceled - order cancellation filter
- orders(done, canceled) - combined status filter for "open orders"
- ausbuchungen.item_id - foreign key for JOINs

All indexes use IF NOT EXISTS for idempotent execution.
This improves performance for:
- Article search/filtering in admin interface
- Order status filtering
- Stock movement queries

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-06 08:16:30 +01:00
a8b26a25da feat: add logging and SMTP error handling
Logging:
- Add structured logging with timestamps
- Log successful and failed login attempts
- Log new orders and order completions
- Log email sending success/failures

SMTP Error Handling:
- Add try/except block around SMTP operations
- Catch authentication errors, SMTP exceptions, and general errors
- Log all email failures with detailed error messages
- Ensure orders are saved even if email fails

This allows monitoring of critical operations and troubleshooting
email delivery issues through systemd journal.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-06 08:11:40 +01:00
630595bce9 security: add SECRET_KEY validation
- Prevent server startup if SECRET_KEY is not set in production
- Raise RuntimeError with helpful message if using default value
- Allow debug mode for local development

This ensures the application never runs with an insecure session
secret in production environments.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-06 08:09:45 +01:00
e062a1e836 security: add CSRF protection to all forms
- Add Flask-WTF dependency for CSRF protection
- Initialize CSRFProtect in app.py
- Add CSRF tokens to all POST forms in templates
- Exempt /order JSON API endpoint (uses API key instead)

This protects against Cross-Site Request Forgery attacks on all
admin and user management operations.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-06 08:01:22 +01:00
12eece0226 docs: anonymize env examples 2026-01-30 12:41:09 +01:00
b9ea2c2625 docs: clarify order key location 2026-01-30 12:39:29 +01:00
68bfbd55a2 docs: add developer env section 2026-01-30 12:36:51 +01:00
7ff74cb18c docs: verbessern inline-doku in wawi app 2026-01-30 12:35:37 +01:00
10 changed files with 318 additions and 45 deletions

31
README.md Normal file → Executable file
View File

@@ -24,6 +24,24 @@ python app.py
```
Standardzugriff: Login über Benutzerverwaltung (siehe unten).
## Developer (ENV variablen)
Diese App liest Konfiguration ausschließlich aus Umgebungsvariablen.
Für lokale Entwicklung kannst du sie direkt im Shell-Session setzen oder
eine `.env`-Datei in dein Startup-Script laden.
Minimal sinnvoll für lokal:
- `SECRET_KEY` Session-Secret (beliebiger String)
- `APP_USER` / `APP_PASSWORD` initialer Admin-User
- `URL_PREFIX` leer lassen, wenn lokal ohne Sub-Pfad
Für Bestellungen per Mail zusätzlich:
- `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`
- `SMTP_FROM`, `ORDER_TO`
Optional:
- `APP_API_KEY` Schutz fuer `/wawi/api/bestand` und `/wawi/order`
- `COOKIE_SECURE` `0` fuer http lokal, `1` fuer https
## Benutzerverwaltung
Beim ersten Start wird **ein Admin** aus ENV erzeugt:
- `APP_USER` (default: `admin`)
@@ -43,6 +61,8 @@ python import_from_html.py /pfad/zu/hellas_bestand.html --truncate
- APIProxy: `/wawi/proxy/bestand`
Wenn du diese Datei auf einem Webserver auslieferst, stelle sicher, dass die WaWiApp unter `/wawi` erreichbar ist.
Wenn du Bestellungen direkt aus `index.html` abschickst, muss der `X-Order-Key` bzw. `?key=` dem `APP_API_KEY` entsprechen.
Der Key wird in `index.html` im ScriptBlock gesetzt: `const ORDER_KEY = ""` (neben der Formularlogik). Trage dort deinen Key ein.
## Umgebungsvariablen (ENV)
**Pflicht/Empfohlen für Produktion**
@@ -89,9 +109,9 @@ Environment="SMTP_HOST=smtp.example.com"
Environment="SMTP_PORT=587"
Environment="SMTP_USER=dein_user"
Environment="SMTP_PASS=dein_pass"
Environment="SMTP_FROM=bestand@hellas.welker.me"
Environment="ORDER_TO=bjoern@welker.me, zweite@domain.de"
Environment="APP_API_KEY=api_f4b8e1c97a2d4e5b8c6a9d3e2f7b1a0c"
Environment="SMTP_FROM=bestand@example.com"
Environment="ORDER_TO=admin@example.com, zweite@example.com"
Environment="APP_API_KEY=api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
ExecStart=/var/www/hellas/wawi/.venv/bin/gunicorn -w 3 -b 127.0.0.1:8000 app:app
Restart=always
@@ -159,8 +179,3 @@ mkdir -p /var/www/hellas/wawi/static/uploads
chown -R www-data:www-data /var/www/hellas/wawi/static/uploads
systemctl restart hellas
```
## Backup (Beispiel)
```bash
sudo /root/fix_wawi_permissions.sh
```

81
index.html Normal file → Executable file
View 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 WaWiApp (kein APIKey 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
View 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

228
wawi/app.py Normal file → Executable file
View File

@@ -1,11 +1,20 @@
from __future__ import annotations
"""Hellas WaWi (Warenwirtschaft) Flask-App.
Kurzüberblick:
- SQLiteDatenbank für Artikel, Ausbuchungen, Users und Bestellungen.
- HTMLViews für Verwaltung sowie JSONAPIs für Bestand & Bestellungen.
- Optionaler Mailversand für eingehende Bestellungen.
"""
import os
import sqlite3
import secrets
import smtplib
import time
import threading
import logging
from uuid import uuid4
from email.message import EmailMessage
from functools import wraps
@@ -13,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
@@ -22,17 +39,42 @@ DB_PATH = BASE_DIR / "hellas.db"
UPLOAD_DIR = BASE_DIR / "static" / "uploads"
ALLOWED_EXT = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
# Optional Prefix, z. B. /wawi, wenn die App hinter einem SubPfad läuft.
# 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 SubPfad 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)
# SessionSecret für LoginCookies (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)
@@ -43,7 +85,9 @@ _RATE_WINDOW = 60
_RATE_MAX = 15
# --- Datenbank & Hilfsfunktionen ---
def get_db() -> sqlite3.Connection:
"""Verbindet zur SQLiteDB (pro Request gecached in Flaskg)."""
if "db" not in g:
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
@@ -53,33 +97,42 @@ def get_db() -> sqlite3.Connection:
@app.teardown_appcontext
def close_db(exc: Exception | None) -> None:
"""Schließt die DBVerbindung am Ende des Requests."""
db = g.pop("db", None)
if db is not None:
db.close()
def init_db() -> None:
"""Initialisiert Tabellen und führt kleine SchemaMigrationen aus.
Tabellen:
- items: Artikelstamm + Soll/Bestand/Verkäufe
- ausbuchungen: Historie von Abgängen
- users: LoginBenutzer
- orders: eingehende Bestellungen (offen/abgeschlossen/storniert)
"""
db = get_db()
db.executescript(
"""
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
artikel TEXT NOT NULL,
groesse TEXT NOT NULL,
preis REAL NOT NULL DEFAULT 0,
bild_url TEXT,
soll INTEGER NOT NULL DEFAULT 0,
gezaehlt INTEGER NOT NULL DEFAULT 0,
verkaeufe INTEGER NOT NULL DEFAULT 0,
artikel TEXT NOT NULL, -- Artikelbezeichnung
groesse TEXT NOT NULL, -- Variante/Größe
preis REAL NOT NULL DEFAULT 0,-- Verkaufspreis
bild_url TEXT, -- Optionales Produktbild
soll INTEGER NOT NULL DEFAULT 0, -- SollBestand
gezaehlt INTEGER NOT NULL DEFAULT 0, -- IstBestand
verkaeufe INTEGER NOT NULL DEFAULT 0, -- Verkäufe gesamt
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS ausbuchungen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id INTEGER NOT NULL,
menge INTEGER NOT NULL,
grund TEXT,
item_id INTEGER NOT NULL, -- Referenz auf items.id
menge INTEGER NOT NULL, -- Abgangsmenge
grund TEXT, -- z. B. Verkauf, Defekt, etc.
created_at TEXT NOT NULL,
FOREIGN KEY(item_id) REFERENCES items(id)
);
@@ -93,20 +146,20 @@ def init_db() -> None:
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
handy TEXT NOT NULL,
mannschaft TEXT NOT NULL,
name TEXT NOT NULL, -- Besteller
handy TEXT NOT NULL, -- Kontakt
mannschaft TEXT NOT NULL, -- Team/Abteilung
artikel TEXT NOT NULL,
groesse TEXT NOT NULL,
menge INTEGER NOT NULL,
notiz TEXT,
created_at TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
completed_by TEXT,
completed_at TEXT,
canceled INTEGER NOT NULL DEFAULT 0,
canceled_by TEXT,
canceled_at TEXT
done INTEGER NOT NULL DEFAULT 0, -- abgeschlossen?
completed_by TEXT, -- Username
completed_at TEXT, -- Timestamp
canceled INTEGER NOT NULL DEFAULT 0,-- storniert?
canceled_by TEXT, -- Username
canceled_at TEXT -- Timestamp
);
"""
)
@@ -114,11 +167,12 @@ def init_db() -> None:
ensure_price_column(db)
ensure_image_column(db)
ensure_orders_columns(db)
ensure_indexes(db)
ensure_admin_user(db)
def ensure_price_column(db: sqlite3.Connection) -> None:
# Fügt die Preisspalte nachträglich hinzu, falls DB älter ist.
"""Fügt die Preisspalte nachträglich hinzu, falls DB älter ist."""
cols = db.execute("PRAGMA table_info(items)").fetchall()
if any(c["name"] == "preis" for c in cols):
return
@@ -127,6 +181,7 @@ def ensure_price_column(db: sqlite3.Connection) -> None:
def ensure_image_column(db: sqlite3.Connection) -> None:
"""Fügt bild_url nachträglich hinzu, falls DB älter ist."""
cols = db.execute("PRAGMA table_info(items)").fetchall()
if any(c["name"] == "bild_url" for c in cols):
return
@@ -135,6 +190,7 @@ def ensure_image_column(db: sqlite3.Connection) -> None:
def ensure_orders_columns(db: sqlite3.Connection) -> None:
"""Sorgt für alle nachträglich eingeführten OrdersSpalten."""
cols = db.execute("PRAGMA table_info(orders)").fetchall()
names = {c["name"] for c in cols}
if "done" not in names:
@@ -152,9 +208,29 @@ 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:
# Erstellt Tabellen einmal pro Worker.
"""Stellt sicher, dass DB/Schema einmal pro Worker initialisiert ist."""
global _DB_INIT_DONE
if not _DB_INIT_DONE:
init_db()
@@ -162,10 +238,12 @@ def ensure_db() -> None:
def now_iso() -> str:
"""Timestamp als kompaktes, DBfreundliches Format."""
return datetime.now().strftime("%Y-%m-%d %H:%M")
def save_upload(file) -> str | None:
"""Speichert einen BildUpload mit automatischer Optimierung und Thumbnail-Erstellung."""
if not file or not getattr(file, "filename", ""):
return None
ext = os.path.splitext(file.filename)[1].lower()
@@ -173,15 +251,65 @@ 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:
# Legt einen AdminUser an, wenn noch kein Benutzer existiert.
"""Legt einen AdminUser an, wenn noch kein Benutzer existiert."""
row = db.execute("SELECT COUNT(*) AS c FROM users").fetchone()
if row and row["c"] > 0:
return
@@ -198,6 +326,7 @@ def ensure_admin_user(db: sqlite3.Connection) -> None:
def login_required(fn):
"""Schützt HTMLViews mit LoginSession."""
@wraps(fn)
def wrapper(*args, **kwargs):
if not session.get("user"):
@@ -209,11 +338,12 @@ def login_required(fn):
@app.context_processor
def inject_auth():
"""Stellt logged_in im TemplateKontext bereit."""
return {"logged_in": bool(session.get("user"))}
def api_key_required(fn):
# Schützt APIEndpoints per X-API-Key oder ?key= Parameter.
"""Schützt APIEndpoints per X-API-Key oder ?key= Parameter."""
@wraps(fn)
def wrapper(*args, **kwargs):
expected = os.environ.get("APP_API_KEY", "")
@@ -228,6 +358,7 @@ def api_key_required(fn):
def rate_limited(ip: str) -> bool:
"""Einfaches InMemory RateLimit pro IP (1MinFenster)."""
now = time.time()
bucket = _RATE_LIMIT.setdefault(ip, [])
bucket[:] = [t for t in bucket if now - t < _RATE_WINDOW]
@@ -237,9 +368,11 @@ def rate_limited(ip: str) -> bool:
return False
# --- HTMLViews (Admin) ---
@bp.route("/")
@login_required
def index():
"""Startseite: Artikelübersicht mit Filter, Sortierung und Summen."""
q = (request.args.get("q") or "").strip()
sort = (request.args.get("sort") or "gezaehlt").strip().lower()
direction = (request.args.get("dir") or "desc").strip().lower()
@@ -289,6 +422,7 @@ def index():
@bp.route("/new", methods=["GET", "POST"])
@login_required
def new_item():
"""Artikel anlegen (inkl. optionalem BildUpload)."""
if request.method == "POST":
artikel = (request.form.get("artikel") or "").strip()
groesse = (request.form.get("groesse") or "").strip()
@@ -320,6 +454,7 @@ def new_item():
@bp.route("/edit/<int:item_id>", methods=["GET", "POST"])
@login_required
def edit_item(item_id: int):
"""Artikel bearbeiten."""
db = get_db()
item = db.execute("SELECT * FROM items WHERE id = ?", (item_id,)).fetchone()
if item is None:
@@ -355,6 +490,7 @@ def edit_item(item_id: int):
@bp.route("/delete/<int:item_id>", methods=["POST"])
@login_required
def delete_item(item_id: int):
"""Artikel löschen."""
db = get_db()
db.execute("DELETE FROM items WHERE id = ?", (item_id,))
db.commit()
@@ -364,6 +500,7 @@ def delete_item(item_id: int):
@bp.route("/ausbuchen/<int:item_id>", methods=["GET", "POST"])
@login_required
def ausbuchen(item_id: int):
"""Ausbuchung erfassen (reduziert Bestand & erhöht Verkäufe)."""
db = get_db()
item = db.execute("SELECT * FROM items WHERE id = ?", (item_id,)).fetchone()
if item is None:
@@ -399,6 +536,7 @@ def ausbuchen(item_id: int):
@bp.route("/verkauf/<int:item_id>", methods=["POST"])
@login_required
def verkauf(item_id: int):
"""SchnellVerkauf: 1 Stück buchen und als Ausbuchung protokollieren."""
db = get_db()
item = db.execute("SELECT * FROM items WHERE id = ?", (item_id,)).fetchone()
if item is None:
@@ -426,8 +564,10 @@ def verkauf(item_id: int):
return redirect(url_for("bp.index"))
# --- Auth ---
@bp.route("/login", methods=["GET", "POST"])
def login():
"""LoginFormular & SessionHandling."""
if request.method == "POST":
user = (request.form.get("user") or "").strip()
password = request.form.get("password") or ""
@@ -437,21 +577,26 @@ 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)
@bp.route("/logout")
def logout():
"""Session beenden."""
session.clear()
return redirect(url_for("bp.login"))
# --- Benutzerverwaltung ---
@bp.route("/users", methods=["GET", "POST"])
@login_required
def users():
"""Benutzerverwaltung (anlegen, anzeigen)."""
db = get_db()
error = None
if request.method == "POST":
@@ -479,6 +624,7 @@ def users():
@bp.route("/users/delete/<int:user_id>", methods=["POST"])
@login_required
def delete_user(user_id: int):
"""Benutzer löschen (mindestens ein User muss bleiben)."""
db = get_db()
count = db.execute("SELECT COUNT(*) AS c FROM users").fetchone()["c"]
if count <= 1:
@@ -488,20 +634,22 @@ def delete_user(user_id: int):
return redirect(url_for("bp.users"))
# --- JSONAPIs ---
@bp.route("/api/bestand", methods=["GET"])
@api_key_required
def api_bestand():
"""Öffentliche JSONAPI (authentifiziert) für Bestände."""
return jsonify(build_bestand())
@bp.route("/proxy/bestand", methods=["GET"])
def proxy_bestand():
# ServerProxy ohne APIKey (z. B. für öffentliche Anzeige).
"""ServerProxy ohne APIKey (z. B. für öffentliche Anzeige)."""
return jsonify(build_bestand())
def build_bestand() -> list[dict]:
# Aggregiert DBZeilen in die Struktur der LiveBestand Ansicht.
"""Aggregiert DBZeilen in die Struktur der LiveBestand Ansicht."""
rows = get_db().execute(
"""
SELECT artikel, groesse, preis, bild_url, soll, gezaehlt, verkaeufe
@@ -556,7 +704,9 @@ 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 APIKey) und versendet Mail."""
ip = request.headers.get("X-Forwarded-For", request.remote_addr or "unknown").split(",")[0].strip()
if rate_limited(ip):
return jsonify({"error": "Zu viele Anfragen."}), 429
@@ -592,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")
@@ -622,19 +773,30 @@ 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()
return jsonify({"ok": True})
# --- Bestellungen (Admin) ---
@bp.route("/orders")
@login_required
def orders():
"""Bestellliste in der Verwaltung."""
rows = get_db().execute(
"""
SELECT id, name, handy, mannschaft, artikel, groesse, menge, notiz, created_at, done, completed_by, completed_at, canceled, canceled_by, canceled_at
@@ -649,6 +811,7 @@ def orders():
@bp.route("/orders/complete/<int:order_id>", methods=["POST"])
@login_required
def complete_order(order_id: int):
"""Bestellung abschließen und Bestand abziehen (falls ausreichend)."""
user = session.get("user") or "unknown"
db = get_db()
order = db.execute(
@@ -694,12 +857,14 @@ 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"))
@bp.route("/orders/cancel/<int:order_id>", methods=["POST"])
@login_required
def cancel_order(order_id: int):
"""Bestellung stornieren (nur wenn noch offen)."""
user = session.get("user") or "unknown"
db = get_db()
db.execute(
@@ -717,6 +882,7 @@ def cancel_order(order_id: int):
@bp.route("/users/reset/<int:user_id>", methods=["POST"])
@login_required
def reset_user_password(user_id: int):
"""Passwort zurücksetzen und neues Passwort als Flash anzeigen."""
db = get_db()
user = db.execute("SELECT id, username FROM users WHERE id = ?", (user_id,)).fetchone()
if user is None:

1
wawi/templates/ausbuchen.html Normal file → Executable file
View 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
View 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
View 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
View 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
View 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
View 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>