Compare commits
10 Commits
db2767a496
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fd3f49a2e1 | |||
| a40b028a0e | |||
| 450a4b062f | |||
| a8b26a25da | |||
| 630595bce9 | |||
| e062a1e836 | |||
| 12eece0226 | |||
| b9ea2c2625 | |||
| 68bfbd55a2 | |||
| 7ff74cb18c |
31
README.md
Normal file → Executable file
31
README.md
Normal file → Executable 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
|
|||||||
- API‑Proxy: `/wawi/proxy/bestand`
|
- API‑Proxy: `/wawi/proxy/bestand`
|
||||||
|
|
||||||
Wenn du diese Datei auf einem Webserver auslieferst, stelle sicher, dass die WaWi‑App unter `/wawi` erreichbar ist.
|
Wenn du diese Datei auf einem Webserver auslieferst, stelle sicher, dass die WaWi‑App 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 Script‑Block 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**
|
||||||
@@ -89,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
|
||||||
|
|
||||||
@@ -159,8 +179,3 @@ mkdir -p /var/www/hellas/wawi/static/uploads
|
|||||||
chown -R www-data:www-data /var/www/hellas/wawi/static/uploads
|
chown -R www-data:www-data /var/www/hellas/wawi/static/uploads
|
||||||
systemctl restart hellas
|
systemctl restart hellas
|
||||||
```
|
```
|
||||||
|
|
||||||
## Backup (Beispiel)
|
|
||||||
```bash
|
|
||||||
sudo /root/fix_wawi_permissions.sh
|
|
||||||
```
|
|
||||||
|
|||||||
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; }
|
.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>
|
||||||
@@ -403,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 WaWi‑App (kein API‑Key im Browser nötig).
|
// Proxy der WaWi‑App (kein API‑Key im Browser nötig).
|
||||||
const API_URL = "/wawi/proxy/bestand";
|
const API_URL = "/wawi/proxy/bestand";
|
||||||
let DATA = [];
|
let DATA = [];
|
||||||
@@ -563,13 +640,13 @@ form.addEventListener("submit", async (e) => {
|
|||||||
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
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
|
||||||
228
wawi/app.py
Normal file → Executable file
228
wawi/app.py
Normal file → Executable file
@@ -1,11 +1,20 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
"""Hellas WaWi (Warenwirtschaft) Flask-App.
|
||||||
|
|
||||||
|
Kurzüberblick:
|
||||||
|
- SQLite‑Datenbank für Artikel, Ausbuchungen, Users und Bestellungen.
|
||||||
|
- HTML‑Views für Verwaltung sowie JSON‑APIs 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 time
|
||||||
import threading
|
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
|
||||||
@@ -13,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
|
||||||
|
|
||||||
@@ -22,17 +39,42 @@ 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 Sub‑Pfad 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 Sub‑Pfad 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)
|
||||||
|
|
||||||
# Session‑Secret für Login‑Cookies (in Produktion unbedingt setzen).
|
# 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_SAMESITE"] = "Lax"
|
||||||
app.config["SESSION_COOKIE_SECURE"] = os.environ.get("COOKIE_SECURE", "1") == "1"
|
app.config["SESSION_COOKIE_SECURE"] = os.environ.get("COOKIE_SECURE", "1") == "1"
|
||||||
app.config["SESSION_COOKIE_HTTPONLY"] = True
|
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)
|
||||||
@@ -43,7 +85,9 @@ _RATE_WINDOW = 60
|
|||||||
_RATE_MAX = 15
|
_RATE_MAX = 15
|
||||||
|
|
||||||
|
|
||||||
|
# --- Datenbank & Hilfsfunktionen ---
|
||||||
def get_db() -> sqlite3.Connection:
|
def get_db() -> sqlite3.Connection:
|
||||||
|
"""Verbindet zur SQLite‑DB (pro Request gecached in Flask‑g)."""
|
||||||
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
|
||||||
@@ -53,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 DB‑Verbindung 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 Schema‑Migrationen aus.
|
||||||
|
|
||||||
|
Tabellen:
|
||||||
|
- items: Artikelstamm + Soll/Bestand/Verkäufe
|
||||||
|
- ausbuchungen: Historie von Abgängen
|
||||||
|
- users: Login‑Benutzer
|
||||||
|
- 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, -- Soll‑Bestand
|
||||||
gezaehlt INTEGER NOT NULL DEFAULT 0,
|
gezaehlt INTEGER NOT NULL DEFAULT 0, -- Ist‑Bestand
|
||||||
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)
|
||||||
);
|
);
|
||||||
@@ -93,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
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
@@ -114,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
|
||||||
@@ -127,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
|
||||||
@@ -135,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 Orders‑Spalten."""
|
||||||
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:
|
||||||
@@ -152,9 +208,29 @@ 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 einmal pro Worker.
|
"""Stellt sicher, dass DB/Schema einmal pro Worker initialisiert ist."""
|
||||||
global _DB_INIT_DONE
|
global _DB_INIT_DONE
|
||||||
if not _DB_INIT_DONE:
|
if not _DB_INIT_DONE:
|
||||||
init_db()
|
init_db()
|
||||||
@@ -162,10 +238,12 @@ def ensure_db() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def now_iso() -> str:
|
def now_iso() -> str:
|
||||||
|
"""Timestamp als kompaktes, DB‑freundliches 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 Bild‑Upload 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()
|
||||||
@@ -173,15 +251,65 @@ def save_upload(file) -> str | None:
|
|||||||
return None
|
return None
|
||||||
if not (file.mimetype or "").startswith("image/"):
|
if not (file.mimetype or "").startswith("image/"):
|
||||||
return None
|
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 Admin‑User an, wenn noch kein Benutzer existiert.
|
"""Legt einen Admin‑User 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
|
||||||
@@ -198,6 +326,7 @@ def ensure_admin_user(db: sqlite3.Connection) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def login_required(fn):
|
def login_required(fn):
|
||||||
|
"""Schützt HTML‑Views mit Login‑Session."""
|
||||||
@wraps(fn)
|
@wraps(fn)
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
if not session.get("user"):
|
if not session.get("user"):
|
||||||
@@ -209,11 +338,12 @@ def login_required(fn):
|
|||||||
|
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
def inject_auth():
|
def inject_auth():
|
||||||
|
"""Stellt logged_in im Template‑Kontext 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 API‑Endpoints per X-API-Key oder ?key= Parameter.
|
"""Schützt API‑Endpoints 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", "")
|
||||||
@@ -228,6 +358,7 @@ def api_key_required(fn):
|
|||||||
|
|
||||||
|
|
||||||
def rate_limited(ip: str) -> bool:
|
def rate_limited(ip: str) -> bool:
|
||||||
|
"""Einfaches In‑Memory Rate‑Limit pro IP (1‑Min‑Fenster)."""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
bucket = _RATE_LIMIT.setdefault(ip, [])
|
bucket = _RATE_LIMIT.setdefault(ip, [])
|
||||||
bucket[:] = [t for t in bucket if now - t < _RATE_WINDOW]
|
bucket[:] = [t for t in bucket if now - t < _RATE_WINDOW]
|
||||||
@@ -237,9 +368,11 @@ def rate_limited(ip: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# --- HTML‑Views (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()
|
||||||
@@ -289,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 Bild‑Upload)."""
|
||||||
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()
|
||||||
@@ -320,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:
|
||||||
@@ -355,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()
|
||||||
@@ -364,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:
|
||||||
@@ -399,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):
|
||||||
|
"""Schnell‑Verkauf: 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:
|
||||||
@@ -426,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():
|
||||||
|
"""Login‑Formular & Session‑Handling."""
|
||||||
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 ""
|
||||||
@@ -437,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":
|
||||||
@@ -479,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:
|
||||||
@@ -488,20 +634,22 @@ def delete_user(user_id: int):
|
|||||||
return redirect(url_for("bp.users"))
|
return redirect(url_for("bp.users"))
|
||||||
|
|
||||||
|
|
||||||
|
# --- JSON‑APIs ---
|
||||||
@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 JSON‑API (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():
|
||||||
# Server‑Proxy ohne API‑Key (z. B. für öffentliche Anzeige).
|
"""Server‑Proxy ohne API‑Key (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 DB‑Zeilen in die Struktur der Live‑Bestand Ansicht.
|
"""Aggregiert DB‑Zeilen in die Struktur der Live‑Bestand 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
|
||||||
@@ -556,7 +704,9 @@ 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 API‑Key) und versendet Mail."""
|
||||||
ip = request.headers.get("X-Forwarded-For", request.remote_addr or "unknown").split(",")[0].strip()
|
ip = request.headers.get("X-Forwarded-For", request.remote_addr or "unknown").split(",")[0].strip()
|
||||||
if rate_limited(ip):
|
if rate_limited(ip):
|
||||||
return jsonify({"error": "Zu viele Anfragen."}), 429
|
return jsonify({"error": "Zu viele Anfragen."}), 429
|
||||||
@@ -592,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")
|
||||||
@@ -622,19 +773,30 @@ def order():
|
|||||||
msg.set_content(body)
|
msg.set_content(body)
|
||||||
|
|
||||||
def _send():
|
def _send():
|
||||||
|
"""Sendet Bestellungs-Email asynchron mit Error Handling."""
|
||||||
|
try:
|
||||||
with smtplib.SMTP(smtp_host, smtp_port) as server:
|
with smtplib.SMTP(smtp_host, smtp_port) as server:
|
||||||
server.starttls()
|
server.starttls()
|
||||||
server.login(smtp_user, smtp_pass)
|
server.login(smtp_user, smtp_pass)
|
||||||
server.send_message(msg, to_addrs=recipients)
|
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()
|
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
|
||||||
@@ -649,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(
|
||||||
@@ -694,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(
|
||||||
@@ -717,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
1
wawi/templates/ausbuchen.html
Normal file → Executable 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
1
wawi/templates/edit.html
Normal file → Executable 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
2
wawi/templates/index.html
Normal file → Executable 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
1
wawi/templates/login.html
Normal file → Executable 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
2
wawi/templates/orders.html
Normal file → Executable 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
3
wawi/templates/users.html
Normal file → Executable 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user