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 tarfile
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
|
import smtplib
|
||||||
from datetime import timedelta, datetime, date
|
from datetime import timedelta, datetime, date
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
from email.message import EmailMessage
|
||||||
from urllib.parse import parse_qs, quote, urlparse
|
from urllib.parse import parse_qs, quote, urlparse
|
||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
@@ -22,6 +24,7 @@ from pymongo import MongoClient
|
|||||||
from pymongo.errors import PyMongoError
|
from pymongo.errors import PyMongoError
|
||||||
from bson.objectid import ObjectId
|
from bson.objectid import ObjectId
|
||||||
import user as user_store
|
import user as user_store
|
||||||
|
from buchungen import buchen
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.secret_key = "ASDfhbsdfseiufhgildsrfrjg874368546987s6e8468f4!?FAUS/&s"
|
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
|
@app.after_request
|
||||||
def set_security_headers(response):
|
def set_security_headers(response):
|
||||||
@@ -103,6 +124,12 @@ def _env_int(name: str, default: int) -> int:
|
|||||||
return default
|
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_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_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)
|
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"),
|
"username": doc.get("Username") or doc.get("username"),
|
||||||
"display_name": doc.get("name") or doc.get("display_name") 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)),
|
"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"),
|
"created_at": doc.get("created_at"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,6 +258,39 @@ def _list_users_for_admin() -> list:
|
|||||||
return users
|
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:
|
def _sanitize_text(text: str, max_length: int = 255) -> str:
|
||||||
"""Sanitize user text input: strip, limit length, and escape HTML."""
|
"""Sanitize user text input: strip, limit length, and escape HTML."""
|
||||||
text = (text or "").strip()
|
text = (text or "").strip()
|
||||||
@@ -1871,6 +1932,7 @@ def booking_payment():
|
|||||||
"billing_zip": _sanitize_text(request.values.get("billing_zip") or "", 20),
|
"billing_zip": _sanitize_text(request.values.get("billing_zip") or "", 20),
|
||||||
"billing_city": _sanitize_text(request.values.get("billing_city") or "", 120),
|
"billing_city": _sanitize_text(request.values.get("billing_city") or "", 120),
|
||||||
"notes": _sanitize_text(request.values.get("notes") or "", 600),
|
"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"]:
|
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")
|
flash(f"Bitte folgende Pflichtfelder ausfüllen: {', '.join(missing_labels)}.", "error")
|
||||||
return render_template("schnell_buchung.html", **review_data)
|
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"]):
|
if not _validate_email(form_data["contact_email"]):
|
||||||
flash("Bitte eine gültige E-Mail-Adresse angeben.", "error")
|
flash("Bitte eine gültige E-Mail-Adresse angeben.", "error")
|
||||||
return render_template("schnell_buchung.html", **review_data)
|
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 = (
|
message = (
|
||||||
f"{flow_config['message_prefix']} {selected_package} {flow_config['message_suffix']}\n"
|
f"{flow_config['message_prefix']} {selected_package} {flow_config['message_suffix']}\n"
|
||||||
f"Ansprechpartner: {form_data['contact_person']}\n"
|
f"Ansprechpartner: {form_data['contact_person']}\n"
|
||||||
@@ -1920,6 +1991,16 @@ def booking_payment():
|
|||||||
|
|
||||||
client = None
|
client = None
|
||||||
try:
|
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")
|
client, col = _get_collection("chat_messages")
|
||||||
col.insert_one(
|
col.insert_one(
|
||||||
{
|
{
|
||||||
@@ -1933,6 +2014,55 @@ def booking_payment():
|
|||||||
"booking_data": form_data,
|
"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:
|
except PyMongoError:
|
||||||
flash("Buchungsanfrage konnte nicht gesendet werden.", "error")
|
flash("Buchungsanfrage konnte nicht gesendet werden.", "error")
|
||||||
return redirect(url_for("booking_payment", package=package_raw, flow=booking_flow))
|
return redirect(url_for("booking_payment", package=package_raw, flow=booking_flow))
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h1>Rechnungsverwaltung</h1>
|
<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>
|
||||||
|
|
||||||
<section class="layout">
|
<section class="layout">
|
||||||
@@ -33,6 +33,12 @@
|
|||||||
<h3>{{ item.invoice_number }}</h3>
|
<h3>{{ item.invoice_number }}</h3>
|
||||||
<p><strong>Nutzer:</strong> {{ item.username }} | <strong>Zeitraum:</strong> {{ item.period }}</p>
|
<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>
|
<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 %}
|
{% if item.pdf_path %}
|
||||||
<p><strong>PDF:</strong> <a href="{{ url_for('static', filename=item.pdf_path) }}" target="_blank" rel="noopener">Ansehen</a></p>
|
<p><strong>PDF:</strong> <a href="{{ url_for('static', filename=item.pdf_path) }}" target="_blank" rel="noopener">Ansehen</a></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -521,7 +521,7 @@
|
|||||||
<div class="custom-modal-content">
|
<div class="custom-modal-content">
|
||||||
<h3>Buchung bestätigen</h3>
|
<h3>Buchung bestätigen</h3>
|
||||||
<p>Möchten Sie dieses Paket jetzt direkt buchen oder zuerst einen Gesprächstermin vereinbaren?</p>
|
<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">
|
<div class="custom-modal-actions">
|
||||||
<button class="btn secondary" type="button" onclick="closeBookingModal()">Abbrechen</button>
|
<button class="btn secondary" type="button" onclick="closeBookingModal()">Abbrechen</button>
|
||||||
<button class="btn" type="button" onclick="goToBookingReview('consultation')">Gesprächstermin vereinbaren</button>
|
<button class="btn" type="button" onclick="goToBookingReview('consultation')">Gesprächstermin vereinbaren</button>
|
||||||
|
|||||||
@@ -112,6 +112,33 @@
|
|||||||
resize: vertical;
|
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 {
|
.booking-review__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -202,6 +229,12 @@
|
|||||||
<label for="notes">Zusatzinformationen</label>
|
<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>
|
<textarea id="notes" name="notes" maxlength="600" placeholder="Optional: gewünschter Starttermin, besondere Anforderungen, Rückrufzeiten">{{ form_data.notes }}</textarea>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="booking-review__actions">
|
<div class="booking-review__actions">
|
||||||
|
|||||||
Reference in New Issue
Block a user