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:
+133
-52
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user