feat: add email notifications, order references, stock badges, and sale flags
- Add customer email field and PayPal payment reminder emails with order reference (HEL-YEAR-ID format) - Display stock availability with color-coded badges (available/low/unavailable) - Add sale/clearance flag with animated red badge overlay - Implement automatic fallback placeholder for missing/broken product images - Add email column to order management view Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
207
wawi/app.py
207
wawi/app.py
@@ -166,6 +166,7 @@ def init_db() -> None:
|
||||
db.commit()
|
||||
ensure_price_column(db)
|
||||
ensure_image_column(db)
|
||||
ensure_sale_column(db)
|
||||
ensure_orders_columns(db)
|
||||
ensure_payment_columns(db)
|
||||
ensure_indexes(db)
|
||||
@@ -190,6 +191,16 @@ def ensure_image_column(db: sqlite3.Connection) -> None:
|
||||
db.commit()
|
||||
|
||||
|
||||
def ensure_sale_column(db: sqlite3.Connection) -> None:
|
||||
"""Fügt sale-Spalte nachträglich hinzu."""
|
||||
cols = db.execute("PRAGMA table_info(items)").fetchall()
|
||||
if any(c["name"] == "sale" for c in cols):
|
||||
return
|
||||
db.execute("ALTER TABLE items ADD COLUMN sale INTEGER NOT NULL DEFAULT 0")
|
||||
db.commit()
|
||||
logger.info("Sale-Spalte für items erstellt")
|
||||
|
||||
|
||||
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()
|
||||
@@ -206,6 +217,8 @@ def ensure_orders_columns(db: sqlite3.Connection) -> None:
|
||||
db.execute("ALTER TABLE orders ADD COLUMN canceled_by TEXT")
|
||||
if "canceled_at" not in names:
|
||||
db.execute("ALTER TABLE orders ADD COLUMN canceled_at TEXT")
|
||||
if "email" not in names:
|
||||
db.execute("ALTER TABLE orders ADD COLUMN email TEXT")
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -393,6 +406,7 @@ def index():
|
||||
q = (request.args.get("q") or "").strip()
|
||||
sort = (request.args.get("sort") or "gezaehlt").strip().lower()
|
||||
direction = (request.args.get("dir") or "desc").strip().lower()
|
||||
current_year = datetime.now().year
|
||||
|
||||
allowed = {"artikel", "groesse", "soll", "gezaehlt", "verkaeufe"}
|
||||
if sort not in allowed:
|
||||
@@ -400,7 +414,7 @@ def index():
|
||||
if direction not in {"asc", "desc"}:
|
||||
direction = "desc"
|
||||
|
||||
params: list[Any] = []
|
||||
params: list[Any] = [str(current_year)]
|
||||
where = ""
|
||||
if q:
|
||||
where = "WHERE artikel LIKE ? OR groesse LIKE ?"
|
||||
@@ -408,9 +422,20 @@ def index():
|
||||
params.extend([like, like])
|
||||
|
||||
sql = f"""
|
||||
SELECT *
|
||||
SELECT
|
||||
items.*,
|
||||
COALESCE(SUM(CASE
|
||||
WHEN orders.done = 1
|
||||
AND orders.canceled = 0
|
||||
AND orders.completed_at IS NOT NULL
|
||||
AND substr(orders.completed_at, 1, 4) = ?
|
||||
THEN orders.menge
|
||||
ELSE 0
|
||||
END), 0) AS verkaeufe_aktuell
|
||||
FROM items
|
||||
LEFT JOIN orders ON items.artikel = orders.artikel AND items.groesse = orders.groesse
|
||||
{where}
|
||||
GROUP BY items.id
|
||||
ORDER BY {sort} {direction}, artikel ASC, groesse ASC
|
||||
"""
|
||||
rows = get_db().execute(sql, params).fetchall()
|
||||
@@ -433,6 +458,7 @@ def index():
|
||||
total=total,
|
||||
total_bestand=total_bestand,
|
||||
open_orders=open_orders,
|
||||
current_year=current_year,
|
||||
)
|
||||
|
||||
|
||||
@@ -448,6 +474,7 @@ def new_item():
|
||||
uploaded = save_upload(request.files.get("bild_file"))
|
||||
if uploaded:
|
||||
bild_url = uploaded
|
||||
sale = 1 if request.form.get("sale") else 0
|
||||
soll = int(request.form.get("soll") or 0)
|
||||
gezaehlt = int(request.form.get("gezaehlt") or 0)
|
||||
verkaeufe = int(request.form.get("verkaeufe") or 0)
|
||||
@@ -456,12 +483,12 @@ def new_item():
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO items (artikel, groesse, preis, bild_url, soll, gezaehlt, verkaeufe, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO items (artikel, groesse, preis, bild_url, sale, soll, gezaehlt, verkaeufe, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(artikel, groesse, preis, bild_url, soll, gezaehlt, verkaeufe, now_iso(), now_iso()),
|
||||
(artikel, groesse, preis, bild_url, sale, soll, gezaehlt, verkaeufe, now_iso(), now_iso()),
|
||||
)
|
||||
db.execute("UPDATE items SET preis = ?, bild_url = ? WHERE artikel = ?", (preis, bild_url, artikel))
|
||||
db.execute("UPDATE items SET preis = ?, bild_url = ?, sale = ? WHERE artikel = ?", (preis, bild_url, sale, artikel))
|
||||
db.commit()
|
||||
return redirect(url_for("bp.index"))
|
||||
|
||||
@@ -485,6 +512,7 @@ def edit_item(item_id: int):
|
||||
uploaded = save_upload(request.files.get("bild_file"))
|
||||
if uploaded:
|
||||
bild_url = uploaded
|
||||
sale = 1 if request.form.get("sale") else 0
|
||||
soll = int(request.form.get("soll") or 0)
|
||||
gezaehlt = int(request.form.get("gezaehlt") or 0)
|
||||
verkaeufe = int(request.form.get("verkaeufe") or 0)
|
||||
@@ -492,12 +520,12 @@ def edit_item(item_id: int):
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE items
|
||||
SET artikel = ?, groesse = ?, preis = ?, bild_url = ?, soll = ?, gezaehlt = ?, verkaeufe = ?, updated_at = ?
|
||||
SET artikel = ?, groesse = ?, preis = ?, bild_url = ?, sale = ?, soll = ?, gezaehlt = ?, verkaeufe = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(artikel, groesse, preis, bild_url, soll, gezaehlt, verkaeufe, now_iso(), item_id),
|
||||
(artikel, groesse, preis, bild_url, sale, soll, gezaehlt, verkaeufe, now_iso(), item_id),
|
||||
)
|
||||
db.execute("UPDATE items SET preis = ?, bild_url = ? WHERE artikel = ?", (preis, bild_url, artikel))
|
||||
db.execute("UPDATE items SET preis = ?, bild_url = ?, sale = ? WHERE artikel = ?", (preis, bild_url, sale, artikel))
|
||||
db.commit()
|
||||
return redirect(url_for("bp.index"))
|
||||
|
||||
@@ -677,7 +705,7 @@ def build_bestand() -> list[dict]:
|
||||
"""Aggregiert DB‑Zeilen in die Struktur der Live‑Bestand Ansicht."""
|
||||
rows = get_db().execute(
|
||||
"""
|
||||
SELECT artikel, groesse, preis, bild_url, soll, gezaehlt, verkaeufe
|
||||
SELECT artikel, groesse, preis, bild_url, sale, soll, gezaehlt, verkaeufe
|
||||
FROM items
|
||||
ORDER BY artikel, groesse
|
||||
"""
|
||||
@@ -690,12 +718,15 @@ def build_bestand() -> list[dict]:
|
||||
continue
|
||||
item = data.setdefault(
|
||||
artikel,
|
||||
{"artikel": artikel, "preis": 0, "bild_url": "", "rows": [], "totals": {"soll": 0, "gezaehlt": 0, "abweichung": 0, "fehlbestand": 0, "verkaeufe": 0}},
|
||||
{"artikel": artikel, "preis": 0, "bild_url": "", "sale": 0, "rows": [], "totals": {"soll": 0, "gezaehlt": 0, "abweichung": 0, "fehlbestand": 0, "verkaeufe": 0}},
|
||||
)
|
||||
if not item["preis"]:
|
||||
item["preis"] = float(r["preis"] or 0)
|
||||
if not item["bild_url"]:
|
||||
item["bild_url"] = (r["bild_url"] or "").strip()
|
||||
# Sale-Status (1 wenn mindestens eine Größe sale=1 hat)
|
||||
if r["sale"]:
|
||||
item["sale"] = 1
|
||||
soll = int(r["soll"] or 0)
|
||||
gezaehlt = int(r["gezaehlt"] or 0)
|
||||
verkaeufe = int(r["verkaeufe"] or 0)
|
||||
@@ -743,7 +774,7 @@ def order():
|
||||
return jsonify({"error": "Unauthorized"}), 401
|
||||
|
||||
data = request.get_json(silent=True) or request.form
|
||||
required = ["name", "handy", "mannschaft", "artikel", "groesse", "menge"]
|
||||
required = ["name", "handy", "email", "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:
|
||||
@@ -754,14 +785,15 @@ def order():
|
||||
payment_method = "bar"
|
||||
|
||||
db = get_db()
|
||||
db.execute(
|
||||
cursor = db.execute(
|
||||
"""
|
||||
INSERT INTO orders (name, handy, mannschaft, artikel, groesse, menge, notiz, created_at, done, completed_by, completed_at, canceled, canceled_by, canceled_at, payment_method, payment_status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, NULL, 0, NULL, NULL, ?, 'unpaid')
|
||||
INSERT INTO orders (name, handy, email, mannschaft, artikel, groesse, menge, notiz, created_at, done, completed_by, completed_at, canceled, canceled_by, canceled_at, payment_method, payment_status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, NULL, 0, NULL, NULL, ?, 'unpaid')
|
||||
""",
|
||||
(
|
||||
data.get("name"),
|
||||
data.get("handy"),
|
||||
data.get("email"),
|
||||
data.get("mannschaft"),
|
||||
data.get("artikel"),
|
||||
data.get("groesse"),
|
||||
@@ -771,8 +803,9 @@ def order():
|
||||
payment_method,
|
||||
),
|
||||
)
|
||||
order_id = cursor.lastrowid
|
||||
db.commit()
|
||||
logger.info(f"Neue Bestellung: {data.get('artikel')} ({data.get('groesse')}) x{data.get('menge')} von {data.get('name')}")
|
||||
logger.info(f"Neue Bestellung #{order_id}: {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")
|
||||
@@ -821,6 +854,69 @@ def order():
|
||||
|
||||
threading.Thread(target=_send, daemon=True).start()
|
||||
|
||||
# PayPal payment reminder to customer
|
||||
if payment_method == "paypal":
|
||||
customer_email = data.get("email", "").strip()
|
||||
if customer_email and smtp_host and smtp_user and smtp_pass:
|
||||
paypal_account = os.environ.get("PAYPAL_ACCOUNT", "")
|
||||
|
||||
customer_msg = EmailMessage()
|
||||
customer_msg["Subject"] = "Zahlungshinweis für deine Hellas-Bestellung"
|
||||
customer_msg["From"] = smtp_from
|
||||
customer_msg["To"] = customer_email
|
||||
|
||||
# Calculate total price (need to get item price from DB)
|
||||
item = db.execute(
|
||||
"SELECT preis FROM items WHERE artikel = ? AND groesse = ? LIMIT 1",
|
||||
(data.get("artikel"), data.get("groesse"))
|
||||
).fetchone()
|
||||
preis = float(item["preis"]) if item else 0
|
||||
menge = int(data.get("menge") or 0)
|
||||
total = preis * menge
|
||||
|
||||
# Referenznummer im Format HEL-2026-123
|
||||
year = datetime.now().year
|
||||
ref_number = f"HEL-{year}-{order_id}"
|
||||
|
||||
customer_body = (
|
||||
f"Hallo {data.get('name')},\n\n"
|
||||
f"vielen Dank für deine Bestellung bei Hellas 1899!\n\n"
|
||||
f"Bestelldetails:\n"
|
||||
f"• Bestellnummer: {ref_number}\n"
|
||||
f"• Artikel: {data.get('artikel')}\n"
|
||||
f"• Größe: {data.get('groesse')}\n"
|
||||
f"• Menge: {menge}\n"
|
||||
f"• Preis: {total:.2f} €\n\n"
|
||||
f"⚠️ WICHTIG - Zahlungshinweis:\n"
|
||||
f"Du hast PayPal als Zahlungsart gewählt. Bitte überweise den Betrag an:\n\n"
|
||||
f"PayPal-Konto: {paypal_account}\n"
|
||||
f"Betrag: {total:.2f} €\n"
|
||||
f"Verwendungszweck: {ref_number}\n\n"
|
||||
f"WICHTIG: Bitte wähle unbedingt die Option \"Familie & Freunde\", damit keine zusätzlichen Gebühren anfallen!\n"
|
||||
f"Bitte gib die Bestellnummer {ref_number} als Verwendungszweck an, damit wir deine Zahlung zuordnen können.\n\n"
|
||||
f"Sobald der Betrag eingegangen ist, wird deine Bestellung bearbeitet und du wirst benachrichtigt, wenn sie zur Abholung bereit ist.\n\n"
|
||||
f"Viele Grüße\n"
|
||||
f"Dein Hellas 1899 Team\n"
|
||||
)
|
||||
customer_msg.set_content(customer_body)
|
||||
|
||||
def _send_customer():
|
||||
"""Sendet Zahlungs-Erinnerung an Kunden asynchron."""
|
||||
try:
|
||||
with smtplib.SMTP(smtp_host, smtp_port) as server:
|
||||
server.starttls()
|
||||
server.login(smtp_user, smtp_pass)
|
||||
server.send_message(customer_msg, to_addrs=[customer_email])
|
||||
logger.info(f"PayPal-Zahlungshinweis gesendet an {customer_email}")
|
||||
except smtplib.SMTPAuthenticationError as e:
|
||||
logger.error(f"SMTP-Authentifizierung fehlgeschlagen (Kunden-Email): {e}")
|
||||
except smtplib.SMTPException as e:
|
||||
logger.error(f"SMTP-Fehler beim Kunden-Email-Versand: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Unerwarteter Fehler beim Kunden-Email-Versand: {e}", exc_info=True)
|
||||
|
||||
threading.Thread(target=_send_customer, daemon=True).start()
|
||||
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@@ -831,7 +927,7 @@ 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, payment_method, payment_status, paid_at, paid_by
|
||||
SELECT id, name, handy, email, mannschaft, artikel, groesse, menge, notiz, created_at, done, completed_by, completed_at, canceled, canceled_by, canceled_at, payment_method, payment_status, paid_at, paid_by
|
||||
FROM orders
|
||||
ORDER BY id DESC
|
||||
LIMIT 500
|
||||
@@ -874,11 +970,12 @@ def complete_order(order_id: int):
|
||||
"""
|
||||
UPDATE items
|
||||
SET gezaehlt = CASE WHEN gezaehlt - ? < 0 THEN 0 ELSE gezaehlt - ? END,
|
||||
soll = CASE WHEN soll - ? < 0 THEN 0 ELSE soll - ? END,
|
||||
verkaeufe = verkaeufe + ?,
|
||||
updated_at = ?
|
||||
WHERE artikel = ? AND groesse = ?
|
||||
""",
|
||||
(menge, menge, menge, now_iso(), order["artikel"], order["groesse"]),
|
||||
(menge, menge, menge, menge, menge, now_iso(), order["artikel"], order["groesse"]),
|
||||
)
|
||||
db.execute(
|
||||
"""
|
||||
@@ -930,6 +1027,80 @@ def cancel_order(order_id: int):
|
||||
return redirect(url_for("bp.orders"))
|
||||
|
||||
|
||||
@bp.route("/abrechnung")
|
||||
@login_required
|
||||
def abrechnung():
|
||||
"""Abrechnungs-Ansicht für das aktuelle Jahr."""
|
||||
db = get_db()
|
||||
current_year = datetime.now().year
|
||||
year = request.args.get("year", str(current_year))
|
||||
|
||||
# Alle erledigten Bestellungen im gewählten Jahr oder vor 2026
|
||||
if year == "ALT":
|
||||
orders = db.execute(
|
||||
"""
|
||||
SELECT o.*, i.preis
|
||||
FROM orders o
|
||||
LEFT JOIN items i ON o.artikel = i.artikel AND o.groesse = i.groesse
|
||||
WHERE o.done = 1 AND o.canceled = 0
|
||||
AND o.completed_at IS NOT NULL
|
||||
AND substr(o.completed_at, 1, 4) < '2026'
|
||||
ORDER BY o.completed_at DESC
|
||||
""",
|
||||
).fetchall()
|
||||
else:
|
||||
orders = db.execute(
|
||||
"""
|
||||
SELECT o.*, i.preis
|
||||
FROM orders o
|
||||
LEFT JOIN items i ON o.artikel = i.artikel AND o.groesse = i.groesse
|
||||
WHERE o.done = 1 AND o.canceled = 0
|
||||
AND o.completed_at IS NOT NULL
|
||||
AND substr(o.completed_at, 1, 4) = ?
|
||||
ORDER BY o.completed_at DESC
|
||||
""",
|
||||
(year,),
|
||||
).fetchall()
|
||||
|
||||
# Gesamtumsatz berechnen
|
||||
total_revenue = sum((o["preis"] or 0) * (o["menge"] or 0) for o in orders)
|
||||
total_items = sum(o["menge"] or 0 for o in orders)
|
||||
|
||||
# Nach Zahlungsart gruppieren
|
||||
paypal_orders = [o for o in orders if o["payment_method"] == "paypal"]
|
||||
bar_orders = [o for o in orders if o["payment_method"] == "bar"]
|
||||
paypal_revenue = sum((o["preis"] or 0) * (o["menge"] or 0) for o in paypal_orders)
|
||||
bar_revenue = sum((o["preis"] or 0) * (o["menge"] or 0) for o in bar_orders)
|
||||
|
||||
# Nach Artikel gruppieren
|
||||
artikel_stats = {}
|
||||
for o in orders:
|
||||
key = o["artikel"]
|
||||
if key not in artikel_stats:
|
||||
artikel_stats[key] = {"menge": 0, "umsatz": 0}
|
||||
artikel_stats[key]["menge"] += o["menge"] or 0
|
||||
artikel_stats[key]["umsatz"] += (o["preis"] or 0) * (o["menge"] or 0)
|
||||
|
||||
artikel_list = [
|
||||
{"artikel": k, "menge": v["menge"], "umsatz": v["umsatz"]}
|
||||
for k, v in sorted(artikel_stats.items(), key=lambda x: x[1]["umsatz"], reverse=True)
|
||||
]
|
||||
|
||||
return render_template(
|
||||
"abrechnung.html",
|
||||
year=year,
|
||||
current_year=current_year,
|
||||
orders=orders,
|
||||
total_revenue=total_revenue,
|
||||
total_items=total_items,
|
||||
paypal_count=len(paypal_orders),
|
||||
bar_count=len(bar_orders),
|
||||
paypal_revenue=paypal_revenue,
|
||||
bar_revenue=bar_revenue,
|
||||
artikel_list=artikel_list,
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/users/reset/<int:user_id>", methods=["POST"])
|
||||
@login_required
|
||||
def reset_user_password(user_id: int):
|
||||
|
||||
Reference in New Issue
Block a user