Booking lifecycle notifications and status updates
This commit is contained in:
@@ -4,7 +4,7 @@ This document describes the requirements for an execution plan ("ExecPlan"), a d
|
|||||||
|
|
||||||
## Active ExecPlans
|
## Active ExecPlans
|
||||||
|
|
||||||
The current execution plan is `docs/execplans/payments-moyasar.md`. It focuses on Moyasar payments integration with webhooks and idempotency as the next Phase 1 reliability milestone. Keep it updated in line with the requirements below.
|
The current execution plan is `docs/execplans/booking-notifications.md`. It focuses on booking lifecycle notifications (confirmation/cancellation) as the next Phase 1 reliability milestone. Keep it updated in line with the requirements below.
|
||||||
|
|
||||||
## How to use ExecPlans and PLANS.md
|
## How to use ExecPlans and PLANS.md
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
from rest_framework import serializers
|
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.bookings.services import validate_booking_request
|
||||||
from apps.salons.models import Service, StaffProfile
|
from apps.salons.models import Service, StaffProfile
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ class BookingSerializer(serializers.ModelSerializer):
|
|||||||
"notes",
|
"notes",
|
||||||
"created_at",
|
"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):
|
def get_staff_name(self, obj):
|
||||||
if not obj.staff:
|
if not obj.staff:
|
||||||
@@ -36,6 +37,27 @@ class BookingSerializer(serializers.ModelSerializer):
|
|||||||
last = obj.staff.user.last_name or ""
|
last = obj.staff.user.last_name or ""
|
||||||
return (first + " " + last).strip() or obj.staff.user.email
|
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 BookingCreateSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from rest_framework import permissions, viewsets
|
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.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):
|
class BookingViewSet(viewsets.ModelViewSet):
|
||||||
@@ -21,3 +23,12 @@ class BookingViewSet(viewsets.ModelViewSet):
|
|||||||
if self.action == "create":
|
if self.action == "create":
|
||||||
return BookingCreateSerializer
|
return BookingCreateSerializer
|
||||||
return BookingSerializer
|
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.salons",
|
||||||
"apps.bookings",
|
"apps.bookings",
|
||||||
"apps.payments",
|
"apps.payments",
|
||||||
|
"apps.notifications",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
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_WINDOW_MINUTES = int(os.getenv("OTP_WINDOW_MINUTES", "15"))
|
||||||
OTP_RESEND_COOLDOWN_SECONDS = int(os.getenv("OTP_RESEND_COOLDOWN_SECONDS", "60"))
|
OTP_RESEND_COOLDOWN_SECONDS = int(os.getenv("OTP_RESEND_COOLDOWN_SECONDS", "60"))
|
||||||
DEFAULT_CURRENCY = os.getenv("DEFAULT_CURRENCY", "SAR")
|
DEFAULT_CURRENCY = os.getenv("DEFAULT_CURRENCY", "SAR")
|
||||||
|
NOTIFICATION_PROVIDER = os.getenv("NOTIFICATION_PROVIDER", OTP_PROVIDER)
|
||||||
|
NOTIFICATION_DEFAULT_CHANNEL = os.getenv("NOTIFICATION_DEFAULT_CHANNEL", "sms")
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Booking Lifecycle Notifications (SMS/WhatsApp)
|
||||||
|
|
||||||
|
This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds.
|
||||||
|
|
||||||
|
The requirements for ExecPlans live in `PLANS.md` at the repository root. This document must be maintained in accordance with that file.
|
||||||
|
|
||||||
|
## Purpose / Big Picture
|
||||||
|
|
||||||
|
After this change, a booking will automatically notify the customer and the assigned staff member when it is created, confirmed, or cancelled. You can see it working by creating a booking and observing two notification records (customer + staff), then changing the booking status to confirmed or cancelled and seeing two more notification records for that event. In the console provider, the messages are logged, giving an immediate, user-visible trace of the booking lifecycle.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
- [x] (2026-02-28 17:05Z) Created ExecPlan for booking lifecycle notifications and reviewed bookings + notifications gaps.
|
||||||
|
- [x] (2026-02-28 17:30Z) Implemented notifications app with audit-friendly model, providers, and booking message templates.
|
||||||
|
- [x] (2026-02-28 17:40Z) Connected booking create/update flows to notification dispatch with idempotent event handling.
|
||||||
|
- [x] (2026-02-28 17:55Z) Allowed booking status updates with role checks to enable confirmation/cancellation.
|
||||||
|
- [x] (2026-02-28 18:05Z) Added tests for booking notifications (create, status change, no duplicate sends).
|
||||||
|
- [x] (2026-02-28 18:10Z) Updated `docs/risks.md` and validated tests (`python3 -m pytest`).
|
||||||
|
|
||||||
|
## Surprises & Discoveries
|
||||||
|
|
||||||
|
- Observation: Booking status updates were blocked because `status` was read-only on the default booking serializer.
|
||||||
|
Evidence: `PATCH /api/bookings/<id>` returned HTTP 400 when attempting to confirm.
|
||||||
|
|
||||||
|
## Decision Log
|
||||||
|
|
||||||
|
- Decision: Store every booking notification in a dedicated `Notification` model for auditability, even when skipped.
|
||||||
|
Rationale: Lifecycle messages are user-facing and must be traceable for support and compliance.
|
||||||
|
Date/Author: 2026-02-28, Codex
|
||||||
|
- Decision: Reuse existing OTP provider adapters for SMS/WhatsApp delivery, with a new `NOTIFICATION_PROVIDER` setting.
|
||||||
|
Rationale: Avoid duplicate integration code while still allowing independent provider configuration.
|
||||||
|
Date/Author: 2026-02-28, Codex
|
||||||
|
- Decision: Default to SMS for booking notifications and use the recipient’s preferred language when formatting messages.
|
||||||
|
Rationale: SMS is the most reliable baseline in KSA, and language preference is already captured on the user.
|
||||||
|
Date/Author: 2026-02-28, Codex
|
||||||
|
- Decision: Allow booking status changes via `BookingSerializer` with role-based validation.
|
||||||
|
Rationale: Confirmation/cancellation must be reachable through the existing API, but should still respect basic role boundaries.
|
||||||
|
Date/Author: 2026-02-28, Codex
|
||||||
|
|
||||||
|
## Outcomes & Retrospective
|
||||||
|
|
||||||
|
Booking lifecycle notifications are now implemented with audit-friendly records and idempotent sending. Booking creation and status changes (confirmed/cancelled) trigger SMS/WhatsApp notifications for both customer and staff, and role-based validation now governs status updates. Provider adapters remain scaffolds, so production delivery still requires real SMS/WhatsApp wiring.
|
||||||
|
|
||||||
|
## Context and Orientation
|
||||||
|
|
||||||
|
Booking creation and updates are handled in `backend/apps/bookings/views.py` via a DRF `ModelViewSet`. The booking model is in `backend/apps/bookings/models.py`, with `status` indicating lifecycle state. There is currently no notification system beyond OTP scaffolding in `backend/apps/accounts/services/otp.py`. This plan adds a new Django app at `backend/apps/notifications/` to store notification records, format booking lifecycle messages, and dispatch them via SMS or WhatsApp providers.
|
||||||
|
|
||||||
|
A “notification” in this repository means a user-facing message (SMS or WhatsApp) that is stored for auditability in a `Notification` database row. A “lifecycle event” is a booking change that should inform the customer and staff: booking created, confirmed, or cancelled.
|
||||||
|
|
||||||
|
## Plan of Work
|
||||||
|
|
||||||
|
First, create a `notifications` Django app with models and admin registration. Define `Notification`, `NotificationEvent`, `NotificationStatus`, and `NotificationChannel` in `backend/apps/notifications/models.py`. The model must capture booking, recipient, phone number, event, channel, status, provider, message, and send timestamps, and it must be idempotent by preventing duplicates for the same booking + recipient + event + channel. Register the model in `backend/apps/notifications/admin.py` and add `apps.notifications` to `INSTALLED_APPS` in `backend/salon_api/settings.py`.
|
||||||
|
|
||||||
|
Next, implement notification dispatch in `backend/apps/notifications/services.py`. Reuse OTP provider adapters from `apps.accounts.services.otp` with a new `NOTIFICATION_PROVIDER` setting (default to `OTP_PROVIDER`). Add a `NOTIFICATION_DEFAULT_CHANNEL` setting (default `sms`). Implement `send_booking_notification(booking, recipient, event)` to build localized message text using the recipient’s preferred language, send via the provider, and update the notification status. Implement `notify_booking_lifecycle(booking, event)` for initial sends and `notify_on_status_change(booking, previous_status)` to trigger only on status transitions. If the recipient lacks a phone number, record the notification as `skipped` with a reason.
|
||||||
|
|
||||||
|
Then, wire booking lifecycle events in `backend/apps/bookings/views.py`. On `perform_create`, call `notify_booking_lifecycle(..., booking_created)` so both customer and staff receive a message. On `perform_update`, compare the previous status to the new status and call `notify_on_status_change` for confirmed or cancelled transitions. Avoid sending notifications if the status does not change.
|
||||||
|
|
||||||
|
Finally, add tests in `backend/apps/notifications/tests/test_booking_notifications.py`. Cover booking creation (two notifications), status change to confirmed (two notifications), and a repeat status update that should not create duplicates. Ensure tests use phone numbers on users to avoid skipped notifications. Update `docs/risks.md` to mark “No notifications (email/SMS) beyond OTP scaffolding” as addressed once tests pass.
|
||||||
|
|
||||||
|
## Concrete Steps
|
||||||
|
|
||||||
|
Run these commands from the repository root (`/home/m7md/kshkool/Salon`).
|
||||||
|
|
||||||
|
1. Add notifications app code and migrations.
|
||||||
|
- Create `backend/apps/notifications/` with `apps.py`, `models.py`, `services.py`, `admin.py`, and a migration `0001_initial.py`.
|
||||||
|
- Update `backend/salon_api/settings.py` to include `apps.notifications` and notification settings.
|
||||||
|
|
||||||
|
2. Wire booking lifecycle events.
|
||||||
|
- Update `backend/apps/bookings/views.py` to call notification services on create and status changes.
|
||||||
|
|
||||||
|
3. Add tests.
|
||||||
|
- Create `backend/apps/notifications/tests/test_booking_notifications.py`.
|
||||||
|
|
||||||
|
4. Run backend tests.
|
||||||
|
- From `backend/` with the venv active:
|
||||||
|
python3 -m pytest
|
||||||
|
|
||||||
|
## Validation and Acceptance
|
||||||
|
|
||||||
|
- Creating a booking returns HTTP 201 and creates two notification records (customer + staff) with event `booking_created`.
|
||||||
|
- Updating a booking’s status to `confirmed` creates two notification records with event `booking_confirmed`.
|
||||||
|
- Repeating the same status update does not create duplicate notifications (records remain at two for that event).
|
||||||
|
- `python3 -m pytest` passes, and the new tests fail before the change and pass after.
|
||||||
|
|
||||||
|
## Idempotence and Recovery
|
||||||
|
|
||||||
|
Notification creation is idempotent by a uniqueness constraint on booking + recipient + event + channel. Re-running the send logic will update a pending or failed notification rather than creating duplicates. If a migration needs to be reverted, use standard Django migration rollback and re-apply. If a notification provider is misconfigured, notifications will be marked failed and can be retried after fixing settings.
|
||||||
|
|
||||||
|
## Artifacts and Notes
|
||||||
|
|
||||||
|
Expected console-provider log example when creating a booking:
|
||||||
|
|
||||||
|
INFO OTP SMS to 0500000002: Your booking request is received for Haircut at Main Salon on 2026-03-01 10:00.
|
||||||
|
INFO OTP SMS to 0500000003: Your booking request is received for Haircut at Main Salon on 2026-03-01 10:00.
|
||||||
|
|
||||||
|
## Interfaces and Dependencies
|
||||||
|
|
||||||
|
- `backend/apps/notifications/models.py` must define `Notification`, `NotificationEvent`, `NotificationStatus`, `NotificationChannel`.
|
||||||
|
- `backend/apps/notifications/services.py` must expose `send_booking_notification`, `notify_booking_lifecycle`, and `notify_on_status_change`.
|
||||||
|
- `backend/apps/bookings/views.py` must call notification services in `perform_create` and `perform_update`.
|
||||||
|
- `backend/salon_api/settings.py` must define `NOTIFICATION_PROVIDER` and `NOTIFICATION_DEFAULT_CHANNEL` settings.
|
||||||
|
|
||||||
|
Plan Maintenance Note: Created on 2026-02-28 to implement booking lifecycle notifications as the next Phase 1 reliability milestone.
|
||||||
|
Plan Maintenance Note (Update): Marked milestones complete, recorded the booking status update discovery, and documented role-based status validation after implementing notifications and tests on 2026-02-28.
|
||||||
+1
-1
@@ -21,7 +21,7 @@ This file tracks known gaps and risks to address in future iterations.
|
|||||||
## Data And UX
|
## Data And UX
|
||||||
- Ratings are not recalculated from reviews.
|
- Ratings are not recalculated from reviews.
|
||||||
- No image upload or storage strategy for photos.
|
- No image upload or storage strategy for photos.
|
||||||
- No notifications (email/SMS) beyond OTP scaffolding.
|
- Booking lifecycle notifications are implemented (SMS/WhatsApp via provider scaffolds); production delivery still needs real provider adapters.
|
||||||
- Localization foundations are in progress; full Arabic translation coverage and RTL QA are still pending.
|
- Localization foundations are in progress; full Arabic translation coverage and RTL QA are still pending.
|
||||||
|
|
||||||
## Ops And Compliance
|
## Ops And Compliance
|
||||||
|
|||||||
Reference in New Issue
Block a user