diff --git a/wawi/app.py b/wawi/app.py old mode 100644 new mode 100755 index 35b1120..45911f1 --- a/wawi/app.py +++ b/wawi/app.py @@ -1,5 +1,13 @@ 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 sqlite3 import secrets @@ -22,7 +30,7 @@ DB_PATH = BASE_DIR / "hellas.db" UPLOAD_DIR = BASE_DIR / "static" / "uploads" ALLOWED_EXT = {".png", ".jpg", ".jpeg", ".webp", ".gif"} -# Optional Prefix, z. B. /wawi, wenn die App hinter einem Sub‑Pfad läuft. +# Optionaler Prefix (z. B. /wawi), wenn die App hinter einem Sub‑Pfad läuft. URL_PREFIX = os.environ.get("URL_PREFIX", "").strip().rstrip("/") STATIC_URL_PATH = f"{URL_PREFIX}/static" if URL_PREFIX else "/static" @@ -43,7 +51,9 @@ _RATE_WINDOW = 60 _RATE_MAX = 15 +# --- Datenbank & Hilfsfunktionen --- def get_db() -> sqlite3.Connection: + """Verbindet zur SQLite‑DB (pro Request gecached in Flask‑g).""" if "db" not in g: conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row @@ -53,33 +63,42 @@ def get_db() -> sqlite3.Connection: @app.teardown_appcontext def close_db(exc: Exception | None) -> None: + """Schließt die DB‑Verbindung am Ende des Requests.""" db = g.pop("db", None) if db is not None: db.close() def init_db() -> None: + """Initialisiert Tabellen und führt kleine 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.executescript( """ CREATE TABLE IF NOT EXISTS items ( id INTEGER PRIMARY KEY AUTOINCREMENT, - artikel TEXT NOT NULL, - groesse TEXT NOT NULL, - preis REAL NOT NULL DEFAULT 0, - bild_url TEXT, - soll INTEGER NOT NULL DEFAULT 0, - gezaehlt INTEGER NOT NULL DEFAULT 0, - verkaeufe INTEGER NOT NULL DEFAULT 0, + artikel TEXT NOT NULL, -- Artikelbezeichnung + groesse TEXT NOT NULL, -- Variante/Größe + preis REAL NOT NULL DEFAULT 0,-- Verkaufspreis + bild_url TEXT, -- Optionales Produktbild + soll INTEGER NOT NULL DEFAULT 0, -- Soll‑Bestand + gezaehlt INTEGER NOT NULL DEFAULT 0, -- Ist‑Bestand + verkaeufe INTEGER NOT NULL DEFAULT 0, -- Verkäufe gesamt created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS ausbuchungen ( id INTEGER PRIMARY KEY AUTOINCREMENT, - item_id INTEGER NOT NULL, - menge INTEGER NOT NULL, - grund TEXT, + item_id INTEGER NOT NULL, -- Referenz auf items.id + menge INTEGER NOT NULL, -- Abgangsmenge + grund TEXT, -- z. B. Verkauf, Defekt, etc. created_at TEXT NOT NULL, FOREIGN KEY(item_id) REFERENCES items(id) ); @@ -93,20 +112,20 @@ def init_db() -> None: CREATE TABLE IF NOT EXISTS orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - handy TEXT NOT NULL, - mannschaft TEXT NOT NULL, + name TEXT NOT NULL, -- Besteller + handy TEXT NOT NULL, -- Kontakt + mannschaft TEXT NOT NULL, -- Team/Abteilung artikel TEXT NOT NULL, groesse TEXT NOT NULL, menge INTEGER NOT NULL, notiz TEXT, created_at TEXT NOT NULL, - done INTEGER NOT NULL DEFAULT 0, - completed_by TEXT, - completed_at TEXT, - canceled INTEGER NOT NULL DEFAULT 0, - canceled_by TEXT, - canceled_at TEXT + done INTEGER NOT NULL DEFAULT 0, -- abgeschlossen? + completed_by TEXT, -- Username + completed_at TEXT, -- Timestamp + canceled INTEGER NOT NULL DEFAULT 0,-- storniert? + canceled_by TEXT, -- Username + canceled_at TEXT -- Timestamp ); """ ) @@ -118,7 +137,7 @@ def init_db() -> 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() if any(c["name"] == "preis" for c in cols): return @@ -127,6 +146,7 @@ def ensure_price_column(db: sqlite3.Connection) -> None: def ensure_image_column(db: sqlite3.Connection) -> None: + """Fügt bild_url nachträglich hinzu, falls DB älter ist.""" cols = db.execute("PRAGMA table_info(items)").fetchall() if any(c["name"] == "bild_url" for c in cols): return @@ -135,6 +155,7 @@ def ensure_image_column(db: sqlite3.Connection) -> None: def ensure_orders_columns(db: sqlite3.Connection) -> None: + """Sorgt für alle nachträglich eingeführten Orders‑Spalten.""" cols = db.execute("PRAGMA table_info(orders)").fetchall() names = {c["name"] for c in cols} if "done" not in names: @@ -154,7 +175,7 @@ def ensure_orders_columns(db: sqlite3.Connection) -> None: @app.before_request def ensure_db() -> None: - # Erstellt Tabellen einmal pro Worker. + """Stellt sicher, dass DB/Schema einmal pro Worker initialisiert ist.""" global _DB_INIT_DONE if not _DB_INIT_DONE: init_db() @@ -162,10 +183,12 @@ def ensure_db() -> None: def now_iso() -> str: + """Timestamp als kompaktes, DB‑freundliches Format.""" return datetime.now().strftime("%Y-%m-%d %H:%M") def save_upload(file) -> str | None: + """Speichert einen Bild‑Upload und gibt die öffentliche URL zurück.""" if not file or not getattr(file, "filename", ""): return None 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: - # 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() if row and row["c"] > 0: return @@ -198,6 +221,7 @@ def ensure_admin_user(db: sqlite3.Connection) -> None: def login_required(fn): + """Schützt HTML‑Views mit Login‑Session.""" @wraps(fn) def wrapper(*args, **kwargs): if not session.get("user"): @@ -209,11 +233,12 @@ def login_required(fn): @app.context_processor def inject_auth(): + """Stellt logged_in im Template‑Kontext bereit.""" return {"logged_in": bool(session.get("user"))} 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) def wrapper(*args, **kwargs): expected = os.environ.get("APP_API_KEY", "") @@ -228,6 +253,7 @@ def api_key_required(fn): def rate_limited(ip: str) -> bool: + """Einfaches In‑Memory Rate‑Limit pro IP (1‑Min‑Fenster).""" now = time.time() bucket = _RATE_LIMIT.setdefault(ip, []) bucket[:] = [t for t in bucket if now - t < _RATE_WINDOW] @@ -237,9 +263,11 @@ def rate_limited(ip: str) -> bool: return False +# --- HTML‑Views (Admin) --- @bp.route("/") @login_required def index(): + """Startseite: Artikelübersicht mit Filter, Sortierung und Summen.""" q = (request.args.get("q") or "").strip() sort = (request.args.get("sort") or "gezaehlt").strip().lower() direction = (request.args.get("dir") or "desc").strip().lower() @@ -289,6 +317,7 @@ def index(): @bp.route("/new", methods=["GET", "POST"]) @login_required def new_item(): + """Artikel anlegen (inkl. optionalem Bild‑Upload).""" if request.method == "POST": artikel = (request.form.get("artikel") or "").strip() groesse = (request.form.get("groesse") or "").strip() @@ -320,6 +349,7 @@ def new_item(): @bp.route("/edit/", methods=["GET", "POST"]) @login_required def edit_item(item_id: int): + """Artikel bearbeiten.""" db = get_db() item = db.execute("SELECT * FROM items WHERE id = ?", (item_id,)).fetchone() if item is None: @@ -355,6 +385,7 @@ def edit_item(item_id: int): @bp.route("/delete/", methods=["POST"]) @login_required def delete_item(item_id: int): + """Artikel löschen.""" db = get_db() db.execute("DELETE FROM items WHERE id = ?", (item_id,)) db.commit() @@ -364,6 +395,7 @@ def delete_item(item_id: int): @bp.route("/ausbuchen/", methods=["GET", "POST"]) @login_required def ausbuchen(item_id: int): + """Ausbuchung erfassen (reduziert Bestand & erhöht Verkäufe).""" db = get_db() item = db.execute("SELECT * FROM items WHERE id = ?", (item_id,)).fetchone() if item is None: @@ -399,6 +431,7 @@ def ausbuchen(item_id: int): @bp.route("/verkauf/", methods=["POST"]) @login_required def verkauf(item_id: int): + """Schnell‑Verkauf: 1 Stück buchen und als Ausbuchung protokollieren.""" db = get_db() item = db.execute("SELECT * FROM items WHERE id = ?", (item_id,)).fetchone() if item is None: @@ -426,8 +459,10 @@ def verkauf(item_id: int): return redirect(url_for("bp.index")) +# --- Auth --- @bp.route("/login", methods=["GET", "POST"]) def login(): + """Login‑Formular & Session‑Handling.""" if request.method == "POST": user = (request.form.get("user") or "").strip() password = request.form.get("password") or "" @@ -445,13 +480,16 @@ def login(): @bp.route("/logout") def logout(): + """Session beenden.""" session.clear() return redirect(url_for("bp.login")) +# --- Benutzerverwaltung --- @bp.route("/users", methods=["GET", "POST"]) @login_required def users(): + """Benutzerverwaltung (anlegen, anzeigen).""" db = get_db() error = None if request.method == "POST": @@ -479,6 +517,7 @@ def users(): @bp.route("/users/delete/", methods=["POST"]) @login_required def delete_user(user_id: int): + """Benutzer löschen (mindestens ein User muss bleiben).""" db = get_db() count = db.execute("SELECT COUNT(*) AS c FROM users").fetchone()["c"] if count <= 1: @@ -488,20 +527,22 @@ def delete_user(user_id: int): return redirect(url_for("bp.users")) +# --- JSON‑APIs --- @bp.route("/api/bestand", methods=["GET"]) @api_key_required def api_bestand(): + """Öffentliche JSON‑API (authentifiziert) für Bestände.""" return jsonify(build_bestand()) @bp.route("/proxy/bestand", methods=["GET"]) 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()) 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( """ SELECT artikel, groesse, preis, bild_url, soll, gezaehlt, verkaeufe @@ -557,6 +598,7 @@ def build_bestand() -> list[dict]: @bp.route("/order", methods=["POST"]) 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() if rate_limited(ip): return jsonify({"error": "Zu viele Anfragen."}), 429 @@ -632,9 +674,11 @@ def order(): return jsonify({"ok": True}) +# --- Bestellungen (Admin) --- @bp.route("/orders") @login_required def orders(): + """Bestellliste in der Verwaltung.""" rows = get_db().execute( """ SELECT id, name, handy, mannschaft, artikel, groesse, menge, notiz, created_at, done, completed_by, completed_at, canceled, canceled_by, canceled_at @@ -649,6 +693,7 @@ def orders(): @bp.route("/orders/complete/", methods=["POST"]) @login_required def complete_order(order_id: int): + """Bestellung abschließen und Bestand abziehen (falls ausreichend).""" user = session.get("user") or "unknown" db = get_db() order = db.execute( @@ -700,6 +745,7 @@ def complete_order(order_id: int): @bp.route("/orders/cancel/", methods=["POST"]) @login_required def cancel_order(order_id: int): + """Bestellung stornieren (nur wenn noch offen).""" user = session.get("user") or "unknown" db = get_db() db.execute( @@ -717,6 +763,7 @@ def cancel_order(order_id: int): @bp.route("/users/reset/", methods=["POST"]) @login_required def reset_user_password(user_id: int): + """Passwort zurücksetzen und neues Passwort als Flash anzeigen.""" db = get_db() user = db.execute("SELECT id, username FROM users WHERE id = ?", (user_id,)).fetchone() if user is None: