Files
smb-online/backoffice/app/booking_api.py
T
mivanchenko 528a13ca7c Owner calendar view + manual booking + owner cancel/reschedule (#20)
Adds an owner-authenticated weekly agenda (grouped by day, today highlighted)
with manual walk-in/phone booking creation, cancel, and reschedule -- all
routed through booking_api.py's create/cancel/reschedule logic (refactored
into shared helpers) so the EXCLUDE overlap constraint and confirmation
email stay on the single existing code path. Manual creation can skip the
opening-hours/min-notice/max-advance/buffer checks via an explicit override,
but never the overlap constraint itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 17:16:19 +02:00

342 lines
14 KiB
Python

"""Booking API blueprint (#16): availability + create/cancel/reschedule.
Headless JSON API -- no browser UI yet (that's #17/#18). Routes here are the
only place that mints/verifies the signed cancel/reschedule token and the
only caller of the availability engine; all DB access still goes through
booking_db.py.
"""
import os
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
import jwt
from flask import Blueprint, jsonify, request
import availability
import booking_db as bdb
import booking_mail
bp = Blueprint("booking_api", __name__, url_prefix="/api/booking")
# Dedicated secret -- deliberately not shared with CRM_API_TOKEN, so rotating
# one never silently invalidates (or, worse, cross-signs) the other.
TOKEN_SECRET = os.environ.get("BOOKING_TOKEN_SECRET", "")
TOKEN_TTL_DAYS = 30
def _mint_manage_token(client_id, booking_id):
payload = {
"client_id": client_id,
"booking_id": booking_id,
"exp": datetime.now(timezone.utc) + timedelta(days=TOKEN_TTL_DAYS),
}
return jwt.encode(payload, TOKEN_SECRET, algorithm="HS256")
def verify_manage_token(token):
"""Returns (client_id, booking_id), or None if the token is
missing/expired/malformed. Public: manage_booking.py (#18) also verifies
tokens to decide what to render, without itself owning the token format."""
try:
payload = jwt.decode(token, TOKEN_SECRET, algorithms=["HS256"])
except jwt.PyJWTError:
return None
return payload.get("client_id"), payload.get("booking_id")
def _parse_dt(value):
if not value:
return None
try:
return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
except ValueError:
return None
def _parse_date(value):
try:
return datetime.fromisoformat(str(value)).date()
except (ValueError, TypeError):
return None
def _tz_name(client):
return (client or {}).get("timezone") or "Europe/Berlin"
def _local_date(dt, tz_name):
"""The calendar date dt falls on in tz_name -- used to pick the right
business day (and the right resource_hours row) regardless of what UTC
offset the caller's ISO string happened to use."""
return dt.astimezone(ZoneInfo(tz_name)).date()
def _available_slots(client_id, resource, tz_name, duration_minutes, date_from,
date_to, exclude_booking_id=None):
hours = bdb.get_resource_hours(client_id, resource["resource_id"])
# date_from/date_to are local calendar dates -- widen the busy-booking
# query to the UTC instants that actually cover them in tz_name, not a
# literal UTC midnight window (which would miss/misalign bookings near
# local midnight, e.g. in winter Berlin midnight is 23:00 UTC the day
# before).
tz = ZoneInfo(tz_name)
day_start = datetime.combine(date_from, datetime.min.time(), tzinfo=tz).astimezone(timezone.utc)
day_end = (datetime.combine(date_to, datetime.min.time(), tzinfo=tz)
+ timedelta(days=1)).astimezone(timezone.utc)
busy_rows = bdb.list_active_bookings_for_resource(
client_id, resource["resource_id"], day_start, day_end,
exclude_booking_id=exclude_booking_id)
busy = [(r["start_time"], r["end_time"]) for r in busy_rows]
return availability.generate_slots(
hours, duration_minutes, date_from, date_to, tz_name,
now=datetime.now(timezone.utc),
min_notice_minutes=resource["min_notice_minutes"],
max_advance_days=resource["max_advance_days"],
buffer_minutes=resource["buffer_minutes"],
busy=busy)
class BookingRequestError(Exception):
"""Base for the errors create_booking_row/cancel_booking_row/
reschedule_booking_row raise -- kept distinct per case so each caller
(this module's JSON routes, owner_booking.py's session-authenticated
routes) can translate the same failure into its own response shape."""
class NotFound(BookingRequestError):
pass
class SlotUnavailable(BookingRequestError):
"""The requested slot violates a business rule (opening hours, min
notice, max advance, buffer) -- never raised when skip_availability_check
is set."""
class SlotTaken(BookingRequestError):
"""The Postgres EXCLUDE constraint rejected the write -- always checked,
override or not."""
class AlreadyCancelled(BookingRequestError):
pass
def create_booking_row(client_id, resource_id, service_id, start_time,
customer_name, customer_contact, source,
skip_availability_check=False):
"""Shared booking-creation path for the public API (#16/#17) and the
owner's manual-entry flow (#20). skip_availability_check bypasses only
the business-rule slot check (opening hours/min-notice/max-advance/
buffer) for an owner-entered walk-in/phone booking -- it never touches
bdb.create_booking's EXCLUDE-constraint check, which stays enforced
either way. Returns (booking, manage_token); raises NotFound/
SlotUnavailable/SlotTaken instead of building a response itself, so each
caller renders the failure its own way."""
resource = bdb.get_resource(client_id, resource_id)
service = bdb.get_service(client_id, service_id)
client = bdb.get_client(client_id)
if resource is None or service is None or client is None:
raise NotFound()
tz_name = _tz_name(client)
if not skip_availability_check:
day = _local_date(start_time, tz_name)
valid_starts = _available_slots(client_id, resource, tz_name,
service["duration_minutes"], day, day)
if start_time not in valid_starts:
raise SlotUnavailable()
end_time = start_time + timedelta(minutes=service["duration_minutes"])
status = "confirmed" if client.get("auto_confirm", True) else "pending"
try:
booking = bdb.create_booking(
client_id, resource_id, customer_name, customer_contact,
service["name"], start_time, end_time, source=source, status=status)
except bdb.BookingConflict:
raise SlotTaken() from None
token = _mint_manage_token(client_id, booking["booking_id"])
# #18: fires for every caller of this helper, public page (#17) and
# owner manual-entry (#20) included -- calling bdb.create_booking()
# directly would bypass it.
booking_mail.send_booking_confirmation(client, booking, token)
return booking, token
def cancel_booking_row(client_id, booking_id):
"""Shared cancel path for the customer's token-authenticated route
below and the owner's session-authenticated one (#20). client_id already
scopes the lookup, so an owner session can never cancel another
tenant's booking_id."""
existing = bdb.get_booking(client_id, booking_id)
if existing is None:
raise NotFound()
if existing["status"] == "cancelled":
raise AlreadyCancelled()
return bdb.update_booking(client_id, booking_id, status="cancelled")
def reschedule_booking_row(client_id, booking_id, new_start):
"""Shared reschedule path for the customer's token-authenticated route
below and the owner's session-authenticated one (#20). Unlike manual
creation, this never skips the business-rule slot check -- #20 only
calls out an override for creating a booking, not for moving one."""
existing = bdb.get_booking(client_id, booking_id)
if existing is None:
raise NotFound()
if existing["status"] == "cancelled":
raise AlreadyCancelled()
duration = existing["end_time"] - existing["start_time"]
new_end = new_start + duration
resource = bdb.get_resource(client_id, existing["resource_id"])
client = bdb.get_client(client_id)
if resource is None or client is None:
raise NotFound()
tz_name = _tz_name(client)
day = _local_date(new_start, tz_name)
valid_starts = _available_slots(client_id, resource, tz_name,
duration.total_seconds() // 60, day, day,
exclude_booking_id=booking_id)
if new_start not in valid_starts:
raise SlotUnavailable()
try:
return bdb.update_booking(client_id, booking_id, start_time=new_start,
end_time=new_end)
except bdb.BookingConflict:
raise SlotTaken() from None
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")
def slots():
client_id = request.args.get("client_id")
resource_id = request.args.get("resource_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_to = _parse_date(request.args.get("date_to"))
exclude_booking_id = request.args.get("exclude_booking_id")
if not (client_id and resource_id and (service_id or duration_param)
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)
client = bdb.get_client(client_id)
if resource is None or client is None:
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),
duration_minutes, date_from, date_to,
exclude_booking_id=exclude_booking_id)
return jsonify({"slots": [s.isoformat() for s in slot_list]})
@bp.post("")
def create_booking():
body = request.get_json(force=True, silent=True) or {}
if (body.get("website") or "").strip():
# Honeypot field: real customers never see or fill it (hidden from
# sighted users and screen readers alike), so a filled value means a
# scripted bot filled every field it could find. Fake a normal-looking
# success instead of a 4xx so a scripted client has no signal it was
# caught -- no booking is created, but the id/token are shaped exactly
# like a real create_booking() response (same id format, same client_id
# in the token's claims) so nothing about this response is
# distinguishable from a genuine one by a client inspecting it.
fake_booking_id = bdb.new_id("BK")
return jsonify({"booking_id": fake_booking_id, "status": "confirmed",
"token": _mint_manage_token(body.get("client_id") or "",
fake_booking_id)}), 201
client_id = body.get("client_id")
resource_id = body.get("resource_id")
service_id = body.get("service_id")
start_time = _parse_dt(body.get("start_time"))
if not (client_id and resource_id and service_id and start_time
and body.get("customer_name") and body.get("customer_contact")):
return jsonify({"error": "client_id, resource_id, service_id, start_time, "
"customer_name, customer_contact are required"}), 400
try:
booking, token = create_booking_row(
client_id, resource_id, service_id, start_time,
body["customer_name"], body["customer_contact"], source="public")
except NotFound:
return jsonify({"error": "not found"}), 404
except SlotUnavailable:
return jsonify({"error": "that slot is no longer available"}), 409
except SlotTaken:
return jsonify({"error": "that slot was just taken"}), 409
return jsonify({"booking_id": booking["booking_id"], "status": booking["status"],
"token": token}), 201
@bp.post("/cancel")
def cancel_booking():
body = request.get_json(force=True, silent=True) or {}
resolved = verify_manage_token(body.get("token"))
if resolved is None:
return jsonify({"error": "invalid or expired token"}), 400
client_id, booking_id = resolved
try:
updated = cancel_booking_row(client_id, booking_id)
except NotFound:
return jsonify({"error": "not found"}), 404
except AlreadyCancelled:
return jsonify({"error": "already cancelled"}), 409
return jsonify({"cancelled": updated["booking_id"]})
@bp.post("/reschedule")
def reschedule_booking():
body = request.get_json(force=True, silent=True) or {}
resolved = verify_manage_token(body.get("token"))
if resolved is None:
return jsonify({"error": "invalid or expired token"}), 400
client_id, booking_id = resolved
new_start = _parse_dt(body.get("start_time"))
if new_start is None:
return jsonify({"error": "start_time is required"}), 400
try:
updated = reschedule_booking_row(client_id, booking_id, new_start)
except NotFound:
return jsonify({"error": "not found"}), 404
except AlreadyCancelled:
return jsonify({"error": "already cancelled"}), 409
except SlotUnavailable:
return jsonify({"error": "that slot is no longer available"}), 409
except SlotTaken:
return jsonify({"error": "that slot was just taken"}), 409
return jsonify({"booking_id": updated["booking_id"],
"start_time": updated["start_time"].isoformat(),
"end_time": updated["end_time"].isoformat()})