Files
mivanchenko 644c99ee30 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>
2026-07-23 15:40:45 +02:00

52 lines
1.9 KiB
Python

"""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"],
)