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

1
booking/__init__.py Normal file
View File

@@ -0,0 +1 @@

79
booking/admin.py Normal file
View 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
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class BookingConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "booking"

View 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'),
),
]

View 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'),
),
]

View File

@@ -0,0 +1 @@

104
booking/models.py Normal file
View 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
View 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
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

256
booking/tests.py Normal file
View 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
View 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
View 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)