Compare commits

..

3 Commits

Author SHA1 Message Date
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
2 changed files with 93 additions and 31 deletions

25
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**
@@ -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
```

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

@@ -1,5 +1,13 @@
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
@@ -22,7 +30,7 @@ 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. # 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"
@@ -43,7 +51,9 @@ _RATE_WINDOW = 60
_RATE_MAX = 15 _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
@@ -53,33 +63,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)
); );
@@ -93,20 +112,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
); );
""" """
) )
@@ -118,7 +137,7 @@ def init_db() -> None:
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 +146,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 +155,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:
@@ -154,7 +175,7 @@ def ensure_orders_columns(db: sqlite3.Connection) -> None:
@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 +183,12 @@ def ensure_db() -> None:
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 und gibt die öffentliche URL zurück."""
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()
@@ -181,7 +204,7 @@ def save_upload(file) -> str | None:
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
@@ -198,6 +221,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"):
@@ -209,11 +233,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", "")
@@ -228,6 +253,7 @@ def api_key_required(fn):
def rate_limited(ip: str) -> bool: def rate_limited(ip: str) -> bool:
"""Einfaches InMemory RateLimit pro IP (1MinFenster)."""
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 +263,11 @@ def rate_limited(ip: str) -> bool:
return False 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()
@@ -289,6 +317,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()
@@ -320,6 +349,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 +385,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 +395,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 +431,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:
@@ -426,8 +459,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 ""
@@ -445,13 +480,16 @@ def login():
@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 +517,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 +527,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
@@ -557,6 +598,7 @@ def build_bestand() -> list[dict]:
@bp.route("/order", methods=["POST"]) @bp.route("/order", methods=["POST"])
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() 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
@@ -632,9 +674,11 @@ def order():
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 +693,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(
@@ -700,6 +745,7 @@ def complete_order(order_id: int):
@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 +763,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: