inital commit
This commit is contained in:
1
equipment/__init__.py
Normal file
1
equipment/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
23
equipment/admin.py
Normal file
23
equipment/admin.py
Normal 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
6
equipment/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class EquipmentConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "equipment"
|
||||
60
equipment/migrations/0001_initial.py
Normal file
60
equipment/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='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'),
|
||||
},
|
||||
),
|
||||
]
|
||||
1
equipment/migrations/__init__.py
Normal file
1
equipment/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
45
equipment/models.py
Normal file
45
equipment/models.py
Normal 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
58
equipment/serializers.py
Normal 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
111
equipment/tests.py
Normal 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
14
equipment/urls.py
Normal 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
118
equipment/views.py
Normal 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,
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user