Booking lifecycle notifications and status updates
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.utils import timezone, translation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.accounts.services.otp import PROVIDERS as OTP_PROVIDERS
|
||||
from apps.bookings.models import Booking, BookingStatus
|
||||
from apps.notifications.models import (
|
||||
Notification,
|
||||
NotificationChannel,
|
||||
NotificationEvent,
|
||||
NotificationStatus,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class NotificationSendResult:
|
||||
status: str
|
||||
payload: dict
|
||||
error_message: str = ""
|
||||
|
||||
|
||||
def _get_provider():
|
||||
provider_key = getattr(settings, "NOTIFICATION_PROVIDER", settings.OTP_PROVIDER)
|
||||
provider_cls = OTP_PROVIDERS.get(provider_key)
|
||||
if not provider_cls:
|
||||
raise ValueError(_("Unknown notification provider: %(provider)s") % {"provider": provider_key})
|
||||
return provider_cls(), provider_key
|
||||
|
||||
|
||||
def _format_start_time(booking: Booking) -> str:
|
||||
start_local = timezone.localtime(booking.start_time)
|
||||
return start_local.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
def _build_message(booking: Booking, event: str) -> str:
|
||||
start_text = _format_start_time(booking)
|
||||
service_name = booking.service.name
|
||||
salon_name = booking.salon.name
|
||||
|
||||
if event == NotificationEvent.BOOKING_CREATED:
|
||||
return _(
|
||||
"Your booking request is received for %(service)s at %(salon)s on %(start)s."
|
||||
) % {
|
||||
"service": service_name,
|
||||
"salon": salon_name,
|
||||
"start": start_text,
|
||||
}
|
||||
if event == NotificationEvent.BOOKING_CONFIRMED:
|
||||
return _(
|
||||
"Your booking is confirmed for %(service)s at %(salon)s on %(start)s."
|
||||
) % {
|
||||
"service": service_name,
|
||||
"salon": salon_name,
|
||||
"start": start_text,
|
||||
}
|
||||
if event == NotificationEvent.BOOKING_CANCELLED:
|
||||
return _(
|
||||
"Your booking was cancelled for %(service)s at %(salon)s on %(start)s."
|
||||
) % {
|
||||
"service": service_name,
|
||||
"salon": salon_name,
|
||||
"start": start_text,
|
||||
}
|
||||
|
||||
return _("Booking update for %(service)s at %(salon)s on %(start)s.") % {
|
||||
"service": service_name,
|
||||
"salon": salon_name,
|
||||
"start": start_text,
|
||||
}
|
||||
|
||||
|
||||
def _send_message(phone_number: str, channel: str, message: str) -> NotificationSendResult:
|
||||
provider, _ = _get_provider()
|
||||
try:
|
||||
if channel == NotificationChannel.SMS:
|
||||
provider.send_sms(phone_number, message)
|
||||
elif channel == NotificationChannel.WHATSAPP:
|
||||
provider.send_whatsapp(phone_number, message)
|
||||
else:
|
||||
raise ValueError(_("Unsupported notification channel"))
|
||||
except Exception as exc: # pragma: no cover - provider failures are environment specific
|
||||
return NotificationSendResult(status=NotificationStatus.FAILED, payload={}, error_message=str(exc))
|
||||
|
||||
return NotificationSendResult(status=NotificationStatus.SENT, payload={}, error_message="")
|
||||
|
||||
|
||||
def _notification_channel() -> str:
|
||||
return getattr(settings, "NOTIFICATION_DEFAULT_CHANNEL", NotificationChannel.SMS)
|
||||
|
||||
|
||||
def send_booking_notification(booking: Booking, recipient, event: str) -> Notification:
|
||||
channel = _notification_channel()
|
||||
phone_number = getattr(recipient, "phone_number", None) or ""
|
||||
|
||||
# Render the message in the recipient's preferred language.
|
||||
with translation.override(getattr(recipient, "preferred_language", None)):
|
||||
message = _build_message(booking, event)
|
||||
|
||||
with transaction.atomic():
|
||||
notification, created = Notification.objects.get_or_create(
|
||||
booking=booking,
|
||||
recipient=recipient,
|
||||
event=event,
|
||||
channel=channel,
|
||||
defaults={
|
||||
"phone_number": phone_number,
|
||||
"message": message,
|
||||
},
|
||||
)
|
||||
|
||||
if not created and notification.status == NotificationStatus.SENT:
|
||||
return notification
|
||||
|
||||
if not phone_number:
|
||||
# Record the skip for auditability when we cannot deliver.
|
||||
notification.status = NotificationStatus.SKIPPED
|
||||
notification.error_message = "Recipient has no phone number"
|
||||
notification.save(update_fields=["status", "error_message"])
|
||||
return notification
|
||||
|
||||
notification.phone_number = phone_number
|
||||
notification.message = message
|
||||
send_result = _send_message(phone_number, channel, message)
|
||||
notification.status = send_result.status
|
||||
notification.provider = getattr(settings, "NOTIFICATION_PROVIDER", settings.OTP_PROVIDER)
|
||||
notification.provider_payload = send_result.payload
|
||||
notification.error_message = send_result.error_message
|
||||
notification.sent_at = timezone.now() if send_result.status == NotificationStatus.SENT else None
|
||||
notification.save(
|
||||
update_fields=[
|
||||
"phone_number",
|
||||
"message",
|
||||
"status",
|
||||
"provider",
|
||||
"provider_payload",
|
||||
"error_message",
|
||||
"sent_at",
|
||||
]
|
||||
)
|
||||
|
||||
return notification
|
||||
|
||||
|
||||
def notify_booking_lifecycle(booking: Booking, event: str) -> list[Notification]:
|
||||
recipients = [booking.customer]
|
||||
if booking.staff and booking.staff.user:
|
||||
recipients.append(booking.staff.user)
|
||||
|
||||
notifications = []
|
||||
for recipient in recipients:
|
||||
notifications.append(send_booking_notification(booking, recipient, event))
|
||||
|
||||
return notifications
|
||||
|
||||
|
||||
def notify_on_status_change(booking: Booking, previous_status: str) -> list[Notification]:
|
||||
if booking.status == previous_status:
|
||||
return []
|
||||
|
||||
# Only notify for lifecycle transitions we explicitly support today.
|
||||
if booking.status == BookingStatus.CONFIRMED:
|
||||
return notify_booking_lifecycle(booking, NotificationEvent.BOOKING_CONFIRMED)
|
||||
if booking.status == BookingStatus.CANCELLED:
|
||||
return notify_booking_lifecycle(booking, NotificationEvent.BOOKING_CANCELLED)
|
||||
|
||||
return []
|
||||
Reference in New Issue
Block a user