Initial commit

This commit is contained in:
Bjoern Welker
2026-01-30 08:55:14 +01:00
commit 81a1ed7eef
17 changed files with 2824 additions and 0 deletions

699
wawi/app.py Normal file
View File

@@ -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 SubPfad 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)
# SessionSecret für LoginCookies (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 AdminUser 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 APIEndpoints 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/<int:item_id>", 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/<int:item_id>", 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/<int:item_id>", 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/<int:item_id>", 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/<int:user_id>", 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():
# ServerProxy ohne APIKey (z. B. für öffentliche Anzeige).
return jsonify(build_bestand())
def build_bestand() -> list[dict]:
# Aggregiert DBZeilen in die Struktur der LiveBestand 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/<int:order_id>", 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/<int:order_id>", 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/<int:user_id>", 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)

88
wawi/import_from_html.py Normal file
View File

@@ -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 HTMLDatei.
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())

BIN
wawi/static/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

204
wawi/static/style.css Normal file
View File

@@ -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; }
}

View File

@@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block content %}
<div class="card form-card">
<h2>Ausbuchen: {{ item.artikel }} ({{ item.groesse }})</h2>
<div class="note">Aktueller Bestand: <strong>{{ item.gezaehlt }}</strong></div>
<form method="post" onsubmit="return confirm('Wirklich ausbuchen?');">
<div class="form-grid">
<label>
Menge
<input type="number" name="menge" min="1" required />
</label>
<label>
Grund (optional)
<input type="text" name="grund" placeholder="z. B. Verkauf, Defekt, Muster" />
</label>
</div>
<div class="form-actions">
<button class="btn btn-accent" type="submit">Ausbuchen</button>
<a class="btn ghost" href="{{ url_for('bp.index') }}">Abbrechen</a>
</div>
</form>
</div>
{% endblock %}

37
wawi/templates/base.html Normal file
View File

@@ -0,0 +1,37 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>{{ title or "Hellas Artikelverwaltung" }}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" />
</head>
<body>
<header>
<div class="wrap top">
<div class="brand">
<img src="{{ url_for('static', filename='logo.png') }}" alt="Hellas 1899 Logo" />
<div>
<h1>Hellas 1899 · Artikelverwaltung</h1>
<div class="meta">Bestand &amp; Ausbuchungen</div>
</div>
</div>
<nav class="nav">
{% if logged_in %}
<a class="btn icon" href="{{ url_for('bp.index') }}" title="Übersicht" aria-label="Übersicht"><span></span></a>
<a class="btn icon accent" href="{{ url_for('bp.new_item') }}" title="Neuer Artikel" aria-label="Neuer Artikel"><span></span></a>
<a class="btn icon" href="{{ url_for('bp.users') }}" title="Benutzer" aria-label="Benutzer"><span>☺︎</span></a>
<a class="btn icon" href="{{ url_for('bp.orders') }}" title="Bestellungen" aria-label="Bestellungen"><span>☑︎</span></a>
<a class="btn icon ghost" href="{{ url_for('bp.logout') }}" title="Logout" aria-label="Logout"><span></span></a>
{% endif %}
</nav>
</div>
</header>
<main>
<div class="wrap">
{% block content %}{% endblock %}
</div>
</main>
<a href="#" class="to-top" title="Nach oben" aria-label="Nach oben"></a>
</body>
</html>

46
wawi/templates/edit.html Normal file
View File

@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block content %}
<div class="card form-card">
<h2>{{ "Artikel bearbeiten" if item else "Neuen Artikel anlegen" }}</h2>
<form method="post" enctype="multipart/form-data">
<div class="form-grid">
<label>
Artikel
<input type="text" name="artikel" required value="{{ item.artikel if item else '' }}" />
</label>
<label>
Größe
<input type="text" name="groesse" required value="{{ item.groesse if item else '' }}" />
</label>
<label>
Preis (EUR)
<input type="number" name="preis" step="0.01" min="0" value="{{ item.preis if item else 0 }}" />
</label>
<label>
BildURL (optional)
<input type="text" name="bild_url" placeholder="/images/artikel.jpg" value="{{ item.bild_url if item else '' }}" />
</label>
<label>
Bild hochladen (optional)
<input type="file" name="bild_file" accept="image/*" />
</label>
<label>
Soll
<input type="number" name="soll" min="0" value="{{ item.soll if item else 0 }}" />
</label>
<label>
Bestand
<input type="number" name="gezaehlt" min="0" value="{{ item.gezaehlt if item else 0 }}" />
</label>
<label>
Verkäufe
<input type="number" name="verkaeufe" min="0" value="{{ item.verkaeufe if item else 0 }}" />
</label>
</div>
<div class="form-actions">
<button class="btn btn-accent" type="submit">Speichern</button>
<a class="btn ghost" href="{{ url_for('bp.index') }}">Abbrechen</a>
</div>
</form>
</div>
{% endblock %}

88
wawi/templates/index.html Normal file
View File

@@ -0,0 +1,88 @@
{% extends "base.html" %}
{% block content %}
<div class="toolbar">
<form class="search" method="get" action="{{ url_for('bp.index') }}">
<input type="search" name="q" placeholder="Artikel oder Größe suchen…" value="{{ q }}" />
<select name="sort">
<option value="gezaehlt" {% if sort == "gezaehlt" %}selected{% endif %}>Bestand</option>
<option value="soll" {% if sort == "soll" %}selected{% endif %}>Soll</option>
<option value="artikel" {% if sort == "artikel" %}selected{% endif %}>Artikel</option>
<option value="groesse" {% if sort == "groesse" %}selected{% endif %}>Größe</option>
<option value="verkaeufe" {% if sort == "verkaeufe" %}selected{% endif %}>Verkäufe</option>
</select>
<select name="dir">
<option value="desc" {% if direction == "desc" %}selected{% endif %}></option>
<option value="asc" {% if direction == "asc" %}selected{% endif %}></option>
</select>
<button class="btn" type="submit">Filtern</button>
<a class="btn ghost" href="{{ url_for('bp.index') }}">Zurücksetzen</a>
</form>
<div class="stat">
<div class="label">Artikel gesamt</div>
<div class="value">{{ total }}</div>
</div>
<div class="stat">
<div class="label">Bestand gesamt</div>
<div class="value">{{ total_bestand }}</div>
</div>
<a class="stat stat-link" href="{{ url_for('bp.orders') }}" title="Offene Bestellungen anzeigen">
<div class="label">Offene Bestellungen</div>
<div class="value">{{ open_orders }}</div>
</a>
</div>
<div class="card">
<table>
<thead>
<tr>
<th>Artikel</th>
<th>Größe</th>
<th>Preis</th>
<th>Soll</th>
<th>Bestand</th>
<th>Abweichung</th>
<th>Fehlbestand</th>
<th>Verkäufe</th>
<th class="actions">Aktionen</th>
</tr>
</thead>
<tbody>
{% if groups %}
{% for g in groups %}
<tr class="group-row">
<td colspan="9"><strong>{{ g.artikel }}</strong></td>
</tr>
{% 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) %}
<tr>
<td></td>
<td>{{ r.groesse }}</td>
<td>{{ "%.2f"|format(r.preis or 0) }} €</td>
<td>{{ r.soll }}</td>
<td>{{ r.gezaehlt }}</td>
<td class="{{ 'pos' if diff > 0 else 'neg' if diff < 0 else '' }}">{{ diff }}</td>
<td>{{ fehl if fehl > 0 else "" }}</td>
<td>{{ r.verkaeufe }}</td>
<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>
<form method="post" action="{{ url_for('bp.verkauf', item_id=r.id) }}" onsubmit="return confirm('Wirklich 1 Stück als verkauft buchen?');">
<button class="btn icon" type="submit" title="Verkauf +1" aria-label="Verkauf +1"><span>🛒</span></button>
</form>
<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.');">
<button class="btn icon danger" type="submit" title="Löschen" aria-label="Löschen"><span>🗑</span></button>
</form>
</td>
</tr>
{% endfor %}
{% endfor %}
{% else %}
<tr>
<td colspan="9" class="empty">Keine Treffer.</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
{% endblock %}

24
wawi/templates/login.html Normal file
View File

@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block content %}
<div class="card form-card">
<h2>Login</h2>
{% if error %}
<div class="note">Benutzername oder Passwort ist falsch.</div>
{% endif %}
<form method="post">
<div class="form-grid">
<label>
Benutzer
<input type="text" name="user" required />
</label>
<label>
Passwort
<input type="password" name="password" required />
</label>
</div>
<div class="form-actions">
<button class="btn btn-accent" type="submit">Anmelden</button>
</div>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block content %}
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="note">
{% for m in messages %}
<div>{{ m }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<div class="card">
<table>
<thead>
<tr>
<th>Datum</th>
<th>Name</th>
<th>Handy</th>
<th>Mannschaft</th>
<th>Artikel</th>
<th>Größe</th>
<th>Menge</th>
<th>Notiz</th>
<th>Status</th>
<th class="actions">Aktion</th>
</tr>
</thead>
<tbody>
{% for o in rows %}
<tr>
<td>{{ o.created_at }}</td>
<td>{{ o.name }}</td>
<td>{{ o.handy }}</td>
<td>{{ o.mannschaft }}</td>
<td>{{ o.artikel }}</td>
<td>{{ o.groesse }}</td>
<td>{{ o.menge }}</td>
<td>{{ o.notiz or "" }}</td>
<td>
{% if o.canceled %}Storniert
{% elif o.done %}Erledigt
{% else %}Offen
{% endif %}
</td>
<td class="actions">
{% 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?');">
<button class="btn small" type="submit">Erledigt</button>
</form>
<form method="post" action="{{ url_for('bp.cancel_order', order_id=o.id) }}" onsubmit="return confirm('Bestellung wirklich stornieren?');">
<button class="btn small danger" type="submit">Stornieren</button>
</form>
{% else %}
{% endif %}
</td>
</tr>
<tr class="order-history">
<td colspan="10">
<details class="inline-details">
<summary>Historie</summary>
<div class="history-grid">
<div><strong>Abgeschlossen von:</strong> {{ o.completed_by or "" }}</div>
<div><strong>Abgeschlossen am:</strong> {{ o.completed_at or "" }}</div>
<div><strong>Storniert von:</strong> {{ o.canceled_by or "" }}</div>
<div><strong>Storniert am:</strong> {{ o.canceled_at or "" }}</div>
</div>
</details>
</td>
</tr>
{% else %}
<tr>
<td colspan="10" class="empty">Keine Bestellungen.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

65
wawi/templates/users.html Normal file
View File

@@ -0,0 +1,65 @@
{% extends "base.html" %}
{% block content %}
<div class="card form-card">
<h2>Benutzer verwalten</h2>
{% if error %}
<div class="note">{{ error }}</div>
{% endif %}
{% with messages = get_flashed_messages() %}
{% if messages %}
<div class="note">
{% for m in messages %}
<div>{{ m }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<form method="post">
<div class="form-grid">
<label>
Benutzername
<input type="text" name="username" required />
</label>
<label>
Passwort
<input type="password" name="password" required />
</label>
</div>
<div class="form-actions">
<button class="btn btn-accent" type="submit">Anlegen</button>
</div>
</form>
</div>
<div class="card" style="margin-top: 14px;">
<table>
<thead>
<tr>
<th>Benutzer</th>
<th>Erstellt</th>
<th class="actions">Aktion</th>
</tr>
</thead>
<tbody>
{% for u in rows %}
<tr>
<td>{{ u.username }}</td>
<td>{{ u.created_at }}</td>
<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?');">
<button class="btn small" type="submit">Passwort neu</button>
</form>
<form method="post" action="{{ url_for('bp.delete_user', user_id=u.id) }}" onsubmit="return confirm('Benutzer wirklich löschen?');">
<button class="btn small danger" type="submit">Löschen</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="3" class="empty">Keine Benutzer.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}