feat: Implement booking email notifications and consent handling in booking flow
This commit is contained in:
@@ -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
@@ -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))
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user