Booking lifecycle notifications and status updates

This commit is contained in:
2026-02-28 15:06:35 +03:00
parent db36551211
commit ca2a6b58b6
15 changed files with 613 additions and 5 deletions
+21
View File
@@ -0,0 +1,21 @@
from django.contrib import admin
from apps.notifications.models import Notification
@admin.register(Notification)
class NotificationAdmin(admin.ModelAdmin):
list_display = (
"id",
"event",
"channel",
"status",
"booking",
"recipient",
"phone_number",
"provider",
"sent_at",
"created_at",
)
list_filter = ("event", "channel", "status", "provider")
search_fields = ("phone_number", "message")
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class NotificationsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.notifications"
@@ -0,0 +1,85 @@
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("bookings", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Notification",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("phone_number", models.CharField(blank=True, max_length=20)),
(
"channel",
models.CharField(
choices=[("sms", "SMS"), ("whatsapp", "WhatsApp")],
max_length=20,
),
),
(
"event",
models.CharField(
choices=[
("booking_created", "Booking Created"),
("booking_confirmed", "Booking Confirmed"),
("booking_cancelled", "Booking Cancelled"),
],
max_length=50,
),
),
(
"status",
models.CharField(
choices=[
("pending", "Pending"),
("sent", "Sent"),
("failed", "Failed"),
("skipped", "Skipped"),
],
default="pending",
max_length=20,
),
),
("provider", models.CharField(blank=True, max_length=50)),
("message", models.TextField(blank=True)),
("provider_payload", models.JSONField(blank=True, default=dict)),
("error_message", models.TextField(blank=True)),
("sent_at", models.DateTimeField(blank=True, null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"booking",
models.ForeignKey(
blank=True,
null=True,
on_delete=models.deletion.CASCADE,
related_name="notifications",
to="bookings.booking",
),
),
(
"recipient",
models.ForeignKey(
blank=True,
null=True,
on_delete=models.deletion.SET_NULL,
related_name="notifications",
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.AddConstraint(
model_name="notification",
constraint=models.UniqueConstraint(
fields=("booking", "recipient", "event", "channel"),
name="uniq_notification_booking_event",
),
),
]
+64
View File
@@ -0,0 +1,64 @@
from django.conf import settings
from django.db import models
from apps.bookings.models import Booking
class NotificationChannel(models.TextChoices):
SMS = "sms", "SMS"
WHATSAPP = "whatsapp", "WhatsApp"
class NotificationEvent(models.TextChoices):
BOOKING_CREATED = "booking_created", "Booking Created"
BOOKING_CONFIRMED = "booking_confirmed", "Booking Confirmed"
BOOKING_CANCELLED = "booking_cancelled", "Booking Cancelled"
class NotificationStatus(models.TextChoices):
PENDING = "pending", "Pending"
SENT = "sent", "Sent"
FAILED = "failed", "Failed"
SKIPPED = "skipped", "Skipped"
class Notification(models.Model):
booking = models.ForeignKey(
Booking,
on_delete=models.CASCADE,
related_name="notifications",
null=True,
blank=True,
)
recipient = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
related_name="notifications",
null=True,
blank=True,
)
phone_number = models.CharField(max_length=20, blank=True)
channel = models.CharField(max_length=20, choices=NotificationChannel.choices)
event = models.CharField(max_length=50, choices=NotificationEvent.choices)
status = models.CharField(
max_length=20,
choices=NotificationStatus.choices,
default=NotificationStatus.PENDING,
)
provider = models.CharField(max_length=50, blank=True)
message = models.TextField(blank=True)
provider_payload = models.JSONField(default=dict, blank=True)
error_message = models.TextField(blank=True)
sent_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["booking", "recipient", "event", "channel"],
name="uniq_notification_booking_event",
)
]
def __str__(self) -> str:
return f"{self.event} to {self.phone_number or self.recipient_id}"
+171
View File
@@ -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 []
@@ -0,0 +1,121 @@
from datetime import timedelta
import pytest
from django.urls import reverse
from django.utils import timezone
from rest_framework.test import APIClient
from apps.accounts.models import User, UserRole
from apps.bookings.models import Booking, BookingStatus
from apps.notifications.models import Notification, NotificationEvent, NotificationStatus
from apps.salons.models import Salon, Service, StaffProfile
@pytest.fixture
def booking_payload():
owner = User.objects.create_user(
email="owner@example.com",
password="pass",
role=UserRole.MANAGER,
phone_number="0500000001",
)
customer = User.objects.create_user(
email="customer@example.com",
password="pass",
phone_number="0500000002",
)
staff_user = User.objects.create_user(
email="staff@example.com",
password="pass",
role=UserRole.STAFF,
phone_number="0500000003",
)
salon = Salon.objects.create(
owner=owner,
name="Main Salon",
description="",
address="123 King Rd",
city="Riyadh",
phone_number="0512345678",
)
service = Service.objects.create(
salon=salon,
name="Haircut",
description="",
duration_minutes=60,
price_amount=120,
currency="SAR",
)
staff = StaffProfile.objects.create(user=staff_user, salon=salon)
start_time = timezone.now() + timedelta(days=1)
end_time = start_time + timedelta(minutes=60)
return {
"customer": customer,
"staff_user": staff_user,
"service": service,
"staff": staff,
"payload": {
"service": service.id,
"staff": staff.id,
"start_time": start_time.isoformat(),
"end_time": end_time.isoformat(),
"notes": "",
},
}
@pytest.mark.django_db
def test_booking_create_sends_notifications(booking_payload):
client = APIClient()
client.force_authenticate(user=booking_payload["customer"])
response = client.post(
reverse("booking-list"),
booking_payload["payload"],
content_type="application/json",
)
assert response.status_code == 201
notifications = Notification.objects.filter(event=NotificationEvent.BOOKING_CREATED)
assert notifications.count() == 2
assert all(notification.status == NotificationStatus.SENT for notification in notifications)
@pytest.mark.django_db
def test_booking_status_change_sends_notifications_once(booking_payload):
client = APIClient()
client.force_authenticate(user=booking_payload["customer"])
response = client.post(
reverse("booking-list"),
booking_payload["payload"],
content_type="application/json",
)
assert response.status_code == 201
booking_id = Booking.objects.get(customer=booking_payload["customer"]).id
update_payload = {"status": BookingStatus.CONFIRMED}
client.force_authenticate(user=booking_payload["staff_user"])
response_update = client.patch(
reverse("booking-detail", args=[booking_id]),
update_payload,
content_type="application/json",
)
assert response_update.status_code == 200
notifications = Notification.objects.filter(event=NotificationEvent.BOOKING_CONFIRMED)
assert notifications.count() == 2
response_repeat = client.patch(
reverse("booking-detail", args=[booking_id]),
update_payload,
content_type="application/json",
)
assert response_repeat.status_code == 200
notifications_repeat = Notification.objects.filter(event=NotificationEvent.BOOKING_CONFIRMED)
assert notifications_repeat.count() == 2