Compare commits
3 Commits
db2767a496
...
b9ea2c2625
| Author | SHA1 | Date | |
|---|---|---|---|
| b9ea2c2625 | |||
| 68bfbd55a2 | |||
| 7ff74cb18c |
25
README.md
Normal file → Executable file
25
README.md
Normal file → Executable file
@@ -24,6 +24,24 @@ python app.py
|
|||||||
```
|
```
|
||||||
Standardzugriff: Login über Benutzerverwaltung (siehe unten).
|
Standardzugriff: Login über Benutzerverwaltung (siehe unten).
|
||||||
|
|
||||||
|
## Developer (ENV variablen)
|
||||||
|
Diese App liest Konfiguration ausschließlich aus Umgebungsvariablen.
|
||||||
|
Für lokale Entwicklung kannst du sie direkt im Shell-Session setzen oder
|
||||||
|
eine `.env`-Datei in dein Startup-Script laden.
|
||||||
|
|
||||||
|
Minimal sinnvoll für lokal:
|
||||||
|
- `SECRET_KEY` – Session-Secret (beliebiger String)
|
||||||
|
- `APP_USER` / `APP_PASSWORD` – initialer Admin-User
|
||||||
|
- `URL_PREFIX` – leer lassen, wenn lokal ohne Sub-Pfad
|
||||||
|
|
||||||
|
Für Bestellungen per Mail zusätzlich:
|
||||||
|
- `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`
|
||||||
|
- `SMTP_FROM`, `ORDER_TO`
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
- `APP_API_KEY` – Schutz fuer `/wawi/api/bestand` und `/wawi/order`
|
||||||
|
- `COOKIE_SECURE` – `0` fuer http lokal, `1` fuer https
|
||||||
|
|
||||||
## Benutzerverwaltung
|
## Benutzerverwaltung
|
||||||
Beim ersten Start wird **ein Admin** aus ENV erzeugt:
|
Beim ersten Start wird **ein Admin** aus ENV erzeugt:
|
||||||
- `APP_USER` (default: `admin`)
|
- `APP_USER` (default: `admin`)
|
||||||
@@ -43,6 +61,8 @@ python import_from_html.py /pfad/zu/hellas_bestand.html --truncate
|
|||||||
- API‑Proxy: `/wawi/proxy/bestand`
|
- API‑Proxy: `/wawi/proxy/bestand`
|
||||||
|
|
||||||
Wenn du diese Datei auf einem Webserver auslieferst, stelle sicher, dass die WaWi‑App unter `/wawi` erreichbar ist.
|
Wenn du diese Datei auf einem Webserver auslieferst, stelle sicher, dass die WaWi‑App unter `/wawi` erreichbar ist.
|
||||||
|
Wenn du Bestellungen direkt aus `index.html` abschickst, muss der `X-Order-Key` bzw. `?key=` dem `APP_API_KEY` entsprechen.
|
||||||
|
Der Key wird in `index.html` im Script‑Block gesetzt: `const ORDER_KEY = ""` (neben der Formularlogik). Trage dort deinen Key ein.
|
||||||
|
|
||||||
## Umgebungsvariablen (ENV)
|
## Umgebungsvariablen (ENV)
|
||||||
**Pflicht/Empfohlen für Produktion**
|
**Pflicht/Empfohlen für Produktion**
|
||||||
@@ -159,8 +179,3 @@ mkdir -p /var/www/hellas/wawi/static/uploads
|
|||||||
chown -R www-data:www-data /var/www/hellas/wawi/static/uploads
|
chown -R www-data:www-data /var/www/hellas/wawi/static/uploads
|
||||||
systemctl restart hellas
|
systemctl restart hellas
|
||||||
```
|
```
|
||||||
|
|
||||||
## Backup (Beispiel)
|
|
||||||
```bash
|
|
||||||
sudo /root/fix_wawi_permissions.sh
|
|
||||||
```
|
|
||||||
|
|||||||
99
wawi/app.py
Normal file → Executable file
99
wawi/app.py
Normal file → Executable file
@@ -1,5 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
"""Hellas WaWi (Warenwirtschaft) Flask-App.
|
||||||
|
|
||||||
|
Kurzüberblick:
|
||||||
|
- SQLite‑Datenbank für Artikel, Ausbuchungen, Users und Bestellungen.
|
||||||
|
- HTML‑Views für Verwaltung sowie JSON‑APIs für Bestand & Bestellungen.
|
||||||
|
- Optionaler Mailversand für eingehende Bestellungen.
|
||||||
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import secrets
|
import secrets
|
||||||
@@ -22,7 +30,7 @@ DB_PATH = BASE_DIR / "hellas.db"
|
|||||||
UPLOAD_DIR = BASE_DIR / "static" / "uploads"
|
UPLOAD_DIR = BASE_DIR / "static" / "uploads"
|
||||||
ALLOWED_EXT = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
|
ALLOWED_EXT = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
|
||||||
|
|
||||||
# Optional Prefix, z. B. /wawi, wenn die App hinter einem Sub‑Pfad läuft.
|
# Optionaler Prefix (z. B. /wawi), wenn die App hinter einem Sub‑Pfad läuft.
|
||||||
URL_PREFIX = os.environ.get("URL_PREFIX", "").strip().rstrip("/")
|
URL_PREFIX = os.environ.get("URL_PREFIX", "").strip().rstrip("/")
|
||||||
STATIC_URL_PATH = f"{URL_PREFIX}/static" if URL_PREFIX else "/static"
|
STATIC_URL_PATH = f"{URL_PREFIX}/static" if URL_PREFIX else "/static"
|
||||||
|
|
||||||
@@ -43,7 +51,9 @@ _RATE_WINDOW = 60
|
|||||||
_RATE_MAX = 15
|
_RATE_MAX = 15
|
||||||
|
|
||||||
|
|
||||||
|
# --- Datenbank & Hilfsfunktionen ---
|
||||||
def get_db() -> sqlite3.Connection:
|
def get_db() -> sqlite3.Connection:
|
||||||
|
"""Verbindet zur SQLite‑DB (pro Request gecached in Flask‑g)."""
|
||||||
if "db" not in g:
|
if "db" not in g:
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
@@ -53,33 +63,42 @@ def get_db() -> sqlite3.Connection:
|
|||||||
|
|
||||||
@app.teardown_appcontext
|
@app.teardown_appcontext
|
||||||
def close_db(exc: Exception | None) -> None:
|
def close_db(exc: Exception | None) -> None:
|
||||||
|
"""Schließt die DB‑Verbindung am Ende des Requests."""
|
||||||
db = g.pop("db", None)
|
db = g.pop("db", None)
|
||||||
if db is not None:
|
if db is not None:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def init_db() -> None:
|
def init_db() -> None:
|
||||||
|
"""Initialisiert Tabellen und führt kleine Schema‑Migrationen aus.
|
||||||
|
|
||||||
|
Tabellen:
|
||||||
|
- items: Artikelstamm + Soll/Bestand/Verkäufe
|
||||||
|
- ausbuchungen: Historie von Abgängen
|
||||||
|
- users: Login‑Benutzer
|
||||||
|
- orders: eingehende Bestellungen (offen/abgeschlossen/storniert)
|
||||||
|
"""
|
||||||
db = get_db()
|
db = get_db()
|
||||||
db.executescript(
|
db.executescript(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS items (
|
CREATE TABLE IF NOT EXISTS items (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
artikel TEXT NOT NULL,
|
artikel TEXT NOT NULL, -- Artikelbezeichnung
|
||||||
groesse TEXT NOT NULL,
|
groesse TEXT NOT NULL, -- Variante/Größe
|
||||||
preis REAL NOT NULL DEFAULT 0,
|
preis REAL NOT NULL DEFAULT 0,-- Verkaufspreis
|
||||||
bild_url TEXT,
|
bild_url TEXT, -- Optionales Produktbild
|
||||||
soll INTEGER NOT NULL DEFAULT 0,
|
soll INTEGER NOT NULL DEFAULT 0, -- Soll‑Bestand
|
||||||
gezaehlt INTEGER NOT NULL DEFAULT 0,
|
gezaehlt INTEGER NOT NULL DEFAULT 0, -- Ist‑Bestand
|
||||||
verkaeufe INTEGER NOT NULL DEFAULT 0,
|
verkaeufe INTEGER NOT NULL DEFAULT 0, -- Verkäufe gesamt
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ausbuchungen (
|
CREATE TABLE IF NOT EXISTS ausbuchungen (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
item_id INTEGER NOT NULL,
|
item_id INTEGER NOT NULL, -- Referenz auf items.id
|
||||||
menge INTEGER NOT NULL,
|
menge INTEGER NOT NULL, -- Abgangsmenge
|
||||||
grund TEXT,
|
grund TEXT, -- z. B. Verkauf, Defekt, etc.
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
FOREIGN KEY(item_id) REFERENCES items(id)
|
FOREIGN KEY(item_id) REFERENCES items(id)
|
||||||
);
|
);
|
||||||
@@ -93,20 +112,20 @@ def init_db() -> None:
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS orders (
|
CREATE TABLE IF NOT EXISTS orders (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL, -- Besteller
|
||||||
handy TEXT NOT NULL,
|
handy TEXT NOT NULL, -- Kontakt
|
||||||
mannschaft TEXT NOT NULL,
|
mannschaft TEXT NOT NULL, -- Team/Abteilung
|
||||||
artikel TEXT NOT NULL,
|
artikel TEXT NOT NULL,
|
||||||
groesse TEXT NOT NULL,
|
groesse TEXT NOT NULL,
|
||||||
menge INTEGER NOT NULL,
|
menge INTEGER NOT NULL,
|
||||||
notiz TEXT,
|
notiz TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
done INTEGER NOT NULL DEFAULT 0,
|
done INTEGER NOT NULL DEFAULT 0, -- abgeschlossen?
|
||||||
completed_by TEXT,
|
completed_by TEXT, -- Username
|
||||||
completed_at TEXT,
|
completed_at TEXT, -- Timestamp
|
||||||
canceled INTEGER NOT NULL DEFAULT 0,
|
canceled INTEGER NOT NULL DEFAULT 0,-- storniert?
|
||||||
canceled_by TEXT,
|
canceled_by TEXT, -- Username
|
||||||
canceled_at TEXT
|
canceled_at TEXT -- Timestamp
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
@@ -118,7 +137,7 @@ def init_db() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def ensure_price_column(db: sqlite3.Connection) -> None:
|
def ensure_price_column(db: sqlite3.Connection) -> None:
|
||||||
# Fügt die Preisspalte nachträglich hinzu, falls DB älter ist.
|
"""Fügt die Preisspalte nachträglich hinzu, falls DB älter ist."""
|
||||||
cols = db.execute("PRAGMA table_info(items)").fetchall()
|
cols = db.execute("PRAGMA table_info(items)").fetchall()
|
||||||
if any(c["name"] == "preis" for c in cols):
|
if any(c["name"] == "preis" for c in cols):
|
||||||
return
|
return
|
||||||
@@ -127,6 +146,7 @@ def ensure_price_column(db: sqlite3.Connection) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def ensure_image_column(db: sqlite3.Connection) -> None:
|
def ensure_image_column(db: sqlite3.Connection) -> None:
|
||||||
|
"""Fügt bild_url nachträglich hinzu, falls DB älter ist."""
|
||||||
cols = db.execute("PRAGMA table_info(items)").fetchall()
|
cols = db.execute("PRAGMA table_info(items)").fetchall()
|
||||||
if any(c["name"] == "bild_url" for c in cols):
|
if any(c["name"] == "bild_url" for c in cols):
|
||||||
return
|
return
|
||||||
@@ -135,6 +155,7 @@ def ensure_image_column(db: sqlite3.Connection) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def ensure_orders_columns(db: sqlite3.Connection) -> None:
|
def ensure_orders_columns(db: sqlite3.Connection) -> None:
|
||||||
|
"""Sorgt für alle nachträglich eingeführten Orders‑Spalten."""
|
||||||
cols = db.execute("PRAGMA table_info(orders)").fetchall()
|
cols = db.execute("PRAGMA table_info(orders)").fetchall()
|
||||||
names = {c["name"] for c in cols}
|
names = {c["name"] for c in cols}
|
||||||
if "done" not in names:
|
if "done" not in names:
|
||||||
@@ -154,7 +175,7 @@ def ensure_orders_columns(db: sqlite3.Connection) -> None:
|
|||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def ensure_db() -> None:
|
def ensure_db() -> None:
|
||||||
# Erstellt Tabellen einmal pro Worker.
|
"""Stellt sicher, dass DB/Schema einmal pro Worker initialisiert ist."""
|
||||||
global _DB_INIT_DONE
|
global _DB_INIT_DONE
|
||||||
if not _DB_INIT_DONE:
|
if not _DB_INIT_DONE:
|
||||||
init_db()
|
init_db()
|
||||||
@@ -162,10 +183,12 @@ def ensure_db() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def now_iso() -> str:
|
def now_iso() -> str:
|
||||||
|
"""Timestamp als kompaktes, DB‑freundliches Format."""
|
||||||
return datetime.now().strftime("%Y-%m-%d %H:%M")
|
return datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||||
|
|
||||||
|
|
||||||
def save_upload(file) -> str | None:
|
def save_upload(file) -> str | None:
|
||||||
|
"""Speichert einen Bild‑Upload und gibt die öffentliche URL zurück."""
|
||||||
if not file or not getattr(file, "filename", ""):
|
if not file or not getattr(file, "filename", ""):
|
||||||
return None
|
return None
|
||||||
ext = os.path.splitext(file.filename)[1].lower()
|
ext = os.path.splitext(file.filename)[1].lower()
|
||||||
@@ -181,7 +204,7 @@ def save_upload(file) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def ensure_admin_user(db: sqlite3.Connection) -> None:
|
def ensure_admin_user(db: sqlite3.Connection) -> None:
|
||||||
# Legt einen Admin‑User an, wenn noch kein Benutzer existiert.
|
"""Legt einen Admin‑User an, wenn noch kein Benutzer existiert."""
|
||||||
row = db.execute("SELECT COUNT(*) AS c FROM users").fetchone()
|
row = db.execute("SELECT COUNT(*) AS c FROM users").fetchone()
|
||||||
if row and row["c"] > 0:
|
if row and row["c"] > 0:
|
||||||
return
|
return
|
||||||
@@ -198,6 +221,7 @@ def ensure_admin_user(db: sqlite3.Connection) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def login_required(fn):
|
def login_required(fn):
|
||||||
|
"""Schützt HTML‑Views mit Login‑Session."""
|
||||||
@wraps(fn)
|
@wraps(fn)
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
if not session.get("user"):
|
if not session.get("user"):
|
||||||
@@ -209,11 +233,12 @@ def login_required(fn):
|
|||||||
|
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
def inject_auth():
|
def inject_auth():
|
||||||
|
"""Stellt logged_in im Template‑Kontext bereit."""
|
||||||
return {"logged_in": bool(session.get("user"))}
|
return {"logged_in": bool(session.get("user"))}
|
||||||
|
|
||||||
|
|
||||||
def api_key_required(fn):
|
def api_key_required(fn):
|
||||||
# Schützt API‑Endpoints per X-API-Key oder ?key= Parameter.
|
"""Schützt API‑Endpoints per X-API-Key oder ?key= Parameter."""
|
||||||
@wraps(fn)
|
@wraps(fn)
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
expected = os.environ.get("APP_API_KEY", "")
|
expected = os.environ.get("APP_API_KEY", "")
|
||||||
@@ -228,6 +253,7 @@ def api_key_required(fn):
|
|||||||
|
|
||||||
|
|
||||||
def rate_limited(ip: str) -> bool:
|
def rate_limited(ip: str) -> bool:
|
||||||
|
"""Einfaches In‑Memory Rate‑Limit pro IP (1‑Min‑Fenster)."""
|
||||||
now = time.time()
|
now = time.time()
|
||||||
bucket = _RATE_LIMIT.setdefault(ip, [])
|
bucket = _RATE_LIMIT.setdefault(ip, [])
|
||||||
bucket[:] = [t for t in bucket if now - t < _RATE_WINDOW]
|
bucket[:] = [t for t in bucket if now - t < _RATE_WINDOW]
|
||||||
@@ -237,9 +263,11 @@ def rate_limited(ip: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# --- HTML‑Views (Admin) ---
|
||||||
@bp.route("/")
|
@bp.route("/")
|
||||||
@login_required
|
@login_required
|
||||||
def index():
|
def index():
|
||||||
|
"""Startseite: Artikelübersicht mit Filter, Sortierung und Summen."""
|
||||||
q = (request.args.get("q") or "").strip()
|
q = (request.args.get("q") or "").strip()
|
||||||
sort = (request.args.get("sort") or "gezaehlt").strip().lower()
|
sort = (request.args.get("sort") or "gezaehlt").strip().lower()
|
||||||
direction = (request.args.get("dir") or "desc").strip().lower()
|
direction = (request.args.get("dir") or "desc").strip().lower()
|
||||||
@@ -289,6 +317,7 @@ def index():
|
|||||||
@bp.route("/new", methods=["GET", "POST"])
|
@bp.route("/new", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def new_item():
|
def new_item():
|
||||||
|
"""Artikel anlegen (inkl. optionalem Bild‑Upload)."""
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
artikel = (request.form.get("artikel") or "").strip()
|
artikel = (request.form.get("artikel") or "").strip()
|
||||||
groesse = (request.form.get("groesse") or "").strip()
|
groesse = (request.form.get("groesse") or "").strip()
|
||||||
@@ -320,6 +349,7 @@ def new_item():
|
|||||||
@bp.route("/edit/<int:item_id>", methods=["GET", "POST"])
|
@bp.route("/edit/<int:item_id>", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def edit_item(item_id: int):
|
def edit_item(item_id: int):
|
||||||
|
"""Artikel bearbeiten."""
|
||||||
db = get_db()
|
db = get_db()
|
||||||
item = db.execute("SELECT * FROM items WHERE id = ?", (item_id,)).fetchone()
|
item = db.execute("SELECT * FROM items WHERE id = ?", (item_id,)).fetchone()
|
||||||
if item is None:
|
if item is None:
|
||||||
@@ -355,6 +385,7 @@ def edit_item(item_id: int):
|
|||||||
@bp.route("/delete/<int:item_id>", methods=["POST"])
|
@bp.route("/delete/<int:item_id>", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def delete_item(item_id: int):
|
def delete_item(item_id: int):
|
||||||
|
"""Artikel löschen."""
|
||||||
db = get_db()
|
db = get_db()
|
||||||
db.execute("DELETE FROM items WHERE id = ?", (item_id,))
|
db.execute("DELETE FROM items WHERE id = ?", (item_id,))
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -364,6 +395,7 @@ def delete_item(item_id: int):
|
|||||||
@bp.route("/ausbuchen/<int:item_id>", methods=["GET", "POST"])
|
@bp.route("/ausbuchen/<int:item_id>", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def ausbuchen(item_id: int):
|
def ausbuchen(item_id: int):
|
||||||
|
"""Ausbuchung erfassen (reduziert Bestand & erhöht Verkäufe)."""
|
||||||
db = get_db()
|
db = get_db()
|
||||||
item = db.execute("SELECT * FROM items WHERE id = ?", (item_id,)).fetchone()
|
item = db.execute("SELECT * FROM items WHERE id = ?", (item_id,)).fetchone()
|
||||||
if item is None:
|
if item is None:
|
||||||
@@ -399,6 +431,7 @@ def ausbuchen(item_id: int):
|
|||||||
@bp.route("/verkauf/<int:item_id>", methods=["POST"])
|
@bp.route("/verkauf/<int:item_id>", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def verkauf(item_id: int):
|
def verkauf(item_id: int):
|
||||||
|
"""Schnell‑Verkauf: 1 Stück buchen und als Ausbuchung protokollieren."""
|
||||||
db = get_db()
|
db = get_db()
|
||||||
item = db.execute("SELECT * FROM items WHERE id = ?", (item_id,)).fetchone()
|
item = db.execute("SELECT * FROM items WHERE id = ?", (item_id,)).fetchone()
|
||||||
if item is None:
|
if item is None:
|
||||||
@@ -426,8 +459,10 @@ def verkauf(item_id: int):
|
|||||||
return redirect(url_for("bp.index"))
|
return redirect(url_for("bp.index"))
|
||||||
|
|
||||||
|
|
||||||
|
# --- Auth ---
|
||||||
@bp.route("/login", methods=["GET", "POST"])
|
@bp.route("/login", methods=["GET", "POST"])
|
||||||
def login():
|
def login():
|
||||||
|
"""Login‑Formular & Session‑Handling."""
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
user = (request.form.get("user") or "").strip()
|
user = (request.form.get("user") or "").strip()
|
||||||
password = request.form.get("password") or ""
|
password = request.form.get("password") or ""
|
||||||
@@ -445,13 +480,16 @@ def login():
|
|||||||
|
|
||||||
@bp.route("/logout")
|
@bp.route("/logout")
|
||||||
def logout():
|
def logout():
|
||||||
|
"""Session beenden."""
|
||||||
session.clear()
|
session.clear()
|
||||||
return redirect(url_for("bp.login"))
|
return redirect(url_for("bp.login"))
|
||||||
|
|
||||||
|
|
||||||
|
# --- Benutzerverwaltung ---
|
||||||
@bp.route("/users", methods=["GET", "POST"])
|
@bp.route("/users", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def users():
|
def users():
|
||||||
|
"""Benutzerverwaltung (anlegen, anzeigen)."""
|
||||||
db = get_db()
|
db = get_db()
|
||||||
error = None
|
error = None
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
@@ -479,6 +517,7 @@ def users():
|
|||||||
@bp.route("/users/delete/<int:user_id>", methods=["POST"])
|
@bp.route("/users/delete/<int:user_id>", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def delete_user(user_id: int):
|
def delete_user(user_id: int):
|
||||||
|
"""Benutzer löschen (mindestens ein User muss bleiben)."""
|
||||||
db = get_db()
|
db = get_db()
|
||||||
count = db.execute("SELECT COUNT(*) AS c FROM users").fetchone()["c"]
|
count = db.execute("SELECT COUNT(*) AS c FROM users").fetchone()["c"]
|
||||||
if count <= 1:
|
if count <= 1:
|
||||||
@@ -488,20 +527,22 @@ def delete_user(user_id: int):
|
|||||||
return redirect(url_for("bp.users"))
|
return redirect(url_for("bp.users"))
|
||||||
|
|
||||||
|
|
||||||
|
# --- JSON‑APIs ---
|
||||||
@bp.route("/api/bestand", methods=["GET"])
|
@bp.route("/api/bestand", methods=["GET"])
|
||||||
@api_key_required
|
@api_key_required
|
||||||
def api_bestand():
|
def api_bestand():
|
||||||
|
"""Öffentliche JSON‑API (authentifiziert) für Bestände."""
|
||||||
return jsonify(build_bestand())
|
return jsonify(build_bestand())
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/proxy/bestand", methods=["GET"])
|
@bp.route("/proxy/bestand", methods=["GET"])
|
||||||
def proxy_bestand():
|
def proxy_bestand():
|
||||||
# Server‑Proxy ohne API‑Key (z. B. für öffentliche Anzeige).
|
"""Server‑Proxy ohne API‑Key (z. B. für öffentliche Anzeige)."""
|
||||||
return jsonify(build_bestand())
|
return jsonify(build_bestand())
|
||||||
|
|
||||||
|
|
||||||
def build_bestand() -> list[dict]:
|
def build_bestand() -> list[dict]:
|
||||||
# Aggregiert DB‑Zeilen in die Struktur der Live‑Bestand Ansicht.
|
"""Aggregiert DB‑Zeilen in die Struktur der Live‑Bestand Ansicht."""
|
||||||
rows = get_db().execute(
|
rows = get_db().execute(
|
||||||
"""
|
"""
|
||||||
SELECT artikel, groesse, preis, bild_url, soll, gezaehlt, verkaeufe
|
SELECT artikel, groesse, preis, bild_url, soll, gezaehlt, verkaeufe
|
||||||
@@ -557,6 +598,7 @@ def build_bestand() -> list[dict]:
|
|||||||
|
|
||||||
@bp.route("/order", methods=["POST"])
|
@bp.route("/order", methods=["POST"])
|
||||||
def order():
|
def order():
|
||||||
|
"""Erstellt eine Bestellung (optional API‑Key) und versendet Mail."""
|
||||||
ip = request.headers.get("X-Forwarded-For", request.remote_addr or "unknown").split(",")[0].strip()
|
ip = request.headers.get("X-Forwarded-For", request.remote_addr or "unknown").split(",")[0].strip()
|
||||||
if rate_limited(ip):
|
if rate_limited(ip):
|
||||||
return jsonify({"error": "Zu viele Anfragen."}), 429
|
return jsonify({"error": "Zu viele Anfragen."}), 429
|
||||||
@@ -632,9 +674,11 @@ def order():
|
|||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
# --- Bestellungen (Admin) ---
|
||||||
@bp.route("/orders")
|
@bp.route("/orders")
|
||||||
@login_required
|
@login_required
|
||||||
def orders():
|
def orders():
|
||||||
|
"""Bestellliste in der Verwaltung."""
|
||||||
rows = get_db().execute(
|
rows = get_db().execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, name, handy, mannschaft, artikel, groesse, menge, notiz, created_at, done, completed_by, completed_at, canceled, canceled_by, canceled_at
|
SELECT id, name, handy, mannschaft, artikel, groesse, menge, notiz, created_at, done, completed_by, completed_at, canceled, canceled_by, canceled_at
|
||||||
@@ -649,6 +693,7 @@ def orders():
|
|||||||
@bp.route("/orders/complete/<int:order_id>", methods=["POST"])
|
@bp.route("/orders/complete/<int:order_id>", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def complete_order(order_id: int):
|
def complete_order(order_id: int):
|
||||||
|
"""Bestellung abschließen und Bestand abziehen (falls ausreichend)."""
|
||||||
user = session.get("user") or "unknown"
|
user = session.get("user") or "unknown"
|
||||||
db = get_db()
|
db = get_db()
|
||||||
order = db.execute(
|
order = db.execute(
|
||||||
@@ -700,6 +745,7 @@ def complete_order(order_id: int):
|
|||||||
@bp.route("/orders/cancel/<int:order_id>", methods=["POST"])
|
@bp.route("/orders/cancel/<int:order_id>", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def cancel_order(order_id: int):
|
def cancel_order(order_id: int):
|
||||||
|
"""Bestellung stornieren (nur wenn noch offen)."""
|
||||||
user = session.get("user") or "unknown"
|
user = session.get("user") or "unknown"
|
||||||
db = get_db()
|
db = get_db()
|
||||||
db.execute(
|
db.execute(
|
||||||
@@ -717,6 +763,7 @@ def cancel_order(order_id: int):
|
|||||||
@bp.route("/users/reset/<int:user_id>", methods=["POST"])
|
@bp.route("/users/reset/<int:user_id>", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def reset_user_password(user_id: int):
|
def reset_user_password(user_id: int):
|
||||||
|
"""Passwort zurücksetzen und neues Passwort als Flash anzeigen."""
|
||||||
db = get_db()
|
db = get_db()
|
||||||
user = db.execute("SELECT id, username FROM users WHERE id = ?", (user_id,)).fetchone()
|
user = db.execute("SELECT id, username FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||||
if user is None:
|
if user is None:
|
||||||
|
|||||||
Reference in New Issue
Block a user