Booking lifecycle notifications and status updates
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
from apps.bookings.models import Booking
|
||||
from apps.bookings.models import Booking, BookingStatus
|
||||
from apps.bookings.services import validate_booking_request
|
||||
from apps.salons.models import Service, StaffProfile
|
||||
|
||||
@@ -27,7 +28,7 @@ class BookingSerializer(serializers.ModelSerializer):
|
||||
"notes",
|
||||
"created_at",
|
||||
]
|
||||
read_only_fields = ["id", "salon", "status", "price_amount", "currency", "created_at"]
|
||||
read_only_fields = ["id", "salon", "price_amount", "currency", "created_at"]
|
||||
|
||||
def get_staff_name(self, obj):
|
||||
if not obj.staff:
|
||||
@@ -36,6 +37,27 @@ class BookingSerializer(serializers.ModelSerializer):
|
||||
last = obj.staff.user.last_name or ""
|
||||
return (first + " " + last).strip() or obj.staff.user.email
|
||||
|
||||
def validate(self, attrs):
|
||||
if not self.instance or "status" not in attrs:
|
||||
return attrs
|
||||
|
||||
new_status = attrs["status"]
|
||||
old_status = self.instance.status
|
||||
if new_status == old_status:
|
||||
return attrs
|
||||
|
||||
user = self.context["request"].user
|
||||
role = getattr(user, "role", None)
|
||||
|
||||
if new_status == BookingStatus.CONFIRMED and role not in {"manager", "staff", "admin"}:
|
||||
raise serializers.ValidationError({"status": _("Only staff or managers can confirm bookings.")})
|
||||
if new_status == BookingStatus.COMPLETED and role not in {"manager", "staff", "admin"}:
|
||||
raise serializers.ValidationError({"status": _("Only staff or managers can complete bookings.")})
|
||||
if new_status == BookingStatus.CANCELLED and role not in {"manager", "staff", "admin", "customer"}:
|
||||
raise serializers.ValidationError({"status": _("You are not allowed to cancel this booking.")})
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class BookingCreateSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from rest_framework import permissions, viewsets
|
||||
|
||||
from apps.bookings.models import Booking
|
||||
from apps.bookings.models import Booking, BookingStatus
|
||||
from apps.bookings.serializers import BookingCreateSerializer, BookingSerializer
|
||||
from apps.notifications.models import NotificationEvent
|
||||
from apps.notifications.services import notify_booking_lifecycle, notify_on_status_change
|
||||
|
||||
|
||||
class BookingViewSet(viewsets.ModelViewSet):
|
||||
@@ -21,3 +23,12 @@ class BookingViewSet(viewsets.ModelViewSet):
|
||||
if self.action == "create":
|
||||
return BookingCreateSerializer
|
||||
return BookingSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
booking = serializer.save()
|
||||
notify_booking_lifecycle(booking, NotificationEvent.BOOKING_CREATED)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
previous_status = self.get_object().status
|
||||
booking = serializer.save()
|
||||
notify_on_status_change(booking, previous_status)
|
||||
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -26,6 +26,7 @@ INSTALLED_APPS = [
|
||||
"apps.salons",
|
||||
"apps.bookings",
|
||||
"apps.payments",
|
||||
"apps.notifications",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
@@ -140,3 +141,5 @@ OTP_MAX_PER_WINDOW = int(os.getenv("OTP_MAX_PER_WINDOW", "5"))
|
||||
OTP_WINDOW_MINUTES = int(os.getenv("OTP_WINDOW_MINUTES", "15"))
|
||||
OTP_RESEND_COOLDOWN_SECONDS = int(os.getenv("OTP_RESEND_COOLDOWN_SECONDS", "60"))
|
||||
DEFAULT_CURRENCY = os.getenv("DEFAULT_CURRENCY", "SAR")
|
||||
NOTIFICATION_PROVIDER = os.getenv("NOTIFICATION_PROVIDER", OTP_PROVIDER)
|
||||
NOTIFICATION_DEFAULT_CHANNEL = os.getenv("NOTIFICATION_DEFAULT_CHANNEL", "sms")
|
||||
|
||||
Reference in New Issue
Block a user