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
adventrues/__init__.py Normal file
View File

@@ -0,0 +1 @@

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

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

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

View File

@@ -0,0 +1 @@

45
adventrues/models.py Normal file
View 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
View 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
View 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
View 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
View 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,
}
)