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 []