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

@@ -0,0 +1 @@

23
equipment/admin.py Normal file
View File

@@ -0,0 +1,23 @@
from django.contrib import admin
from .models import EquipmentCategory, EquipmentImage, EquipmentItem
class EquipmentImageInline(admin.TabularInline):
model = EquipmentImage
extra = 0
@admin.register(EquipmentCategory)
class EquipmentCategoryAdmin(admin.ModelAdmin):
list_display = ("name", "slug")
search_fields = ("name", "slug")
@admin.register(EquipmentItem)
class EquipmentItemAdmin(admin.ModelAdmin):
list_display = ("title", "public_id", "vendor", "category", "price_per_day", "is_active", "created_at")
list_filter = ("is_active", "category", "vendor")
search_fields = ("title", "public_id", "vendor__business_name")
readonly_fields = ("created_at", "updated_at")
inlines = [EquipmentImageInline]

6
equipment/apps.py Normal file
View File

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

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='EquipmentCategory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=64, unique=True)),
('slug', models.SlugField(max_length=64, unique=True)),
('description', models.TextField(blank=True)),
],
),
migrations.CreateModel(
name='EquipmentItem',
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)),
('details', models.JSONField(blank=True, default=dict)),
('location', models.CharField(blank=True, max_length=255)),
('price_per_day', 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='items', to='equipment.equipmentcategory')),
('vendor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='equipment_items', to='accounts.vendorprofile')),
],
options={
'ordering': ('-created_at',),
},
),
migrations.CreateModel(
name='EquipmentImage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(upload_to='equipment_images/')),
('alt_text', models.CharField(blank=True, max_length=255)),
('sort_order', models.PositiveIntegerField(default=0)),
('is_primary', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='equipment.equipmentitem')),
],
options={
'ordering': ('sort_order', 'id'),
},
),
]

View File

@@ -0,0 +1 @@

45
equipment/models.py Normal file
View File

@@ -0,0 +1,45 @@
from django.db import models
class EquipmentCategory(models.Model):
name = models.CharField(max_length=64, unique=True)
slug = models.SlugField(max_length=64, unique=True)
description = models.TextField(blank=True)
def __str__(self) -> str:
return self.name
class EquipmentItem(models.Model):
vendor = models.ForeignKey("accounts.VendorProfile", on_delete=models.CASCADE, related_name="equipment_items")
category = models.ForeignKey(EquipmentCategory, on_delete=models.PROTECT, related_name="items")
title = models.CharField(max_length=255)
public_id = models.CharField(max_length=64, unique=True)
description = models.TextField(blank=True)
details = models.JSONField(default=dict, blank=True)
location = models.CharField(max_length=255, blank=True)
price_per_day = 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 EquipmentImage(models.Model):
item = models.ForeignKey(EquipmentItem, on_delete=models.CASCADE, related_name="images")
image = models.ImageField(upload_to="equipment_images/")
alt_text = models.CharField(max_length=255, blank=True)
sort_order = models.PositiveIntegerField(default=0)
is_primary = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ("sort_order", "id")
def __str__(self) -> str:
return f"Image for {self.item.public_id}"

58
equipment/serializers.py Normal file
View File

@@ -0,0 +1,58 @@
from rest_framework import serializers
from .models import EquipmentCategory, EquipmentImage, EquipmentItem
class EquipmentCategorySerializer(serializers.ModelSerializer):
class Meta:
model = EquipmentCategory
fields = ("id", "name", "slug", "description")
class EquipmentImageSerializer(serializers.ModelSerializer):
image_url = serializers.SerializerMethodField()
class Meta:
model = EquipmentImage
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 EquipmentItemSerializer(serializers.ModelSerializer):
category = EquipmentCategorySerializer(read_only=True)
category_id = serializers.PrimaryKeyRelatedField(
queryset=EquipmentCategory.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 = EquipmentImageSerializer(many=True, read_only=True)
class Meta:
model = EquipmentItem
fields = (
"id",
"public_id",
"title",
"description",
"details",
"location",
"price_per_day",
"is_active",
"category",
"category_id",
"vendor_slug",
"vendor_business_name",
"images",
"created_at",
"updated_at",
)
read_only_fields = ("created_at", "updated_at")

111
equipment/tests.py Normal file
View File

@@ -0,0 +1,111 @@
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 booking.models import Booking
from equipment.models import EquipmentCategory, EquipmentItem
User = get_user_model()
class EquipmentApiTests(APITestCase):
def setUp(self):
self.vendor_user = User.objects.create_user(
email="vendor@example.com",
password="Pass123456!",
is_vendor=True,
is_customer=False,
)
self.vendor_profile = VendorProfile.objects.create(user=self.vendor_user, business_name="Blue Boats")
self.customer_user = User.objects.create_user(
email="customer@example.com",
password="Pass123456!",
is_vendor=False,
is_customer=True,
)
self.category = EquipmentCategory.objects.create(name="Boat", slug="boat")
self.item = EquipmentItem.objects.create(
vendor=self.vendor_profile,
category=self.category,
title="Speed Boat",
public_id="speed-001",
location="Miami",
price_per_day="200.00",
is_active=True,
)
def test_public_equipment_list_detail_and_filter(self):
list_res = self.client.get("/api/v1/equipment/items/")
self.assertEqual(list_res.status_code, status.HTTP_200_OK)
self.assertEqual(len(list_res.data), 1)
filter_res = self.client.get("/api/v1/equipment/items/?location=miami&category=boat")
self.assertEqual(filter_res.status_code, status.HTTP_200_OK)
self.assertEqual(len(filter_res.data), 1)
detail_res = self.client.get("/api/v1/equipment/items/speed-001/")
self.assertEqual(detail_res.status_code, status.HTTP_200_OK)
self.assertEqual(detail_res.data["public_id"], "speed-001")
def test_vendor_crud_access_control(self):
unauth_create = self.client.post(
"/api/v1/equipment/vendor/items/",
{
"public_id": "unauth-1",
"title": "No Access",
"price_per_day": "10.00",
"category_id": self.category.id,
},
format="json",
)
self.assertEqual(unauth_create.status_code, status.HTTP_401_UNAUTHORIZED)
self.client.force_authenticate(self.customer_user)
customer_create = self.client.post(
"/api/v1/equipment/vendor/items/",
{
"public_id": "cust-1",
"title": "Customer Create",
"price_per_day": "10.00",
"category_id": self.category.id,
},
format="json",
)
self.assertEqual(customer_create.status_code, status.HTTP_403_FORBIDDEN)
self.client.force_authenticate(self.vendor_user)
vendor_create = self.client.post(
"/api/v1/equipment/vendor/items/",
{
"public_id": "vendor-1",
"title": "Vendor Create",
"price_per_day": "150.00",
"category_id": self.category.id,
"location": "Miami Beach",
},
format="json",
)
self.assertEqual(vendor_create.status_code, status.HTTP_201_CREATED)
self.assertEqual(vendor_create.data["vendor_slug"], self.vendor_profile.slug)
def test_public_list_excludes_unavailable_range(self):
starts = timezone.now() + timedelta(days=2)
ends = starts + timedelta(days=1)
Booking.objects.create(
customer=self.customer_user,
vendor=self.vendor_profile,
equipment_item=self.item,
starts_at=starts,
ends_at=ends,
status=Booking.Status.REQUESTED,
total_price="200.00",
)
res = self.client.get(
f"/api/v1/equipment/items/?available_from={starts.isoformat()}&available_to={ends.isoformat()}"
)
self.assertEqual(res.status_code, status.HTTP_200_OK)
self.assertEqual(len(res.data), 0)

14
equipment/urls.py Normal file
View File

@@ -0,0 +1,14 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from .views import PublicEquipmentDetailView, PublicEquipmentListView, VendorEquipmentViewSet, VendorStorefrontView
router = DefaultRouter()
router.register("vendor/items", VendorEquipmentViewSet, basename="vendor-equipment-item")
urlpatterns = [
path("", include(router.urls)),
path("items/", PublicEquipmentListView.as_view(), name="equipment_public_list"),
path("items/<str:public_id>/", PublicEquipmentDetailView.as_view(), name="equipment_public_detail"),
path("storefront/<slug:slug>/", VendorStorefrontView.as_view(), name="vendor_storefront"),
]

118
equipment/views.py Normal file
View File

@@ -0,0 +1,118 @@
from django.db.models import Q
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 EquipmentListingClickTrackingMixin
from .models import EquipmentItem
from .serializers import EquipmentItemSerializer
class PublicEquipmentListView(generics.ListAPIView):
serializer_class = EquipmentItemSerializer
permission_classes = (permissions.AllowAny,)
def get_queryset(self):
queryset = EquipmentItem.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(location__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_day__gte=min_price)
max_price = params.get("max_price")
if max_price:
queryset = queryset.filter(price_per_day__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_item_ids = (
Booking.objects.filter(
equipment_item__isnull=False,
status__in=[Booking.Status.REQUESTED, Booking.Status.APPROVED, Booking.Status.CONFIRMED],
)
.filter(starts_at__lt=end, ends_at__gt=start)
.values_list("equipment_item_id", flat=True)
)
queryset = queryset.exclude(id__in=blocked_item_ids)
return queryset
class PublicEquipmentDetailView(EquipmentListingClickTrackingMixin, generics.RetrieveAPIView):
serializer_class = EquipmentItemSerializer
permission_classes = (permissions.AllowAny,)
lookup_field = "public_id"
def get_queryset(self):
return EquipmentItem.objects.filter(is_active=True).select_related("category", "vendor").prefetch_related("images")
class VendorEquipmentViewSet(viewsets.ModelViewSet):
serializer_class = EquipmentItemSerializer
permission_classes = (permissions.IsAuthenticated,)
def get_queryset(self):
user = self.request.user
if not user.is_vendor or not hasattr(user, "vendor_profile"):
return EquipmentItem.objects.none()
return (
EquipmentItem.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 equipment.")
serializer.save(vendor=user.vendor_profile)
class VendorStorefrontView(APIView):
permission_classes = (permissions.AllowAny,)
def get(self, request, slug):
items = (
EquipmentItem.objects.filter(vendor__slug=slug, is_active=True)
.select_related("category", "vendor")
.prefetch_related("images")
)
if not items.exists():
return Response({"detail": "Vendor storefront not found."}, status=404)
vendor = items.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,
},
"items": EquipmentItemSerializer(items, many=True, context={"request": request}).data,
}
)