ef60218c4c
Wrap the overlap query and Booking.objects.create() in a single transaction.atomic() block inside BookingCreateSerializer.create(). Lock the StaffProfile row with select_for_update() so concurrent requests for the same staff slot are serialized at the DB level; only one writer can hold the lock at a time, eliminating the race window between validate() and save(). The early check in validate() is kept for fast user feedback in the common non-concurrent case. The locked re-check in create() is the correctness guarantee. On SQLite (dev/tests) FOR UPDATE is silently ignored but writes are still serialized. PostgreSQL (production) gets row-level locking. Update docs/risks.md to mark the race condition as fixed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
115 lines
4.5 KiB
Python
115 lines
4.5 KiB
Python
from django.db import transaction
|
|
from django.utils.translation import gettext_lazy as _
|
|
from rest_framework import serializers
|
|
from apps.bookings.models import Booking, BookingStatus
|
|
from apps.bookings.services import validate_booking_request
|
|
from apps.salons.models import Service, StaffProfile
|
|
|
|
|
|
class BookingSerializer(serializers.ModelSerializer):
|
|
salon_name = serializers.CharField(source="salon.name", read_only=True)
|
|
service_name = serializers.CharField(source="service.name", read_only=True)
|
|
staff_name = serializers.SerializerMethodField()
|
|
|
|
class Meta:
|
|
model = Booking
|
|
fields = [
|
|
"id",
|
|
"salon",
|
|
"salon_name",
|
|
"service",
|
|
"service_name",
|
|
"staff",
|
|
"staff_name",
|
|
"start_time",
|
|
"end_time",
|
|
"status",
|
|
"price_amount",
|
|
"currency",
|
|
"notes",
|
|
"created_at",
|
|
]
|
|
read_only_fields = ["id", "salon", "price_amount", "currency", "created_at"]
|
|
|
|
def get_staff_name(self, obj):
|
|
if not obj.staff:
|
|
return None
|
|
first = obj.staff.user.first_name or ""
|
|
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:
|
|
model = Booking
|
|
fields = ["service", "staff", "start_time", "end_time", "notes"]
|
|
extra_kwargs = {"staff": {"required": True}}
|
|
|
|
def validate(self, attrs):
|
|
service: Service = attrs["service"]
|
|
staff = attrs.get("staff")
|
|
validate_booking_request(service, staff, attrs["start_time"], attrs["end_time"])
|
|
return attrs
|
|
|
|
def create(self, validated_data):
|
|
request = self.context["request"]
|
|
service = validated_data["service"]
|
|
staff = validated_data.get("staff")
|
|
start_time = validated_data["start_time"]
|
|
end_time = validated_data["end_time"]
|
|
|
|
with transaction.atomic():
|
|
# Lock the staff row so concurrent booking requests for the same staff
|
|
# member are serialized. Without this, two requests that both pass the
|
|
# overlap check in validate() can race and both commit overlapping
|
|
# bookings. On SQLite (dev/tests) the FOR UPDATE clause is silently
|
|
# ignored but the transaction still serializes writes; PostgreSQL
|
|
# (production) gets true row-level locking.
|
|
StaffProfile.objects.select_for_update().get(pk=staff.pk)
|
|
|
|
# Re-run the overlap check inside the lock so the check and the insert
|
|
# are atomic with respect to other writers.
|
|
overlap = Booking.objects.filter(
|
|
staff=staff,
|
|
status__in=[BookingStatus.PENDING, BookingStatus.CONFIRMED],
|
|
start_time__lt=end_time,
|
|
end_time__gt=start_time,
|
|
).exists()
|
|
if overlap:
|
|
raise serializers.ValidationError(
|
|
{"start_time": _("Booking overlaps an existing appointment")}
|
|
)
|
|
|
|
return Booking.objects.create(
|
|
salon=service.salon,
|
|
customer=request.user,
|
|
service=service,
|
|
staff=staff,
|
|
start_time=start_time,
|
|
end_time=end_time,
|
|
notes=validated_data.get("notes", ""),
|
|
price_amount=service.price_amount,
|
|
currency=service.currency,
|
|
)
|