Initial commit
This commit is contained in:
699
wawi/app.py
Normal file
699
wawi/app.py
Normal 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 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/<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():
|
||||
# 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/<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
88
wawi/import_from_html.py
Normal 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 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())
|
||||
BIN
wawi/static/logo.png
Normal file
BIN
wawi/static/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
204
wawi/static/style.css
Normal file
204
wawi/static/style.css
Normal 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; }
|
||||
}
|
||||
23
wawi/templates/ausbuchen.html
Normal file
23
wawi/templates/ausbuchen.html
Normal 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
37
wawi/templates/base.html
Normal 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 & 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
46
wawi/templates/edit.html
Normal 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>
|
||||
Bild‑URL (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
88
wawi/templates/index.html
Normal 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
24
wawi/templates/login.html
Normal 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 %}
|
||||
79
wawi/templates/orders.html
Normal file
79
wawi/templates/orders.html
Normal 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
65
wawi/templates/users.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user