commit 81a1ed7eef3949de447cf7a3e8234373baabaa30 Author: Bjoern Welker Date: Fri Jan 30 08:55:14 2026 +0100 Initial commit diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..c4b411b Binary files /dev/null and b/.DS_Store differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..9612e68 --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# Hellas – Bestand & WaWi + +## Überblick +Dieses Repository enthält: +- **WaWi‑App** (Flask + SQLite) für Artikelverwaltung, Ausbuchen und Verkäufe. +- **Live‑Bestand Ansicht** (`index.html`) die den Bestand aus der WaWi‑API lädt. +- **Import‑Script** für Daten aus der ursprünglichen `hellas_bestand.html`. + +## Struktur +- `wawi/app.py` – Flask‑App (CRUD, Ausbuchen, Verkäufe, Benutzerverwaltung) +- `wawi/templates/` – HTML‑Templates der WaWi‑App +- `wawi/static/` – Styles + Logo +- `wawi/hellas.db` – SQLite‑Datenbank (wird automatisch erstellt) +- `wawi/import_from_html.py` – Import aus `hellas_bestand.html` +- `index.html` – Live‑Bestand Ansicht (ruft `/wawi/proxy/bestand`) + +## WaWi lokal starten +```bash +cd wawi +python3 -m venv .venv +source .venv/bin/activate +pip install flask +python app.py +``` +Standardzugriff: Login über Benutzerverwaltung (siehe unten). + +## Benutzerverwaltung +Beim ersten Start wird **ein Admin** aus ENV erzeugt: +- `APP_USER` (default: `admin`) +- `APP_PASSWORD` (default: `admin`) + +Passwörter werden **gehasht** gespeichert. +Über `/users` können weitere Benutzer angelegt und Passwörter zurückgesetzt werden. + +## Import aus hellas_bestand.html +```bash +cd wawi +python import_from_html.py /pfad/zu/hellas_bestand.html --truncate +``` + +## Live‑Bestand Ansicht (index.html) +`index.html` lädt den Bestand aus der WaWi‑App: +- API‑Proxy: `/wawi/proxy/bestand` + +Wenn du diese Datei auf einem Webserver auslieferst, stelle sicher, dass die WaWi‑App unter `/wawi` erreichbar ist. + +## Deployment (Kurz) +1) App nach `/var/www/hellas/wawi` kopieren +2) Gunicorn + systemd starten +3) Nginx Reverse‑Proxy auf `/wawi` + +Für Produktion setze: +- `SECRET_KEY` (Session‑Cookie) +- `APP_USER`, `APP_PASSWORD` (Admin) +- optional `APP_API_KEY` (für direkten API‑Zugriff) + diff --git a/hellas_bestand Kopie.html b/hellas_bestand Kopie.html new file mode 100644 index 0000000..c3ac80d --- /dev/null +++ b/hellas_bestand Kopie.html @@ -0,0 +1,397 @@ + + + + + + Hellas Bestand – Übersicht + + + +
+
+
+
+ Hellas 1899 Logo +
+

Vereinskleidung – Übersicht

+
Erstellt: 2026-01-28 11:25 · Quelle: Hellas_Bestand_neu.xlsx
+
+
+
+ + + +
+
+
+
+ +
+
+
+
+ +
+
+ + + + diff --git a/hellas_bestand.html b/hellas_bestand.html new file mode 100644 index 0000000..c3ac80d --- /dev/null +++ b/hellas_bestand.html @@ -0,0 +1,397 @@ + + + + + + Hellas Bestand – Übersicht + + + +
+
+
+
+ Hellas 1899 Logo +
+

Vereinskleidung – Übersicht

+
Erstellt: 2026-01-28 11:25 · Quelle: Hellas_Bestand_neu.xlsx
+
+
+
+ + + +
+
+
+
+ +
+
+
+
+ +
+
+ + + + diff --git a/index.html b/index.html new file mode 100644 index 0000000..101f711 --- /dev/null +++ b/index.html @@ -0,0 +1,621 @@ + + + + + + Hellas – Shop + + + +
+
+
+
+ Hellas 1899 Logo +
+

Shop

+
Stand: –
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+ + + + + + + + + + diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..26241ed Binary files /dev/null and b/logo.png differ diff --git a/wawi/app.py b/wawi/app.py new file mode 100644 index 0000000..906680f --- /dev/null +++ b/wawi/app.py @@ -0,0 +1,699 @@ +from __future__ import annotations + +import os +import sqlite3 +import secrets +import smtplib +from uuid import uuid4 +from email.message import EmailMessage +from functools import wraps +from pathlib import Path +from datetime import datetime +from typing import Any + +from flask import Flask, Blueprint, g, flash, jsonify, redirect, render_template, request, session, url_for +from werkzeug.security import check_password_hash, generate_password_hash +from werkzeug.utils import secure_filename + +BASE_DIR = Path(__file__).resolve().parent +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. +URL_PREFIX = os.environ.get("URL_PREFIX", "").strip().rstrip("/") +STATIC_URL_PATH = f"{URL_PREFIX}/static" if URL_PREFIX else "/static" + +app = Flask(__name__, static_url_path=STATIC_URL_PATH) +# Session‑Secret für Login‑Cookies (in Produktion unbedingt setzen). +app.secret_key = os.environ.get("SECRET_KEY", "change-me") +app.config["SESSION_COOKIE_SAMESITE"] = "Lax" +app.config["SESSION_COOKIE_SECURE"] = False +app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024 +bp = Blueprint("bp", __name__) + +UPLOAD_DIR.mkdir(parents=True, exist_ok=True) + + +def get_db() -> sqlite3.Connection: + if "db" not in g: + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + g.db = conn + return g.db + + +@app.teardown_appcontext +def close_db(exc: Exception | None) -> None: + db = g.pop("db", None) + if db is not None: + db.close() + + +def init_db() -> None: + 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, + 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, + created_at TEXT NOT NULL, + FOREIGN KEY(item_id) REFERENCES items(id) + ); + + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + handy TEXT NOT NULL, + mannschaft TEXT NOT NULL, + 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 + ); + """ + ) + db.commit() + ensure_price_column(db) + ensure_image_column(db) + ensure_orders_columns(db) + ensure_admin_user(db) + + +def ensure_price_column(db: sqlite3.Connection) -> None: + # 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 + db.execute("ALTER TABLE items ADD COLUMN preis REAL NOT NULL DEFAULT 0") + db.commit() + + +def ensure_image_column(db: sqlite3.Connection) -> None: + cols = db.execute("PRAGMA table_info(items)").fetchall() + if any(c["name"] == "bild_url" for c in cols): + return + db.execute("ALTER TABLE items ADD COLUMN bild_url TEXT") + db.commit() + + +def ensure_orders_columns(db: sqlite3.Connection) -> None: + cols = db.execute("PRAGMA table_info(orders)").fetchall() + names = {c["name"] for c in cols} + if "done" not in names: + db.execute("ALTER TABLE orders ADD COLUMN done INTEGER NOT NULL DEFAULT 0") + if "completed_by" not in names: + db.execute("ALTER TABLE orders ADD COLUMN completed_by TEXT") + if "completed_at" not in names: + db.execute("ALTER TABLE orders ADD COLUMN completed_at TEXT") + if "canceled" not in names: + db.execute("ALTER TABLE orders ADD COLUMN canceled INTEGER NOT NULL DEFAULT 0") + if "canceled_by" not in names: + db.execute("ALTER TABLE orders ADD COLUMN canceled_by TEXT") + if "canceled_at" not in names: + db.execute("ALTER TABLE orders ADD COLUMN canceled_at TEXT") + db.commit() + + +@app.before_request +def ensure_db() -> None: + # Erstellt Tabellen, wenn sie fehlen (bei jedem Request idempotent). + init_db() + + +def now_iso() -> str: + return datetime.now().strftime("%Y-%m-%d %H:%M") + + +def save_upload(file) -> str | None: + if not file or not getattr(file, "filename", ""): + return None + ext = os.path.splitext(file.filename)[1].lower() + if ext not in ALLOWED_EXT: + return None + name = secure_filename(Path(file.filename).stem) or "image" + safe_name = f"{name}-{uuid4().hex}{ext}" + dest = UPLOAD_DIR / safe_name + file.save(dest) + return f"{URL_PREFIX}/static/uploads/{safe_name}" if URL_PREFIX else f"/static/uploads/{safe_name}" + + +def ensure_admin_user(db: sqlite3.Connection) -> None: + # 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 + user = os.environ.get("APP_USER", "admin") + password = os.environ.get("APP_PASSWORD", "admin") + db.execute( + """ + INSERT INTO users (username, password_hash, created_at) + VALUES (?, ?, ?) + """, + (user, generate_password_hash(password), now_iso()), + ) + db.commit() + + +def login_required(fn): + @wraps(fn) + def wrapper(*args, **kwargs): + if not session.get("user"): + return redirect(url_for("bp.login", next=request.path)) + return fn(*args, **kwargs) + + return wrapper + + +@app.context_processor +def inject_auth(): + return {"logged_in": bool(session.get("user"))} + + +def api_key_required(fn): + # Schützt API‑Endpoints per X-API-Key oder ?key= Parameter. + @wraps(fn) + def wrapper(*args, **kwargs): + expected = os.environ.get("APP_API_KEY", "") + if not expected: + return jsonify({"error": "API key not configured"}), 500 + provided = request.headers.get("X-API-Key") or request.args.get("key") or "" + if provided != expected: + return jsonify({"error": "Unauthorized"}), 401 + return fn(*args, **kwargs) + + return wrapper + + +@bp.route("/") +@login_required +def index(): + 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() + + allowed = {"artikel", "groesse", "soll", "gezaehlt", "verkaeufe"} + if sort not in allowed: + sort = "gezaehlt" + if direction not in {"asc", "desc"}: + direction = "desc" + + params: list[Any] = [] + where = "" + if q: + where = "WHERE artikel LIKE ? OR groesse LIKE ?" + like = f"%{q}%" + params.extend([like, like]) + + sql = f""" + SELECT * + FROM items + {where} + ORDER BY {sort} {direction}, artikel ASC, groesse ASC + """ + rows = get_db().execute(sql, params).fetchall() + + grouped = {} + for r in rows: + key = r["artikel"] or "" + grouped.setdefault(key, []).append(r) + groups = [{"artikel": k, "rows": v} for k, v in grouped.items()] + + total = get_db().execute("SELECT COUNT(*) AS c FROM items").fetchone()["c"] + total_bestand = get_db().execute("SELECT COALESCE(SUM(gezaehlt), 0) AS s FROM items").fetchone()["s"] + open_orders = get_db().execute("SELECT COUNT(*) AS c FROM orders WHERE done = 0 AND canceled = 0").fetchone()["c"] + return render_template( + "index.html", + groups=groups, + q=q, + sort=sort, + direction=direction, + total=total, + total_bestand=total_bestand, + open_orders=open_orders, + ) + + +@bp.route("/new", methods=["GET", "POST"]) +@login_required +def new_item(): + if request.method == "POST": + artikel = (request.form.get("artikel") or "").strip() + groesse = (request.form.get("groesse") or "").strip() + preis = float(request.form.get("preis") or 0) + bild_url = (request.form.get("bild_url") or "").strip() + uploaded = save_upload(request.files.get("bild_file")) + if uploaded: + bild_url = uploaded + soll = int(request.form.get("soll") or 0) + gezaehlt = int(request.form.get("gezaehlt") or 0) + verkaeufe = int(request.form.get("verkaeufe") or 0) + + if artikel and groesse: + db = get_db() + db.execute( + """ + INSERT INTO items (artikel, groesse, preis, bild_url, soll, gezaehlt, verkaeufe, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (artikel, groesse, preis, bild_url, soll, gezaehlt, verkaeufe, now_iso(), now_iso()), + ) + db.execute("UPDATE items SET preis = ?, bild_url = ? WHERE artikel = ?", (preis, bild_url, artikel)) + db.commit() + return redirect(url_for("bp.index")) + + return render_template("edit.html", item=None) + + +@bp.route("/edit/", methods=["GET", "POST"]) +@login_required +def edit_item(item_id: int): + db = get_db() + item = db.execute("SELECT * FROM items WHERE id = ?", (item_id,)).fetchone() + if item is None: + return redirect(url_for("bp.index")) + + if request.method == "POST": + artikel = (request.form.get("artikel") or "").strip() + groesse = (request.form.get("groesse") or "").strip() + preis = float(request.form.get("preis") or 0) + bild_url = (request.form.get("bild_url") or "").strip() + uploaded = save_upload(request.files.get("bild_file")) + if uploaded: + bild_url = uploaded + soll = int(request.form.get("soll") or 0) + gezaehlt = int(request.form.get("gezaehlt") or 0) + verkaeufe = int(request.form.get("verkaeufe") or 0) + + db.execute( + """ + UPDATE items + SET artikel = ?, groesse = ?, preis = ?, bild_url = ?, soll = ?, gezaehlt = ?, verkaeufe = ?, updated_at = ? + WHERE id = ? + """, + (artikel, groesse, preis, bild_url, soll, gezaehlt, verkaeufe, now_iso(), item_id), + ) + db.execute("UPDATE items SET preis = ?, bild_url = ? WHERE artikel = ?", (preis, bild_url, artikel)) + db.commit() + return redirect(url_for("bp.index")) + + return render_template("edit.html", item=item) + + +@bp.route("/delete/", methods=["POST"]) +@login_required +def delete_item(item_id: int): + db = get_db() + db.execute("DELETE FROM items WHERE id = ?", (item_id,)) + db.commit() + return redirect(url_for("bp.index")) + + +@bp.route("/ausbuchen/", methods=["GET", "POST"]) +@login_required +def ausbuchen(item_id: int): + db = get_db() + item = db.execute("SELECT * FROM items WHERE id = ?", (item_id,)).fetchone() + if item is None: + return redirect(url_for("bp.index")) + + if request.method == "POST": + menge = int(request.form.get("menge") or 0) + grund = (request.form.get("grund") or "").strip() or None + if menge > 0: + neue_gezaehlt = max(int(item["gezaehlt"]) - menge, 0) + neue_verkaeufe = int(item["verkaeufe"]) + menge + db.execute( + """ + UPDATE items + SET gezaehlt = ?, verkaeufe = ?, updated_at = ? + WHERE id = ? + """, + (neue_gezaehlt, neue_verkaeufe, now_iso(), item_id), + ) + db.execute( + """ + INSERT INTO ausbuchungen (item_id, menge, grund, created_at) + VALUES (?, ?, ?, ?) + """, + (item_id, menge, grund, now_iso()), + ) + db.commit() + return redirect(url_for("bp.index")) + + return render_template("ausbuchen.html", item=item) + + +@bp.route("/verkauf/", methods=["POST"]) +@login_required +def verkauf(item_id: int): + db = get_db() + item = db.execute("SELECT * FROM items WHERE id = ?", (item_id,)).fetchone() + if item is None: + return redirect(url_for("bp.index")) + + menge = 1 + neue_gezaehlt = max(int(item["gezaehlt"]) - menge, 0) + neue_verkaeufe = int(item["verkaeufe"]) + menge + db.execute( + """ + UPDATE items + SET gezaehlt = ?, verkaeufe = ?, updated_at = ? + WHERE id = ? + """, + (neue_gezaehlt, neue_verkaeufe, now_iso(), item_id), + ) + db.execute( + """ + INSERT INTO ausbuchungen (item_id, menge, grund, created_at) + VALUES (?, ?, ?, ?) + """, + (item_id, menge, "Verkauf", now_iso()), + ) + db.commit() + return redirect(url_for("bp.index")) + + +@bp.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + user = (request.form.get("user") or "").strip() + password = request.form.get("password") or "" + row = get_db().execute( + "SELECT id, username, password_hash FROM users WHERE username = ?", + (user,), + ).fetchone() + if row and check_password_hash(row["password_hash"], password): + session["user"] = user + nxt = request.args.get("next") or url_for("bp.index") + return redirect(nxt) + return render_template("login.html", error=True) + return render_template("login.html", error=False) + + +@bp.route("/logout") +def logout(): + session.clear() + return redirect(url_for("bp.login")) + + +@bp.route("/users", methods=["GET", "POST"]) +@login_required +def users(): + db = get_db() + error = None + if request.method == "POST": + username = (request.form.get("username") or "").strip() + password = request.form.get("password") or "" + if not username or not password: + error = "Benutzer und Passwort sind erforderlich." + else: + try: + db.execute( + """ + INSERT INTO users (username, password_hash, created_at) + VALUES (?, ?, ?) + """, + (username, generate_password_hash(password), now_iso()), + ) + db.commit() + except sqlite3.IntegrityError: + error = "Benutzername existiert bereits." + + rows = db.execute("SELECT id, username, created_at FROM users ORDER BY username").fetchall() + return render_template("users.html", rows=rows, error=error) + + +@bp.route("/users/delete/", methods=["POST"]) +@login_required +def delete_user(user_id: int): + db = get_db() + count = db.execute("SELECT COUNT(*) AS c FROM users").fetchone()["c"] + if count <= 1: + return redirect(url_for("bp.users")) + db.execute("DELETE FROM users WHERE id = ?", (user_id,)) + db.commit() + return redirect(url_for("bp.users")) + + +@bp.route("/api/bestand", methods=["GET"]) +@api_key_required +def api_bestand(): + return jsonify(build_bestand()) + + +@bp.route("/proxy/bestand", methods=["GET"]) +def proxy_bestand(): + # 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. + rows = get_db().execute( + """ + SELECT artikel, groesse, preis, bild_url, soll, gezaehlt, verkaeufe + FROM items + ORDER BY artikel, groesse + """ + ).fetchall() + + data: dict[str, dict] = {} + for r in rows: + artikel = (r["artikel"] or "").strip() + if not artikel: + continue + item = data.setdefault( + artikel, + {"artikel": artikel, "preis": 0, "bild_url": "", "rows": [], "totals": {"soll": 0, "gezaehlt": 0, "abweichung": 0, "fehlbestand": 0, "verkaeufe": 0}}, + ) + if not item["preis"]: + item["preis"] = float(r["preis"] or 0) + if not item["bild_url"]: + item["bild_url"] = (r["bild_url"] or "").strip() + soll = int(r["soll"] or 0) + gezaehlt = int(r["gezaehlt"] or 0) + verkaeufe = int(r["verkaeufe"] or 0) + abw = gezaehlt - soll + fehl = max(soll - gezaehlt, 0) + item["rows"].append( + { + "groesse": r["groesse"], + "soll": soll, + "gezaehlt": gezaehlt, + "abweichung": abw, + "fehlbestand": fehl if fehl > 0 else None, + "verkaeufe": verkaeufe, + } + ) + t = item["totals"] + t["soll"] += soll + t["gezaehlt"] += gezaehlt + t["abweichung"] += abw + t["fehlbestand"] += fehl + t["verkaeufe"] += verkaeufe + + result: list[dict] = [] + for artikel, item in data.items(): + t = item["totals"] + if t["fehlbestand"] == 0: + t["fehlbestand"] = None + result.append(item) + + return result + + +@bp.route("/order", methods=["POST"]) +def order(): + data = request.get_json(silent=True) or request.form + required = ["name", "handy", "mannschaft", "artikel", "groesse", "menge"] + if any(not (data.get(k) or "").strip() for k in required): + return jsonify({"error": "Pflichtfelder fehlen."}), 400 + + db = get_db() + db.execute( + """ + INSERT INTO orders (name, handy, mannschaft, artikel, groesse, menge, notiz, created_at, done, completed_by, completed_at, canceled, canceled_by, canceled_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, NULL, 0, NULL, NULL) + """, + ( + data.get("name"), + data.get("handy"), + data.get("mannschaft"), + data.get("artikel"), + data.get("groesse"), + int(data.get("menge") or 0), + data.get("notiz"), + now_iso(), + ), + ) + db.commit() + + to_addr = os.environ.get("ORDER_TO", "bjoern@welker.me") + smtp_host = os.environ.get("SMTP_HOST") + smtp_user = os.environ.get("SMTP_USER") + smtp_pass = os.environ.get("SMTP_PASS") + smtp_port = int(os.environ.get("SMTP_PORT", "587")) + smtp_from = os.environ.get("SMTP_FROM", smtp_user or "no-reply@localhost") + + if not smtp_host or not smtp_user or not smtp_pass: + return jsonify({"error": "Mailversand nicht konfiguriert."}), 500 + + msg = EmailMessage() + msg["Subject"] = "Neue Bestellung (Hellas Bestand)" + msg["From"] = smtp_from + recipients = [a.strip() for a in to_addr.split(",") if a.strip()] + msg["To"] = ", ".join(recipients) + body = ( + "Neue Bestellung:\n" + f"Name: {data.get('name')}\n" + f"Handy: {data.get('handy')}\n" + f"Mannschaft: {data.get('mannschaft')}\n" + f"Artikel: {data.get('artikel')}\n" + f"Größe: {data.get('groesse')}\n" + f"Menge: {data.get('menge')}\n" + f"Notiz: {data.get('notiz') or '-'}\n" + "WaWi: https://hellas.welker.me/wawi\n" + ) + msg.set_content(body) + + with smtplib.SMTP(smtp_host, smtp_port) as server: + server.starttls() + server.login(smtp_user, smtp_pass) + server.send_message(msg, to_addrs=recipients) + + return jsonify({"ok": True}) + + +@bp.route("/orders") +@login_required +def orders(): + 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 + FROM orders + ORDER BY id DESC + LIMIT 500 + """ + ).fetchall() + return render_template("orders.html", rows=rows) + + +@bp.route("/orders/complete/", methods=["POST"]) +@login_required +def complete_order(order_id: int): + user = session.get("user") or "unknown" + db = get_db() + order = db.execute( + """ + SELECT id, artikel, groesse, menge, done, canceled + FROM orders + WHERE id = ? + """, + (order_id,), + ).fetchone() + if order is None: + return redirect(url_for("bp.orders")) + if not order["done"] and not order["canceled"]: + menge = int(order["menge"] or 0) + item = db.execute( + """ + SELECT gezaehlt + FROM items + WHERE artikel = ? AND groesse = ? + """, + (order["artikel"], order["groesse"]), + ).fetchone() + current = int(item["gezaehlt"] or 0) if item else 0 + if current < menge: + flash("Bestand reicht nicht aus, um die Bestellung abzuschließen.") + return redirect(url_for("bp.orders")) + db.execute( + """ + UPDATE items + SET gezaehlt = CASE WHEN gezaehlt - ? < 0 THEN 0 ELSE gezaehlt - ? END, + verkaeufe = verkaeufe + ?, + updated_at = ? + WHERE artikel = ? AND groesse = ? + """, + (menge, menge, menge, now_iso(), order["artikel"], order["groesse"]), + ) + db.execute( + """ + UPDATE orders + SET done = 1, completed_by = ?, completed_at = ? + WHERE id = ? + """, + (user, now_iso(), order_id), + ) + db.commit() + return redirect(url_for("bp.orders")) + + +@bp.route("/orders/cancel/", methods=["POST"]) +@login_required +def cancel_order(order_id: int): + user = session.get("user") or "unknown" + db = get_db() + db.execute( + """ + UPDATE orders + SET canceled = 1, canceled_by = ?, canceled_at = ? + WHERE id = ? AND done = 0 + """, + (user, now_iso(), order_id), + ) + db.commit() + return redirect(url_for("bp.orders")) + + +@bp.route("/users/reset/", methods=["POST"]) +@login_required +def reset_user_password(user_id: int): + db = get_db() + user = db.execute("SELECT id, username FROM users WHERE id = ?", (user_id,)).fetchone() + if user is None: + return redirect(url_for("bp.users")) + new_password = secrets.token_urlsafe(8).replace("-", "").replace("_", "")[:12] + db.execute( + "UPDATE users SET password_hash = ? WHERE id = ?", + (generate_password_hash(new_password), user_id), + ) + db.commit() + flash(f"Neues Passwort für {user['username']}: {new_password}") + return redirect(url_for("bp.users")) + + +app.register_blueprint(bp, url_prefix=URL_PREFIX or "") + +if __name__ == "__main__": + app.run(debug=True) diff --git a/wawi/import_from_html.py b/wawi/import_from_html.py new file mode 100644 index 0000000..264a86a --- /dev/null +++ b/wawi/import_from_html.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import argparse +import json +import re +import sqlite3 +from pathlib import Path +from datetime import datetime + +BASE_DIR = Path(__file__).resolve().parent +DB_PATH = BASE_DIR / "hellas.db" + + +def now_iso() -> str: + return datetime.now().strftime("%Y-%m-%d %H:%M") + + +def ensure_db(conn: sqlite3.Connection) -> None: + conn.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, + soll INTEGER NOT NULL DEFAULT 0, + gezaehlt INTEGER NOT NULL DEFAULT 0, + verkaeufe INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + """ + ) + conn.commit() + + +def extract_data(html_text: str) -> list[dict]: + # Liest den const DATA = [...] Block aus der alten HTML‑Datei. + match = re.search(r"const\s+DATA\s*=\s*(\[[\s\S]*?\]);", html_text) + if not match: + raise ValueError("DATA-Block nicht gefunden.") + raw = match.group(1) + return json.loads(raw) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Import aus hellas_bestand.html in SQLite.") + parser.add_argument("html", type=Path, help="Pfad zur hellas_bestand.html") + parser.add_argument("--truncate", action="store_true", help="Vor Import alle Items löschen.") + args = parser.parse_args() + + html_text = args.html.read_text(encoding="utf-8") + data = extract_data(html_text) + + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + ensure_db(conn) + + if args.truncate: + conn.execute("DELETE FROM items") + conn.commit() + + now = now_iso() + insert_sql = """ + INSERT INTO items (artikel, groesse, soll, gezaehlt, verkaeufe, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """ + + rows_added = 0 + for item in data: + artikel = (item.get("artikel") or "").strip() + for row in item.get("rows") or []: + groesse = (row.get("groesse") or "").strip() + soll = int(row.get("soll") or 0) + gezaehlt = int(row.get("gezaehlt") or 0) + verkaeufe = int(row.get("verkaeufe") or 0) + if not artikel or not groesse: + continue + conn.execute(insert_sql, (artikel, groesse, soll, gezaehlt, verkaeufe, now, now)) + rows_added += 1 + + conn.commit() + print(f"Import fertig. Zeilen eingefügt: {rows_added}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/wawi/static/logo.png b/wawi/static/logo.png new file mode 100644 index 0000000..26241ed Binary files /dev/null and b/wawi/static/logo.png differ diff --git a/wawi/static/style.css b/wawi/static/style.css new file mode 100644 index 0000000..b82b9b1 --- /dev/null +++ b/wawi/static/style.css @@ -0,0 +1,204 @@ +:root { + --bg: #0a2036; + --card: #0f2236; + --text: #f1f4f8; + --muted: #9fb1c8; + --line: rgba(255,255,255,.12); + --accent: #f3d52a; + --teal: #2a8a8a; + --ok: #7bd58d; + --bad: #ff6b7d; +} +* { box-sizing: border-box; } +body { + margin: 0; + font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, "Noto Sans", "Liberation Sans", sans-serif; + background: + radial-gradient(900px 520px at 50% -10%, rgba(255,255,255,.10), transparent 60%), + radial-gradient(900px 600px at 50% 20%, rgba(16,44,70,.85), transparent 65%), + radial-gradient(1200px 700px at 50% 80%, rgba(7,26,44,.95), transparent 70%), + linear-gradient(180deg, #0a2036 0%, #0b243b 45%, #092135 100%); + color: var(--text); +} +html { scroll-behavior: smooth; } +header { + background: rgba(9,24,40,.9); + border-bottom: 1px solid var(--line); + box-shadow: 0 8px 22px rgba(0,0,0,.35); +} +header::before { + content: ""; + display: block; + height: 3px; + background: linear-gradient(90deg, var(--teal), rgba(42,138,138,.0)); +} +.wrap { + max-width: 1100px; + margin: 0 auto; + padding: 18px 16px; +} +.top { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } +.brand { display: flex; align-items: center; gap: 12px; } +.brand img { height: 42px; width: auto; display: block; filter: drop-shadow(0 2px 6px rgba(0,0,0,.4)); } +h1 { + font-family: "Arial Narrow", "Helvetica Neue Condensed", Impact, "Franklin Gothic Medium", "Arial Black", sans-serif; + font-size: 22px; + margin: 0; + letter-spacing: 1px; + text-transform: uppercase; +} +.meta { color: var(--muted); font-size: 12px; } +.nav { display: flex; gap: 10px; align-items: center; } +.btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 999px; + border: 1px solid var(--line); + background: rgba(255,255,255,.06); + color: var(--text); + text-decoration: none; + font-size: 13px; + cursor: pointer; +} +.btn:hover { border-color: rgba(255,255,255,.2); } +.btn.icon { + width: 40px; + height: 40px; + justify-content: center; + padding: 0; + border-radius: 12px; + font-size: 18px; + font-weight: 700; +} +.btn.icon span { line-height: 1; } +.btn.icon:hover { transform: translateY(-1px); } +.btn.icon.danger { background: rgba(255,107,125,.2); } +.btn.icon.accent { background: var(--accent); color: #071320; border-color: transparent; } +.actions .btn.icon { margin-right: 6px; } +.nav .btn.icon { width: 38px; height: 38px; font-size: 18px; } +.btn-accent { background: var(--accent); color: #071320; border-color: transparent; font-weight: 700; } +.btn.ghost { background: transparent; } +.btn.small { padding: 6px 10px; font-size: 12px; } +.btn.danger { background: rgba(255,107,125,.2); border-color: rgba(255,107,125,.4); color: #ffe6ea; } + +.toolbar { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + margin: 12px 0 14px; +} +.search { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} +.search input, .search select { + background: rgba(255,255,255,.06); + border: 1px solid var(--line); + color: var(--text); + padding: 8px 10px; + border-radius: 10px; +} +.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)); + box-shadow: 0 16px 28px rgba(0,0,0,.35); +} +.stat-link { text-decoration: none; color: inherit; } +.stat-link:hover { border-color: rgba(255,255,255,.25); } +.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); } + +.card { + border: 1px solid var(--line); + border-radius: 14px; + background: linear-gradient(180deg, rgba(18,45,70,.92), rgba(12,31,51,.98)); + box-shadow: 0 16px 28px rgba(0,0,0,.35); + overflow: hidden; +} +table { width: 100%; border-collapse: collapse; font-size: 13px; } +th, td { padding: 10px 8px; border-bottom: 1px solid rgba(255,255,255,.08); text-align: right; } +th:first-child, td:first-child { text-align: left; } +thead th { background: rgba(0,0,0,.18); color: var(--muted); font-weight: 600; } +tbody tr:nth-child(odd) { background: rgba(255,255,255,.03); } +.group-row td { + background: rgba(255,255,255,.06); + font-weight: 700; + letter-spacing: .2px; +} +.group-row td strong { font-size: 14px; } +.order-history td { + background: rgba(255,255,255,.02); + border-bottom: 1px solid rgba(255,255,255,.06); +} +.inline-details summary { + cursor: pointer; + color: var(--muted); + font-size: 12px; + letter-spacing: .6px; + text-transform: uppercase; + list-style: none; +} +.inline-details summary::-webkit-details-marker { display: none; } +.history-grid { + margin-top: 8px; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 6px 16px; + color: var(--text); + font-size: 13px; +} +.actions { text-align: right; white-space: nowrap; } +.actions form { display: inline; } +.pos { color: var(--ok); font-weight: 700; } +.neg { color: var(--bad); font-weight: 700; } +.empty { text-align: center; padding: 18px; color: var(--muted); } + +.form-card { padding: 16px; } +.form-card h2 { margin: 0 0 10px; font-size: 18px; } +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; +} +label { display: grid; gap: 6px; font-size: 13px; color: var(--muted); } +input[type="text"], input[type="number"] { + background: rgba(255,255,255,.06); + border: 1px solid var(--line); + color: var(--text); + padding: 9px 10px; + border-radius: 10px; +} +.form-actions { margin-top: 14px; display: flex; gap: 10px; } +.note { color: var(--muted); margin-bottom: 10px; } + +.to-top { + position: fixed; + right: 18px; + bottom: 18px; + width: 42px; + height: 42px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 12px; + border: 1px solid var(--line); + background: rgba(255,255,255,.08); + color: var(--text); + text-decoration: none; + font-weight: 700; + box-shadow: 0 10px 20px rgba(0,0,0,.35); +} +.to-top:hover { transform: translateY(-2px); } + +@media (max-width: 720px) { + .actions { display: flex; gap: 6px; flex-wrap: wrap; } + .btn.small { padding: 6px 8px; } +} diff --git a/wawi/templates/ausbuchen.html b/wawi/templates/ausbuchen.html new file mode 100644 index 0000000..f941ce1 --- /dev/null +++ b/wawi/templates/ausbuchen.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% block content %} +
+

Ausbuchen: {{ item.artikel }} ({{ item.groesse }})

+
Aktueller Bestand: {{ item.gezaehlt }}
+
+
+ + +
+
+ + Abbrechen +
+
+
+{% endblock %} diff --git a/wawi/templates/base.html b/wawi/templates/base.html new file mode 100644 index 0000000..e29ef63 --- /dev/null +++ b/wawi/templates/base.html @@ -0,0 +1,37 @@ + + + + + + {{ title or "Hellas Artikelverwaltung" }} + + + +
+
+
+ Hellas 1899 Logo +
+

Hellas 1899 · Artikelverwaltung

+
Bestand & Ausbuchungen
+
+
+ +
+
+
+
+ {% block content %}{% endblock %} +
+
+ + + diff --git a/wawi/templates/edit.html b/wawi/templates/edit.html new file mode 100644 index 0000000..2190485 --- /dev/null +++ b/wawi/templates/edit.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% block content %} +
+

{{ "Artikel bearbeiten" if item else "Neuen Artikel anlegen" }}

+
+
+ + + + + + + + +
+
+ + Abbrechen +
+
+
+{% endblock %} diff --git a/wawi/templates/index.html b/wawi/templates/index.html new file mode 100644 index 0000000..c822e55 --- /dev/null +++ b/wawi/templates/index.html @@ -0,0 +1,88 @@ +{% extends "base.html" %} +{% block content %} +
+ +
+
Artikel gesamt
+
{{ total }}
+
+
+
Bestand gesamt
+
{{ total_bestand }}
+
+ +
Offene Bestellungen
+
{{ open_orders }}
+
+
+ +
+ + + + + + + + + + + + + + + + {% if groups %} + {% for g in groups %} + + + + {% for r in g.rows %} + {% set diff = (r.gezaehlt or 0) - (r.soll or 0) %} + {% set fehl = (r.soll or 0) - (r.gezaehlt or 0) %} + + + + + + + + + + + + {% endfor %} + {% endfor %} + {% else %} + + + + {% endif %} + +
ArtikelGrößePreisSollBestandAbweichungFehlbestandVerkäufeAktionen
{{ g.artikel }}
{{ r.groesse }}{{ "%.2f"|format(r.preis or 0) }} €{{ r.soll }}{{ r.gezaehlt }}{{ diff }}{{ fehl if fehl > 0 else "–" }}{{ r.verkaeufe }} + +
+ +
+ +
+ +
+
Keine Treffer.
+
+{% endblock %} diff --git a/wawi/templates/login.html b/wawi/templates/login.html new file mode 100644 index 0000000..63afd36 --- /dev/null +++ b/wawi/templates/login.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block content %} +
+

Login

+ {% if error %} +
Benutzername oder Passwort ist falsch.
+ {% endif %} +
+
+ + +
+
+ +
+
+
+{% endblock %} diff --git a/wawi/templates/orders.html b/wawi/templates/orders.html new file mode 100644 index 0000000..00424d5 --- /dev/null +++ b/wawi/templates/orders.html @@ -0,0 +1,79 @@ +{% extends "base.html" %} +{% block content %} + {% with messages = get_flashed_messages() %} + {% if messages %} +
+ {% for m in messages %} +
{{ m }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} +
+ + + + + + + + + + + + + + + + + {% for o in rows %} + + + + + + + + + + + + + + + + {% else %} + + + + {% endfor %} + +
DatumNameHandyMannschaftArtikelGrößeMengeNotizStatusAktion
{{ o.created_at }}{{ o.name }}{{ o.handy }}{{ o.mannschaft }}{{ o.artikel }}{{ o.groesse }}{{ o.menge }}{{ o.notiz or "–" }} + {% if o.canceled %}Storniert + {% elif o.done %}Erledigt + {% else %}Offen + {% endif %} + + {% if not o.done and not o.canceled %} +
+ +
+
+ +
+ {% else %} + – + {% endif %} +
+
+ Historie +
+
Abgeschlossen von: {{ o.completed_by or "–" }}
+
Abgeschlossen am: {{ o.completed_at or "–" }}
+
Storniert von: {{ o.canceled_by or "–" }}
+
Storniert am: {{ o.canceled_at or "–" }}
+
+
+
Keine Bestellungen.
+
+{% endblock %} diff --git a/wawi/templates/users.html b/wawi/templates/users.html new file mode 100644 index 0000000..1eda894 --- /dev/null +++ b/wawi/templates/users.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} +{% block content %} +
+

Benutzer verwalten

+ {% if error %} +
{{ error }}
+ {% endif %} + {% with messages = get_flashed_messages() %} + {% if messages %} +
+ {% for m in messages %} +
{{ m }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} +
+
+ + +
+
+ +
+
+
+ +
+ + + + + + + + + + {% for u in rows %} + + + + + + {% else %} + + + + {% endfor %} + +
BenutzerErstelltAktion
{{ u.username }}{{ u.created_at }} +
+ +
+
+ +
+
Keine Benutzer.
+
+{% endblock %}