feat: Implement booking email notifications and consent handling in booking flow

This commit is contained in:
2026-05-23 10:16:25 +02:00
parent 0a11d366a5
commit 2a96e8bbb4
5 changed files with 175 additions and 2 deletions
+4
View File
@@ -0,0 +1,4 @@
# Buchungsrelatet processing
def buchen(name: str, Straße: str, plz:str, ort:str, ansprech:str, email:str, notes:str, agreements_accepted:str):
print("Gebucht")
+130
View File
@@ -10,9 +10,11 @@ import shutil
import tarfile
import tempfile
import threading
import smtplib
from datetime import timedelta, datetime, date
from functools import wraps
from io import BytesIO
from email.message import EmailMessage
from urllib.parse import parse_qs, quote, urlparse
from werkzeug.security import generate_password_hash, check_password_hash
from werkzeug.utils import secure_filename
@@ -22,6 +24,7 @@ from pymongo import MongoClient
from pymongo.errors import PyMongoError
from bson.objectid import ObjectId
import user as user_store
from buchungen import buchen
app = Flask(__name__)
app.secret_key = "ASDfhbsdfseiufhgildsrfrjg874368546987s6e8468f4!?FAUS/&s"
@@ -58,6 +61,24 @@ BOOKING_FLOW_MAP = {
},
}
BOOKING_PRICE_MAP = {
"starter_package": 320.0,
"advanced_package": 0.0,
"inventarsystem": 250.0,
"buecherei": 150.0,
"terminverwaltung": 0.0,
"emailversand": 0.0,
"schultraeger": 0.0,
"telefonsupport": 20.0,
}
SMTP_HOST = os.environ.get("SMTP_HOST", "").strip()
SMTP_USERNAME = os.environ.get("SMTP_USERNAME", "").strip()
SMTP_PASSWORD = os.environ.get("SMTP_PASSWORD", "")
SMTP_USE_TLS = os.environ.get("SMTP_USE_TLS", "1").strip().lower() not in {"0", "false", "no"}
SMTP_FROM_ADDRESS = os.environ.get("SMTP_FROM_ADDRESS", SMTP_USERNAME or "noreply@localhost").strip()
BOOKING_ADMIN_EMAIL_FALLBACK = os.environ.get("BOOKING_ADMIN_EMAIL", "").strip()
@app.after_request
def set_security_headers(response):
@@ -103,6 +124,12 @@ def _env_int(name: str, default: int) -> int:
return default
try:
SMTP_PORT = max(int(os.environ.get("SMTP_PORT", "587")), 1)
except ValueError:
SMTP_PORT = 587
MONGO_MAX_POOL_SIZE = max(_env_int("MONGO_MAX_POOL_SIZE", 12), 1)
MONGO_MIN_POOL_SIZE = max(_env_int("MONGO_MIN_POOL_SIZE", 0), 0)
MONGO_MAX_IDLE_MS = max(_env_int("MONGO_MAX_IDLE_MS", 60000), 1000)
@@ -210,6 +237,7 @@ def _normalize_user_doc(doc: dict | None) -> dict | None:
"username": doc.get("Username") or doc.get("username"),
"display_name": doc.get("name") or doc.get("display_name") or doc.get("Username"),
"is_admin": bool(doc.get("Admin") or doc.get("is_admin", False)),
"email": doc.get("email") or doc.get("Email") or "",
"created_at": doc.get("created_at"),
}
@@ -230,6 +258,39 @@ def _list_users_for_admin() -> list:
return users
def _list_admin_users() -> list:
return [user for user in _list_users_for_admin() if user.get("is_admin")]
def _select_booking_admin() -> dict | None:
admins = _list_admin_users()
if admins:
return admins[0]
return None
def _send_booking_email(recipient_email: str, subject: str, body: str) -> tuple[bool, str]:
if not SMTP_HOST or not recipient_email:
return False, "SMTP nicht konfiguriert"
message = EmailMessage()
message["Subject"] = subject
message["From"] = SMTP_FROM_ADDRESS
message["To"] = recipient_email
message.set_content(body)
try:
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=12) as smtp:
if SMTP_USE_TLS:
smtp.starttls()
if SMTP_USERNAME:
smtp.login(SMTP_USERNAME, SMTP_PASSWORD)
smtp.send_message(message)
return True, "E-Mail wurde versendet"
except Exception as exc:
return False, _sanitize_text(str(exc), 240)
def _sanitize_text(text: str, max_length: int = 255) -> str:
"""Sanitize user text input: strip, limit length, and escape HTML."""
text = (text or "").strip()
@@ -1871,6 +1932,7 @@ def booking_payment():
"billing_zip": _sanitize_text(request.values.get("billing_zip") or "", 20),
"billing_city": _sanitize_text(request.values.get("billing_city") or "", 120),
"notes": _sanitize_text(request.values.get("notes") or "", 600),
"agreements_accepted": (request.values.get("agreements_accepted") or "").strip().lower() in {"1", "on", "true", "yes"},
}
if request.method == "GET" and not form_data["contact_person"]:
@@ -1904,10 +1966,19 @@ def booking_payment():
flash(f"Bitte folgende Pflichtfelder ausfüllen: {', '.join(missing_labels)}.", "error")
return render_template("schnell_buchung.html", **review_data)
if not form_data["agreements_accepted"]:
flash("Bitte Nutzungsbedingungen, Lizenzvereinbarung und Datenschutzerklärung bestätigen.", "error")
return render_template("schnell_buchung.html", **review_data)
if not _validate_email(form_data["contact_email"]):
flash("Bitte eine gültige E-Mail-Adresse angeben.", "error")
return render_template("schnell_buchung.html", **review_data)
assigned_admin = _select_booking_admin()
assigned_admin_username = assigned_admin.get("username") if assigned_admin else ""
assigned_admin_display_name = assigned_admin.get("display_name") if assigned_admin else ""
assigned_admin_email = (assigned_admin.get("email") if assigned_admin else "") or BOOKING_ADMIN_EMAIL_FALLBACK
message = (
f"{flow_config['message_prefix']} {selected_package} {flow_config['message_suffix']}\n"
f"Ansprechpartner: {form_data['contact_person']}\n"
@@ -1920,6 +1991,16 @@ def booking_payment():
client = None
try:
if booking_flow == "payment":
buchen(
name=selected_package,
Straße=form_data["billing_street"],
plz=form_data["billing_zip"],
ort=form_data["billing_city"],
ansprech=form_data["contact_person"],
email=form_data["contact_email"],
)
client, col = _get_collection("chat_messages")
col.insert_one(
{
@@ -1933,6 +2014,55 @@ def booking_payment():
"booking_data": form_data,
}
)
if booking_flow == "payment":
booking_number = f"BOOK-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}"
invoice_amount = BOOKING_PRICE_MAP.get(package_raw, 0.0)
invoice_payload = {
"username": session.get("username"),
"invoice_number": booking_number,
"period": "Sofortbuchung",
"amount_eur": invoice_amount,
"status": "Zu prüfen",
"due_date": date.today().isoformat(),
"pdf_path": "",
"created_at": _utc_now_iso(),
"source": "booking_payment",
"booking_flow": booking_flow,
"booking_package": package_raw,
"booking_package_label": selected_package,
"booking_data": form_data,
"paperwork_status": "Offen",
"paperwork_required": True,
"assigned_admin_username": assigned_admin_username,
"assigned_admin_display_name": assigned_admin_display_name,
"assigned_admin_email": assigned_admin_email,
"consent_accepted": True,
}
invoice_client = None
try:
invoice_client, invoice_col = _get_collection("invoices")
invoice_col.insert_one(invoice_payload)
finally:
if invoice_client:
invoice_client.close()
if assigned_admin_email:
email_subject = f"Neue Zahlungsbuchung: {selected_package}"
email_body = (
f"Es wurde eine neue direkte Buchung eingereicht.\n\n"
f"Paket: {selected_package}\n"
f"Ansprechpartner: {form_data['contact_person']}\n"
f"Schule: {form_data['school_name']}\n"
f"E-Mail: {form_data['contact_email']}\n"
f"Telefon: {form_data['contact_phone'] or '-'}\n"
f"Rechnungsadresse: {form_data['billing_street']}, {form_data['billing_zip']} {form_data['billing_city']}\n"
f"Status: Bitte die Unterlagen in der Rechnungsverwaltung prüfen.\n"
f"Buchungsnummer: {booking_number}\n"
)
mail_ok, mail_message = _send_booking_email(assigned_admin_email, email_subject, email_body)
if not mail_ok:
flash(f"Rechnung wurde angelegt, E-Mail an den Admin konnte nicht gesendet werden: {mail_message}", "info")
except PyMongoError:
flash("Buchungsanfrage konnte nicht gesendet werden.", "error")
return redirect(url_for("booking_payment", package=package_raw, flow=booking_flow))
+7 -1
View File
@@ -5,7 +5,7 @@
{% block content %}
<section class="panel">
<h1>Rechnungsverwaltung</h1>
<p>Rechnungen für Nutzer erstellen, aktualisieren und löschen.</p>
<p>Rechnungen für Nutzer erstellen, aktualisieren und löschen. Direktbuchungen werden hier automatisch mit zugewiesenem Admin und Buchungsunterlagen angezeigt.</p>
</section>
<section class="layout">
@@ -33,6 +33,12 @@
<h3>{{ item.invoice_number }}</h3>
<p><strong>Nutzer:</strong> {{ item.username }} | <strong>Zeitraum:</strong> {{ item.period }}</p>
<p><strong>Betrag:</strong> {{ '%.2f'|format(item.amount_eur) }} EUR | <strong>Status:</strong> {{ item.status }} | <strong>Fälligkeit:</strong> {{ item.due_date }}</p>
{% if item.source == 'booking_payment' %}
<p><strong>Buchung:</strong> {{ item.booking_package_label or item.booking_package }} | <strong>Flow:</strong> {{ item.booking_flow }}</p>
<p><strong>Zugewiesener Admin:</strong> {{ item.assigned_admin_display_name or item.assigned_admin_username or 'Nicht gesetzt' }}{% if item.assigned_admin_email %} | <strong>E-Mail:</strong> {{ item.assigned_admin_email }}{% endif %}</p>
<p><strong>Unterlagen:</strong> {{ item.paperwork_status or 'Offen' }}{% if item.consent_accepted %} | Einwilligung bestätigt{% endif %}</p>
<p><strong>Kontaktdaten:</strong> {{ item.booking_data.contact_person }} | {{ item.booking_data.contact_email }} | {{ item.booking_data.billing_city }}</p>
{% endif %}
{% if item.pdf_path %}
<p><strong>PDF:</strong> <a href="{{ url_for('static', filename=item.pdf_path) }}" target="_blank" rel="noopener">Ansehen</a></p>
{% endif %}
+1 -1
View File
@@ -521,7 +521,7 @@
<div class="custom-modal-content">
<h3>Buchung bestätigen</h3>
<p>Möchten Sie dieses Paket jetzt direkt buchen oder zuerst einen Gesprächstermin vereinbaren?</p>
<p style="font-size: 0.95rem;">In beiden Fällen werden Ihre Angaben noch einmal geprüft, bevor die Buchung per POST an /booking/payment gesendet wird.</p>
<p style="font-size: 0.95rem;">Bitte wählen sie eine der Beiden Optionen aus um mit dem Verfahren weiter fortzufahren.</p>
<div class="custom-modal-actions">
<button class="btn secondary" type="button" onclick="closeBookingModal()">Abbrechen</button>
<button class="btn" type="button" onclick="goToBookingReview('consultation')">Gesprächstermin vereinbaren</button>
+33
View File
@@ -112,6 +112,33 @@
resize: vertical;
}
.booking-review__consent {
padding: 0.95rem 1rem;
border-radius: 16px;
background: #eef6f6;
border: 1px solid #d2e2e2;
}
.booking-review__consent label {
display: flex;
gap: 0.7rem;
align-items: flex-start;
font-weight: 600;
color: #21414a;
line-height: 1.5;
}
.booking-review__consent input {
margin-top: 0.2rem;
width: auto;
flex: 0 0 auto;
}
.booking-review__consent a {
color: #0a4c74;
text-decoration: underline;
}
.booking-review__actions {
display: flex;
flex-wrap: wrap;
@@ -202,6 +229,12 @@
<label for="notes">Zusatzinformationen</label>
<textarea id="notes" name="notes" maxlength="600" placeholder="Optional: gewünschter Starttermin, besondere Anforderungen, Rückrufzeiten">{{ form_data.notes }}</textarea>
</div>
<div class="booking-review__field booking-review__field--full booking-review__consent">
<label for="agreements_accepted">
<input id="agreements_accepted" name="agreements_accepted" type="checkbox" value="yes" required {% if form_data.agreements_accepted %}checked{% endif %}>
<span>Ich habe die <a href="{{ url_for('nutzungsbedingungen') }}" target="_blank" rel="noopener">Lizenzvereinbarung</a>, die <a href="{{ url_for('datenschutz') }}" target="_blank" rel="noopener">Datenschutzerklärung</a> und die DSGVO-Hinweise gelesen und akzeptiere sie.</span>
</label>
</div>
</div>
<div class="booking-review__actions">