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, } )