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).
|
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**
|
||||||
@@ -58,7 +78,8 @@ Wenn du diese Datei auf einem Webserver auslieferst, stelle sicher, dass die WaW
|
|||||||
- `ORDER_TO` – Empfänger, mehrere per Komma
|
- `ORDER_TO` – Empfänger, mehrere per Komma
|
||||||
|
|
||||||
**Optional**
|
**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)
|
## Deployment (systemd + Gunicorn)
|
||||||
1) App nach `/var/www/hellas/wawi` kopieren
|
1) App nach `/var/www/hellas/wawi` kopieren
|
||||||
@@ -88,9 +109,9 @@ Environment="SMTP_HOST=smtp.example.com"
|
|||||||
Environment="SMTP_PORT=587"
|
Environment="SMTP_PORT=587"
|
||||||
Environment="SMTP_USER=dein_user"
|
Environment="SMTP_USER=dein_user"
|
||||||
Environment="SMTP_PASS=dein_pass"
|
Environment="SMTP_PASS=dein_pass"
|
||||||
Environment="SMTP_FROM=bestand@hellas.welker.me"
|
Environment="SMTP_FROM=bestand@example.com"
|
||||||
Environment="ORDER_TO=bjoern@welker.me, zweite@domain.de"
|
Environment="ORDER_TO=admin@example.com, zweite@example.com"
|
||||||
Environment="APP_API_KEY=api_f4b8e1c97a2d4e5b8c6a9d3e2f7b1a0c"
|
Environment="APP_API_KEY=api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||||
ExecStart=/var/www/hellas/wawi/.venv/bin/gunicorn -w 3 -b 127.0.0.1:8000 app:app
|
ExecStart=/var/www/hellas/wawi/.venv/bin/gunicorn -w 3 -b 127.0.0.1:8000 app:app
|
||||||
Restart=always
|
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
|
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
|
```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); }
|
.pill input { accent-color: var(--accent); }
|
||||||
main .wrap { padding-top: 14px; padding-bottom: 28px; }
|
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)); }
|
.grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
|
||||||
.card-tile {
|
.card-tile {
|
||||||
border: 1px solid rgba(255,255,255,.08);
|
border: 1px solid rgba(255,255,255,.08);
|
||||||
@@ -212,8 +195,6 @@
|
|||||||
tr:last-child td { border-bottom: 0; }
|
tr:last-child td { border-bottom: 0; }
|
||||||
thead th { background: rgba(0,0,0,.12); }
|
thead th { background: rgba(0,0,0,.12); }
|
||||||
tbody tr:nth-child(odd) { background: rgba(255,255,255,.02); }
|
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); }
|
.muted { color: var(--muted); }
|
||||||
.small { font-size: 12px; }
|
.small { font-size: 12px; }
|
||||||
.footer {
|
.footer {
|
||||||
@@ -328,11 +309,66 @@
|
|||||||
.brand { width: 100%; }
|
.brand { width: 100%; }
|
||||||
.controls { width: 100%; }
|
.controls { width: 100%; }
|
||||||
.search { min-width: 100%; }
|
.search { min-width: 100%; }
|
||||||
.stats { grid-template-columns: 1fr; }
|
|
||||||
.card-media { aspect-ratio: 4 / 3; }
|
.card-media { aspect-ratio: 4 / 3; }
|
||||||
.order-btn { width: 100%; justify-content: center; }
|
.order-btn { width: 100%; justify-content: center; }
|
||||||
}
|
}
|
||||||
@media (max-width: 640px) { .grid { grid-template-columns: 1fr; } }
|
@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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -358,7 +394,6 @@
|
|||||||
|
|
||||||
<main>
|
<main>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div id="stats" class="stats" aria-live="polite"></div>
|
|
||||||
<div id="grid" class="grid" aria-live="polite"></div>
|
<div id="grid" class="grid" aria-live="polite"></div>
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<div>Tip: Auf einen Artikel klicken, um die Größen‑Tabelle zu öffnen.</div>
|
<div>Tip: Auf einen Artikel klicken, um die Größen‑Tabelle zu öffnen.</div>
|
||||||
@@ -424,7 +459,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="toastContainer" class="toast-container"></div>
|
||||||
|
|
||||||
<script>
|
<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).
|
// Proxy der WaWi‑App (kein API‑Key im Browser nötig).
|
||||||
const API_URL = "/wawi/proxy/bestand";
|
const API_URL = "/wawi/proxy/bestand";
|
||||||
let DATA = [];
|
let DATA = [];
|
||||||
@@ -440,23 +496,8 @@ function badge(label, value, cls="") {
|
|||||||
return `<span class="badge ${cls}">${label}: <strong>${fmt(value)}</strong></span>`;
|
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) {
|
function render(items) {
|
||||||
const grid = document.getElementById("grid");
|
const grid = document.getElementById("grid");
|
||||||
const stats = document.getElementById("stats");
|
|
||||||
|
|
||||||
stats.innerHTML = "";
|
|
||||||
|
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
grid.innerHTML = `<div class="empty">Keine Treffer. (Suchbegriff anpassen)</div>`;
|
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 modal = document.getElementById("orderModal");
|
||||||
const form = document.getElementById("orderForm");
|
const form = document.getElementById("orderForm");
|
||||||
const setField = (id, v) => document.getElementById(id).value = v || "";
|
const setField = (id, v) => document.getElementById(id).value = v || "";
|
||||||
|
const ORDER_KEY = "";
|
||||||
|
|
||||||
document.addEventListener("click", (e) => {
|
document.addEventListener("click", (e) => {
|
||||||
const imgBtn = e.target.closest(".thumb-btn");
|
const imgBtn = e.target.closest(".thumb-btn");
|
||||||
@@ -589,19 +631,22 @@ document.getElementById("detailModal").addEventListener("click", (e) => {
|
|||||||
form.addEventListener("submit", async (e) => {
|
form.addEventListener("submit", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const payload = Object.fromEntries(new FormData(form).entries());
|
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", {
|
const res = await fetch("/wawi/order", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers,
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
alert("Bestellung gesendet.");
|
showToast("Bestellung erfolgreich gesendet! Wir melden uns bei dir.", "success");
|
||||||
modal.classList.remove("open");
|
modal.classList.remove("open");
|
||||||
document.getElementById("detailModal").classList.remove("open");
|
document.getElementById("detailModal").classList.remove("open");
|
||||||
form.reset();
|
form.reset();
|
||||||
setField("fMenge", 1);
|
setField("fMenge", 1);
|
||||||
} else {
|
} 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
|
||||||
278
wawi/app.py
Normal file → Executable file
278
wawi/app.py
Normal file → Executable file
@@ -1,9 +1,20 @@
|
|||||||
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
|
||||||
import smtplib
|
import smtplib
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
import logging
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
@@ -11,7 +22,15 @@ from pathlib import Path
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
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 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.security import check_password_hash, generate_password_hash
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
@@ -20,22 +39,55 @@ 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.
|
# 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("/")
|
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"
|
||||||
|
|
||||||
app = Flask(__name__, static_url_path=STATIC_URL_PATH)
|
app = Flask(__name__, static_url_path=STATIC_URL_PATH)
|
||||||
|
|
||||||
# Session‑Secret für Login‑Cookies (in Produktion unbedingt setzen).
|
# 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_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
|
app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024
|
||||||
|
|
||||||
|
# CSRF-Schutz aktivieren
|
||||||
|
csrf = CSRFProtect(app)
|
||||||
|
|
||||||
bp = Blueprint("bp", __name__)
|
bp = Blueprint("bp", __name__)
|
||||||
|
|
||||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
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:
|
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
|
||||||
@@ -45,33 +97,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)
|
||||||
);
|
);
|
||||||
@@ -85,20 +146,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
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
@@ -106,11 +167,12 @@ def init_db() -> None:
|
|||||||
ensure_price_column(db)
|
ensure_price_column(db)
|
||||||
ensure_image_column(db)
|
ensure_image_column(db)
|
||||||
ensure_orders_columns(db)
|
ensure_orders_columns(db)
|
||||||
|
ensure_indexes(db)
|
||||||
ensure_admin_user(db)
|
ensure_admin_user(db)
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
@@ -119,6 +181,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
|
||||||
@@ -127,6 +190,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:
|
||||||
@@ -144,31 +208,108 @@ def ensure_orders_columns(db: sqlite3.Connection) -> None:
|
|||||||
db.commit()
|
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
|
@app.before_request
|
||||||
def ensure_db() -> None:
|
def ensure_db() -> None:
|
||||||
# Erstellt Tabellen, wenn sie fehlen (bei jedem Request idempotent).
|
"""Stellt sicher, dass DB/Schema einmal pro Worker initialisiert ist."""
|
||||||
init_db()
|
global _DB_INIT_DONE
|
||||||
|
if not _DB_INIT_DONE:
|
||||||
|
init_db()
|
||||||
|
_DB_INIT_DONE = True
|
||||||
|
|
||||||
|
|
||||||
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 mit automatischer Optimierung und Thumbnail-Erstellung."""
|
||||||
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()
|
||||||
if ext not in ALLOWED_EXT:
|
if ext not in ALLOWED_EXT:
|
||||||
return None
|
return None
|
||||||
|
if not (file.mimetype or "").startswith("image/"):
|
||||||
|
return None
|
||||||
|
|
||||||
name = secure_filename(Path(file.filename).stem) or "image"
|
name = secure_filename(Path(file.filename).stem) or "image"
|
||||||
safe_name = f"{name}-{uuid4().hex}{ext}"
|
unique_id = uuid4().hex
|
||||||
dest = UPLOAD_DIR / safe_name
|
|
||||||
file.save(dest)
|
# Original speichern
|
||||||
return f"{URL_PREFIX}/static/uploads/{safe_name}" if URL_PREFIX else f"/static/uploads/{safe_name}"
|
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:
|
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
|
||||||
@@ -185,6 +326,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"):
|
||||||
@@ -196,11 +338,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", "")
|
||||||
@@ -214,9 +357,22 @@ def api_key_required(fn):
|
|||||||
return wrapper
|
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("/")
|
@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()
|
||||||
@@ -266,6 +422,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()
|
||||||
@@ -297,6 +454,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:
|
||||||
@@ -332,6 +490,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()
|
||||||
@@ -341,6 +500,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:
|
||||||
@@ -376,6 +536,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:
|
||||||
@@ -403,8 +564,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 ""
|
||||||
@@ -414,21 +577,26 @@ def login():
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
if row and check_password_hash(row["password_hash"], password):
|
if row and check_password_hash(row["password_hash"], password):
|
||||||
session["user"] = user
|
session["user"] = user
|
||||||
|
logger.info(f"Erfolgreicher Login: {user}")
|
||||||
nxt = request.args.get("next") or url_for("bp.index")
|
nxt = request.args.get("next") or url_for("bp.index")
|
||||||
return redirect(nxt)
|
return redirect(nxt)
|
||||||
|
logger.warning(f"Fehlgeschlagener Login-Versuch: {user}")
|
||||||
return render_template("login.html", error=True)
|
return render_template("login.html", error=True)
|
||||||
return render_template("login.html", error=False)
|
return render_template("login.html", error=False)
|
||||||
|
|
||||||
|
|
||||||
@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":
|
||||||
@@ -456,6 +624,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:
|
||||||
@@ -465,20 +634,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
|
||||||
@@ -533,11 +704,25 @@ def build_bestand() -> list[dict]:
|
|||||||
|
|
||||||
|
|
||||||
@bp.route("/order", methods=["POST"])
|
@bp.route("/order", methods=["POST"])
|
||||||
|
@csrf.exempt # JSON API ohne CSRF-Schutz (nutzt API-Key stattdessen)
|
||||||
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()
|
||||||
|
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
|
data = request.get_json(silent=True) or request.form
|
||||||
required = ["name", "handy", "mannschaft", "artikel", "groesse", "menge"]
|
required = ["name", "handy", "mannschaft", "artikel", "groesse", "menge"]
|
||||||
if any(not (data.get(k) or "").strip() for k in required):
|
if any(not (data.get(k) or "").strip() for k in required):
|
||||||
return jsonify({"error": "Pflichtfelder fehlen."}), 400
|
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 = get_db()
|
||||||
db.execute(
|
db.execute(
|
||||||
@@ -557,6 +742,7 @@ def order():
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
db.commit()
|
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")
|
to_addr = os.environ.get("ORDER_TO", "bjoern@welker.me")
|
||||||
smtp_host = os.environ.get("SMTP_HOST")
|
smtp_host = os.environ.get("SMTP_HOST")
|
||||||
@@ -586,17 +772,31 @@ def order():
|
|||||||
)
|
)
|
||||||
msg.set_content(body)
|
msg.set_content(body)
|
||||||
|
|
||||||
with smtplib.SMTP(smtp_host, smtp_port) as server:
|
def _send():
|
||||||
server.starttls()
|
"""Sendet Bestellungs-Email asynchron mit Error Handling."""
|
||||||
server.login(smtp_user, smtp_pass)
|
try:
|
||||||
server.send_message(msg, to_addrs=recipients)
|
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})
|
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
|
||||||
@@ -611,6 +811,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(
|
||||||
@@ -656,12 +857,14 @@ def complete_order(order_id: int):
|
|||||||
(user, now_iso(), order_id),
|
(user, now_iso(), order_id),
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
logger.info(f"Bestellung #{order_id} abgeschlossen von {user}")
|
||||||
return redirect(url_for("bp.orders"))
|
return redirect(url_for("bp.orders"))
|
||||||
|
|
||||||
|
|
||||||
@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(
|
||||||
@@ -679,6 +882,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:
|
||||||
|
|||||||
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>
|
<h2>Ausbuchen: {{ item.artikel }} ({{ item.groesse }})</h2>
|
||||||
<div class="note">Aktueller Bestand: <strong>{{ item.gezaehlt }}</strong></div>
|
<div class="note">Aktueller Bestand: <strong>{{ item.gezaehlt }}</strong></div>
|
||||||
<form method="post" onsubmit="return confirm('Wirklich ausbuchen?');">
|
<form method="post" onsubmit="return confirm('Wirklich ausbuchen?');">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<label>
|
<label>
|
||||||
Menge
|
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">
|
<div class="card form-card">
|
||||||
<h2>{{ "Artikel bearbeiten" if item else "Neuen Artikel anlegen" }}</h2>
|
<h2>{{ "Artikel bearbeiten" if item else "Neuen Artikel anlegen" }}</h2>
|
||||||
<form method="post" enctype="multipart/form-data">
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<label>
|
<label>
|
||||||
Artikel
|
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">
|
<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>
|
<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?');">
|
<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>
|
<button class="btn icon" type="submit" title="Verkauf +1" aria-label="Verkauf +1"><span>🛒</span></button>
|
||||||
</form>
|
</form>
|
||||||
<a class="btn icon" href="{{ url_for('bp.ausbuchen', item_id=r.id) }}" title="Ausbuchen" aria-label="Ausbuchen"><span>⇩</span></a>
|
<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.');">
|
<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>
|
<button class="btn icon danger" type="submit" title="Löschen" aria-label="Löschen"><span>🗑</span></button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</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>
|
<div class="note">Benutzername oder Passwort ist falsch.</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="post">
|
<form method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<label>
|
<label>
|
||||||
Benutzer
|
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">
|
<td class="actions">
|
||||||
{% if not o.done and not o.canceled %}
|
{% 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?');">
|
<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>
|
<button class="btn small" type="submit">Erledigt</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="post" action="{{ url_for('bp.cancel_order', order_id=o.id) }}" onsubmit="return confirm('Bestellung wirklich stornieren?');">
|
<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>
|
<button class="btn small danger" type="submit">Stornieren</button>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
3
wawi/templates/users.html
Normal file → Executable file
3
wawi/templates/users.html
Normal file → Executable file
@@ -15,6 +15,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
<form method="post">
|
<form method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<label>
|
<label>
|
||||||
Benutzername
|
Benutzername
|
||||||
@@ -47,9 +48,11 @@
|
|||||||
<td>{{ u.created_at }}</td>
|
<td>{{ u.created_at }}</td>
|
||||||
<td class="actions">
|
<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?');">
|
<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>
|
<button class="btn small" type="submit">Passwort neu</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="post" action="{{ url_for('bp.delete_user', user_id=u.id) }}" onsubmit="return confirm('Benutzer wirklich löschen?');">
|
<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>
|
<button class="btn small danger" type="submit">Löschen</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
Reference in New Issue
Block a user