172 lines
5.9 KiB
Python
172 lines
5.9 KiB
Python
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 []
|