inital commit
This commit is contained in:
1
booking/__init__.py
Normal file
1
booking/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
79
booking/admin.py
Normal file
79
booking/admin.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from django.contrib import admin
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from .models import AvailabilitySlot, Booking, BookingEventLog
|
||||
from .services import transition_booking_status
|
||||
|
||||
|
||||
@admin.action(description="Mark selected bookings as approved")
|
||||
def mark_approved(modeladmin, request, queryset):
|
||||
for booking in queryset:
|
||||
if booking.status == Booking.Status.REQUESTED:
|
||||
try:
|
||||
transition_booking_status(
|
||||
booking=booking,
|
||||
to_status=Booking.Status.APPROVED,
|
||||
actor=request.user,
|
||||
note="Booking approved via admin action.",
|
||||
vendor_notes=booking.vendor_notes,
|
||||
)
|
||||
except ValidationError:
|
||||
continue
|
||||
|
||||
|
||||
@admin.action(description="Mark selected bookings as declined")
|
||||
def mark_declined(modeladmin, request, queryset):
|
||||
for booking in queryset:
|
||||
if booking.status == Booking.Status.REQUESTED:
|
||||
try:
|
||||
transition_booking_status(
|
||||
booking=booking,
|
||||
to_status=Booking.Status.DECLINED,
|
||||
actor=request.user,
|
||||
note="Booking declined via admin action.",
|
||||
vendor_notes=booking.vendor_notes,
|
||||
)
|
||||
except ValidationError:
|
||||
continue
|
||||
|
||||
|
||||
@admin.action(description="Mark selected bookings as confirmed")
|
||||
def mark_confirmed(modeladmin, request, queryset):
|
||||
for booking in queryset:
|
||||
if booking.status == Booking.Status.APPROVED:
|
||||
try:
|
||||
transition_booking_status(
|
||||
booking=booking,
|
||||
to_status=Booking.Status.CONFIRMED,
|
||||
actor=request.user,
|
||||
note="Booking confirmed via admin action.",
|
||||
vendor_notes=booking.vendor_notes,
|
||||
)
|
||||
except ValidationError:
|
||||
continue
|
||||
|
||||
|
||||
@admin.register(AvailabilitySlot)
|
||||
class AvailabilitySlotAdmin(admin.ModelAdmin):
|
||||
list_display = ("id", "equipment_item", "adventure_offering", "starts_at", "ends_at", "is_available")
|
||||
list_filter = ("is_available",)
|
||||
search_fields = ("equipment_item__public_id", "adventure_offering__public_id")
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
|
||||
@admin.register(Booking)
|
||||
class BookingAdmin(admin.ModelAdmin):
|
||||
list_display = ("id", "status", "vendor", "customer", "starts_at", "ends_at", "total_price", "created_at")
|
||||
list_filter = ("status", "vendor")
|
||||
search_fields = ("id", "customer__email", "vendor__business_name")
|
||||
readonly_fields = ("created_at", "updated_at", "total_price")
|
||||
actions = (mark_approved, mark_declined, mark_confirmed)
|
||||
|
||||
|
||||
@admin.register(BookingEventLog)
|
||||
class BookingEventLogAdmin(admin.ModelAdmin):
|
||||
list_display = ("booking", "from_status", "to_status", "actor", "created_at")
|
||||
list_filter = ("to_status",)
|
||||
search_fields = ("booking__id", "actor__email")
|
||||
readonly_fields = ("booking", "from_status", "to_status", "note", "actor", "created_at")
|
||||
|
||||
6
booking/apps.py
Normal file
6
booking/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BookingConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "booking"
|
||||
81
booking/migrations/0001_initial.py
Normal file
81
booking/migrations/0001_initial.py
Normal file
@@ -0,0 +1,81 @@
|
||||
# Generated by Django 6.0.4 on 2026-04-08 16:53
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0001_initial'),
|
||||
('adventrues', '0001_initial'),
|
||||
('equipment', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Booking',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('starts_at', models.DateTimeField()),
|
||||
('ends_at', models.DateTimeField()),
|
||||
('status', models.CharField(choices=[('requested', 'Requested'), ('approved', 'Approved'), ('declined', 'Declined'), ('cancelled', 'Cancelled'), ('confirmed', 'Confirmed')], default='requested', max_length=16)),
|
||||
('total_price', models.DecimalField(decimal_places=2, default=0, max_digits=10)),
|
||||
('customer_notes', models.TextField(blank=True)),
|
||||
('vendor_notes', models.TextField(blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('adventure_offering', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bookings', to='adventrues.adventureoffering')),
|
||||
('customer', models.ForeignKey(limit_choices_to={'is_customer': True}, on_delete=django.db.models.deletion.CASCADE, related_name='bookings', to=settings.AUTH_USER_MODEL)),
|
||||
('equipment_item', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bookings', to='equipment.equipmentitem')),
|
||||
('vendor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bookings', to='accounts.vendorprofile')),
|
||||
],
|
||||
options={
|
||||
'ordering': ('-created_at',),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='BookingEventLog',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('from_status', models.CharField(blank=True, choices=[('requested', 'Requested'), ('approved', 'Approved'), ('declined', 'Declined'), ('cancelled', 'Cancelled'), ('confirmed', 'Confirmed')], max_length=16)),
|
||||
('to_status', models.CharField(choices=[('requested', 'Requested'), ('approved', 'Approved'), ('declined', 'Declined'), ('cancelled', 'Cancelled'), ('confirmed', 'Confirmed')], max_length=16)),
|
||||
('note', models.TextField(blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||
('booking', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='booking.booking')),
|
||||
],
|
||||
options={
|
||||
'ordering': ('-created_at',),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AvailabilitySlot',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('starts_at', models.DateTimeField()),
|
||||
('ends_at', models.DateTimeField()),
|
||||
('is_available', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('adventure_offering', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='availability_slots', to='adventrues.adventureoffering')),
|
||||
('equipment_item', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='availability_slots', to='equipment.equipmentitem')),
|
||||
],
|
||||
options={
|
||||
'ordering': ('starts_at',),
|
||||
'constraints': [models.CheckConstraint(condition=models.Q(models.Q(('adventure_offering__isnull', True), ('equipment_item__isnull', False)), models.Q(('adventure_offering__isnull', False), ('equipment_item__isnull', True)), _connector='OR'), name='availability_slot_exactly_one_target'), models.CheckConstraint(condition=models.Q(('ends_at__gt', models.F('starts_at'))), name='availability_slot_valid_range')],
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='booking',
|
||||
constraint=models.CheckConstraint(condition=models.Q(models.Q(('adventure_offering__isnull', True), ('equipment_item__isnull', False)), models.Q(('adventure_offering__isnull', False), ('equipment_item__isnull', True)), _connector='OR'), name='booking_exactly_one_target'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='booking',
|
||||
constraint=models.CheckConstraint(condition=models.Q(('ends_at__gt', models.F('starts_at'))), name='booking_valid_range'),
|
||||
),
|
||||
]
|
||||
20
booking/migrations/0002_booking_listing_click.py
Normal file
20
booking/migrations/0002_booking_listing_click.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 6.0.4 on 2026-04-11 01:49
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('booking', '0001_initial'),
|
||||
('marketing', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='booking',
|
||||
name='listing_click',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bookings', to='marketing.listingclick'),
|
||||
),
|
||||
]
|
||||
1
booking/migrations/__init__.py
Normal file
1
booking/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
104
booking/models.py
Normal file
104
booking/models.py
Normal file
@@ -0,0 +1,104 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
|
||||
|
||||
class AvailabilitySlot(models.Model):
|
||||
equipment_item = models.ForeignKey(
|
||||
"equipment.EquipmentItem",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="availability_slots",
|
||||
)
|
||||
adventure_offering = models.ForeignKey(
|
||||
"adventrues.AdventureOffering",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="availability_slots",
|
||||
)
|
||||
starts_at = models.DateTimeField()
|
||||
ends_at = models.DateTimeField()
|
||||
is_available = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
condition=Q(equipment_item__isnull=False, adventure_offering__isnull=True)
|
||||
| Q(equipment_item__isnull=True, adventure_offering__isnull=False),
|
||||
name="availability_slot_exactly_one_target",
|
||||
),
|
||||
models.CheckConstraint(condition=Q(ends_at__gt=models.F("starts_at")), name="availability_slot_valid_range"),
|
||||
]
|
||||
ordering = ("starts_at",)
|
||||
|
||||
def __str__(self) -> str:
|
||||
target = self.equipment_item or self.adventure_offering
|
||||
return f"{target} | {self.starts_at} - {self.ends_at}"
|
||||
|
||||
|
||||
class Booking(models.Model):
|
||||
class Status(models.TextChoices):
|
||||
REQUESTED = "requested", "Requested"
|
||||
APPROVED = "approved", "Approved"
|
||||
DECLINED = "declined", "Declined"
|
||||
CANCELLED = "cancelled", "Cancelled"
|
||||
CONFIRMED = "confirmed", "Confirmed"
|
||||
|
||||
customer = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="bookings", limit_choices_to={"is_customer": True}
|
||||
)
|
||||
vendor = models.ForeignKey("accounts.VendorProfile", on_delete=models.CASCADE, related_name="bookings")
|
||||
equipment_item = models.ForeignKey(
|
||||
"equipment.EquipmentItem", on_delete=models.SET_NULL, null=True, blank=True, related_name="bookings"
|
||||
)
|
||||
adventure_offering = models.ForeignKey(
|
||||
"adventrues.AdventureOffering", on_delete=models.SET_NULL, null=True, blank=True, related_name="bookings"
|
||||
)
|
||||
starts_at = models.DateTimeField()
|
||||
ends_at = models.DateTimeField()
|
||||
status = models.CharField(max_length=16, choices=Status.choices, default=Status.REQUESTED)
|
||||
total_price = models.DecimalField(max_digits=10, decimal_places=2, default=0)
|
||||
customer_notes = models.TextField(blank=True)
|
||||
vendor_notes = models.TextField(blank=True)
|
||||
listing_click = models.ForeignKey(
|
||||
"marketing.ListingClick",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="bookings",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
condition=Q(equipment_item__isnull=False, adventure_offering__isnull=True)
|
||||
| Q(equipment_item__isnull=True, adventure_offering__isnull=False),
|
||||
name="booking_exactly_one_target",
|
||||
),
|
||||
models.CheckConstraint(condition=Q(ends_at__gt=models.F("starts_at")), name="booking_valid_range"),
|
||||
]
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Booking #{self.id} ({self.status})"
|
||||
|
||||
|
||||
class BookingEventLog(models.Model):
|
||||
booking = models.ForeignKey(Booking, on_delete=models.CASCADE, related_name="events")
|
||||
from_status = models.CharField(max_length=16, choices=Booking.Status.choices, blank=True)
|
||||
to_status = models.CharField(max_length=16, choices=Booking.Status.choices)
|
||||
note = models.TextField(blank=True)
|
||||
actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Booking {self.booking_id}: {self.from_status} -> {self.to_status}"
|
||||
114
booking/serializers.py
Normal file
114
booking/serializers.py
Normal file
@@ -0,0 +1,114 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from adventrues.models import AdventureOffering
|
||||
from equipment.models import EquipmentItem
|
||||
from marketing.models import ListingClick
|
||||
from marketing.services import listing_click_valid_for_booking
|
||||
|
||||
from .models import Booking, BookingEventLog
|
||||
from .services import create_booking_request, quote_booking
|
||||
|
||||
|
||||
class BookingEventLogSerializer(serializers.ModelSerializer):
|
||||
actor_email = serializers.EmailField(source="actor.email", read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = BookingEventLog
|
||||
fields = ("id", "from_status", "to_status", "note", "actor_email", "created_at")
|
||||
|
||||
|
||||
class BookingSerializer(serializers.ModelSerializer):
|
||||
customer_email = serializers.EmailField(source="customer.email", read_only=True)
|
||||
vendor_slug = serializers.CharField(source="vendor.slug", read_only=True)
|
||||
equipment_public_id = serializers.CharField(source="equipment_item.public_id", read_only=True)
|
||||
adventure_public_id = serializers.CharField(source="adventure_offering.public_id", read_only=True)
|
||||
events = BookingEventLogSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Booking
|
||||
fields = (
|
||||
"id",
|
||||
"customer_email",
|
||||
"vendor_slug",
|
||||
"equipment_item",
|
||||
"equipment_public_id",
|
||||
"adventure_public_id",
|
||||
"starts_at",
|
||||
"ends_at",
|
||||
"status",
|
||||
"total_price",
|
||||
"customer_notes",
|
||||
"vendor_notes",
|
||||
"events",
|
||||
"listing_click",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
read_only_fields = ("status", "total_price", "created_at", "updated_at", "events", "listing_click")
|
||||
|
||||
|
||||
class BookingCreateSerializer(serializers.Serializer):
|
||||
equipment_item_id = serializers.PrimaryKeyRelatedField(
|
||||
queryset=EquipmentItem.objects.filter(is_active=True),
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
adventure_offering_id = serializers.PrimaryKeyRelatedField(
|
||||
queryset=AdventureOffering.objects.filter(is_active=True),
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
starts_at = serializers.DateTimeField()
|
||||
ends_at = serializers.DateTimeField()
|
||||
customer_notes = serializers.CharField(required=False, allow_blank=True)
|
||||
participants_count = serializers.IntegerField(required=False, min_value=1, default=1)
|
||||
marketing_click_id = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
def validate(self, attrs):
|
||||
equipment_item = attrs.get("equipment_item_id")
|
||||
adventure_offering = attrs.get("adventure_offering_id")
|
||||
if bool(equipment_item) == bool(adventure_offering):
|
||||
raise serializers.ValidationError("Provide exactly one target: equipment_item_id or adventure_offering_id.")
|
||||
click_id = attrs.get("marketing_click_id")
|
||||
listing_click = None
|
||||
if click_id is not None:
|
||||
listing_click = ListingClick.objects.filter(pk=click_id).first()
|
||||
if not listing_click:
|
||||
raise serializers.ValidationError({"marketing_click_id": "Invalid marketing_click_id."})
|
||||
if not listing_click_valid_for_booking(
|
||||
click=listing_click,
|
||||
equipment_item=equipment_item,
|
||||
adventure_offering=adventure_offering,
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
{"marketing_click_id": "Click does not match this listing or is outside the attribution window."}
|
||||
)
|
||||
attrs["_listing_click"] = listing_click
|
||||
try:
|
||||
quote_booking(
|
||||
equipment_item=equipment_item,
|
||||
adventure_offering=adventure_offering,
|
||||
starts_at=attrs["starts_at"],
|
||||
ends_at=attrs["ends_at"],
|
||||
participants_count=attrs.get("participants_count", 1),
|
||||
)
|
||||
except Exception as exc:
|
||||
raise serializers.ValidationError(str(exc)) from exc
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
try:
|
||||
booking = create_booking_request(
|
||||
customer=self.context["request"].user,
|
||||
starts_at=validated_data["starts_at"],
|
||||
ends_at=validated_data["ends_at"],
|
||||
equipment_item=validated_data.get("equipment_item_id"),
|
||||
adventure_offering=validated_data.get("adventure_offering_id"),
|
||||
participants_count=validated_data.get("participants_count", 1),
|
||||
customer_notes=validated_data.get("customer_notes", ""),
|
||||
listing_click=validated_data.get("_listing_click"),
|
||||
)
|
||||
except Exception as exc:
|
||||
raise serializers.ValidationError({"non_field_errors": [str(exc)]}) from exc
|
||||
|
||||
return booking
|
||||
159
booking/services.py
Normal file
159
booking/services.py
Normal 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
|
||||
256
booking/tests.py
Normal file
256
booking/tests.py
Normal file
@@ -0,0 +1,256 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.contrib.admin.sites import AdminSite
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import RequestFactory, TestCase
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from accounts.models import VendorProfile
|
||||
from adventrues.models import AdventureCategory, AdventureOffering
|
||||
from equipment.models import EquipmentCategory, EquipmentItem
|
||||
|
||||
from .admin import BookingAdmin, mark_approved
|
||||
from .models import AvailabilitySlot, Booking
|
||||
from .services import is_bookable, quote_booking, transition_booking_status
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class BookingServiceTests(TestCase):
|
||||
def setUp(self):
|
||||
self.vendor_user = User.objects.create_user(
|
||||
email="vendor-booking@example.com",
|
||||
password="Pass123456!",
|
||||
is_vendor=True,
|
||||
is_customer=False,
|
||||
)
|
||||
self.vendor_profile = VendorProfile.objects.create(user=self.vendor_user, business_name="Wave Rentals")
|
||||
self.customer = User.objects.create_user(
|
||||
email="customer-booking@example.com",
|
||||
password="Pass123456!",
|
||||
is_vendor=False,
|
||||
is_customer=True,
|
||||
)
|
||||
category = EquipmentCategory.objects.create(name="Jet Ski", slug="jetski")
|
||||
self.item = EquipmentItem.objects.create(
|
||||
vendor=self.vendor_profile,
|
||||
category=category,
|
||||
title="Jet Ski 1",
|
||||
public_id="jet-001",
|
||||
price_per_day="120.00",
|
||||
is_active=True,
|
||||
)
|
||||
adv_category = AdventureCategory.objects.create(name="Tour", slug="tour")
|
||||
self.offering = AdventureOffering.objects.create(
|
||||
vendor=self.vendor_profile,
|
||||
category=adv_category,
|
||||
title="Harbor Tour",
|
||||
public_id="tour-001",
|
||||
duration_minutes=90,
|
||||
capacity=6,
|
||||
price_per_person="60.00",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
def test_availability_overlap_validation(self):
|
||||
starts = timezone.now() + timedelta(days=1)
|
||||
ends = starts + timedelta(days=1)
|
||||
Booking.objects.create(
|
||||
customer=self.customer,
|
||||
vendor=self.vendor_profile,
|
||||
equipment_item=self.item,
|
||||
starts_at=starts,
|
||||
ends_at=ends,
|
||||
status=Booking.Status.APPROVED,
|
||||
total_price="120.00",
|
||||
)
|
||||
self.assertFalse(
|
||||
is_bookable(
|
||||
equipment_item=self.item,
|
||||
starts_at=starts + timedelta(hours=1),
|
||||
ends_at=ends + timedelta(hours=1),
|
||||
)
|
||||
)
|
||||
|
||||
def test_booking_quote_and_state_transition_validation(self):
|
||||
starts = timezone.now() + timedelta(days=2)
|
||||
ends = starts + timedelta(days=2)
|
||||
quote = quote_booking(equipment_item=self.item, starts_at=starts, ends_at=ends)
|
||||
self.assertEqual(str(quote), "240.00")
|
||||
|
||||
booking = Booking.objects.create(
|
||||
customer=self.customer,
|
||||
vendor=self.vendor_profile,
|
||||
equipment_item=self.item,
|
||||
starts_at=starts,
|
||||
ends_at=ends,
|
||||
status=Booking.Status.REQUESTED,
|
||||
total_price=quote,
|
||||
)
|
||||
transition_booking_status(booking=booking, to_status=Booking.Status.APPROVED, actor=self.vendor_user, note="ok")
|
||||
booking.refresh_from_db()
|
||||
self.assertEqual(booking.status, Booking.Status.APPROVED)
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
transition_booking_status(booking=booking, to_status=Booking.Status.DECLINED, actor=self.vendor_user)
|
||||
|
||||
def test_slot_rule_blocks_when_unavailable_slot_overlaps(self):
|
||||
starts = timezone.now() + timedelta(days=3)
|
||||
ends = starts + timedelta(hours=4)
|
||||
AvailabilitySlot.objects.create(
|
||||
equipment_item=self.item,
|
||||
starts_at=starts - timedelta(hours=1),
|
||||
ends_at=ends + timedelta(hours=1),
|
||||
is_available=False,
|
||||
)
|
||||
self.assertFalse(is_bookable(equipment_item=self.item, starts_at=starts, ends_at=ends))
|
||||
|
||||
|
||||
class BookingApiTests(APITestCase):
|
||||
def setUp(self):
|
||||
self.vendor_user = User.objects.create_user(
|
||||
email="vendor-api@example.com",
|
||||
password="Pass123456!",
|
||||
is_vendor=True,
|
||||
is_customer=False,
|
||||
)
|
||||
self.vendor_profile = VendorProfile.objects.create(user=self.vendor_user, business_name="Vendor API")
|
||||
self.customer = User.objects.create_user(
|
||||
email="customer-api@example.com",
|
||||
password="Pass123456!",
|
||||
is_vendor=False,
|
||||
is_customer=True,
|
||||
)
|
||||
self.other_customer = User.objects.create_user(
|
||||
email="other@example.com",
|
||||
password="Pass123456!",
|
||||
is_vendor=False,
|
||||
is_customer=True,
|
||||
)
|
||||
category = EquipmentCategory.objects.create(name="Board", slug="board")
|
||||
self.item = EquipmentItem.objects.create(
|
||||
vendor=self.vendor_profile,
|
||||
category=category,
|
||||
title="Board 1",
|
||||
public_id="board-001",
|
||||
price_per_day="80.00",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
def test_booking_request_happy_path_and_overlap_rejection(self):
|
||||
starts = timezone.now() + timedelta(days=5)
|
||||
ends = starts + timedelta(days=1)
|
||||
self.client.force_authenticate(self.customer)
|
||||
first = self.client.post(
|
||||
"/api/v1/booking/bookings/request/",
|
||||
{
|
||||
"equipment_item_id": self.item.id,
|
||||
"starts_at": starts.isoformat(),
|
||||
"ends_at": ends.isoformat(),
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(first.status_code, status.HTTP_201_CREATED)
|
||||
|
||||
overlap = self.client.post(
|
||||
"/api/v1/booking/bookings/request/",
|
||||
{
|
||||
"equipment_item_id": self.item.id,
|
||||
"starts_at": (starts + timedelta(hours=1)).isoformat(),
|
||||
"ends_at": (ends + timedelta(hours=1)).isoformat(),
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(overlap.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_booking_invalid_range_rejected(self):
|
||||
starts = timezone.now() + timedelta(days=2)
|
||||
self.client.force_authenticate(self.customer)
|
||||
res = self.client.post(
|
||||
"/api/v1/booking/bookings/request/",
|
||||
{
|
||||
"equipment_item_id": self.item.id,
|
||||
"starts_at": (starts + timedelta(hours=2)).isoformat(),
|
||||
"ends_at": starts.isoformat(),
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def test_vendor_approve_and_decline_permissions(self):
|
||||
starts = timezone.now() + timedelta(days=6)
|
||||
ends = starts + timedelta(days=1)
|
||||
booking = Booking.objects.create(
|
||||
customer=self.customer,
|
||||
vendor=self.vendor_profile,
|
||||
equipment_item=self.item,
|
||||
starts_at=starts,
|
||||
ends_at=ends,
|
||||
status=Booking.Status.REQUESTED,
|
||||
total_price="80.00",
|
||||
)
|
||||
|
||||
self.client.force_authenticate(self.other_customer)
|
||||
forbidden = self.client.post(f"/api/v1/booking/bookings/{booking.id}/approve/", {}, format="json")
|
||||
self.assertEqual(forbidden.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
self.client.force_authenticate(self.vendor_user)
|
||||
approved = self.client.post(f"/api/v1/booking/bookings/{booking.id}/approve/", {}, format="json")
|
||||
self.assertEqual(approved.status_code, status.HTTP_200_OK)
|
||||
booking.refresh_from_db()
|
||||
self.assertEqual(booking.status, Booking.Status.APPROVED)
|
||||
|
||||
|
||||
class BookingAdminTests(TestCase):
|
||||
def setUp(self):
|
||||
self.site = AdminSite()
|
||||
self.factory = RequestFactory()
|
||||
self.admin_user = User.objects.create_superuser(email="admin@example.com", password="Pass123456!")
|
||||
vendor_user = User.objects.create_user(
|
||||
email="vendor-admin@example.com",
|
||||
password="Pass123456!",
|
||||
is_vendor=True,
|
||||
is_customer=False,
|
||||
)
|
||||
self.vendor_profile = VendorProfile.objects.create(user=vendor_user, business_name="Admin Vendor")
|
||||
customer = User.objects.create_user(
|
||||
email="customer-admin@example.com",
|
||||
password="Pass123456!",
|
||||
is_vendor=False,
|
||||
is_customer=True,
|
||||
)
|
||||
category = EquipmentCategory.objects.create(name="Sail", slug="sail")
|
||||
item = EquipmentItem.objects.create(
|
||||
vendor=self.vendor_profile,
|
||||
category=category,
|
||||
title="Sail Boat",
|
||||
public_id="sail-001",
|
||||
price_per_day="90.00",
|
||||
is_active=True,
|
||||
)
|
||||
starts = timezone.now() + timedelta(days=7)
|
||||
ends = starts + timedelta(days=1)
|
||||
self.booking = Booking.objects.create(
|
||||
customer=customer,
|
||||
vendor=self.vendor_profile,
|
||||
equipment_item=item,
|
||||
starts_at=starts,
|
||||
ends_at=ends,
|
||||
status=Booking.Status.REQUESTED,
|
||||
total_price="90.00",
|
||||
)
|
||||
|
||||
def test_admin_model_registration_smoke(self):
|
||||
admin_obj = BookingAdmin(Booking, self.site)
|
||||
self.assertIsNotNone(admin_obj)
|
||||
|
||||
def test_admin_action_status_update(self):
|
||||
request = self.factory.post("/admin/booking/booking/")
|
||||
request.user = self.admin_user
|
||||
mark_approved(None, request, Booking.objects.filter(id=self.booking.id))
|
||||
self.booking.refresh_from_db()
|
||||
self.assertEqual(self.booking.status, Booking.Status.APPROVED)
|
||||
23
booking/urls.py
Normal file
23
booking/urls.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import (
|
||||
AvailabilityView,
|
||||
BookingCreateView,
|
||||
BookingDetailView,
|
||||
BookingListView,
|
||||
CustomerCancelBookingView,
|
||||
VendorApproveBookingView,
|
||||
VendorDeclineBookingView,
|
||||
health_check,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path("health/", health_check, name="health_check"),
|
||||
path("availability/", AvailabilityView.as_view(), name="availability"),
|
||||
path("bookings/", BookingListView.as_view(), name="booking_list"),
|
||||
path("bookings/request/", BookingCreateView.as_view(), name="booking_request"),
|
||||
path("bookings/<int:pk>/", BookingDetailView.as_view(), name="booking_detail"),
|
||||
path("bookings/<int:pk>/approve/", VendorApproveBookingView.as_view(), name="booking_approve"),
|
||||
path("bookings/<int:pk>/decline/", VendorDeclineBookingView.as_view(), name="booking_decline"),
|
||||
path("bookings/<int:pk>/cancel/", CustomerCancelBookingView.as_view(), name="booking_cancel"),
|
||||
]
|
||||
169
booking/views.py
Normal file
169
booking/views.py
Normal file
@@ -0,0 +1,169 @@
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework import generics, permissions, status
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from .models import Booking
|
||||
from .serializers import BookingCreateSerializer, BookingSerializer
|
||||
from .services import is_bookable, transition_booking_status
|
||||
|
||||
|
||||
def health_check(request):
|
||||
return JsonResponse({"status": "ok", "service": "WaterTrek"})
|
||||
|
||||
|
||||
class AvailabilityView(APIView):
|
||||
permission_classes = (permissions.AllowAny,)
|
||||
|
||||
def get(self, request):
|
||||
equipment_item_id = request.query_params.get("equipment_item_id")
|
||||
adventure_offering_id = request.query_params.get("adventure_offering_id")
|
||||
starts_at = request.query_params.get("starts_at")
|
||||
ends_at = request.query_params.get("ends_at")
|
||||
if not starts_at or not ends_at:
|
||||
return Response(
|
||||
{"detail": "starts_at and ends_at are required query params."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if bool(equipment_item_id) == bool(adventure_offering_id):
|
||||
return Response(
|
||||
{"detail": "Provide exactly one target: equipment_item_id or adventure_offering_id."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
payload = {"starts_at": starts_at, "ends_at": ends_at}
|
||||
if equipment_item_id:
|
||||
payload["equipment_item_id"] = equipment_item_id
|
||||
if adventure_offering_id:
|
||||
payload["adventure_offering_id"] = adventure_offering_id
|
||||
serializer = BookingCreateSerializer(data=payload)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
equipment_item = serializer.validated_data.get("equipment_item_id")
|
||||
adventure_offering = serializer.validated_data.get("adventure_offering_id")
|
||||
starts_at_value = serializer.validated_data["starts_at"]
|
||||
ends_at_value = serializer.validated_data["ends_at"]
|
||||
conflict_filter = {
|
||||
"starts_at__lt": ends_at_value,
|
||||
"ends_at__gt": starts_at_value,
|
||||
"status__in": [Booking.Status.REQUESTED, Booking.Status.APPROVED, Booking.Status.CONFIRMED],
|
||||
}
|
||||
if equipment_item:
|
||||
conflict_filter["equipment_item"] = equipment_item
|
||||
else:
|
||||
conflict_filter["adventure_offering"] = adventure_offering
|
||||
conflicts = Booking.objects.filter(**conflict_filter).count()
|
||||
available = is_bookable(
|
||||
equipment_item=equipment_item,
|
||||
adventure_offering=adventure_offering,
|
||||
starts_at=starts_at_value,
|
||||
ends_at=ends_at_value,
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"equipment_item_id": equipment_item.id if equipment_item else None,
|
||||
"adventure_offering_id": adventure_offering.id if adventure_offering else None,
|
||||
"starts_at": starts_at_value,
|
||||
"ends_at": ends_at_value,
|
||||
"is_available": available,
|
||||
"conflicts": conflicts,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class BookingCreateView(generics.CreateAPIView):
|
||||
serializer_class = BookingCreateSerializer
|
||||
permission_classes = (permissions.IsAuthenticated,)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
if not self.request.user.is_customer:
|
||||
raise PermissionDenied("Only customers can request bookings.")
|
||||
self.instance = serializer.save()
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_create(serializer)
|
||||
return Response(BookingSerializer(self.instance).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class BookingListView(generics.ListAPIView):
|
||||
serializer_class = BookingSerializer
|
||||
permission_classes = (permissions.IsAuthenticated,)
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
if user.is_vendor and hasattr(user, "vendor_profile"):
|
||||
return Booking.objects.filter(vendor=user.vendor_profile).select_related(
|
||||
"customer", "vendor", "equipment_item", "adventure_offering"
|
||||
)
|
||||
return Booking.objects.filter(customer=user).select_related("customer", "vendor", "equipment_item", "adventure_offering")
|
||||
|
||||
|
||||
class BookingDetailView(generics.RetrieveAPIView):
|
||||
serializer_class = BookingSerializer
|
||||
permission_classes = (permissions.IsAuthenticated,)
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
if user.is_vendor and hasattr(user, "vendor_profile"):
|
||||
return Booking.objects.filter(vendor=user.vendor_profile).select_related(
|
||||
"customer", "vendor", "equipment_item", "adventure_offering"
|
||||
)
|
||||
return Booking.objects.filter(customer=user).select_related("customer", "vendor", "equipment_item", "adventure_offering")
|
||||
|
||||
|
||||
class VendorApproveBookingView(APIView):
|
||||
permission_classes = (permissions.IsAuthenticated,)
|
||||
|
||||
def post(self, request, pk):
|
||||
if not request.user.is_vendor or not hasattr(request.user, "vendor_profile"):
|
||||
raise PermissionDenied("Only vendors can approve bookings.")
|
||||
booking = get_object_or_404(Booking, pk=pk, vendor=request.user.vendor_profile)
|
||||
if booking.status != Booking.Status.REQUESTED:
|
||||
return Response({"detail": "Only requested bookings can be approved."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
transition_booking_status(
|
||||
booking=booking,
|
||||
to_status=Booking.Status.APPROVED,
|
||||
actor=request.user,
|
||||
note=request.data.get("note", "Booking approved."),
|
||||
vendor_notes=request.data.get("vendor_notes", booking.vendor_notes),
|
||||
)
|
||||
return Response(BookingSerializer(booking).data)
|
||||
|
||||
|
||||
class VendorDeclineBookingView(APIView):
|
||||
permission_classes = (permissions.IsAuthenticated,)
|
||||
|
||||
def post(self, request, pk):
|
||||
if not request.user.is_vendor or not hasattr(request.user, "vendor_profile"):
|
||||
raise PermissionDenied("Only vendors can decline bookings.")
|
||||
booking = get_object_or_404(Booking, pk=pk, vendor=request.user.vendor_profile)
|
||||
if booking.status != Booking.Status.REQUESTED:
|
||||
return Response({"detail": "Only requested bookings can be declined."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
transition_booking_status(
|
||||
booking=booking,
|
||||
to_status=Booking.Status.DECLINED,
|
||||
actor=request.user,
|
||||
note=request.data.get("note", "Booking declined."),
|
||||
vendor_notes=request.data.get("vendor_notes", booking.vendor_notes),
|
||||
)
|
||||
return Response(BookingSerializer(booking).data)
|
||||
|
||||
|
||||
class CustomerCancelBookingView(APIView):
|
||||
permission_classes = (permissions.IsAuthenticated,)
|
||||
|
||||
def post(self, request, pk):
|
||||
booking = get_object_or_404(Booking, pk=pk, customer=request.user)
|
||||
if booking.status in [Booking.Status.CANCELLED, Booking.Status.DECLINED]:
|
||||
return Response({"detail": "This booking cannot be cancelled."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
transition_booking_status(
|
||||
booking=booking,
|
||||
to_status=Booking.Status.CANCELLED,
|
||||
actor=request.user,
|
||||
note=request.data.get("note", "Booking cancelled by customer."),
|
||||
)
|
||||
return Response(BookingSerializer(booking).data)
|
||||
Reference in New Issue
Block a user