Files
booking_backend/marketing/views.py
2026-04-10 20:51:43 -05:00

205 lines
8.9 KiB
Python

from django.db.models import Count
from django.utils import timezone
from django.utils.dateparse import parse_datetime
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 booking.models import Booking
from .models import ListingClick
from .serializers import ListingClickSerializer, TrackListingClickSerializer
from .services import record_adventure_listing_click, record_equipment_listing_click, record_listing_click_from_payload
def _parse_range(request):
from_raw = request.query_params.get("from")
to_raw = request.query_params.get("to")
if not from_raw or not to_raw:
return None, None, Response(
{"detail": "Query params 'from' and 'to' are required (ISO-8601 datetimes, UTC)."},
status=status.HTTP_400_BAD_REQUEST,
)
start = parse_datetime(from_raw)
end = parse_datetime(to_raw)
if not start or not end:
return None, None, Response({"detail": "Invalid 'from' or 'to' datetime."}, status=status.HTTP_400_BAD_REQUEST)
if timezone.is_naive(start):
start = timezone.make_aware(start, timezone.utc)
if timezone.is_naive(end):
end = timezone.make_aware(end, timezone.utc)
if end <= start:
return None, None, Response({"detail": "'to' must be after 'from'."}, status=status.HTTP_400_BAD_REQUEST)
return start, end, None
class TrackListingClickView(APIView):
"""Explicit click-through when the client does not refetch the public detail endpoint."""
permission_classes = (permissions.AllowAny,)
def post(self, request):
ser = TrackListingClickSerializer(data=request.data)
ser.is_valid(raise_exception=True)
listing_type = ser.validated_data["listing_type"]
user = request.user if request.user.is_authenticated else None
if listing_type == ListingClick.ListingType.EQUIPMENT:
item = ser.validated_data["_equipment_item"]
click = record_listing_click_from_payload(
listing_type=listing_type,
equipment_item=item,
utm_source=ser.validated_data.get("utm_source", ""),
utm_medium=ser.validated_data.get("utm_medium", ""),
utm_campaign=ser.validated_data.get("utm_campaign", ""),
utm_term=ser.validated_data.get("utm_term", ""),
utm_content=ser.validated_data.get("utm_content", ""),
gclid=ser.validated_data.get("gclid", ""),
fbclid=ser.validated_data.get("fbclid", ""),
referrer=ser.validated_data.get("referrer", ""),
user=user,
)
else:
offering = ser.validated_data["_adventure_offering"]
click = record_listing_click_from_payload(
listing_type=listing_type,
adventure_offering=offering,
utm_source=ser.validated_data.get("utm_source", ""),
utm_medium=ser.validated_data.get("utm_medium", ""),
utm_campaign=ser.validated_data.get("utm_campaign", ""),
utm_term=ser.validated_data.get("utm_term", ""),
utm_content=ser.validated_data.get("utm_content", ""),
gclid=ser.validated_data.get("gclid", ""),
fbclid=ser.validated_data.get("fbclid", ""),
referrer=ser.validated_data.get("referrer", ""),
user=user,
)
return Response({"marketing_click_id": click.id, "traffic_type": click.traffic_type}, status=status.HTTP_201_CREATED)
class VendorListingClickListView(generics.ListAPIView):
serializer_class = ListingClickSerializer
permission_classes = (permissions.IsAuthenticated,)
def get_queryset(self):
user = self.request.user
if not user.is_vendor or not hasattr(user, "vendor_profile"):
raise PermissionDenied("Only vendors can list listing clicks.")
vp = user.vendor_profile
qs = ListingClick.objects.filter(vendor=vp).select_related("equipment_item", "adventure_offering")
start, end, err = _parse_range(self.request)
if err:
return ListingClick.objects.none()
qs = qs.filter(created_at__gte=start, created_at__lt=end)
tt = self.request.query_params.get("traffic_type")
if tt in (ListingClick.TrafficType.ORGANIC, ListingClick.TrafficType.MARKETING):
qs = qs.filter(traffic_type=tt)
lt = self.request.query_params.get("listing_type")
if lt in (ListingClick.ListingType.EQUIPMENT, ListingClick.ListingType.ADVENTURE):
qs = qs.filter(listing_type=lt)
campaign = self.request.query_params.get("utm_campaign")
if campaign:
qs = qs.filter(utm_campaign=campaign)
return qs
def list(self, request, *args, **kwargs):
start, end, err = _parse_range(request)
if err:
return err
return super().list(request, *args, **kwargs)
class VendorMarketingSummaryView(APIView):
permission_classes = (permissions.IsAuthenticated,)
def get(self, request):
user = request.user
if not user.is_vendor or not hasattr(user, "vendor_profile"):
raise PermissionDenied("Only vendors can view marketing summary.")
start, end, err = _parse_range(request)
if err:
return err
vp = user.vendor_profile
clicks_qs = ListingClick.objects.filter(vendor=vp, created_at__gte=start, created_at__lt=end)
click_counts = clicks_qs.values("traffic_type").annotate(c=Count("id"))
clicks_by_traffic = {row["traffic_type"]: row["c"] for row in click_counts}
organic_clicks = clicks_by_traffic.get(ListingClick.TrafficType.ORGANIC, 0)
marketing_clicks = clicks_by_traffic.get(ListingClick.TrafficType.MARKETING, 0)
bookings_qs = Booking.objects.filter(vendor=vp, created_at__gte=start, created_at__lt=end)
attributed = bookings_qs.filter(listing_click__isnull=False)
unattributed_bookings = bookings_qs.filter(listing_click__isnull=True).count()
organic_bookings = attributed.filter(listing_click__traffic_type=ListingClick.TrafficType.ORGANIC).count()
marketing_bookings = attributed.filter(listing_click__traffic_type=ListingClick.TrafficType.MARKETING).count()
def rate(bookings: int, clicks: int) -> float | None:
if clicks == 0:
return None
return round(bookings / clicks, 6)
campaign_rows = (
clicks_qs.exclude(utm_campaign="")
.values("utm_source", "utm_medium", "utm_campaign")
.annotate(clicks=Count("id"))
.order_by("-clicks")[:50]
)
campaign_bookings = (
attributed.filter(listing_click__utm_campaign__gt="")
.values(
"listing_click__utm_source",
"listing_click__utm_medium",
"listing_click__utm_campaign",
)
.annotate(bookings=Count("id"))
)
booking_key_counts = {
(
row["listing_click__utm_source"] or "",
row["listing_click__utm_medium"] or "",
row["listing_click__utm_campaign"] or "",
): row["bookings"]
for row in campaign_bookings
}
campaigns = []
for row in campaign_rows:
key = (row["utm_source"] or "", row["utm_medium"] or "", row["utm_campaign"] or "")
c_clicks = row["clicks"]
c_bookings = booking_key_counts.get(key, 0)
campaigns.append(
{
"utm_source": row["utm_source"],
"utm_medium": row["utm_medium"],
"utm_campaign": row["utm_campaign"],
"clicks": c_clicks,
"bookings": c_bookings,
"conversion_rate": rate(c_bookings, c_clicks),
}
)
return Response(
{
"from": start,
"to": end,
"clicks": {
"organic": organic_clicks,
"marketing": marketing_clicks,
"total": organic_clicks + marketing_clicks,
},
"bookings_attributed": {
"organic": organic_bookings,
"marketing": marketing_bookings,
"total_attributed": organic_bookings + marketing_bookings,
"unattributed": unattributed_bookings,
"total_all": organic_bookings + marketing_bookings + unattributed_bookings,
},
"conversion_rate_click_to_booking": {
"organic": rate(organic_bookings, organic_clicks),
"marketing": rate(marketing_bookings, marketing_clicks),
},
"campaigns": campaigns,
}
)