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>
This commit is contained in:
2026-08-03 17:16:19 +02:00
parent 59c35ce39f
commit 528a13ca7c
8 changed files with 678 additions and 56 deletions
+133 -52
View File
@@ -96,6 +96,119 @@ def _available_slots(client_id, resource, tz_name, duration_minutes, date_from,
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()."""
@@ -171,34 +284,17 @@ def create_booking():
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
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:
return jsonify({"error": "not found"}), 404
tz_name = _tz_name(client)
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:
return jsonify({"error": "that slot is no longer available"}), 409
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, body["customer_name"], body["customer_contact"],
service["name"], start_time, end_time, source="public", status=status)
except bdb.BookingConflict:
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
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"],
"token": token}), 201
@@ -210,12 +306,12 @@ def cancel_booking():
if resolved is None:
return jsonify({"error": "invalid or expired token"}), 400
client_id, booking_id = resolved
existing = bdb.get_booking(client_id, booking_id)
if existing is None:
try:
updated = cancel_booking_row(client_id, booking_id)
except NotFound:
return jsonify({"error": "not found"}), 404
if existing["status"] == "cancelled":
except AlreadyCancelled:
return jsonify({"error": "already cancelled"}), 409
updated = bdb.update_booking(client_id, booking_id, status="cancelled")
return jsonify({"cancelled": updated["booking_id"]})
@@ -230,30 +326,15 @@ def reschedule_booking():
if new_start is None:
return jsonify({"error": "start_time is required"}), 400
existing = bdb.get_booking(client_id, booking_id)
if existing is None:
return jsonify({"error": "not found"}), 404
if existing["status"] == "cancelled":
return jsonify({"error": "already cancelled"}), 409
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:
return jsonify({"error": "not found"}), 404
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:
return jsonify({"error": "that slot is no longer available"}), 409
try:
updated = bdb.update_booking(client_id, booking_id, start_time=new_start,
end_time=new_end)
except bdb.BookingConflict:
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(),