Compare commits
17 Commits
f6495eb82a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fd3f49a2e1 | |||
| a40b028a0e | |||
| 450a4b062f | |||
| a8b26a25da | |||
| 630595bce9 | |||
| e062a1e836 | |||
| 12eece0226 | |||
| b9ea2c2625 | |||
| 68bfbd55a2 | |||
| 7ff74cb18c | |||
| db2767a496 | |||
| efffdfa3fa | |||
| 65ec9466eb | |||
| ec2d8945b3 | |||
| 513c126fba | |||
| a61e96e8b8 | |||
|
|
a7d058b57c |
41
README.md
Normal file → Executable file
41
README.md
Normal file → Executable file
@@ -24,6 +24,24 @@ python app.py
|
||||
```
|
||||
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
|
||||
Beim ersten Start wird **ein Admin** aus ENV erzeugt:
|
||||
- `APP_USER` (default: `admin`)
|
||||
@@ -43,6 +61,8 @@ python import_from_html.py /pfad/zu/hellas_bestand.html --truncate
|
||||
- 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 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)
|
||||
**Pflicht/Empfohlen für Produktion**
|
||||
@@ -58,7 +78,8 @@ Wenn du diese Datei auf einem Webserver auslieferst, stelle sicher, dass die WaW
|
||||
- `ORDER_TO` – Empfänger, mehrere per Komma
|
||||
|
||||
**Optional**
|
||||
- `APP_API_KEY` – API‑Key für direkten Zugriff auf `/wawi/api/bestand`
|
||||
- `APP_API_KEY` – gemeinsamer API‑Key für `/wawi/api/bestand` **und** `/wawi/order`
|
||||
- `COOKIE_SECURE` – `1` (default) setzt Secure‑Cookie, `0` deaktiviert für http
|
||||
|
||||
## Deployment (systemd + Gunicorn)
|
||||
1) App nach `/var/www/hellas/wawi` kopieren
|
||||
@@ -88,9 +109,9 @@ Environment="SMTP_HOST=smtp.example.com"
|
||||
Environment="SMTP_PORT=587"
|
||||
Environment="SMTP_USER=dein_user"
|
||||
Environment="SMTP_PASS=dein_pass"
|
||||
Environment="SMTP_FROM=bestand@hellas.welker.me"
|
||||
Environment="ORDER_TO=bjoern@welker.me, zweite@domain.de"
|
||||
Environment="APP_API_KEY=api_f4b8e1c97a2d4e5b8c6a9d3e2f7b1a0c"
|
||||
Environment="SMTP_FROM=bestand@example.com"
|
||||
Environment="ORDER_TO=admin@example.com, zweite@example.com"
|
||||
Environment="APP_API_KEY=api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
ExecStart=/var/www/hellas/wawi/.venv/bin/gunicorn -w 3 -b 127.0.0.1:8000 app:app
|
||||
Restart=always
|
||||
|
||||
@@ -146,7 +167,15 @@ sudo mkdir -p /var/www/hellas/wawi/static/uploads
|
||||
sudo chown -R www-data:www-data /var/www/hellas/wawi/static/uploads
|
||||
```
|
||||
|
||||
## Backup (Beispiel)
|
||||
## Fix‑Permissions Script (inkl. Uploads)
|
||||
`/root/fix_wawi_permissions.sh` sollte auch das Upload‑Verzeichnis setzen:
|
||||
```bash
|
||||
sudo /root/fix_wawi_permissions.sh
|
||||
#!/bin/sh
|
||||
set -e
|
||||
chown -R www-data:www-data /var/www/hellas/wawi
|
||||
chmod 750 /var/www/hellas/wawi
|
||||
chmod 640 /var/www/hellas/wawi/hellas.db
|
||||
mkdir -p /var/www/hellas/wawi/static/uploads
|
||||
chown -R www-data:www-data /var/www/hellas/wawi/static/uploads
|
||||
systemctl restart hellas
|
||||
```
|
||||
|
||||
123
index.html
Normal file → Executable file
123
index.html
Normal file → Executable file
@@ -101,23 +101,6 @@
|
||||
}
|
||||
.pill input { accent-color: var(--accent); }
|
||||
main .wrap { padding-top: 14px; padding-bottom: 28px; }
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.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));
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.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); }
|
||||
.grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
|
||||
.card-tile {
|
||||
border: 1px solid rgba(255,255,255,.08);
|
||||
@@ -212,8 +195,6 @@
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
thead th { background: rgba(0,0,0,.12); }
|
||||
tbody tr:nth-child(odd) { background: rgba(255,255,255,.02); }
|
||||
.delta-pos { color: rgba(123,213,141,.98); font-weight: 700; }
|
||||
.delta-neg { color: rgba(255,107,125,.98); font-weight: 700; }
|
||||
.muted { color: var(--muted); }
|
||||
.small { font-size: 12px; }
|
||||
.footer {
|
||||
@@ -328,11 +309,66 @@
|
||||
.brand { width: 100%; }
|
||||
.controls { width: 100%; }
|
||||
.search { min-width: 100%; }
|
||||
.stats { grid-template-columns: 1fr; }
|
||||
.card-media { aspect-ratio: 4 / 3; }
|
||||
.order-btn { width: 100%; justify-content: center; }
|
||||
}
|
||||
@media (max-width: 640px) { .grid { grid-template-columns: 1fr; } }
|
||||
|
||||
/* Toast Notifications */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
min-width: 300px;
|
||||
max-width: 400px;
|
||||
padding: 16px 20px;
|
||||
border-radius: 12px;
|
||||
background: rgba(12, 31, 51, 0.98);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.4), 0 0 1px rgba(255, 255, 255, 0.3);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
pointer-events: all;
|
||||
transform: translateX(400px);
|
||||
opacity: 0;
|
||||
transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
|
||||
}
|
||||
.toast.show {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
.toast.success {
|
||||
border-left: 4px solid var(--ok);
|
||||
background: linear-gradient(90deg, rgba(123, 213, 141, 0.15), rgba(12, 31, 51, 0.98));
|
||||
}
|
||||
.toast.error {
|
||||
border-left: 4px solid var(--bad);
|
||||
background: linear-gradient(90deg, rgba(255, 107, 125, 0.15), rgba(12, 31, 51, 0.98));
|
||||
}
|
||||
.toast.info {
|
||||
border-left: 4px solid var(--accent);
|
||||
background: linear-gradient(90deg, rgba(243, 213, 42, 0.15), rgba(12, 31, 51, 0.98));
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.toast-container {
|
||||
top: auto;
|
||||
bottom: 20px;
|
||||
right: 16px;
|
||||
left: 16px;
|
||||
}
|
||||
.toast {
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -358,7 +394,6 @@
|
||||
|
||||
<main>
|
||||
<div class="wrap">
|
||||
<div id="stats" class="stats" aria-live="polite"></div>
|
||||
<div id="grid" class="grid" aria-live="polite"></div>
|
||||
<div class="footer">
|
||||
<div>Tip: Auf einen Artikel klicken, um die Größen‑Tabelle zu öffnen.</div>
|
||||
@@ -424,7 +459,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toastContainer" class="toast-container"></div>
|
||||
|
||||
<script>
|
||||
// Toast Notification System
|
||||
function showToast(message, type = 'info') {
|
||||
const container = document.getElementById('toastContainer');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.textContent = message;
|
||||
|
||||
container.appendChild(toast);
|
||||
|
||||
// Trigger animation
|
||||
setTimeout(() => toast.classList.add('show'), 10);
|
||||
|
||||
// Auto-remove after 4 seconds
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => container.removeChild(toast), 300);
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
// Proxy der WaWi‑App (kein API‑Key im Browser nötig).
|
||||
const API_URL = "/wawi/proxy/bestand";
|
||||
let DATA = [];
|
||||
@@ -440,23 +496,8 @@ function badge(label, value, cls="") {
|
||||
return `<span class="badge ${cls}">${label}: <strong>${fmt(value)}</strong></span>`;
|
||||
}
|
||||
|
||||
function deltaClass(d) {
|
||||
if (d === null || d === undefined) return "";
|
||||
if (d > 0) return "delta-pos";
|
||||
if (d < 0) return "delta-neg";
|
||||
return "";
|
||||
}
|
||||
|
||||
function hasDiff(item) {
|
||||
const t = item.totals || {};
|
||||
return (t.abweichung ?? 0) !== 0 || (t.fehlbestand ?? 0) !== 0;
|
||||
}
|
||||
|
||||
function render(items) {
|
||||
const grid = document.getElementById("grid");
|
||||
const stats = document.getElementById("stats");
|
||||
|
||||
stats.innerHTML = "";
|
||||
|
||||
if (!items.length) {
|
||||
grid.innerHTML = `<div class="empty">Keine Treffer. (Suchbegriff anpassen)</div>`;
|
||||
@@ -525,6 +566,7 @@ document.getElementById("q").addEventListener("input", applyFilters);
|
||||
const modal = document.getElementById("orderModal");
|
||||
const form = document.getElementById("orderForm");
|
||||
const setField = (id, v) => document.getElementById(id).value = v || "";
|
||||
const ORDER_KEY = "";
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const imgBtn = e.target.closest(".thumb-btn");
|
||||
@@ -589,19 +631,22 @@ document.getElementById("detailModal").addEventListener("click", (e) => {
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const payload = Object.fromEntries(new FormData(form).entries());
|
||||
const key = ORDER_KEY || "";
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (key) headers["X-Order-Key"] = key;
|
||||
const res = await fetch("/wawi/order", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers,
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (res.ok) {
|
||||
alert("Bestellung gesendet.");
|
||||
showToast("Bestellung erfolgreich gesendet! Wir melden uns bei dir.", "success");
|
||||
modal.classList.remove("open");
|
||||
document.getElementById("detailModal").classList.remove("open");
|
||||
form.reset();
|
||||
setField("fMenge", 1);
|
||||
} else {
|
||||
alert("Fehler beim Senden der Bestellung.");
|
||||
showToast("Fehler beim Senden der Bestellung. Bitte versuche es erneut.", "error");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
5
requirements.txt
Executable file
5
requirements.txt
Executable file
@@ -0,0 +1,5 @@
|
||||
Flask>=3.0.0
|
||||
Flask-WTF>=1.2.0
|
||||
Werkzeug>=3.0.0
|
||||
gunicorn>=21.0.0
|
||||
Pillow>=10.0.0
|
||||
268
wawi/app.py
Normal file → Executable file
268
wawi/app.py
Normal file → Executable file
@@ -1,9 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""Hellas WaWi (Warenwirtschaft) Flask-App.
|
||||
|
||||
Kurzüberblick:
|
||||
- SQLite‑Datenbank für Artikel, Ausbuchungen, Users und Bestellungen.
|
||||
- HTML‑Views für Verwaltung sowie JSON‑APIs für Bestand & Bestellungen.
|
||||
- Optionaler Mailversand für eingehende Bestellungen.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import secrets
|
||||
import smtplib
|
||||
import time
|
||||
import threading
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
from email.message import EmailMessage
|
||||
from functools import wraps
|
||||
@@ -11,7 +22,15 @@ from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
HAS_PIL = True
|
||||
except ImportError:
|
||||
HAS_PIL = False
|
||||
logger.warning("Pillow nicht installiert - Bild-Optimierung deaktiviert")
|
||||
|
||||
from flask import Flask, Blueprint, g, flash, jsonify, redirect, render_template, request, session, url_for
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
@@ -20,22 +39,55 @@ 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.
|
||||
# Logging konfigurieren
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s [%(levelname)s] %(name)s: %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Optionaler Prefix (z. B. /wawi), wenn die App hinter einem Sub‑Pfad läuft.
|
||||
URL_PREFIX = os.environ.get("URL_PREFIX", "").strip().rstrip("/")
|
||||
STATIC_URL_PATH = f"{URL_PREFIX}/static" if URL_PREFIX else "/static"
|
||||
|
||||
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")
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY", "change-me")
|
||||
|
||||
# Validierung: SECRET_KEY muss in Produktion gesetzt sein
|
||||
if SECRET_KEY == "change-me":
|
||||
import sys
|
||||
if not app.debug and "pytest" not in sys.modules:
|
||||
raise RuntimeError(
|
||||
"SECURITY ERROR: SECRET_KEY ist nicht gesetzt!\n"
|
||||
"Setze die Umgebungsvariable SECRET_KEY mit einem sicheren Wert.\n"
|
||||
"Beispiel: export SECRET_KEY=$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')"
|
||||
)
|
||||
|
||||
app.secret_key = SECRET_KEY
|
||||
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
||||
app.config["SESSION_COOKIE_SECURE"] = False
|
||||
app.config["SESSION_COOKIE_SECURE"] = os.environ.get("COOKIE_SECURE", "1") == "1"
|
||||
app.config["SESSION_COOKIE_HTTPONLY"] = True
|
||||
app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024
|
||||
|
||||
# CSRF-Schutz aktivieren
|
||||
csrf = CSRFProtect(app)
|
||||
|
||||
bp = Blueprint("bp", __name__)
|
||||
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_DB_INIT_DONE = False
|
||||
_RATE_LIMIT = {}
|
||||
_RATE_WINDOW = 60
|
||||
_RATE_MAX = 15
|
||||
|
||||
|
||||
# --- Datenbank & Hilfsfunktionen ---
|
||||
def get_db() -> sqlite3.Connection:
|
||||
"""Verbindet zur SQLite‑DB (pro Request gecached in Flask‑g)."""
|
||||
if "db" not in g:
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
@@ -45,33 +97,42 @@ def get_db() -> sqlite3.Connection:
|
||||
|
||||
@app.teardown_appcontext
|
||||
def close_db(exc: Exception | None) -> None:
|
||||
"""Schließt die DB‑Verbindung am Ende des Requests."""
|
||||
db = g.pop("db", None)
|
||||
if db is not None:
|
||||
db.close()
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
"""Initialisiert Tabellen und führt kleine Schema‑Migrationen aus.
|
||||
|
||||
Tabellen:
|
||||
- items: Artikelstamm + Soll/Bestand/Verkäufe
|
||||
- ausbuchungen: Historie von Abgängen
|
||||
- users: Login‑Benutzer
|
||||
- orders: eingehende Bestellungen (offen/abgeschlossen/storniert)
|
||||
"""
|
||||
db = get_db()
|
||||
db.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
artikel TEXT NOT NULL,
|
||||
groesse TEXT NOT NULL,
|
||||
preis REAL NOT NULL DEFAULT 0,
|
||||
bild_url TEXT,
|
||||
soll INTEGER NOT NULL DEFAULT 0,
|
||||
gezaehlt INTEGER NOT NULL DEFAULT 0,
|
||||
verkaeufe INTEGER NOT NULL DEFAULT 0,
|
||||
artikel TEXT NOT NULL, -- Artikelbezeichnung
|
||||
groesse TEXT NOT NULL, -- Variante/Größe
|
||||
preis REAL NOT NULL DEFAULT 0,-- Verkaufspreis
|
||||
bild_url TEXT, -- Optionales Produktbild
|
||||
soll INTEGER NOT NULL DEFAULT 0, -- Soll‑Bestand
|
||||
gezaehlt INTEGER NOT NULL DEFAULT 0, -- Ist‑Bestand
|
||||
verkaeufe INTEGER NOT NULL DEFAULT 0, -- Verkäufe gesamt
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ausbuchungen (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_id INTEGER NOT NULL,
|
||||
menge INTEGER NOT NULL,
|
||||
grund TEXT,
|
||||
item_id INTEGER NOT NULL, -- Referenz auf items.id
|
||||
menge INTEGER NOT NULL, -- Abgangsmenge
|
||||
grund TEXT, -- z. B. Verkauf, Defekt, etc.
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(item_id) REFERENCES items(id)
|
||||
);
|
||||
@@ -85,20 +146,20 @@ def init_db() -> None:
|
||||
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
handy TEXT NOT NULL,
|
||||
mannschaft TEXT NOT NULL,
|
||||
name TEXT NOT NULL, -- Besteller
|
||||
handy TEXT NOT NULL, -- Kontakt
|
||||
mannschaft TEXT NOT NULL, -- Team/Abteilung
|
||||
artikel TEXT NOT NULL,
|
||||
groesse TEXT NOT NULL,
|
||||
menge INTEGER NOT NULL,
|
||||
notiz TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
done INTEGER NOT NULL DEFAULT 0,
|
||||
completed_by TEXT,
|
||||
completed_at TEXT,
|
||||
canceled INTEGER NOT NULL DEFAULT 0,
|
||||
canceled_by TEXT,
|
||||
canceled_at TEXT
|
||||
done INTEGER NOT NULL DEFAULT 0, -- abgeschlossen?
|
||||
completed_by TEXT, -- Username
|
||||
completed_at TEXT, -- Timestamp
|
||||
canceled INTEGER NOT NULL DEFAULT 0,-- storniert?
|
||||
canceled_by TEXT, -- Username
|
||||
canceled_at TEXT -- Timestamp
|
||||
);
|
||||
"""
|
||||
)
|
||||
@@ -106,11 +167,12 @@ def init_db() -> None:
|
||||
ensure_price_column(db)
|
||||
ensure_image_column(db)
|
||||
ensure_orders_columns(db)
|
||||
ensure_indexes(db)
|
||||
ensure_admin_user(db)
|
||||
|
||||
|
||||
def ensure_price_column(db: sqlite3.Connection) -> None:
|
||||
# Fügt die Preisspalte nachträglich hinzu, falls DB älter ist.
|
||||
"""Fügt die Preisspalte nachträglich hinzu, falls DB älter ist."""
|
||||
cols = db.execute("PRAGMA table_info(items)").fetchall()
|
||||
if any(c["name"] == "preis" for c in cols):
|
||||
return
|
||||
@@ -119,6 +181,7 @@ def ensure_price_column(db: sqlite3.Connection) -> None:
|
||||
|
||||
|
||||
def ensure_image_column(db: sqlite3.Connection) -> None:
|
||||
"""Fügt bild_url nachträglich hinzu, falls DB älter ist."""
|
||||
cols = db.execute("PRAGMA table_info(items)").fetchall()
|
||||
if any(c["name"] == "bild_url" for c in cols):
|
||||
return
|
||||
@@ -127,6 +190,7 @@ def ensure_image_column(db: sqlite3.Connection) -> None:
|
||||
|
||||
|
||||
def ensure_orders_columns(db: sqlite3.Connection) -> None:
|
||||
"""Sorgt für alle nachträglich eingeführten Orders‑Spalten."""
|
||||
cols = db.execute("PRAGMA table_info(orders)").fetchall()
|
||||
names = {c["name"] for c in cols}
|
||||
if "done" not in names:
|
||||
@@ -144,31 +208,108 @@ def ensure_orders_columns(db: sqlite3.Connection) -> None:
|
||||
db.commit()
|
||||
|
||||
|
||||
def ensure_indexes(db: sqlite3.Connection) -> None:
|
||||
"""Erstellt Indizes für bessere Query-Performance (idempotent)."""
|
||||
# Index für items.artikel (häufig gesucht/gefiltert)
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_items_artikel ON items(artikel)")
|
||||
|
||||
# Index für items(artikel, groesse) für eindeutige Zuordnung
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_items_artikel_groesse ON items(artikel, groesse)")
|
||||
|
||||
# Index für orders.done und orders.canceled (Filter "offene Bestellungen")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_orders_done ON orders(done)")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_orders_canceled ON orders(canceled)")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(done, canceled)")
|
||||
|
||||
# Index für ausbuchungen.item_id (Foreign Key, JOINs)
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_ausbuchungen_item_id ON ausbuchungen(item_id)")
|
||||
|
||||
db.commit()
|
||||
logger.info("Datenbank-Indizes überprüft/erstellt")
|
||||
|
||||
|
||||
@app.before_request
|
||||
def ensure_db() -> None:
|
||||
# Erstellt Tabellen, wenn sie fehlen (bei jedem Request idempotent).
|
||||
"""Stellt sicher, dass DB/Schema einmal pro Worker initialisiert ist."""
|
||||
global _DB_INIT_DONE
|
||||
if not _DB_INIT_DONE:
|
||||
init_db()
|
||||
_DB_INIT_DONE = True
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
"""Timestamp als kompaktes, DB‑freundliches Format."""
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
def save_upload(file) -> str | None:
|
||||
"""Speichert einen Bild‑Upload mit automatischer Optimierung und Thumbnail-Erstellung."""
|
||||
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
|
||||
if not (file.mimetype or "").startswith("image/"):
|
||||
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}"
|
||||
unique_id = uuid4().hex
|
||||
|
||||
# Original speichern
|
||||
original_name = f"{name}-{unique_id}{ext}"
|
||||
original_path = UPLOAD_DIR / original_name
|
||||
file.save(original_path)
|
||||
|
||||
# Bild optimieren (falls Pillow verfügbar)
|
||||
if HAS_PIL:
|
||||
try:
|
||||
with Image.open(original_path) as img:
|
||||
# EXIF-Orientierung korrigieren
|
||||
try:
|
||||
from PIL import ImageOps
|
||||
img = ImageOps.exif_transpose(img)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Konvertiere RGBA zu RGB (für JPEG)
|
||||
if img.mode in ('RGBA', 'LA', 'P'):
|
||||
background = Image.new('RGB', img.size, (255, 255, 255))
|
||||
if img.mode == 'P':
|
||||
img = img.convert('RGBA')
|
||||
background.paste(img, mask=img.split()[-1] if img.mode in ('RGBA', 'LA') else None)
|
||||
img = background
|
||||
|
||||
# Optimiertes Hauptbild (max 800x800)
|
||||
optimized_name = f"{name}-{unique_id}-opt.jpg"
|
||||
optimized_path = UPLOAD_DIR / optimized_name
|
||||
img_copy = img.copy()
|
||||
img_copy.thumbnail((800, 800), Image.Resampling.LANCZOS)
|
||||
img_copy.save(optimized_path, 'JPEG', quality=85, optimize=True)
|
||||
|
||||
# Thumbnail (max 400x400)
|
||||
thumb_name = f"{name}-{unique_id}-thumb.jpg"
|
||||
thumb_path = UPLOAD_DIR / thumb_name
|
||||
img.thumbnail((400, 400), Image.Resampling.LANCZOS)
|
||||
img.save(thumb_path, 'JPEG', quality=80, optimize=True)
|
||||
|
||||
# Original löschen (verwenden nur optimierte Versionen)
|
||||
original_path.unlink()
|
||||
|
||||
logger.info(f"Bild optimiert: {optimized_name} & {thumb_name}")
|
||||
|
||||
# Thumbnail-URL zurückgeben (wird als Standard verwendet)
|
||||
return f"{URL_PREFIX}/static/uploads/{thumb_name}" if URL_PREFIX else f"/static/uploads/{thumb_name}"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Fehler bei Bild-Optimierung: {e}")
|
||||
# Fallback: Original verwenden
|
||||
|
||||
# Fallback wenn Pillow fehlt oder Fehler aufgetreten ist
|
||||
return f"{URL_PREFIX}/static/uploads/{original_name}" if URL_PREFIX else f"/static/uploads/{original_name}"
|
||||
|
||||
|
||||
def ensure_admin_user(db: sqlite3.Connection) -> None:
|
||||
# Legt einen Admin‑User an, wenn noch kein Benutzer existiert.
|
||||
"""Legt einen Admin‑User an, wenn noch kein Benutzer existiert."""
|
||||
row = db.execute("SELECT COUNT(*) AS c FROM users").fetchone()
|
||||
if row and row["c"] > 0:
|
||||
return
|
||||
@@ -185,6 +326,7 @@ def ensure_admin_user(db: sqlite3.Connection) -> None:
|
||||
|
||||
|
||||
def login_required(fn):
|
||||
"""Schützt HTML‑Views mit Login‑Session."""
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not session.get("user"):
|
||||
@@ -196,11 +338,12 @@ def login_required(fn):
|
||||
|
||||
@app.context_processor
|
||||
def inject_auth():
|
||||
"""Stellt logged_in im Template‑Kontext bereit."""
|
||||
return {"logged_in": bool(session.get("user"))}
|
||||
|
||||
|
||||
def api_key_required(fn):
|
||||
# Schützt API‑Endpoints per X-API-Key oder ?key= Parameter.
|
||||
"""Schützt API‑Endpoints per X-API-Key oder ?key= Parameter."""
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
expected = os.environ.get("APP_API_KEY", "")
|
||||
@@ -214,9 +357,22 @@ def api_key_required(fn):
|
||||
return wrapper
|
||||
|
||||
|
||||
def rate_limited(ip: str) -> bool:
|
||||
"""Einfaches In‑Memory Rate‑Limit pro IP (1‑Min‑Fenster)."""
|
||||
now = time.time()
|
||||
bucket = _RATE_LIMIT.setdefault(ip, [])
|
||||
bucket[:] = [t for t in bucket if now - t < _RATE_WINDOW]
|
||||
if len(bucket) >= _RATE_MAX:
|
||||
return True
|
||||
bucket.append(now)
|
||||
return False
|
||||
|
||||
|
||||
# --- HTML‑Views (Admin) ---
|
||||
@bp.route("/")
|
||||
@login_required
|
||||
def index():
|
||||
"""Startseite: Artikelübersicht mit Filter, Sortierung und Summen."""
|
||||
q = (request.args.get("q") or "").strip()
|
||||
sort = (request.args.get("sort") or "gezaehlt").strip().lower()
|
||||
direction = (request.args.get("dir") or "desc").strip().lower()
|
||||
@@ -266,6 +422,7 @@ def index():
|
||||
@bp.route("/new", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def new_item():
|
||||
"""Artikel anlegen (inkl. optionalem Bild‑Upload)."""
|
||||
if request.method == "POST":
|
||||
artikel = (request.form.get("artikel") or "").strip()
|
||||
groesse = (request.form.get("groesse") or "").strip()
|
||||
@@ -297,6 +454,7 @@ def new_item():
|
||||
@bp.route("/edit/<int:item_id>", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def edit_item(item_id: int):
|
||||
"""Artikel bearbeiten."""
|
||||
db = get_db()
|
||||
item = db.execute("SELECT * FROM items WHERE id = ?", (item_id,)).fetchone()
|
||||
if item is None:
|
||||
@@ -332,6 +490,7 @@ def edit_item(item_id: int):
|
||||
@bp.route("/delete/<int:item_id>", methods=["POST"])
|
||||
@login_required
|
||||
def delete_item(item_id: int):
|
||||
"""Artikel löschen."""
|
||||
db = get_db()
|
||||
db.execute("DELETE FROM items WHERE id = ?", (item_id,))
|
||||
db.commit()
|
||||
@@ -341,6 +500,7 @@ def delete_item(item_id: int):
|
||||
@bp.route("/ausbuchen/<int:item_id>", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def ausbuchen(item_id: int):
|
||||
"""Ausbuchung erfassen (reduziert Bestand & erhöht Verkäufe)."""
|
||||
db = get_db()
|
||||
item = db.execute("SELECT * FROM items WHERE id = ?", (item_id,)).fetchone()
|
||||
if item is None:
|
||||
@@ -376,6 +536,7 @@ def ausbuchen(item_id: int):
|
||||
@bp.route("/verkauf/<int:item_id>", methods=["POST"])
|
||||
@login_required
|
||||
def verkauf(item_id: int):
|
||||
"""Schnell‑Verkauf: 1 Stück buchen und als Ausbuchung protokollieren."""
|
||||
db = get_db()
|
||||
item = db.execute("SELECT * FROM items WHERE id = ?", (item_id,)).fetchone()
|
||||
if item is None:
|
||||
@@ -403,8 +564,10 @@ def verkauf(item_id: int):
|
||||
return redirect(url_for("bp.index"))
|
||||
|
||||
|
||||
# --- Auth ---
|
||||
@bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
"""Login‑Formular & Session‑Handling."""
|
||||
if request.method == "POST":
|
||||
user = (request.form.get("user") or "").strip()
|
||||
password = request.form.get("password") or ""
|
||||
@@ -414,21 +577,26 @@ def login():
|
||||
).fetchone()
|
||||
if row and check_password_hash(row["password_hash"], password):
|
||||
session["user"] = user
|
||||
logger.info(f"Erfolgreicher Login: {user}")
|
||||
nxt = request.args.get("next") or url_for("bp.index")
|
||||
return redirect(nxt)
|
||||
logger.warning(f"Fehlgeschlagener Login-Versuch: {user}")
|
||||
return render_template("login.html", error=True)
|
||||
return render_template("login.html", error=False)
|
||||
|
||||
|
||||
@bp.route("/logout")
|
||||
def logout():
|
||||
"""Session beenden."""
|
||||
session.clear()
|
||||
return redirect(url_for("bp.login"))
|
||||
|
||||
|
||||
# --- Benutzerverwaltung ---
|
||||
@bp.route("/users", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def users():
|
||||
"""Benutzerverwaltung (anlegen, anzeigen)."""
|
||||
db = get_db()
|
||||
error = None
|
||||
if request.method == "POST":
|
||||
@@ -456,6 +624,7 @@ def users():
|
||||
@bp.route("/users/delete/<int:user_id>", methods=["POST"])
|
||||
@login_required
|
||||
def delete_user(user_id: int):
|
||||
"""Benutzer löschen (mindestens ein User muss bleiben)."""
|
||||
db = get_db()
|
||||
count = db.execute("SELECT COUNT(*) AS c FROM users").fetchone()["c"]
|
||||
if count <= 1:
|
||||
@@ -465,20 +634,22 @@ def delete_user(user_id: int):
|
||||
return redirect(url_for("bp.users"))
|
||||
|
||||
|
||||
# --- JSON‑APIs ---
|
||||
@bp.route("/api/bestand", methods=["GET"])
|
||||
@api_key_required
|
||||
def api_bestand():
|
||||
"""Öffentliche JSON‑API (authentifiziert) für Bestände."""
|
||||
return jsonify(build_bestand())
|
||||
|
||||
|
||||
@bp.route("/proxy/bestand", methods=["GET"])
|
||||
def proxy_bestand():
|
||||
# Server‑Proxy ohne API‑Key (z. B. für öffentliche Anzeige).
|
||||
"""Server‑Proxy ohne API‑Key (z. B. für öffentliche Anzeige)."""
|
||||
return jsonify(build_bestand())
|
||||
|
||||
|
||||
def build_bestand() -> list[dict]:
|
||||
# Aggregiert DB‑Zeilen in die Struktur der Live‑Bestand Ansicht.
|
||||
"""Aggregiert DB‑Zeilen in die Struktur der Live‑Bestand Ansicht."""
|
||||
rows = get_db().execute(
|
||||
"""
|
||||
SELECT artikel, groesse, preis, bild_url, soll, gezaehlt, verkaeufe
|
||||
@@ -533,11 +704,25 @@ def build_bestand() -> list[dict]:
|
||||
|
||||
|
||||
@bp.route("/order", methods=["POST"])
|
||||
@csrf.exempt # JSON API ohne CSRF-Schutz (nutzt API-Key stattdessen)
|
||||
def order():
|
||||
"""Erstellt eine Bestellung (optional API‑Key) und versendet Mail."""
|
||||
ip = request.headers.get("X-Forwarded-For", request.remote_addr or "unknown").split(",")[0].strip()
|
||||
if rate_limited(ip):
|
||||
return jsonify({"error": "Zu viele Anfragen."}), 429
|
||||
|
||||
expected_key = os.environ.get("APP_API_KEY", "")
|
||||
if expected_key:
|
||||
provided = request.headers.get("X-Order-Key") or request.args.get("key") or ""
|
||||
if provided != expected_key:
|
||||
return jsonify({"error": "Unauthorized"}), 401
|
||||
|
||||
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
|
||||
if int(data.get("menge") or 0) <= 0:
|
||||
return jsonify({"error": "Menge muss größer als 0 sein."}), 400
|
||||
|
||||
db = get_db()
|
||||
db.execute(
|
||||
@@ -557,6 +742,7 @@ def order():
|
||||
),
|
||||
)
|
||||
db.commit()
|
||||
logger.info(f"Neue Bestellung: {data.get('artikel')} ({data.get('groesse')}) x{data.get('menge')} von {data.get('name')}")
|
||||
|
||||
to_addr = os.environ.get("ORDER_TO", "bjoern@welker.me")
|
||||
smtp_host = os.environ.get("SMTP_HOST")
|
||||
@@ -586,17 +772,31 @@ def order():
|
||||
)
|
||||
msg.set_content(body)
|
||||
|
||||
def _send():
|
||||
"""Sendet Bestellungs-Email asynchron mit Error Handling."""
|
||||
try:
|
||||
with smtplib.SMTP(smtp_host, smtp_port) as server:
|
||||
server.starttls()
|
||||
server.login(smtp_user, smtp_pass)
|
||||
server.send_message(msg, to_addrs=recipients)
|
||||
logger.info(f"Bestellungs-Email erfolgreich versendet an {', '.join(recipients)}")
|
||||
except smtplib.SMTPAuthenticationError as e:
|
||||
logger.error(f"SMTP-Authentifizierung fehlgeschlagen: {e}")
|
||||
except smtplib.SMTPException as e:
|
||||
logger.error(f"SMTP-Fehler beim Email-Versand: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Unerwarteter Fehler beim Email-Versand: {e}", exc_info=True)
|
||||
|
||||
threading.Thread(target=_send, daemon=True).start()
|
||||
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
# --- Bestellungen (Admin) ---
|
||||
@bp.route("/orders")
|
||||
@login_required
|
||||
def orders():
|
||||
"""Bestellliste in der Verwaltung."""
|
||||
rows = get_db().execute(
|
||||
"""
|
||||
SELECT id, name, handy, mannschaft, artikel, groesse, menge, notiz, created_at, done, completed_by, completed_at, canceled, canceled_by, canceled_at
|
||||
@@ -611,6 +811,7 @@ def orders():
|
||||
@bp.route("/orders/complete/<int:order_id>", methods=["POST"])
|
||||
@login_required
|
||||
def complete_order(order_id: int):
|
||||
"""Bestellung abschließen und Bestand abziehen (falls ausreichend)."""
|
||||
user = session.get("user") or "unknown"
|
||||
db = get_db()
|
||||
order = db.execute(
|
||||
@@ -656,12 +857,14 @@ def complete_order(order_id: int):
|
||||
(user, now_iso(), order_id),
|
||||
)
|
||||
db.commit()
|
||||
logger.info(f"Bestellung #{order_id} abgeschlossen von {user}")
|
||||
return redirect(url_for("bp.orders"))
|
||||
|
||||
|
||||
@bp.route("/orders/cancel/<int:order_id>", methods=["POST"])
|
||||
@login_required
|
||||
def cancel_order(order_id: int):
|
||||
"""Bestellung stornieren (nur wenn noch offen)."""
|
||||
user = session.get("user") or "unknown"
|
||||
db = get_db()
|
||||
db.execute(
|
||||
@@ -679,6 +882,7 @@ def cancel_order(order_id: int):
|
||||
@bp.route("/users/reset/<int:user_id>", methods=["POST"])
|
||||
@login_required
|
||||
def reset_user_password(user_id: int):
|
||||
"""Passwort zurücksetzen und neues Passwort als Flash anzeigen."""
|
||||
db = get_db()
|
||||
user = db.execute("SELECT id, username FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||
if user is None:
|
||||
|
||||
1
wawi/templates/ausbuchen.html
Normal file → Executable file
1
wawi/templates/ausbuchen.html
Normal file → Executable file
@@ -4,6 +4,7 @@
|
||||
<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?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
Menge
|
||||
|
||||
1
wawi/templates/edit.html
Normal file → Executable file
1
wawi/templates/edit.html
Normal file → Executable file
@@ -3,6 +3,7 @@
|
||||
<div class="card form-card">
|
||||
<h2>{{ "Artikel bearbeiten" if item else "Neuen Artikel anlegen" }}</h2>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
Artikel
|
||||
|
||||
2
wawi/templates/index.html
Normal file → Executable file
2
wawi/templates/index.html
Normal file → Executable file
@@ -67,10 +67,12 @@
|
||||
<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?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<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.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<button class="btn icon danger" type="submit" title="Löschen" aria-label="Löschen"><span>🗑</span></button>
|
||||
</form>
|
||||
</td>
|
||||
|
||||
1
wawi/templates/login.html
Normal file → Executable file
1
wawi/templates/login.html
Normal file → Executable file
@@ -6,6 +6,7 @@
|
||||
<div class="note">Benutzername oder Passwort ist falsch.</div>
|
||||
{% endif %}
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
Benutzer
|
||||
|
||||
2
wawi/templates/orders.html
Normal file → Executable file
2
wawi/templates/orders.html
Normal file → Executable file
@@ -45,9 +45,11 @@
|
||||
<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?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<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?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<button class="btn small danger" type="submit">Stornieren</button>
|
||||
</form>
|
||||
{% else %}
|
||||
|
||||
3
wawi/templates/users.html
Normal file → Executable file
3
wawi/templates/users.html
Normal file → Executable file
@@ -15,6 +15,7 @@
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
Benutzername
|
||||
@@ -47,9 +48,11 @@
|
||||
<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?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<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?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<button class="btn small danger" type="submit">Löschen</button>
|
||||
</form>
|
||||
</td>
|
||||
|
||||
Reference in New Issue
Block a user