inital commit

This commit is contained in:
2026-04-10 20:51:43 -05:00
parent cd1f2eae29
commit 562a8525d0
85 changed files with 4820 additions and 2 deletions

159
booking/services.py Normal file
View File

@@ -0,0 +1,159 @@
from decimal import Decimal
from datetime import timezone as dt_timezone
from django.core.exceptions import ValidationError
from django.db.models import Q
from django.utils import timezone
from .models import AvailabilitySlot, Booking, BookingEventLog
ACTIVE_BOOKING_STATUSES = [Booking.Status.REQUESTED, Booking.Status.APPROVED, Booking.Status.CONFIRMED]
def _normalize_utc(dt):
if timezone.is_naive(dt):
raise ValidationError("Datetime values must include timezone information (UTC ISO-8601).")
return dt.astimezone(dt_timezone.utc)
def is_bookable(*, equipment_item=None, adventure_offering=None, starts_at, ends_at, exclude_booking_id=None):
starts_at = _normalize_utc(starts_at)
ends_at = _normalize_utc(ends_at)
if starts_at >= ends_at:
return False
filters = Q(starts_at__lt=ends_at, ends_at__gt=starts_at, status__in=ACTIVE_BOOKING_STATUSES)
if equipment_item is not None:
filters &= Q(equipment_item=equipment_item)
elif adventure_offering is not None:
filters &= Q(adventure_offering=adventure_offering)
else:
return False
queryset = Booking.objects.filter(filters)
if exclude_booking_id is not None:
queryset = queryset.exclude(id=exclude_booking_id)
has_overlap = queryset.exists()
if has_overlap:
return False
slot_filters = Q(starts_at__lt=ends_at, ends_at__gt=starts_at)
if equipment_item is not None:
slot_filters &= Q(equipment_item=equipment_item)
else:
slot_filters &= Q(adventure_offering=adventure_offering)
target_slots = AvailabilitySlot.objects.filter(slot_filters)
if not target_slots.exists():
# If no slot schedule exists yet, keep backward-compatible "bookable by default" behavior.
return True
has_blocking_unavailable = target_slots.filter(is_available=False).exists()
if has_blocking_unavailable:
return False
has_covering_available_slot = target_slots.filter(is_available=True, starts_at__lte=starts_at, ends_at__gte=ends_at).exists()
return has_covering_available_slot
def quote_booking(*, equipment_item=None, adventure_offering=None, starts_at, ends_at, participants_count=1):
starts_at = _normalize_utc(starts_at)
ends_at = _normalize_utc(ends_at)
if ends_at <= starts_at:
raise ValidationError("ends_at must be greater than starts_at.")
if starts_at < timezone.now():
raise ValidationError("starts_at must be in the future.")
if equipment_item is not None and adventure_offering is not None:
raise ValidationError("Only one target can be quoted at a time.")
if equipment_item is None and adventure_offering is None:
raise ValidationError("A booking target is required.")
if equipment_item is not None:
duration_days = (ends_at - starts_at).total_seconds() / 86400
billable_days = max(1, int(duration_days) if duration_days.is_integer() else int(duration_days) + 1)
return Decimal(billable_days) * equipment_item.price_per_day
if participants_count < 1:
raise ValidationError("participants_count must be at least 1.")
if participants_count > adventure_offering.capacity:
raise ValidationError("participants_count exceeds adventure capacity.")
return Decimal(participants_count) * adventure_offering.price_per_person
def create_booking_request(
*,
customer,
starts_at,
ends_at,
equipment_item=None,
adventure_offering=None,
customer_notes="",
participants_count=1,
listing_click=None,
):
starts_at = _normalize_utc(starts_at)
ends_at = _normalize_utc(ends_at)
if not is_bookable(
equipment_item=equipment_item,
adventure_offering=adventure_offering,
starts_at=starts_at,
ends_at=ends_at,
):
raise ValidationError("This item/offering is not available for this range.")
total_price = quote_booking(
equipment_item=equipment_item,
adventure_offering=adventure_offering,
starts_at=starts_at,
ends_at=ends_at,
participants_count=participants_count,
)
vendor = equipment_item.vendor if equipment_item else adventure_offering.vendor
booking = Booking.objects.create(
customer=customer,
vendor=vendor,
equipment_item=equipment_item,
adventure_offering=adventure_offering,
starts_at=starts_at,
ends_at=ends_at,
status=Booking.Status.REQUESTED,
total_price=total_price,
customer_notes=customer_notes,
listing_click=listing_click,
)
BookingEventLog.objects.create(
booking=booking,
from_status="",
to_status=Booking.Status.REQUESTED,
note="Booking requested.",
actor=customer,
)
return booking
def transition_booking_status(*, booking, to_status, actor, note="", vendor_notes=None):
allowed_transitions = {
Booking.Status.REQUESTED: {Booking.Status.APPROVED, Booking.Status.DECLINED, Booking.Status.CANCELLED},
Booking.Status.APPROVED: {Booking.Status.CONFIRMED, Booking.Status.CANCELLED},
Booking.Status.CONFIRMED: {Booking.Status.CANCELLED},
Booking.Status.DECLINED: set(),
Booking.Status.CANCELLED: set(),
}
current = booking.status
if to_status not in allowed_transitions[current]:
raise ValidationError(f"Invalid booking status transition: {current} -> {to_status}")
booking.status = to_status
if vendor_notes is not None:
booking.vendor_notes = vendor_notes
booking.save(update_fields=["status", "vendor_notes", "updated_at"])
BookingEventLog.objects.create(
booking=booking,
from_status=current,
to_status=to_status,
note=note,
actor=actor,
)
return booking