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