inital commit
This commit is contained in:
1
adventrues/__init__.py
Normal file
1
adventrues/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
32
adventrues/admin.py
Normal file
32
adventrues/admin.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import AdventureCategory, AdventureImage, AdventureOffering
|
||||
|
||||
|
||||
class AdventureImageInline(admin.TabularInline):
|
||||
model = AdventureImage
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(AdventureCategory)
|
||||
class AdventureCategoryAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "slug")
|
||||
search_fields = ("name", "slug")
|
||||
|
||||
|
||||
@admin.register(AdventureOffering)
|
||||
class AdventureOfferingAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"title",
|
||||
"public_id",
|
||||
"vendor",
|
||||
"category",
|
||||
"duration_minutes",
|
||||
"capacity",
|
||||
"price_per_person",
|
||||
"is_active",
|
||||
)
|
||||
list_filter = ("is_active", "category", "vendor")
|
||||
search_fields = ("title", "public_id", "vendor__business_name")
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
inlines = [AdventureImageInline]
|
||||
6
adventrues/apps.py
Normal file
6
adventrues/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AdventruesConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "adventrues"
|
||||
60
adventrues/migrations/0001_initial.py
Normal file
60
adventrues/migrations/0001_initial.py
Normal file
@@ -0,0 +1,60 @@
|
||||
# Generated by Django 6.0.4 on 2026-04-08 16:53
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AdventureCategory',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, unique=True)),
|
||||
('slug', models.SlugField(max_length=120, unique=True)),
|
||||
('description', models.TextField(blank=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AdventureOffering',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=255)),
|
||||
('public_id', models.CharField(max_length=64, unique=True)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('meeting_point', models.CharField(blank=True, max_length=255)),
|
||||
('duration_minutes', models.PositiveIntegerField()),
|
||||
('capacity', models.PositiveIntegerField(default=1)),
|
||||
('price_per_person', models.DecimalField(decimal_places=2, max_digits=10)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='offerings', to='adventrues.adventurecategory')),
|
||||
('vendor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='adventure_offerings', to='accounts.vendorprofile')),
|
||||
],
|
||||
options={
|
||||
'ordering': ('-created_at',),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AdventureImage',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('image', models.ImageField(upload_to='adventure_images/')),
|
||||
('alt_text', models.CharField(blank=True, max_length=255)),
|
||||
('sort_order', models.PositiveIntegerField(default=0)),
|
||||
('is_primary', models.BooleanField(default=False)),
|
||||
('offering', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='adventrues.adventureoffering')),
|
||||
],
|
||||
options={
|
||||
'ordering': ('sort_order', 'id'),
|
||||
},
|
||||
),
|
||||
]
|
||||
1
adventrues/migrations/__init__.py
Normal file
1
adventrues/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
45
adventrues/models.py
Normal file
45
adventrues/models.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class AdventureCategory(models.Model):
|
||||
name = models.CharField(max_length=100, unique=True)
|
||||
slug = models.SlugField(max_length=120, unique=True)
|
||||
description = models.TextField(blank=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class AdventureOffering(models.Model):
|
||||
vendor = models.ForeignKey("accounts.VendorProfile", on_delete=models.CASCADE, related_name="adventure_offerings")
|
||||
category = models.ForeignKey(AdventureCategory, on_delete=models.PROTECT, related_name="offerings")
|
||||
title = models.CharField(max_length=255)
|
||||
public_id = models.CharField(max_length=64, unique=True)
|
||||
description = models.TextField(blank=True)
|
||||
meeting_point = models.CharField(max_length=255, blank=True)
|
||||
duration_minutes = models.PositiveIntegerField()
|
||||
capacity = models.PositiveIntegerField(default=1)
|
||||
price_per_person = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
is_active = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.title} ({self.public_id})"
|
||||
|
||||
|
||||
class AdventureImage(models.Model):
|
||||
offering = models.ForeignKey(AdventureOffering, on_delete=models.CASCADE, related_name="images")
|
||||
image = models.ImageField(upload_to="adventure_images/")
|
||||
alt_text = models.CharField(max_length=255, blank=True)
|
||||
sort_order = models.PositiveIntegerField(default=0)
|
||||
is_primary = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
ordering = ("sort_order", "id")
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Image for {self.offering.public_id}"
|
||||
59
adventrues/serializers.py
Normal file
59
adventrues/serializers.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import AdventureCategory, AdventureImage, AdventureOffering
|
||||
|
||||
|
||||
class AdventureCategorySerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = AdventureCategory
|
||||
fields = ("id", "name", "slug", "description")
|
||||
|
||||
|
||||
class AdventureImageSerializer(serializers.ModelSerializer):
|
||||
image_url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = AdventureImage
|
||||
fields = ("id", "image_url", "alt_text", "sort_order", "is_primary")
|
||||
|
||||
def get_image_url(self, obj):
|
||||
if not obj.image:
|
||||
return ""
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
return request.build_absolute_uri(obj.image.url)
|
||||
return obj.image.url
|
||||
|
||||
|
||||
class AdventureOfferingSerializer(serializers.ModelSerializer):
|
||||
category = AdventureCategorySerializer(read_only=True)
|
||||
category_id = serializers.PrimaryKeyRelatedField(
|
||||
queryset=AdventureCategory.objects.all(),
|
||||
source="category",
|
||||
write_only=True,
|
||||
)
|
||||
vendor_slug = serializers.CharField(source="vendor.slug", read_only=True)
|
||||
vendor_business_name = serializers.CharField(source="vendor.business_name", read_only=True)
|
||||
images = AdventureImageSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = AdventureOffering
|
||||
fields = (
|
||||
"id",
|
||||
"public_id",
|
||||
"title",
|
||||
"description",
|
||||
"meeting_point",
|
||||
"duration_minutes",
|
||||
"capacity",
|
||||
"price_per_person",
|
||||
"is_active",
|
||||
"category",
|
||||
"category_id",
|
||||
"vendor_slug",
|
||||
"vendor_business_name",
|
||||
"images",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
read_only_fields = ("created_at", "updated_at")
|
||||
104
adventrues/tests.py
Normal file
104
adventrues/tests.py
Normal file
@@ -0,0 +1,104 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
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 booking.models import Booking
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class AdventureApiTests(APITestCase):
|
||||
def setUp(self):
|
||||
self.vendor_user = User.objects.create_user(
|
||||
email="vendor2@example.com",
|
||||
password="Pass123456!",
|
||||
is_vendor=True,
|
||||
is_customer=False,
|
||||
)
|
||||
self.vendor_profile = VendorProfile.objects.create(user=self.vendor_user, business_name="Kayak Co")
|
||||
self.customer_user = User.objects.create_user(
|
||||
email="customer2@example.com",
|
||||
password="Pass123456!",
|
||||
is_vendor=False,
|
||||
is_customer=True,
|
||||
)
|
||||
self.category = AdventureCategory.objects.create(name="Kayak", slug="kayak")
|
||||
self.offering = AdventureOffering.objects.create(
|
||||
vendor=self.vendor_profile,
|
||||
category=self.category,
|
||||
title="Sunset Kayak",
|
||||
public_id="kayak-001",
|
||||
meeting_point="Biscayne Bay",
|
||||
duration_minutes=120,
|
||||
capacity=8,
|
||||
price_per_person="75.00",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
def test_public_adventure_list_detail_and_filter(self):
|
||||
list_res = self.client.get("/api/v1/adventrues/offerings/")
|
||||
self.assertEqual(list_res.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(list_res.data), 1)
|
||||
|
||||
filter_res = self.client.get("/api/v1/adventrues/offerings/?category=kayak&location=biscayne")
|
||||
self.assertEqual(filter_res.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(filter_res.data), 1)
|
||||
|
||||
detail_res = self.client.get("/api/v1/adventrues/offerings/kayak-001/")
|
||||
self.assertEqual(detail_res.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(detail_res.data["public_id"], "kayak-001")
|
||||
|
||||
def test_vendor_adventure_crud_access_control(self):
|
||||
self.client.force_authenticate(self.customer_user)
|
||||
forbidden = self.client.post(
|
||||
"/api/v1/adventrues/vendor/offerings/",
|
||||
{
|
||||
"public_id": "cust-adv",
|
||||
"title": "No Access",
|
||||
"duration_minutes": 90,
|
||||
"capacity": 3,
|
||||
"price_per_person": "20.00",
|
||||
"category_id": self.category.id,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(forbidden.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
self.client.force_authenticate(self.vendor_user)
|
||||
created = self.client.post(
|
||||
"/api/v1/adventrues/vendor/offerings/",
|
||||
{
|
||||
"public_id": "vendor-adv",
|
||||
"title": "Vendor Adventure",
|
||||
"duration_minutes": 90,
|
||||
"capacity": 5,
|
||||
"price_per_person": "55.00",
|
||||
"category_id": self.category.id,
|
||||
"meeting_point": "Harbor",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(created.status_code, status.HTTP_201_CREATED)
|
||||
|
||||
def test_public_adventure_list_excludes_unavailable_range(self):
|
||||
starts = timezone.now() + timedelta(days=4)
|
||||
ends = starts + timedelta(hours=2)
|
||||
Booking.objects.create(
|
||||
customer=self.customer_user,
|
||||
vendor=self.vendor_profile,
|
||||
adventure_offering=self.offering,
|
||||
starts_at=starts,
|
||||
ends_at=ends,
|
||||
status=Booking.Status.APPROVED,
|
||||
total_price="150.00",
|
||||
)
|
||||
res = self.client.get(
|
||||
f"/api/v1/adventrues/offerings/?available_from={starts.isoformat()}&available_to={ends.isoformat()}"
|
||||
)
|
||||
self.assertEqual(res.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(res.data), 0)
|
||||
19
adventrues/urls.py
Normal file
19
adventrues/urls.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import (
|
||||
PublicAdventureDetailView,
|
||||
PublicAdventureListView,
|
||||
VendorAdventureStorefrontView,
|
||||
VendorAdventureViewSet,
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("vendor/offerings", VendorAdventureViewSet, basename="vendor-adventure-offering")
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
path("offerings/", PublicAdventureListView.as_view(), name="adventure_public_list"),
|
||||
path("offerings/<str:public_id>/", PublicAdventureDetailView.as_view(), name="adventure_public_detail"),
|
||||
path("storefront/<slug:slug>/", VendorAdventureStorefrontView.as_view(), name="adventure_vendor_storefront"),
|
||||
]
|
||||
117
adventrues/views.py
Normal file
117
adventrues/views.py
Normal file
@@ -0,0 +1,117 @@
|
||||
from django.utils.dateparse import parse_datetime
|
||||
from rest_framework import generics, permissions, viewsets
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from booking.models import Booking
|
||||
from marketing.mixins import AdventureListingClickTrackingMixin
|
||||
|
||||
from .models import AdventureOffering
|
||||
from .serializers import AdventureOfferingSerializer
|
||||
|
||||
|
||||
class PublicAdventureListView(generics.ListAPIView):
|
||||
serializer_class = AdventureOfferingSerializer
|
||||
permission_classes = (permissions.AllowAny,)
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = AdventureOffering.objects.filter(is_active=True).select_related("category", "vendor").prefetch_related("images")
|
||||
params = self.request.query_params
|
||||
|
||||
category = params.get("category")
|
||||
if category:
|
||||
if category.isdigit():
|
||||
queryset = queryset.filter(category_id=category)
|
||||
else:
|
||||
queryset = queryset.filter(category__slug=category)
|
||||
|
||||
location = params.get("location")
|
||||
if location:
|
||||
queryset = queryset.filter(meeting_point__icontains=location)
|
||||
|
||||
vendor_slug = params.get("vendor_slug")
|
||||
if vendor_slug:
|
||||
queryset = queryset.filter(vendor__slug=vendor_slug)
|
||||
|
||||
min_price = params.get("min_price")
|
||||
if min_price:
|
||||
queryset = queryset.filter(price_per_person__gte=min_price)
|
||||
|
||||
max_price = params.get("max_price")
|
||||
if max_price:
|
||||
queryset = queryset.filter(price_per_person__lte=max_price)
|
||||
|
||||
start = parse_datetime(params.get("available_from", "")) if params.get("available_from") else None
|
||||
end = parse_datetime(params.get("available_to", "")) if params.get("available_to") else None
|
||||
if start and end:
|
||||
blocked_offering_ids = (
|
||||
Booking.objects.filter(
|
||||
adventure_offering__isnull=False,
|
||||
status__in=[Booking.Status.REQUESTED, Booking.Status.APPROVED, Booking.Status.CONFIRMED],
|
||||
)
|
||||
.filter(starts_at__lt=end, ends_at__gt=start)
|
||||
.values_list("adventure_offering_id", flat=True)
|
||||
)
|
||||
queryset = queryset.exclude(id__in=blocked_offering_ids)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class PublicAdventureDetailView(AdventureListingClickTrackingMixin, generics.RetrieveAPIView):
|
||||
serializer_class = AdventureOfferingSerializer
|
||||
permission_classes = (permissions.AllowAny,)
|
||||
lookup_field = "public_id"
|
||||
|
||||
def get_queryset(self):
|
||||
return AdventureOffering.objects.filter(is_active=True).select_related("category", "vendor").prefetch_related("images")
|
||||
|
||||
|
||||
class VendorAdventureViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = AdventureOfferingSerializer
|
||||
permission_classes = (permissions.IsAuthenticated,)
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
if not user.is_vendor or not hasattr(user, "vendor_profile"):
|
||||
return AdventureOffering.objects.none()
|
||||
return (
|
||||
AdventureOffering.objects.filter(vendor=user.vendor_profile)
|
||||
.select_related("category", "vendor")
|
||||
.prefetch_related("images")
|
||||
)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
user = self.request.user
|
||||
if not user.is_vendor or not hasattr(user, "vendor_profile"):
|
||||
raise PermissionDenied("Only vendors can create adventure offerings.")
|
||||
serializer.save(vendor=user.vendor_profile)
|
||||
|
||||
|
||||
class VendorAdventureStorefrontView(APIView):
|
||||
permission_classes = (permissions.AllowAny,)
|
||||
|
||||
def get(self, request, slug):
|
||||
offerings = (
|
||||
AdventureOffering.objects.filter(vendor__slug=slug, is_active=True)
|
||||
.select_related("category", "vendor")
|
||||
.prefetch_related("images")
|
||||
)
|
||||
if not offerings.exists():
|
||||
return Response({"detail": "Vendor adventure storefront not found."}, status=404)
|
||||
|
||||
vendor = offerings.first().vendor
|
||||
return Response(
|
||||
{
|
||||
"vendor": {
|
||||
"business_name": vendor.business_name,
|
||||
"slug": vendor.slug,
|
||||
"description": vendor.description,
|
||||
"contact_email": vendor.contact_email,
|
||||
"contact_phone": vendor.contact_phone,
|
||||
"city": vendor.city,
|
||||
"country": vendor.country,
|
||||
},
|
||||
"offerings": AdventureOfferingSerializer(offerings, many=True, context={"request": request}).data,
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user