Compare commits

..

17 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
db2767a496 Fix api_key_required indentation 2026-01-30 12:24:48 +01:00
efffdfa3fa Document uploads in fix permissions script 2026-01-30 12:17:04 +01:00
65ec9466eb Use APP_API_KEY for order auth 2026-01-30 12:12:40 +01:00
ec2d8945b3 Hide order API key input 2026-01-30 12:10:10 +01:00
513c126fba Send order API key from live page 2026-01-30 12:09:13 +01:00
a61e96e8b8 Harden order endpoint and async mail; improve security defaults 2026-01-30 12:08:08 +01:00
Bjoern Welker
a7d058b57c Clean up unused live page code 2026-01-30 12:00:02 +01:00
10 changed files with 375 additions and 82 deletions

41
README.md Normal file → Executable file
View File

@@ -24,6 +24,24 @@ python app.py
``` ```
Standardzugriff: Login über Benutzerverwaltung (siehe unten). 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 ## Benutzerverwaltung
Beim ersten Start wird **ein Admin** aus ENV erzeugt: Beim ersten Start wird **ein Admin** aus ENV erzeugt:
- `APP_USER` (default: `admin`) - `APP_USER` (default: `admin`)
@@ -43,6 +61,8 @@ python import_from_html.py /pfad/zu/hellas_bestand.html --truncate
- APIProxy: `/wawi/proxy/bestand` - APIProxy: `/wawi/proxy/bestand`
Wenn du diese Datei auf einem Webserver auslieferst, stelle sicher, dass die WaWiApp unter `/wawi` erreichbar ist. 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) ## Umgebungsvariablen (ENV)
**Pflicht/Empfohlen für Produktion** **Pflicht/Empfohlen für Produktion**
@@ -58,7 +78,8 @@ Wenn du diese Datei auf einem Webserver auslieferst, stelle sicher, dass die WaW
- `ORDER_TO` Empfänger, mehrere per Komma - `ORDER_TO` Empfänger, mehrere per Komma
**Optional** **Optional**
- `APP_API_KEY` APIKey für direkten Zugriff auf `/wawi/api/bestand` - `APP_API_KEY` gemeinsamer APIKey für `/wawi/api/bestand` **und** `/wawi/order`
- `COOKIE_SECURE` `1` (default) setzt SecureCookie, `0` deaktiviert für http
## Deployment (systemd + Gunicorn) ## Deployment (systemd + Gunicorn)
1) App nach `/var/www/hellas/wawi` kopieren 1) App nach `/var/www/hellas/wawi` kopieren
@@ -88,9 +109,9 @@ Environment="SMTP_HOST=smtp.example.com"
Environment="SMTP_PORT=587" Environment="SMTP_PORT=587"
Environment="SMTP_USER=dein_user" Environment="SMTP_USER=dein_user"
Environment="SMTP_PASS=dein_pass" Environment="SMTP_PASS=dein_pass"
Environment="SMTP_FROM=bestand@hellas.welker.me" Environment="SMTP_FROM=bestand@example.com"
Environment="ORDER_TO=bjoern@welker.me, zweite@domain.de" Environment="ORDER_TO=admin@example.com, zweite@example.com"
Environment="APP_API_KEY=api_f4b8e1c97a2d4e5b8c6a9d3e2f7b1a0c" Environment="APP_API_KEY=api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
ExecStart=/var/www/hellas/wawi/.venv/bin/gunicorn -w 3 -b 127.0.0.1:8000 app:app ExecStart=/var/www/hellas/wawi/.venv/bin/gunicorn -w 3 -b 127.0.0.1:8000 app:app
Restart=always Restart=always
@@ -146,7 +167,15 @@ sudo mkdir -p /var/www/hellas/wawi/static/uploads
sudo chown -R www-data:www-data /var/www/hellas/wawi/static/uploads sudo chown -R www-data:www-data /var/www/hellas/wawi/static/uploads
``` ```
## Backup (Beispiel) ## FixPermissions Script (inkl. Uploads)
`/root/fix_wawi_permissions.sh` sollte auch das UploadVerzeichnis setzen:
```bash ```bash
sudo /root/fix_wawi_permissions.sh #!/bin/sh
set -e
chown -R www-data:www-data /var/www/hellas/wawi
chmod 750 /var/www/hellas/wawi
chmod 640 /var/www/hellas/wawi/hellas.db
mkdir -p /var/www/hellas/wawi/static/uploads
chown -R www-data:www-data /var/www/hellas/wawi/static/uploads
systemctl restart hellas
``` ```

123
index.html Normal file → Executable file
View File

@@ -101,23 +101,6 @@
} }
.pill input { accent-color: var(--accent); } .pill input { accent-color: var(--accent); }
main .wrap { padding-top: 14px; padding-bottom: 28px; } main .wrap { padding-top: 14px; padding-bottom: 28px; }
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 10px;
margin-bottom: 14px;
}
.stat {
border: 1px solid var(--line);
border-radius: 14px;
padding: 10px 12px;
background: linear-gradient(180deg, rgba(18,45,70,.92), rgba(12,31,51,.98));
display: grid;
gap: 4px;
box-shadow: var(--shadow);
}
.stat .label { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .8px; }
.stat .value { font-size: 18px; font-weight: 700; letter-spacing: .3px; color: var(--accent); }
.grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); } .grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
.card-tile { .card-tile {
border: 1px solid rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.08);
@@ -212,8 +195,6 @@
tr:last-child td { border-bottom: 0; } tr:last-child td { border-bottom: 0; }
thead th { background: rgba(0,0,0,.12); } thead th { background: rgba(0,0,0,.12); }
tbody tr:nth-child(odd) { background: rgba(255,255,255,.02); } tbody tr:nth-child(odd) { background: rgba(255,255,255,.02); }
.delta-pos { color: rgba(123,213,141,.98); font-weight: 700; }
.delta-neg { color: rgba(255,107,125,.98); font-weight: 700; }
.muted { color: var(--muted); } .muted { color: var(--muted); }
.small { font-size: 12px; } .small { font-size: 12px; }
.footer { .footer {
@@ -328,11 +309,66 @@
.brand { width: 100%; } .brand { width: 100%; }
.controls { width: 100%; } .controls { width: 100%; }
.search { min-width: 100%; } .search { min-width: 100%; }
.stats { grid-template-columns: 1fr; }
.card-media { aspect-ratio: 4 / 3; } .card-media { aspect-ratio: 4 / 3; }
.order-btn { width: 100%; justify-content: center; } .order-btn { width: 100%; justify-content: center; }
} }
@media (max-width: 640px) { .grid { grid-template-columns: 1fr; } } @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> </style>
</head> </head>
<body> <body>
@@ -358,7 +394,6 @@
<main> <main>
<div class="wrap"> <div class="wrap">
<div id="stats" class="stats" aria-live="polite"></div>
<div id="grid" class="grid" aria-live="polite"></div> <div id="grid" class="grid" aria-live="polite"></div>
<div class="footer"> <div class="footer">
<div>Tip: Auf einen Artikel klicken, um die GrößenTabelle zu öffnen.</div> <div>Tip: Auf einen Artikel klicken, um die GrößenTabelle zu öffnen.</div>
@@ -424,7 +459,28 @@
</div> </div>
</div> </div>
<div id="toastContainer" class="toast-container"></div>
<script> <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). // Proxy der WaWiApp (kein APIKey im Browser nötig).
const API_URL = "/wawi/proxy/bestand"; const API_URL = "/wawi/proxy/bestand";
let DATA = []; let DATA = [];
@@ -440,23 +496,8 @@ function badge(label, value, cls="") {
return `<span class="badge ${cls}">${label}: <strong>${fmt(value)}</strong></span>`; return `<span class="badge ${cls}">${label}: <strong>${fmt(value)}</strong></span>`;
} }
function deltaClass(d) {
if (d === null || d === undefined) return "";
if (d > 0) return "delta-pos";
if (d < 0) return "delta-neg";
return "";
}
function hasDiff(item) {
const t = item.totals || {};
return (t.abweichung ?? 0) !== 0 || (t.fehlbestand ?? 0) !== 0;
}
function render(items) { function render(items) {
const grid = document.getElementById("grid"); const grid = document.getElementById("grid");
const stats = document.getElementById("stats");
stats.innerHTML = "";
if (!items.length) { if (!items.length) {
grid.innerHTML = `<div class="empty">Keine Treffer. (Suchbegriff anpassen)</div>`; grid.innerHTML = `<div class="empty">Keine Treffer. (Suchbegriff anpassen)</div>`;
@@ -525,6 +566,7 @@ document.getElementById("q").addEventListener("input", applyFilters);
const modal = document.getElementById("orderModal"); const modal = document.getElementById("orderModal");
const form = document.getElementById("orderForm"); const form = document.getElementById("orderForm");
const setField = (id, v) => document.getElementById(id).value = v || ""; const setField = (id, v) => document.getElementById(id).value = v || "";
const ORDER_KEY = "";
document.addEventListener("click", (e) => { document.addEventListener("click", (e) => {
const imgBtn = e.target.closest(".thumb-btn"); const imgBtn = e.target.closest(".thumb-btn");
@@ -589,19 +631,22 @@ document.getElementById("detailModal").addEventListener("click", (e) => {
form.addEventListener("submit", async (e) => { form.addEventListener("submit", async (e) => {
e.preventDefault(); e.preventDefault();
const payload = Object.fromEntries(new FormData(form).entries()); const payload = Object.fromEntries(new FormData(form).entries());
const key = ORDER_KEY || "";
const headers = { "Content-Type": "application/json" };
if (key) headers["X-Order-Key"] = key;
const res = await fetch("/wawi/order", { const res = await fetch("/wawi/order", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers,
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
if (res.ok) { if (res.ok) {
alert("Bestellung gesendet."); showToast("Bestellung erfolgreich gesendet! Wir melden uns bei dir.", "success");
modal.classList.remove("open"); modal.classList.remove("open");
document.getElementById("detailModal").classList.remove("open"); document.getElementById("detailModal").classList.remove("open");
form.reset(); form.reset();
setField("fMenge", 1); setField("fMenge", 1);
} else { } 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

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

@@ -1,9 +1,20 @@
from __future__ import annotations 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 os
import sqlite3 import sqlite3
import secrets import secrets
import smtplib import smtplib
import time
import threading
import logging
from uuid import uuid4 from uuid import uuid4
from email.message import EmailMessage from email.message import EmailMessage
from functools import wraps from functools import wraps
@@ -11,7 +22,15 @@ from pathlib import Path
from datetime import datetime from datetime import datetime
from typing import Any 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 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.security import check_password_hash, generate_password_hash
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
@@ -20,22 +39,55 @@ DB_PATH = BASE_DIR / "hellas.db"
UPLOAD_DIR = BASE_DIR / "static" / "uploads" UPLOAD_DIR = BASE_DIR / "static" / "uploads"
ALLOWED_EXT = {".png", ".jpg", ".jpeg", ".webp", ".gif"} 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("/") URL_PREFIX = os.environ.get("URL_PREFIX", "").strip().rstrip("/")
STATIC_URL_PATH = f"{URL_PREFIX}/static" if URL_PREFIX else "/static" STATIC_URL_PATH = f"{URL_PREFIX}/static" if URL_PREFIX else "/static"
app = Flask(__name__, static_url_path=STATIC_URL_PATH) app = Flask(__name__, static_url_path=STATIC_URL_PATH)
# SessionSecret für LoginCookies (in Produktion unbedingt setzen). # 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_SAMESITE"] = "Lax"
app.config["SESSION_COOKIE_SECURE"] = False 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 app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024
# CSRF-Schutz aktivieren
csrf = CSRFProtect(app)
bp = Blueprint("bp", __name__) bp = Blueprint("bp", __name__)
UPLOAD_DIR.mkdir(parents=True, exist_ok=True) UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
_DB_INIT_DONE = False
_RATE_LIMIT = {}
_RATE_WINDOW = 60
_RATE_MAX = 15
# --- Datenbank & Hilfsfunktionen ---
def get_db() -> sqlite3.Connection: def get_db() -> sqlite3.Connection:
"""Verbindet zur SQLiteDB (pro Request gecached in Flaskg)."""
if "db" not in g: if "db" not in g:
conn = sqlite3.connect(DB_PATH) conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
@@ -45,33 +97,42 @@ def get_db() -> sqlite3.Connection:
@app.teardown_appcontext @app.teardown_appcontext
def close_db(exc: Exception | None) -> None: def close_db(exc: Exception | None) -> None:
"""Schließt die DBVerbindung am Ende des Requests."""
db = g.pop("db", None) db = g.pop("db", None)
if db is not None: if db is not None:
db.close() db.close()
def init_db() -> None: 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 = get_db()
db.executescript( db.executescript(
""" """
CREATE TABLE IF NOT EXISTS items ( CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
artikel TEXT NOT NULL, artikel TEXT NOT NULL, -- Artikelbezeichnung
groesse TEXT NOT NULL, groesse TEXT NOT NULL, -- Variante/Größe
preis REAL NOT NULL DEFAULT 0, preis REAL NOT NULL DEFAULT 0,-- Verkaufspreis
bild_url TEXT, bild_url TEXT, -- Optionales Produktbild
soll INTEGER NOT NULL DEFAULT 0, soll INTEGER NOT NULL DEFAULT 0, -- SollBestand
gezaehlt INTEGER NOT NULL DEFAULT 0, gezaehlt INTEGER NOT NULL DEFAULT 0, -- IstBestand
verkaeufe INTEGER NOT NULL DEFAULT 0, verkaeufe INTEGER NOT NULL DEFAULT 0, -- Verkäufe gesamt
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS ausbuchungen ( CREATE TABLE IF NOT EXISTS ausbuchungen (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id INTEGER NOT NULL, item_id INTEGER NOT NULL, -- Referenz auf items.id
menge INTEGER NOT NULL, menge INTEGER NOT NULL, -- Abgangsmenge
grund TEXT, grund TEXT, -- z. B. Verkauf, Defekt, etc.
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
FOREIGN KEY(item_id) REFERENCES items(id) FOREIGN KEY(item_id) REFERENCES items(id)
); );
@@ -85,20 +146,20 @@ def init_db() -> None:
CREATE TABLE IF NOT EXISTS orders ( CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, name TEXT NOT NULL, -- Besteller
handy TEXT NOT NULL, handy TEXT NOT NULL, -- Kontakt
mannschaft TEXT NOT NULL, mannschaft TEXT NOT NULL, -- Team/Abteilung
artikel TEXT NOT NULL, artikel TEXT NOT NULL,
groesse TEXT NOT NULL, groesse TEXT NOT NULL,
menge INTEGER NOT NULL, menge INTEGER NOT NULL,
notiz TEXT, notiz TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0, done INTEGER NOT NULL DEFAULT 0, -- abgeschlossen?
completed_by TEXT, completed_by TEXT, -- Username
completed_at TEXT, completed_at TEXT, -- Timestamp
canceled INTEGER NOT NULL DEFAULT 0, canceled INTEGER NOT NULL DEFAULT 0,-- storniert?
canceled_by TEXT, canceled_by TEXT, -- Username
canceled_at TEXT canceled_at TEXT -- Timestamp
); );
""" """
) )
@@ -106,11 +167,12 @@ def init_db() -> None:
ensure_price_column(db) ensure_price_column(db)
ensure_image_column(db) ensure_image_column(db)
ensure_orders_columns(db) ensure_orders_columns(db)
ensure_indexes(db)
ensure_admin_user(db) ensure_admin_user(db)
def ensure_price_column(db: sqlite3.Connection) -> None: 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() cols = db.execute("PRAGMA table_info(items)").fetchall()
if any(c["name"] == "preis" for c in cols): if any(c["name"] == "preis" for c in cols):
return return
@@ -119,6 +181,7 @@ def ensure_price_column(db: sqlite3.Connection) -> None:
def ensure_image_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() cols = db.execute("PRAGMA table_info(items)").fetchall()
if any(c["name"] == "bild_url" for c in cols): if any(c["name"] == "bild_url" for c in cols):
return return
@@ -127,6 +190,7 @@ def ensure_image_column(db: sqlite3.Connection) -> None:
def ensure_orders_columns(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() cols = db.execute("PRAGMA table_info(orders)").fetchall()
names = {c["name"] for c in cols} names = {c["name"] for c in cols}
if "done" not in names: if "done" not in names:
@@ -144,31 +208,108 @@ def ensure_orders_columns(db: sqlite3.Connection) -> None:
db.commit() 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 @app.before_request
def ensure_db() -> None: def ensure_db() -> None:
# Erstellt Tabellen, wenn sie fehlen (bei jedem Request idempotent). """Stellt sicher, dass DB/Schema einmal pro Worker initialisiert ist."""
init_db() global _DB_INIT_DONE
if not _DB_INIT_DONE:
init_db()
_DB_INIT_DONE = True
def now_iso() -> str: def now_iso() -> str:
"""Timestamp als kompaktes, DBfreundliches Format."""
return datetime.now().strftime("%Y-%m-%d %H:%M") return datetime.now().strftime("%Y-%m-%d %H:%M")
def save_upload(file) -> str | None: def save_upload(file) -> str | None:
"""Speichert einen BildUpload mit automatischer Optimierung und Thumbnail-Erstellung."""
if not file or not getattr(file, "filename", ""): if not file or not getattr(file, "filename", ""):
return None return None
ext = os.path.splitext(file.filename)[1].lower() ext = os.path.splitext(file.filename)[1].lower()
if ext not in ALLOWED_EXT: if ext not in ALLOWED_EXT:
return None return None
if not (file.mimetype or "").startswith("image/"):
return None
name = secure_filename(Path(file.filename).stem) or "image" name = secure_filename(Path(file.filename).stem) or "image"
safe_name = f"{name}-{uuid4().hex}{ext}" unique_id = uuid4().hex
dest = UPLOAD_DIR / safe_name
file.save(dest) # Original speichern
return f"{URL_PREFIX}/static/uploads/{safe_name}" if URL_PREFIX else f"/static/uploads/{safe_name}" 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: 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() row = db.execute("SELECT COUNT(*) AS c FROM users").fetchone()
if row and row["c"] > 0: if row and row["c"] > 0:
return return
@@ -185,6 +326,7 @@ def ensure_admin_user(db: sqlite3.Connection) -> None:
def login_required(fn): def login_required(fn):
"""Schützt HTMLViews mit LoginSession."""
@wraps(fn) @wraps(fn)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
if not session.get("user"): if not session.get("user"):
@@ -196,11 +338,12 @@ def login_required(fn):
@app.context_processor @app.context_processor
def inject_auth(): def inject_auth():
"""Stellt logged_in im TemplateKontext bereit."""
return {"logged_in": bool(session.get("user"))} return {"logged_in": bool(session.get("user"))}
def api_key_required(fn): 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) @wraps(fn)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
expected = os.environ.get("APP_API_KEY", "") expected = os.environ.get("APP_API_KEY", "")
@@ -214,9 +357,22 @@ def api_key_required(fn):
return wrapper return wrapper
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]
if len(bucket) >= _RATE_MAX:
return True
bucket.append(now)
return False
# --- HTMLViews (Admin) ---
@bp.route("/") @bp.route("/")
@login_required @login_required
def index(): def index():
"""Startseite: Artikelübersicht mit Filter, Sortierung und Summen."""
q = (request.args.get("q") or "").strip() q = (request.args.get("q") or "").strip()
sort = (request.args.get("sort") or "gezaehlt").strip().lower() sort = (request.args.get("sort") or "gezaehlt").strip().lower()
direction = (request.args.get("dir") or "desc").strip().lower() direction = (request.args.get("dir") or "desc").strip().lower()
@@ -266,6 +422,7 @@ def index():
@bp.route("/new", methods=["GET", "POST"]) @bp.route("/new", methods=["GET", "POST"])
@login_required @login_required
def new_item(): def new_item():
"""Artikel anlegen (inkl. optionalem BildUpload)."""
if request.method == "POST": if request.method == "POST":
artikel = (request.form.get("artikel") or "").strip() artikel = (request.form.get("artikel") or "").strip()
groesse = (request.form.get("groesse") or "").strip() groesse = (request.form.get("groesse") or "").strip()
@@ -297,6 +454,7 @@ def new_item():
@bp.route("/edit/<int:item_id>", methods=["GET", "POST"]) @bp.route("/edit/<int:item_id>", methods=["GET", "POST"])
@login_required @login_required
def edit_item(item_id: int): def edit_item(item_id: int):
"""Artikel bearbeiten."""
db = get_db() db = get_db()
item = db.execute("SELECT * FROM items WHERE id = ?", (item_id,)).fetchone() item = db.execute("SELECT * FROM items WHERE id = ?", (item_id,)).fetchone()
if item is None: if item is None:
@@ -332,6 +490,7 @@ def edit_item(item_id: int):
@bp.route("/delete/<int:item_id>", methods=["POST"]) @bp.route("/delete/<int:item_id>", methods=["POST"])
@login_required @login_required
def delete_item(item_id: int): def delete_item(item_id: int):
"""Artikel löschen."""
db = get_db() db = get_db()
db.execute("DELETE FROM items WHERE id = ?", (item_id,)) db.execute("DELETE FROM items WHERE id = ?", (item_id,))
db.commit() db.commit()
@@ -341,6 +500,7 @@ def delete_item(item_id: int):
@bp.route("/ausbuchen/<int:item_id>", methods=["GET", "POST"]) @bp.route("/ausbuchen/<int:item_id>", methods=["GET", "POST"])
@login_required @login_required
def ausbuchen(item_id: int): def ausbuchen(item_id: int):
"""Ausbuchung erfassen (reduziert Bestand & erhöht Verkäufe)."""
db = get_db() db = get_db()
item = db.execute("SELECT * FROM items WHERE id = ?", (item_id,)).fetchone() item = db.execute("SELECT * FROM items WHERE id = ?", (item_id,)).fetchone()
if item is None: if item is None:
@@ -376,6 +536,7 @@ def ausbuchen(item_id: int):
@bp.route("/verkauf/<int:item_id>", methods=["POST"]) @bp.route("/verkauf/<int:item_id>", methods=["POST"])
@login_required @login_required
def verkauf(item_id: int): def verkauf(item_id: int):
"""SchnellVerkauf: 1 Stück buchen und als Ausbuchung protokollieren."""
db = get_db() db = get_db()
item = db.execute("SELECT * FROM items WHERE id = ?", (item_id,)).fetchone() item = db.execute("SELECT * FROM items WHERE id = ?", (item_id,)).fetchone()
if item is None: if item is None:
@@ -403,8 +564,10 @@ def verkauf(item_id: int):
return redirect(url_for("bp.index")) return redirect(url_for("bp.index"))
# --- Auth ---
@bp.route("/login", methods=["GET", "POST"]) @bp.route("/login", methods=["GET", "POST"])
def login(): def login():
"""LoginFormular & SessionHandling."""
if request.method == "POST": if request.method == "POST":
user = (request.form.get("user") or "").strip() user = (request.form.get("user") or "").strip()
password = request.form.get("password") or "" password = request.form.get("password") or ""
@@ -414,21 +577,26 @@ def login():
).fetchone() ).fetchone()
if row and check_password_hash(row["password_hash"], password): if row and check_password_hash(row["password_hash"], password):
session["user"] = user session["user"] = user
logger.info(f"Erfolgreicher Login: {user}")
nxt = request.args.get("next") or url_for("bp.index") nxt = request.args.get("next") or url_for("bp.index")
return redirect(nxt) return redirect(nxt)
logger.warning(f"Fehlgeschlagener Login-Versuch: {user}")
return render_template("login.html", error=True) return render_template("login.html", error=True)
return render_template("login.html", error=False) return render_template("login.html", error=False)
@bp.route("/logout") @bp.route("/logout")
def logout(): def logout():
"""Session beenden."""
session.clear() session.clear()
return redirect(url_for("bp.login")) return redirect(url_for("bp.login"))
# --- Benutzerverwaltung ---
@bp.route("/users", methods=["GET", "POST"]) @bp.route("/users", methods=["GET", "POST"])
@login_required @login_required
def users(): def users():
"""Benutzerverwaltung (anlegen, anzeigen)."""
db = get_db() db = get_db()
error = None error = None
if request.method == "POST": if request.method == "POST":
@@ -456,6 +624,7 @@ def users():
@bp.route("/users/delete/<int:user_id>", methods=["POST"]) @bp.route("/users/delete/<int:user_id>", methods=["POST"])
@login_required @login_required
def delete_user(user_id: int): def delete_user(user_id: int):
"""Benutzer löschen (mindestens ein User muss bleiben)."""
db = get_db() db = get_db()
count = db.execute("SELECT COUNT(*) AS c FROM users").fetchone()["c"] count = db.execute("SELECT COUNT(*) AS c FROM users").fetchone()["c"]
if count <= 1: if count <= 1:
@@ -465,20 +634,22 @@ def delete_user(user_id: int):
return redirect(url_for("bp.users")) return redirect(url_for("bp.users"))
# --- JSONAPIs ---
@bp.route("/api/bestand", methods=["GET"]) @bp.route("/api/bestand", methods=["GET"])
@api_key_required @api_key_required
def api_bestand(): def api_bestand():
"""Öffentliche JSONAPI (authentifiziert) für Bestände."""
return jsonify(build_bestand()) return jsonify(build_bestand())
@bp.route("/proxy/bestand", methods=["GET"]) @bp.route("/proxy/bestand", methods=["GET"])
def proxy_bestand(): 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()) return jsonify(build_bestand())
def build_bestand() -> list[dict]: 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( rows = get_db().execute(
""" """
SELECT artikel, groesse, preis, bild_url, soll, gezaehlt, verkaeufe SELECT artikel, groesse, preis, bild_url, soll, gezaehlt, verkaeufe
@@ -533,11 +704,25 @@ def build_bestand() -> list[dict]:
@bp.route("/order", methods=["POST"]) @bp.route("/order", methods=["POST"])
@csrf.exempt # JSON API ohne CSRF-Schutz (nutzt API-Key stattdessen)
def order(): 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
expected_key = os.environ.get("APP_API_KEY", "")
if expected_key:
provided = request.headers.get("X-Order-Key") or request.args.get("key") or ""
if provided != expected_key:
return jsonify({"error": "Unauthorized"}), 401
data = request.get_json(silent=True) or request.form data = request.get_json(silent=True) or request.form
required = ["name", "handy", "mannschaft", "artikel", "groesse", "menge"] required = ["name", "handy", "mannschaft", "artikel", "groesse", "menge"]
if any(not (data.get(k) or "").strip() for k in required): if any(not (data.get(k) or "").strip() for k in required):
return jsonify({"error": "Pflichtfelder fehlen."}), 400 return jsonify({"error": "Pflichtfelder fehlen."}), 400
if int(data.get("menge") or 0) <= 0:
return jsonify({"error": "Menge muss größer als 0 sein."}), 400
db = get_db() db = get_db()
db.execute( db.execute(
@@ -557,6 +742,7 @@ def order():
), ),
) )
db.commit() 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") to_addr = os.environ.get("ORDER_TO", "bjoern@welker.me")
smtp_host = os.environ.get("SMTP_HOST") smtp_host = os.environ.get("SMTP_HOST")
@@ -586,17 +772,31 @@ def order():
) )
msg.set_content(body) msg.set_content(body)
with smtplib.SMTP(smtp_host, smtp_port) as server: def _send():
server.starttls() """Sendet Bestellungs-Email asynchron mit Error Handling."""
server.login(smtp_user, smtp_pass) try:
server.send_message(msg, to_addrs=recipients) 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}) return jsonify({"ok": True})
# --- Bestellungen (Admin) ---
@bp.route("/orders") @bp.route("/orders")
@login_required @login_required
def orders(): def orders():
"""Bestellliste in der Verwaltung."""
rows = get_db().execute( 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 SELECT id, name, handy, mannschaft, artikel, groesse, menge, notiz, created_at, done, completed_by, completed_at, canceled, canceled_by, canceled_at
@@ -611,6 +811,7 @@ def orders():
@bp.route("/orders/complete/<int:order_id>", methods=["POST"]) @bp.route("/orders/complete/<int:order_id>", methods=["POST"])
@login_required @login_required
def complete_order(order_id: int): def complete_order(order_id: int):
"""Bestellung abschließen und Bestand abziehen (falls ausreichend)."""
user = session.get("user") or "unknown" user = session.get("user") or "unknown"
db = get_db() db = get_db()
order = db.execute( order = db.execute(
@@ -656,12 +857,14 @@ def complete_order(order_id: int):
(user, now_iso(), order_id), (user, now_iso(), order_id),
) )
db.commit() db.commit()
logger.info(f"Bestellung #{order_id} abgeschlossen von {user}")
return redirect(url_for("bp.orders")) return redirect(url_for("bp.orders"))
@bp.route("/orders/cancel/<int:order_id>", methods=["POST"]) @bp.route("/orders/cancel/<int:order_id>", methods=["POST"])
@login_required @login_required
def cancel_order(order_id: int): def cancel_order(order_id: int):
"""Bestellung stornieren (nur wenn noch offen)."""
user = session.get("user") or "unknown" user = session.get("user") or "unknown"
db = get_db() db = get_db()
db.execute( db.execute(
@@ -679,6 +882,7 @@ def cancel_order(order_id: int):
@bp.route("/users/reset/<int:user_id>", methods=["POST"]) @bp.route("/users/reset/<int:user_id>", methods=["POST"])
@login_required @login_required
def reset_user_password(user_id: int): def reset_user_password(user_id: int):
"""Passwort zurücksetzen und neues Passwort als Flash anzeigen."""
db = get_db() db = get_db()
user = db.execute("SELECT id, username FROM users WHERE id = ?", (user_id,)).fetchone() user = db.execute("SELECT id, username FROM users WHERE id = ?", (user_id,)).fetchone()
if user is None: 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> <h2>Ausbuchen: {{ item.artikel }} ({{ item.groesse }})</h2>
<div class="note">Aktueller Bestand: <strong>{{ item.gezaehlt }}</strong></div> <div class="note">Aktueller Bestand: <strong>{{ item.gezaehlt }}</strong></div>
<form method="post" onsubmit="return confirm('Wirklich ausbuchen?');"> <form method="post" onsubmit="return confirm('Wirklich ausbuchen?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-grid"> <div class="form-grid">
<label> <label>
Menge Menge

1
wawi/templates/edit.html Normal file → Executable file
View File

@@ -3,6 +3,7 @@
<div class="card form-card"> <div class="card form-card">
<h2>{{ "Artikel bearbeiten" if item else "Neuen Artikel anlegen" }}</h2> <h2>{{ "Artikel bearbeiten" if item else "Neuen Artikel anlegen" }}</h2>
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-grid"> <div class="form-grid">
<label> <label>
Artikel Artikel

2
wawi/templates/index.html Normal file → Executable file
View File

@@ -67,10 +67,12 @@
<td class="actions"> <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> <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?');"> <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> <button class="btn icon" type="submit" title="Verkauf +1" aria-label="Verkauf +1"><span>🛒</span></button>
</form> </form>
<a class="btn icon" href="{{ url_for('bp.ausbuchen', item_id=r.id) }}" title="Ausbuchen" aria-label="Ausbuchen"><span></span></a> <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.');"> <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> <button class="btn icon danger" type="submit" title="Löschen" aria-label="Löschen"><span>🗑</span></button>
</form> </form>
</td> </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> <div class="note">Benutzername oder Passwort ist falsch.</div>
{% endif %} {% endif %}
<form method="post"> <form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-grid"> <div class="form-grid">
<label> <label>
Benutzer Benutzer

2
wawi/templates/orders.html Normal file → Executable file
View File

@@ -45,9 +45,11 @@
<td class="actions"> <td class="actions">
{% if not o.done and not o.canceled %} {% 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?');"> <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> <button class="btn small" type="submit">Erledigt</button>
</form> </form>
<form method="post" action="{{ url_for('bp.cancel_order', order_id=o.id) }}" onsubmit="return confirm('Bestellung wirklich stornieren?');"> <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> <button class="btn small danger" type="submit">Stornieren</button>
</form> </form>
{% else %} {% else %}

3
wawi/templates/users.html Normal file → Executable file
View File

@@ -15,6 +15,7 @@
{% endif %} {% endif %}
{% endwith %} {% endwith %}
<form method="post"> <form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-grid"> <div class="form-grid">
<label> <label>
Benutzername Benutzername
@@ -47,9 +48,11 @@
<td>{{ u.created_at }}</td> <td>{{ u.created_at }}</td>
<td class="actions"> <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?');"> <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> <button class="btn small" type="submit">Passwort neu</button>
</form> </form>
<form method="post" action="{{ url_for('bp.delete_user', user_id=u.id) }}" onsubmit="return confirm('Benutzer wirklich löschen?');"> <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> <button class="btn small danger" type="submit">Löschen</button>
</form> </form>
</td> </td>