Booking confirmation email + customer self-service cancel/reschedule (#18)
Sends a confirmation email (best-effort, fire-and-forget SMTP via mailer.py) on booking creation, with a manage-booking link embedding the ticket-2 signed token. Adds /manage/<token>, a stateless cancel/reschedule page that reuses the existing slot-picker against booking_api's create/cancel/reschedule API, distinguishing an invalid/expired link from an already-cancelled one. Sender address uses the client's own domain when configured, falling back to a mivanchenko.de address otherwise. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,3 +3,13 @@ DB_PASSWORD=change-me-strong
|
|||||||
CRM_API_TOKEN=change-me-long-random
|
CRM_API_TOKEN=change-me-long-random
|
||||||
BOOKING_TOKEN_SECRET=change-me-long-random-too
|
BOOKING_TOKEN_SECRET=change-me-long-random-too
|
||||||
SHEET_ID=1raMSWRZw_JfHlWqOb3LbhaQ6LWx0VGblxIV4Z2pSzp8
|
SHEET_ID=1raMSWRZw_JfHlWqOb3LbhaQ6LWx0VGblxIV4Z2pSzp8
|
||||||
|
# Booking confirmation email (#18). Left blank, sending is skipped (logged,
|
||||||
|
# not fatal) -- mail relay setup is a separate infra/triage item.
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USERNAME=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
# Used as the From address when a client has no domain configured.
|
||||||
|
MAIL_FALLBACK_FROM=noreply@mivanchenko.de
|
||||||
|
# Base URL the manage-booking link in the confirmation email is built from.
|
||||||
|
PUBLIC_BASE_URL=https://onboard.mivanchenko.de
|
||||||
|
|||||||
@@ -21,10 +21,12 @@ import db
|
|||||||
from sheets import Sheets
|
from sheets import Sheets
|
||||||
from booking_api import bp as booking_bp
|
from booking_api import bp as booking_bp
|
||||||
from public_booking import bp as public_booking_bp
|
from public_booking import bp as public_booking_bp
|
||||||
|
from manage_booking import bp as manage_booking_bp
|
||||||
|
|
||||||
app = Flask(__name__, static_folder="static", static_url_path="")
|
app = Flask(__name__, static_folder="static", static_url_path="")
|
||||||
app.register_blueprint(booking_bp)
|
app.register_blueprint(booking_bp)
|
||||||
app.register_blueprint(public_booking_bp)
|
app.register_blueprint(public_booking_bp)
|
||||||
|
app.register_blueprint(manage_booking_bp)
|
||||||
|
|
||||||
CRM_TOKEN = os.environ.get("CRM_API_TOKEN", "")
|
CRM_TOKEN = os.environ.get("CRM_API_TOKEN", "")
|
||||||
# Separate read-only token for the public iCal feed (calendar apps can't send
|
# Separate read-only token for the public iCal feed (calendar apps can't send
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from flask import Blueprint, jsonify, request
|
|||||||
|
|
||||||
import availability
|
import availability
|
||||||
import booking_db as bdb
|
import booking_db as bdb
|
||||||
|
import booking_mail
|
||||||
|
|
||||||
bp = Blueprint("booking_api", __name__, url_prefix="/api/booking")
|
bp = Blueprint("booking_api", __name__, url_prefix="/api/booking")
|
||||||
|
|
||||||
@@ -32,9 +33,10 @@ def _mint_manage_token(client_id, booking_id):
|
|||||||
return jwt.encode(payload, TOKEN_SECRET, algorithm="HS256")
|
return jwt.encode(payload, TOKEN_SECRET, algorithm="HS256")
|
||||||
|
|
||||||
|
|
||||||
def _verify_manage_token(token):
|
def verify_manage_token(token):
|
||||||
"""Returns (client_id, booking_id), or None if the token is
|
"""Returns (client_id, booking_id), or None if the token is
|
||||||
missing/expired/malformed."""
|
missing/expired/malformed. Public: manage_booking.py (#18) also verifies
|
||||||
|
tokens to decide what to render, without itself owning the token format."""
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, TOKEN_SECRET, algorithms=["HS256"])
|
payload = jwt.decode(token, TOKEN_SECRET, algorithms=["HS256"])
|
||||||
except jwt.PyJWTError:
|
except jwt.PyJWTError:
|
||||||
@@ -94,23 +96,54 @@ def _available_slots(client_id, resource, tz_name, duration_minutes, date_from,
|
|||||||
busy=busy)
|
busy=busy)
|
||||||
|
|
||||||
|
|
||||||
|
class _BadDuration(ValueError):
|
||||||
|
"""Raised by _resolve_duration_minutes on an unknown service_id or a
|
||||||
|
non-integer duration_minutes -- turned into a clean 4xx by slots()."""
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_duration_minutes(client_id, service_id, duration_param):
|
||||||
|
"""service_id is the normal (public-page) path; duration_minutes is an
|
||||||
|
alternative for it: bookings store the service's *name*, not its id
|
||||||
|
(#15's schema), so the manage-booking page (#18) -- which only has the
|
||||||
|
existing booking's duration, not a service_id -- browses reschedule slots
|
||||||
|
by duration directly."""
|
||||||
|
if service_id:
|
||||||
|
service = bdb.get_service(client_id, service_id)
|
||||||
|
if service is None:
|
||||||
|
raise _BadDuration("not found")
|
||||||
|
return service["duration_minutes"]
|
||||||
|
try:
|
||||||
|
return int(duration_param)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise _BadDuration("duration_minutes must be an integer") from None
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/slots")
|
@bp.get("/slots")
|
||||||
def slots():
|
def slots():
|
||||||
client_id = request.args.get("client_id")
|
client_id = request.args.get("client_id")
|
||||||
resource_id = request.args.get("resource_id")
|
resource_id = request.args.get("resource_id")
|
||||||
service_id = request.args.get("service_id")
|
service_id = request.args.get("service_id")
|
||||||
|
duration_param = request.args.get("duration_minutes")
|
||||||
date_from = _parse_date(request.args.get("date_from"))
|
date_from = _parse_date(request.args.get("date_from"))
|
||||||
date_to = _parse_date(request.args.get("date_to"))
|
date_to = _parse_date(request.args.get("date_to"))
|
||||||
if not (client_id and resource_id and service_id and date_from and date_to):
|
exclude_booking_id = request.args.get("exclude_booking_id")
|
||||||
return jsonify({"error": "client_id, resource_id, service_id, date_from, "
|
if not (client_id and resource_id and (service_id or duration_param)
|
||||||
"date_to are required"}), 400
|
and date_from and date_to):
|
||||||
|
return jsonify({"error": "client_id, resource_id, date_from, date_to and "
|
||||||
|
"either service_id or duration_minutes are "
|
||||||
|
"required"}), 400
|
||||||
resource = bdb.get_resource(client_id, resource_id)
|
resource = bdb.get_resource(client_id, resource_id)
|
||||||
service = bdb.get_service(client_id, service_id)
|
|
||||||
client = bdb.get_client(client_id)
|
client = bdb.get_client(client_id)
|
||||||
if resource is None or service is None or client is None:
|
if resource is None or client is None:
|
||||||
return jsonify({"error": "not found"}), 404
|
return jsonify({"error": "not found"}), 404
|
||||||
|
try:
|
||||||
|
duration_minutes = _resolve_duration_minutes(client_id, service_id, duration_param)
|
||||||
|
except _BadDuration as e:
|
||||||
|
status = 404 if str(e) == "not found" else 400
|
||||||
|
return jsonify({"error": str(e)}), status
|
||||||
slot_list = _available_slots(client_id, resource, _tz_name(client),
|
slot_list = _available_slots(client_id, resource, _tz_name(client),
|
||||||
service["duration_minutes"], date_from, date_to)
|
duration_minutes, date_from, date_to,
|
||||||
|
exclude_booking_id=exclude_booking_id)
|
||||||
return jsonify({"slots": [s.isoformat() for s in slot_list]})
|
return jsonify({"slots": [s.isoformat() for s in slot_list]})
|
||||||
|
|
||||||
|
|
||||||
@@ -161,6 +194,11 @@ def create_booking():
|
|||||||
return jsonify({"error": "that slot was just taken"}), 409
|
return jsonify({"error": "that slot was just taken"}), 409
|
||||||
|
|
||||||
token = _mint_manage_token(client_id, booking["booking_id"])
|
token = _mint_manage_token(client_id, booking["booking_id"])
|
||||||
|
# #18: fires for every caller of this endpoint, public page (#17) included.
|
||||||
|
# A future ticket-6 owner-manual-entry flow only gets the confirmation
|
||||||
|
# email for free if it also creates bookings through this endpoint --
|
||||||
|
# calling bdb.create_booking() directly would bypass it.
|
||||||
|
booking_mail.send_booking_confirmation(client, booking, token)
|
||||||
return jsonify({"booking_id": booking["booking_id"], "status": booking["status"],
|
return jsonify({"booking_id": booking["booking_id"], "status": booking["status"],
|
||||||
"token": token}), 201
|
"token": token}), 201
|
||||||
|
|
||||||
@@ -168,7 +206,7 @@ def create_booking():
|
|||||||
@bp.post("/cancel")
|
@bp.post("/cancel")
|
||||||
def cancel_booking():
|
def cancel_booking():
|
||||||
body = request.get_json(force=True, silent=True) or {}
|
body = request.get_json(force=True, silent=True) or {}
|
||||||
resolved = _verify_manage_token(body.get("token"))
|
resolved = verify_manage_token(body.get("token"))
|
||||||
if resolved is None:
|
if resolved is None:
|
||||||
return jsonify({"error": "invalid or expired token"}), 400
|
return jsonify({"error": "invalid or expired token"}), 400
|
||||||
client_id, booking_id = resolved
|
client_id, booking_id = resolved
|
||||||
@@ -184,7 +222,7 @@ def cancel_booking():
|
|||||||
@bp.post("/reschedule")
|
@bp.post("/reschedule")
|
||||||
def reschedule_booking():
|
def reschedule_booking():
|
||||||
body = request.get_json(force=True, silent=True) or {}
|
body = request.get_json(force=True, silent=True) or {}
|
||||||
resolved = _verify_manage_token(body.get("token"))
|
resolved = verify_manage_token(body.get("token"))
|
||||||
if resolved is None:
|
if resolved is None:
|
||||||
return jsonify({"error": "invalid or expired token"}), 400
|
return jsonify({"error": "invalid or expired token"}), 400
|
||||||
client_id, booking_id = resolved
|
client_id, booking_id = resolved
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Booking confirmation email (#18): sender-address resolution, the
|
||||||
|
customer-facing manage-booking link, and the render/send call. Booking
|
||||||
|
creation must succeed even if this fails -- see mailer.py's fire-and-forget
|
||||||
|
send, which this relies on rather than talking to smtplib directly.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from flask import render_template
|
||||||
|
|
||||||
|
import mailer
|
||||||
|
|
||||||
|
PUBLIC_BASE_URL = os.environ.get("PUBLIC_BASE_URL", "https://onboard.mivanchenko.de").rstrip("/")
|
||||||
|
|
||||||
|
# customer_contact is a single free-text "E-Mail oder Telefon" field (#16/#17)
|
||||||
|
# -- not guaranteed to be an email address. Only attempt to send when it
|
||||||
|
# looks like one.
|
||||||
|
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||||
|
|
||||||
|
|
||||||
|
def looks_like_email(contact):
|
||||||
|
return bool(_EMAIL_RE.match((contact or "").strip()))
|
||||||
|
|
||||||
|
|
||||||
|
def manage_url(token):
|
||||||
|
return f"{PUBLIC_BASE_URL}/manage/{token}"
|
||||||
|
|
||||||
|
|
||||||
|
# clients.domain is free text from the onboarding form (e.g. "cafe-lichtblick.de",
|
||||||
|
# but nothing stops "https://cafe-lichtblick.de/" being entered) -- strip any
|
||||||
|
# scheme/path/whitespace so a malformed value can't end up in a From header.
|
||||||
|
_DOMAIN_RE = re.compile(r"^(?:[a-z][a-z0-9+.-]*://)?([^/\s]+)", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_domain(domain):
|
||||||
|
m = _DOMAIN_RE.match((domain or "").strip())
|
||||||
|
return m.group(1).lower() if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def _sender_for(client):
|
||||||
|
domain = _clean_domain((client or {}).get("domain"))
|
||||||
|
return f"noreply@{domain}" if domain else mailer.MAIL_FALLBACK_FROM
|
||||||
|
|
||||||
|
|
||||||
|
def send_booking_confirmation(client, booking, token):
|
||||||
|
"""No-op if customer_contact doesn't look like an email address -- it's a
|
||||||
|
free-text "E-Mail oder Telefon" field (#16/#17), so this is expected for
|
||||||
|
phone-only customers, not an error."""
|
||||||
|
if not looks_like_email(booking.get("customer_contact")):
|
||||||
|
print(f"[booking_mail] customer_contact for booking "
|
||||||
|
f"{booking.get('booking_id')} doesn't look like an email, "
|
||||||
|
f"skipping confirmation send", flush=True)
|
||||||
|
return
|
||||||
|
tz = ZoneInfo((client or {}).get("timezone") or "Europe/Berlin")
|
||||||
|
business_name = (client or {}).get("business_name") or "Ihr Termin"
|
||||||
|
html = render_template(
|
||||||
|
"emails/booking_confirmation.html",
|
||||||
|
business_name=business_name,
|
||||||
|
customer_name=booking["customer_name"],
|
||||||
|
service=booking["service"],
|
||||||
|
start_local=booking["start_time"].astimezone(tz),
|
||||||
|
status=booking["status"],
|
||||||
|
manage_url=manage_url(token),
|
||||||
|
)
|
||||||
|
mailer.send_email(
|
||||||
|
booking["customer_contact"],
|
||||||
|
f"Terminbestätigung – {business_name}",
|
||||||
|
html,
|
||||||
|
from_addr=_sender_for(client),
|
||||||
|
)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Best-effort SMTP email sending (#18).
|
||||||
|
|
||||||
|
Mirrors app.py's DB -> Sheets mirror pattern: a single background worker
|
||||||
|
thread drains a queue, and a send failure is logged, never raised back to the
|
||||||
|
caller -- booking creation must succeed even if the mail relay is down.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import smtplib
|
||||||
|
import threading
|
||||||
|
import traceback
|
||||||
|
from email.message import EmailMessage
|
||||||
|
|
||||||
|
SMTP_HOST = os.environ.get("SMTP_HOST", "")
|
||||||
|
SMTP_PORT = int(os.environ.get("SMTP_PORT", "587"))
|
||||||
|
SMTP_USERNAME = os.environ.get("SMTP_USERNAME", "")
|
||||||
|
SMTP_PASSWORD = os.environ.get("SMTP_PASSWORD", "")
|
||||||
|
# Used when a client has no domain configured -- see booking_mail.py.
|
||||||
|
MAIL_FALLBACK_FROM = os.environ.get("MAIL_FALLBACK_FROM", "noreply@mivanchenko.de")
|
||||||
|
|
||||||
|
_queue = queue.Queue()
|
||||||
|
|
||||||
|
|
||||||
|
def _send_now(msg):
|
||||||
|
if not SMTP_HOST:
|
||||||
|
print(f"[mailer] SMTP_HOST not configured, skipping send to {msg['To']}", flush=True)
|
||||||
|
return
|
||||||
|
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=10) as smtp:
|
||||||
|
smtp.starttls()
|
||||||
|
if SMTP_USERNAME:
|
||||||
|
smtp.login(SMTP_USERNAME, SMTP_PASSWORD)
|
||||||
|
smtp.send_message(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def _worker():
|
||||||
|
while True:
|
||||||
|
msg = _queue.get()
|
||||||
|
try:
|
||||||
|
_send_now(msg)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
print(f"[mailer] send to {msg['To']} failed:", flush=True)
|
||||||
|
traceback.print_exc()
|
||||||
|
finally:
|
||||||
|
_queue.task_done()
|
||||||
|
|
||||||
|
|
||||||
|
threading.Thread(target=_worker, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def send_email(to_addr, subject, html_body, from_addr=None):
|
||||||
|
"""Queue an HTML email for best-effort async delivery. Never raises and
|
||||||
|
never blocks the caller on the network -- the actual SMTP conversation
|
||||||
|
happens on the background worker thread."""
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg["From"] = from_addr or MAIL_FALLBACK_FROM
|
||||||
|
msg["To"] = to_addr
|
||||||
|
msg.set_content(html_body, subtype="html")
|
||||||
|
_queue.put(msg)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Customer self-service manage-booking page (#18), reached via the
|
||||||
|
token-linked link sent in the confirmation email (booking_mail.py). The
|
||||||
|
signed token (booking_api.verify_manage_token) is the only source of
|
||||||
|
identity here -- customers have no account, so this route needs no login.
|
||||||
|
|
||||||
|
The actual cancel/reschedule mutations still go through booking_api.py's
|
||||||
|
JSON API (#16); this route only resolves the token, decides which of the
|
||||||
|
three states (invalid/expired, already used, active) to render, and lets the
|
||||||
|
page's own JS drive the API from there.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint, render_template
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import booking_db as bdb
|
||||||
|
from booking_api import verify_manage_token
|
||||||
|
|
||||||
|
bp = Blueprint("manage_booking", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/manage/<token>")
|
||||||
|
def manage_page(token):
|
||||||
|
resolved = verify_manage_token(token)
|
||||||
|
if resolved is None:
|
||||||
|
# Distinct from "already used" per #18's acceptance criteria -- an
|
||||||
|
# expired/malformed token never resolved to a booking at all.
|
||||||
|
return render_template("manage.html", state="invalid"), 400
|
||||||
|
|
||||||
|
client_id, booking_id = resolved
|
||||||
|
booking = bdb.get_booking(client_id, booking_id)
|
||||||
|
client = bdb.get_client(client_id)
|
||||||
|
if booking is None or client is None:
|
||||||
|
return render_template("manage.html", state="invalid"), 404
|
||||||
|
|
||||||
|
if booking["status"] == "cancelled":
|
||||||
|
return render_template("manage.html", state="used")
|
||||||
|
|
||||||
|
tz = ZoneInfo(client.get("timezone") or "Europe/Berlin")
|
||||||
|
duration_minutes = int(
|
||||||
|
(booking["end_time"] - booking["start_time"]).total_seconds() // 60)
|
||||||
|
return render_template(
|
||||||
|
"manage.html",
|
||||||
|
state="active",
|
||||||
|
token=token,
|
||||||
|
client_id=client_id,
|
||||||
|
booking_id=booking_id,
|
||||||
|
resource_id=booking["resource_id"],
|
||||||
|
service=booking["service"],
|
||||||
|
duration_minutes=duration_minutes,
|
||||||
|
start_local=booking["start_time"].astimezone(tz),
|
||||||
|
status=booking["status"],
|
||||||
|
)
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Terminbestätigung</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0; padding:20px; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; color:#16302f;">
|
||||||
|
<h1 style="font-size:1.2rem; margin:0 0 14px;">{{ business_name }}</h1>
|
||||||
|
<p>Hallo {{ customer_name }},</p>
|
||||||
|
{% if status == 'pending' %}
|
||||||
|
<p>Ihre Buchung wartet noch auf Bestätigung:</p>
|
||||||
|
{% else %}
|
||||||
|
<p>Ihre Buchung ist bestätigt:</p>
|
||||||
|
{% endif %}
|
||||||
|
<ul>
|
||||||
|
<li>Leistung: {{ service }}</li>
|
||||||
|
<li>Termin: {{ start_local.strftime('%d.%m.%Y, %H:%M') }} Uhr</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Über den folgenden Link können Sie Ihren Termin jederzeit einsehen, verschieben oder
|
||||||
|
stornieren:
|
||||||
|
</p>
|
||||||
|
<p><a href="{{ manage_url }}">{{ manage_url }}</a></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Termin verwalten</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--brand: #0f8a7e;
|
||||||
|
--bg: #fff; --ink: #16302f; --muted: #5d716f; --line: #e3eae9;
|
||||||
|
--danger: #b3261e; --danger-bg: #fdecea;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||||
|
background: var(--bg); color: var(--ink); padding: 20px; max-width: 480px; }
|
||||||
|
h1 { font-size: 1.2rem; margin: 0 0 18px; }
|
||||||
|
h2 { font-size: .95rem; margin: 0 0 10px; color: var(--muted);
|
||||||
|
text-transform: uppercase; letter-spacing: .5px; }
|
||||||
|
section { margin-bottom: 22px; }
|
||||||
|
.options { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.opt { border: 1px solid var(--line); border-radius: 9px; padding: 10px 14px;
|
||||||
|
background: #fff; cursor: pointer; font: inherit; font-size: .88rem; }
|
||||||
|
.opt.selected { background: var(--brand); border-color: var(--brand); color: #fff; }
|
||||||
|
input[type=date] { width: 100%; max-width: 320px; padding: 9px 12px;
|
||||||
|
border: 1px solid var(--line); border-radius: 9px; font: inherit; font-size: .9rem; }
|
||||||
|
.field { margin-bottom: 14px; }
|
||||||
|
.btn { background: var(--brand); color: #fff; border: 0; border-radius: 9px;
|
||||||
|
padding: 11px 20px; font: inherit; font-size: .92rem; font-weight: 600; cursor: pointer; }
|
||||||
|
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
.btn-danger { background: var(--danger); }
|
||||||
|
.error { background: var(--danger-bg); color: var(--danger); border-radius: 9px;
|
||||||
|
padding: 10px 14px; font-size: .88rem; margin-bottom: 14px; display: none; }
|
||||||
|
.card { border: 1px solid var(--line); border-radius: 12px; padding: 20px;
|
||||||
|
background: #f6faf9; }
|
||||||
|
.muted { color: var(--muted); font-size: .85rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% if state == "invalid" %}
|
||||||
|
<h1>Termin verwalten</h1>
|
||||||
|
<div class="card">
|
||||||
|
<p>Dieser Link ist ungültig oder abgelaufen.</p>
|
||||||
|
<p class="muted">Bitte wenden Sie sich an das Unternehmen, wenn Sie Ihren Termin ändern
|
||||||
|
möchten.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% elif state == "used" %}
|
||||||
|
<h1>Termin verwalten</h1>
|
||||||
|
<div class="card">
|
||||||
|
<p>Diese Buchung wurde bereits storniert.</p>
|
||||||
|
<p class="muted">Dieser Link kann nicht mehr verwendet werden.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div id="manage-root">
|
||||||
|
<h1>Termin verwalten</h1>
|
||||||
|
|
||||||
|
<div class="error" id="error-banner"></div>
|
||||||
|
|
||||||
|
<section id="current-section">
|
||||||
|
<h2>Ihr Termin</h2>
|
||||||
|
<p id="current-details">
|
||||||
|
{{ service }} am {{ start_local.strftime('%d.%m.%Y') }} um
|
||||||
|
{{ start_local.strftime('%H:%M') }} Uhr
|
||||||
|
({{ 'wartet noch auf Bestätigung' if status == 'pending' else 'bestätigt' }})
|
||||||
|
</p>
|
||||||
|
<button type="button" class="btn btn-danger" id="cancel-btn">Termin stornieren</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="reschedule-section">
|
||||||
|
<h2>Termin verschieben</h2>
|
||||||
|
<div class="field">
|
||||||
|
<input type="date" id="date-input" />
|
||||||
|
</div>
|
||||||
|
<div class="options" id="slot-options"></div>
|
||||||
|
<button type="button" class="btn" id="reschedule-btn" disabled style="margin-top:12px;">
|
||||||
|
Auf neuen Termin verschieben
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="card" id="confirmation" style="display:none;">
|
||||||
|
<h2>Erledigt</h2>
|
||||||
|
<p id="confirmation-message"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var TOKEN = {{ token|tojson }};
|
||||||
|
var CLIENT_ID = {{ client_id|tojson }};
|
||||||
|
var RESOURCE_ID = {{ resource_id|tojson }};
|
||||||
|
var BOOKING_ID = {{ booking_id|tojson }};
|
||||||
|
var DURATION_MINUTES = {{ duration_minutes|tojson }};
|
||||||
|
|
||||||
|
var selectedSlot = null;
|
||||||
|
|
||||||
|
var errorBanner = document.getElementById('error-banner');
|
||||||
|
var dateInput = document.getElementById('date-input');
|
||||||
|
var slotOptions = document.getElementById('slot-options');
|
||||||
|
var rescheduleBtn = document.getElementById('reschedule-btn');
|
||||||
|
var cancelBtn = document.getElementById('cancel-btn');
|
||||||
|
var confirmation = document.getElementById('confirmation');
|
||||||
|
var confirmationMessage = document.getElementById('confirmation-message');
|
||||||
|
|
||||||
|
var today = new Date();
|
||||||
|
dateInput.value = today.toISOString().slice(0, 10);
|
||||||
|
dateInput.min = today.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
errorBanner.textContent = msg;
|
||||||
|
errorBanner.style.display = msg ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDone(msg) {
|
||||||
|
document.getElementById('manage-root').querySelectorAll('section').forEach(function (s) {
|
||||||
|
s.style.display = 'none';
|
||||||
|
});
|
||||||
|
errorBanner.style.display = 'none';
|
||||||
|
confirmationMessage.textContent = msg;
|
||||||
|
confirmation.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSlots() {
|
||||||
|
slotOptions.innerHTML = '';
|
||||||
|
selectedSlot = null;
|
||||||
|
rescheduleBtn.disabled = true;
|
||||||
|
if (!dateInput.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var day = dateInput.value;
|
||||||
|
var params = new URLSearchParams({
|
||||||
|
client_id: CLIENT_ID, resource_id: RESOURCE_ID,
|
||||||
|
duration_minutes: DURATION_MINUTES, exclude_booking_id: BOOKING_ID,
|
||||||
|
date_from: day, date_to: day,
|
||||||
|
});
|
||||||
|
fetch('/api/booking/slots?' + params.toString())
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
if (!data.slots || data.slots.length === 0) {
|
||||||
|
slotOptions.innerHTML = '<p class="muted">Keine freien Termine an diesem Tag.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
data.slots.forEach(function (iso) {
|
||||||
|
var btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'opt';
|
||||||
|
var d = new Date(iso);
|
||||||
|
btn.textContent = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
slotOptions.querySelectorAll('.opt').forEach(function (b) {
|
||||||
|
b.classList.remove('selected');
|
||||||
|
});
|
||||||
|
btn.classList.add('selected');
|
||||||
|
selectedSlot = iso;
|
||||||
|
rescheduleBtn.disabled = false;
|
||||||
|
});
|
||||||
|
slotOptions.appendChild(btn);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
showError('Termine konnten nicht geladen werden. Bitte versuchen Sie es erneut.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
dateInput.addEventListener('change', loadSlots);
|
||||||
|
|
||||||
|
cancelBtn.addEventListener('click', function () {
|
||||||
|
showError('');
|
||||||
|
cancelBtn.disabled = true;
|
||||||
|
fetch('/api/booking/cancel', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token: TOKEN }),
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json().then(function (body) { return { ok: r.ok, body: body }; }); })
|
||||||
|
.then(function (res) {
|
||||||
|
if (!res.ok) {
|
||||||
|
showError(res.body.error || 'Die Stornierung ist fehlgeschlagen.');
|
||||||
|
cancelBtn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showDone('Ihr Termin wurde storniert.');
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
showError('Die Stornierung ist fehlgeschlagen. Bitte versuchen Sie es erneut.');
|
||||||
|
cancelBtn.disabled = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
rescheduleBtn.addEventListener('click', function () {
|
||||||
|
showError('');
|
||||||
|
rescheduleBtn.disabled = true;
|
||||||
|
fetch('/api/booking/reschedule', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token: TOKEN, start_time: selectedSlot }),
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json().then(function (body) { return { ok: r.ok, status: r.status, body: body }; }); })
|
||||||
|
.then(function (res) {
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 409) {
|
||||||
|
showError('Dieser Termin wurde gerade eben vergeben. Bitte wählen Sie einen anderen.');
|
||||||
|
loadSlots();
|
||||||
|
} else {
|
||||||
|
showError(res.body.error || 'Die Verschiebung ist fehlgeschlagen.');
|
||||||
|
}
|
||||||
|
rescheduleBtn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showDone('Ihr Termin wurde verschoben auf '
|
||||||
|
+ new Date(selectedSlot).toLocaleString('de-DE', {
|
||||||
|
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
}) + '.');
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
showError('Die Verschiebung ist fehlgeschlagen. Bitte versuchen Sie es erneut.');
|
||||||
|
rescheduleBtn.disabled = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
loadSlots();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -72,6 +72,36 @@ def test_create_booking_auto_confirm_true_yields_confirmed(client):
|
|||||||
assert "token" in body
|
assert "token" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_booking_via_public_endpoint_sends_confirmation_email(client, monkeypatch):
|
||||||
|
"""#18's acceptance criterion: completing a booking via ticket 3's public
|
||||||
|
page (this same POST /api/booking endpoint) triggers the confirmation
|
||||||
|
email, with the manage-booking token embedded in it."""
|
||||||
|
sent = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"booking_mail.mailer.send_email",
|
||||||
|
lambda to, subject, html, from_addr=None: sent.append(
|
||||||
|
{"to": to, "subject": subject, "html": html, "from_addr": from_addr}))
|
||||||
|
resource, service = _setup_resource_and_service(
|
||||||
|
auto_confirm=True, min_notice_minutes=0, max_advance_days=365)
|
||||||
|
day = _next_monday(date.today())
|
||||||
|
slots = client.get("/api/booking/slots", query_string={
|
||||||
|
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
|
||||||
|
"service_id": service["service_id"],
|
||||||
|
"date_from": day.isoformat(), "date_to": day.isoformat()}).get_json()["slots"]
|
||||||
|
|
||||||
|
resp = client.post("/api/booking", json={
|
||||||
|
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
|
||||||
|
"service_id": service["service_id"], "start_time": slots[0],
|
||||||
|
"customer_name": "Kim", "customer_contact": "kim@example.com"})
|
||||||
|
assert resp.status_code == 201
|
||||||
|
token = resp.get_json()["token"]
|
||||||
|
|
||||||
|
assert len(sent) == 1
|
||||||
|
assert sent[0]["to"] == "kim@example.com"
|
||||||
|
assert f"/manage/{token}" in sent[0]["html"]
|
||||||
|
assert "Haircut" in sent[0]["html"]
|
||||||
|
|
||||||
|
|
||||||
def test_create_booking_auto_confirm_false_yields_pending(client):
|
def test_create_booking_auto_confirm_false_yields_pending(client):
|
||||||
resource, service = _setup_resource_and_service(
|
resource, service = _setup_resource_and_service(
|
||||||
auto_confirm=False, min_notice_minutes=0, max_advance_days=365)
|
auto_confirm=False, min_notice_minutes=0, max_advance_days=365)
|
||||||
@@ -251,12 +281,12 @@ def test_reschedule_to_same_slot_is_a_noop_success(client):
|
|||||||
|
|
||||||
def test_manage_token_is_scoped_to_its_own_client():
|
def test_manage_token_is_scoped_to_its_own_client():
|
||||||
resource, service = _setup_resource_and_service(client_id=CLIENT_A)
|
resource, service = _setup_resource_and_service(client_id=CLIENT_A)
|
||||||
from booking_api import _mint_manage_token, _verify_manage_token
|
from booking_api import _mint_manage_token, verify_manage_token
|
||||||
booking = bdb.create_booking(
|
booking = bdb.create_booking(
|
||||||
CLIENT_A, resource["resource_id"], "Ivy", "i@example.com", "Haircut",
|
CLIENT_A, resource["resource_id"], "Ivy", "i@example.com", "Haircut",
|
||||||
datetime.now(timezone.utc) + timedelta(days=1),
|
datetime.now(timezone.utc) + timedelta(days=1),
|
||||||
datetime.now(timezone.utc) + timedelta(days=1, hours=1))
|
datetime.now(timezone.utc) + timedelta(days=1, hours=1))
|
||||||
token = _mint_manage_token(CLIENT_A, booking["booking_id"])
|
token = _mint_manage_token(CLIENT_A, booking["booking_id"])
|
||||||
assert _verify_manage_token(token) == (CLIENT_A, booking["booking_id"])
|
assert verify_manage_token(token) == (CLIENT_A, booking["booking_id"])
|
||||||
tampered = token[:-1] + ("A" if token[-1] != "A" else "B")
|
tampered = token[:-1] + ("A" if token[-1] != "A" else "B")
|
||||||
assert _verify_manage_token(tampered) is None
|
assert verify_manage_token(tampered) is None
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Unit tests for booking_mail.py (#18): sender-address resolution and the
|
||||||
|
skip-if-not-an-email guard, per the acceptance criterion that customer_contact
|
||||||
|
(free-text "E-Mail oder Telefon") may not actually be an email address.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import booking_mail
|
||||||
|
from app import app as flask_app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("contact,expected", [
|
||||||
|
("alice@example.com", True),
|
||||||
|
("Alice@Example.COM", True),
|
||||||
|
("+49 151 2345678", False),
|
||||||
|
("0151-2345678", False),
|
||||||
|
("not-an-email", False),
|
||||||
|
("", False),
|
||||||
|
(None, False),
|
||||||
|
])
|
||||||
|
def test_looks_like_email(contact, expected):
|
||||||
|
assert booking_mail.looks_like_email(contact) is expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_manage_url_embeds_token(monkeypatch):
|
||||||
|
monkeypatch.setattr(booking_mail, "PUBLIC_BASE_URL", "https://onboard.example.com")
|
||||||
|
assert booking_mail.manage_url("abc.def.ghi") == \
|
||||||
|
"https://onboard.example.com/manage/abc.def.ghi"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sender_uses_client_domain_when_configured():
|
||||||
|
client = {"domain": "happynails.de"}
|
||||||
|
assert booking_mail._sender_for(client) == "noreply@happynails.de"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sender_falls_back_when_client_has_no_domain(monkeypatch):
|
||||||
|
monkeypatch.setattr(booking_mail.mailer, "MAIL_FALLBACK_FROM", "noreply@mivanchenko.de")
|
||||||
|
assert booking_mail._sender_for({"domain": None}) == "noreply@mivanchenko.de"
|
||||||
|
assert booking_mail._sender_for({}) == "noreply@mivanchenko.de"
|
||||||
|
assert booking_mail._sender_for(None) == "noreply@mivanchenko.de"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw,expected", [
|
||||||
|
("happynails.de", "happynails.de"),
|
||||||
|
("https://happynails.de", "happynails.de"),
|
||||||
|
("https://happynails.de/", "happynails.de"),
|
||||||
|
("http://happynails.de/shop", "happynails.de"),
|
||||||
|
(" happynails.de ", "happynails.de"),
|
||||||
|
("HappyNails.de", "happynails.de"),
|
||||||
|
])
|
||||||
|
def test_sender_sanitizes_domain_entered_with_scheme_or_path(raw, expected):
|
||||||
|
assert booking_mail._sender_for({"domain": raw}) == f"noreply@{expected}"
|
||||||
|
|
||||||
|
|
||||||
|
def _booking(contact="alice@example.com"):
|
||||||
|
start = datetime.now(timezone.utc) + timedelta(days=1)
|
||||||
|
return {
|
||||||
|
"customer_name": "Alice",
|
||||||
|
"customer_contact": contact,
|
||||||
|
"service": "Haircut",
|
||||||
|
"start_time": start,
|
||||||
|
"status": "confirmed",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_booking_confirmation_sends_when_contact_is_email(monkeypatch):
|
||||||
|
captured = []
|
||||||
|
monkeypatch.setattr(booking_mail.mailer, "send_email",
|
||||||
|
lambda to, subject, html, from_addr=None:
|
||||||
|
captured.append((to, subject, html, from_addr)))
|
||||||
|
client = {"domain": "happynails.de", "business_name": "Happy Nails",
|
||||||
|
"timezone": "Europe/Berlin"}
|
||||||
|
with flask_app.test_request_context():
|
||||||
|
booking_mail.send_booking_confirmation(client, _booking(), "sometoken")
|
||||||
|
assert len(captured) == 1
|
||||||
|
to, subject, html, from_addr = captured[0]
|
||||||
|
assert to == "alice@example.com"
|
||||||
|
assert from_addr == "noreply@happynails.de"
|
||||||
|
assert "Happy Nails" in subject
|
||||||
|
assert "/manage/sometoken" in html
|
||||||
|
assert "Haircut" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_booking_confirmation_skips_when_contact_is_phone(monkeypatch):
|
||||||
|
captured = []
|
||||||
|
monkeypatch.setattr(booking_mail.mailer, "send_email",
|
||||||
|
lambda *a, **kw: captured.append((a, kw)))
|
||||||
|
client = {"business_name": "Happy Nails", "timezone": "Europe/Berlin"}
|
||||||
|
with flask_app.test_request_context():
|
||||||
|
booking_mail.send_booking_confirmation(client, _booking(contact="0151-2345678"),
|
||||||
|
"sometoken")
|
||||||
|
assert captured == []
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""Unit tests for mailer.py's SMTP call shape (#18). Calls _send_now directly
|
||||||
|
rather than going through the background-thread queue, so assertions are
|
||||||
|
synchronous -- the queue itself is just plumbing, already covered indirectly
|
||||||
|
by app.py's identical Sheets-mirror pattern.
|
||||||
|
"""
|
||||||
|
from email.message import EmailMessage
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import mailer
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSMTP:
|
||||||
|
sent = []
|
||||||
|
login_calls = []
|
||||||
|
|
||||||
|
def __init__(self, host, port, timeout=None):
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def starttls(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def login(self, username, password):
|
||||||
|
_FakeSMTP.login_calls.append((username, password))
|
||||||
|
|
||||||
|
def send_message(self, msg):
|
||||||
|
_FakeSMTP.sent.append(msg)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_fake_smtp():
|
||||||
|
_FakeSMTP.sent = []
|
||||||
|
_FakeSMTP.login_calls = []
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
def _msg(to="alice@example.com"):
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg["Subject"] = "Terminbestätigung"
|
||||||
|
msg["From"] = "noreply@example.com"
|
||||||
|
msg["To"] = to
|
||||||
|
msg.set_content("<p>hi</p>", subtype="html")
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_now_skips_when_smtp_host_not_configured(monkeypatch):
|
||||||
|
monkeypatch.setattr(mailer, "SMTP_HOST", "")
|
||||||
|
monkeypatch.setattr(mailer, "smtplib", type("m", (), {"SMTP": _FakeSMTP}))
|
||||||
|
mailer._send_now(_msg())
|
||||||
|
assert _FakeSMTP.sent == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_now_sends_via_smtp_when_configured(monkeypatch):
|
||||||
|
monkeypatch.setattr(mailer, "SMTP_HOST", "smtp.example.com")
|
||||||
|
monkeypatch.setattr(mailer, "SMTP_PORT", 587)
|
||||||
|
monkeypatch.setattr(mailer, "SMTP_USERNAME", "")
|
||||||
|
monkeypatch.setattr(mailer, "smtplib", type("m", (), {"SMTP": _FakeSMTP}))
|
||||||
|
msg = _msg()
|
||||||
|
mailer._send_now(msg)
|
||||||
|
assert _FakeSMTP.sent == [msg]
|
||||||
|
assert _FakeSMTP.login_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_now_logs_in_when_username_configured(monkeypatch):
|
||||||
|
monkeypatch.setattr(mailer, "SMTP_HOST", "smtp.example.com")
|
||||||
|
monkeypatch.setattr(mailer, "SMTP_USERNAME", "mailer")
|
||||||
|
monkeypatch.setattr(mailer, "SMTP_PASSWORD", "secret")
|
||||||
|
monkeypatch.setattr(mailer, "smtplib", type("m", (), {"SMTP": _FakeSMTP}))
|
||||||
|
mailer._send_now(_msg())
|
||||||
|
assert _FakeSMTP.login_calls == [("mailer", "secret")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_email_builds_html_message_and_enqueues(monkeypatch):
|
||||||
|
captured = []
|
||||||
|
monkeypatch.setattr(mailer._queue, "put", lambda m: captured.append(m))
|
||||||
|
mailer.send_email("bob@example.com", "Subject line", "<p>body</p>",
|
||||||
|
from_addr="noreply@custom.example")
|
||||||
|
assert len(captured) == 1
|
||||||
|
msg = captured[0]
|
||||||
|
assert msg["To"] == "bob@example.com"
|
||||||
|
assert msg["Subject"] == "Subject line"
|
||||||
|
assert msg["From"] == "noreply@custom.example"
|
||||||
|
assert msg.get_content_type() == "text/html"
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_email_defaults_from_addr_to_fallback(monkeypatch):
|
||||||
|
captured = []
|
||||||
|
monkeypatch.setattr(mailer._queue, "put", lambda m: captured.append(m))
|
||||||
|
monkeypatch.setattr(mailer, "MAIL_FALLBACK_FROM", "noreply@mivanchenko.de")
|
||||||
|
mailer.send_email("bob@example.com", "Subject", "<p>body</p>")
|
||||||
|
assert captured[0]["From"] == "noreply@mivanchenko.de"
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
"""Flask test client / real-DB integration tests for the manage-booking page
|
||||||
|
(#18), per #14's testing decision: assert on HTTP response + resulting DB
|
||||||
|
state.
|
||||||
|
"""
|
||||||
|
from datetime import date, time, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import booking_db as bdb
|
||||||
|
from app import app as flask_app
|
||||||
|
from booking_api import _mint_manage_token
|
||||||
|
|
||||||
|
CLIENT_A = "C-TEST-MANAGE-A"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
flask_app.config["TESTING"] = True
|
||||||
|
return flask_app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
def _next_monday(after):
|
||||||
|
d = after + timedelta(days=1)
|
||||||
|
while d.weekday() != 0:
|
||||||
|
d += timedelta(days=1)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_resource_and_service(client_id=CLIENT_A):
|
||||||
|
with bdb.db.connect() as conn, conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO clients (client_id, timezone, auto_confirm) VALUES (%s, %s, %s) "
|
||||||
|
"ON CONFLICT (client_id) DO UPDATE SET timezone = EXCLUDED.timezone, "
|
||||||
|
"auto_confirm = EXCLUDED.auto_confirm",
|
||||||
|
(client_id, "Europe/Berlin", True))
|
||||||
|
conn.commit()
|
||||||
|
resource = bdb.create_resource(client_id, "Chair 1", min_notice_minutes=0,
|
||||||
|
max_advance_days=365)
|
||||||
|
bdb.set_resource_hours(client_id, resource["resource_id"], 0, time(9, 0), time(17, 0))
|
||||||
|
service = bdb.create_service(client_id, "Haircut", 60, price=25)
|
||||||
|
return resource, service
|
||||||
|
|
||||||
|
|
||||||
|
def _create_booking(client, resource, service, slot):
|
||||||
|
return client.post("/api/booking", json={
|
||||||
|
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
|
||||||
|
"service_id": service["service_id"], "start_time": slot,
|
||||||
|
"customer_name": "Dana", "customer_contact": "dana@example.com"}).get_json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_manage_page_shows_invalid_message_for_garbage_token(client):
|
||||||
|
resp = client.get("/manage/not-a-real-token")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
body = resp.get_data(as_text=True)
|
||||||
|
assert "ungültig" in body.lower() or "abgelaufen" in body.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_manage_page_shows_invalid_message_for_unknown_booking(client):
|
||||||
|
token = _mint_manage_token(CLIENT_A, "BK-does-not-exist")
|
||||||
|
resp = client.get(f"/manage/{token}")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_manage_page_shows_used_message_for_cancelled_booking(client):
|
||||||
|
resource, service = _setup_resource_and_service()
|
||||||
|
day = _next_monday(date.today())
|
||||||
|
slot = client.get("/api/booking/slots", query_string={
|
||||||
|
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
|
||||||
|
"service_id": service["service_id"],
|
||||||
|
"date_from": day.isoformat(), "date_to": day.isoformat()}).get_json()["slots"][0]
|
||||||
|
created = _create_booking(client, resource, service, slot)
|
||||||
|
client.post("/api/booking/cancel", json={"token": created["token"]})
|
||||||
|
|
||||||
|
resp = client.get(f"/manage/{created['token']}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_data(as_text=True)
|
||||||
|
assert "storniert" in body.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_manage_page_renders_active_booking_with_manage_ui(client):
|
||||||
|
resource, service = _setup_resource_and_service()
|
||||||
|
day = _next_monday(date.today())
|
||||||
|
slot = client.get("/api/booking/slots", query_string={
|
||||||
|
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
|
||||||
|
"service_id": service["service_id"],
|
||||||
|
"date_from": day.isoformat(), "date_to": day.isoformat()}).get_json()["slots"][0]
|
||||||
|
created = _create_booking(client, resource, service, slot)
|
||||||
|
|
||||||
|
resp = client.get(f"/manage/{created['token']}")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.get_data(as_text=True)
|
||||||
|
assert "Haircut" in body
|
||||||
|
assert resource["resource_id"] in body
|
||||||
|
assert created["token"] in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_manage_page_cancel_flow_reaches_cancel_api(client):
|
||||||
|
resource, service = _setup_resource_and_service()
|
||||||
|
day = _next_monday(date.today())
|
||||||
|
slot = client.get("/api/booking/slots", query_string={
|
||||||
|
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
|
||||||
|
"service_id": service["service_id"],
|
||||||
|
"date_from": day.isoformat(), "date_to": day.isoformat()}).get_json()["slots"][0]
|
||||||
|
created = _create_booking(client, resource, service, slot)
|
||||||
|
|
||||||
|
resp = client.post("/api/booking/cancel", json={"token": created["token"]})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert bdb.get_booking(CLIENT_A, created["booking_id"])["status"] == "cancelled"
|
||||||
|
|
||||||
|
followup = client.get(f"/manage/{created['token']}")
|
||||||
|
assert "storniert" in followup.get_data(as_text=True).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_manage_page_reschedule_slots_browsable_by_duration(client):
|
||||||
|
"""The manage page's reschedule picker uses duration_minutes (not
|
||||||
|
service_id, which bookings don't store) against /api/booking/slots."""
|
||||||
|
resource, service = _setup_resource_and_service()
|
||||||
|
day = _next_monday(date.today())
|
||||||
|
slot = client.get("/api/booking/slots", query_string={
|
||||||
|
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
|
||||||
|
"service_id": service["service_id"],
|
||||||
|
"date_from": day.isoformat(), "date_to": day.isoformat()}).get_json()["slots"][0]
|
||||||
|
created = _create_booking(client, resource, service, slot)
|
||||||
|
|
||||||
|
resp = client.get("/api/booking/slots", query_string={
|
||||||
|
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
|
||||||
|
"duration_minutes": "60", "exclude_booking_id": created["booking_id"],
|
||||||
|
"date_from": day.isoformat(), "date_to": day.isoformat()})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
slots = resp.get_json()["slots"]
|
||||||
|
# The booking's own slot is excluded from the busy check, so it's
|
||||||
|
# available again for a same-slot reschedule (a no-op success).
|
||||||
|
assert slot in slots
|
||||||
|
|
||||||
|
reschedule_resp = client.post("/api/booking/reschedule", json={
|
||||||
|
"token": created["token"], "start_time": slots[1]})
|
||||||
|
assert reschedule_resp.status_code == 200
|
||||||
@@ -28,6 +28,12 @@ services:
|
|||||||
ICS_TOKEN: ${ICS_TOKEN}
|
ICS_TOKEN: ${ICS_TOKEN}
|
||||||
SHEET_ID: ${SHEET_ID}
|
SHEET_ID: ${SHEET_ID}
|
||||||
GOOGLE_SA_JSON: /run/secrets/gcp-sa.json
|
GOOGLE_SA_JSON: /run/secrets/gcp-sa.json
|
||||||
|
SMTP_HOST: ${SMTP_HOST}
|
||||||
|
SMTP_PORT: ${SMTP_PORT}
|
||||||
|
SMTP_USERNAME: ${SMTP_USERNAME}
|
||||||
|
SMTP_PASSWORD: ${SMTP_PASSWORD}
|
||||||
|
MAIL_FALLBACK_FROM: ${MAIL_FALLBACK_FROM}
|
||||||
|
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL}
|
||||||
volumes:
|
volumes:
|
||||||
- ./secrets/gcp-sa.json:/run/secrets/gcp-sa.json:ro
|
- ./secrets/gcp-sa.json:/run/secrets/gcp-sa.json:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# Caddy rate limiting for `/book/*` and `/api/booking/*` (#17)
|
# Caddy rate limiting for `/book/*`, `/manage/*` and `/api/booking/*` (#17, #18)
|
||||||
|
|
||||||
Like every other homelab Caddy change (see `deploy/clients/new-client.sh`), there's no
|
Like every other homelab Caddy change (see `deploy/clients/new-client.sh`), there's no
|
||||||
Caddyfile tracked in this repo -- apply this by hand at `/etc/caddy/Caddyfile` on the host
|
Caddyfile tracked in this repo -- apply this by hand at `/etc/caddy/Caddyfile` on the host
|
||||||
and reload with `docker exec caddy caddy reload --config /etc/caddy/Caddyfile`.
|
and reload with `docker exec caddy caddy reload --config /etc/caddy/Caddyfile`.
|
||||||
|
|
||||||
Add, on the site block that proxies to `smb-crm` (e.g. `onboard.mivanchenko.de`, or wherever
|
Add, on the site block that proxies to `smb-crm` (e.g. `onboard.mivanchenko.de`, or wherever
|
||||||
`/book/` and `/api/booking/` are routed):
|
`/book/`, `/manage/` and `/api/booking/` are routed):
|
||||||
|
|
||||||
```
|
```
|
||||||
handle /book/* {
|
handle /book/* {
|
||||||
@@ -18,6 +18,16 @@ handle /book/* {
|
|||||||
}
|
}
|
||||||
reverse_proxy smb-crm:8080
|
reverse_proxy smb-crm:8080
|
||||||
}
|
}
|
||||||
|
handle /manage/* {
|
||||||
|
rate_limit {
|
||||||
|
zone book_public {
|
||||||
|
key {remote_host}
|
||||||
|
events 20
|
||||||
|
window 1m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reverse_proxy smb-crm:8080
|
||||||
|
}
|
||||||
handle /api/booking/* {
|
handle /api/booking/* {
|
||||||
rate_limit {
|
rate_limit {
|
||||||
zone book_api {
|
zone book_api {
|
||||||
|
|||||||
Reference in New Issue
Block a user