inital commit
This commit is contained in:
0
marketing/__init__.py
Normal file
0
marketing/__init__.py
Normal file
11
marketing/admin.py
Normal file
11
marketing/admin.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import ListingClick
|
||||
|
||||
|
||||
@admin.register(ListingClick)
|
||||
class ListingClickAdmin(admin.ModelAdmin):
|
||||
list_display = ("id", "vendor", "listing_type", "traffic_type", "utm_campaign", "utm_source", "created_at")
|
||||
list_filter = ("listing_type", "traffic_type")
|
||||
search_fields = ("utm_campaign", "utm_source", "utm_medium", "vendor__business_name")
|
||||
raw_id_fields = ("vendor", "equipment_item", "adventure_offering", "user")
|
||||
7
marketing/apps.py
Normal file
7
marketing/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MarketingConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "marketing"
|
||||
verbose_name = "Marketing"
|
||||
46
marketing/migrations/0001_initial.py
Normal file
46
marketing/migrations/0001_initial.py
Normal file
@@ -0,0 +1,46 @@
|
||||
# Generated by Django 6.0.4 on 2026-04-11 01:49
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0002_alter_user_managers'),
|
||||
('adventrues', '0001_initial'),
|
||||
('equipment', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ListingClick',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('listing_type', models.CharField(choices=[('equipment', 'Equipment'), ('adventure', 'Adventure')], max_length=16)),
|
||||
('traffic_type', models.CharField(choices=[('organic', 'Organic'), ('marketing', 'Marketing')], max_length=16)),
|
||||
('utm_source', models.CharField(blank=True, max_length=255)),
|
||||
('utm_medium', models.CharField(blank=True, max_length=255)),
|
||||
('utm_campaign', models.CharField(blank=True, max_length=255)),
|
||||
('utm_term', models.CharField(blank=True, max_length=255)),
|
||||
('utm_content', models.CharField(blank=True, max_length=255)),
|
||||
('gclid', models.CharField(blank=True, max_length=255)),
|
||||
('fbclid', models.CharField(blank=True, max_length=255)),
|
||||
('referrer', models.TextField(blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('adventure_offering', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='marketing_clicks', to='adventrues.adventureoffering')),
|
||||
('equipment_item', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='marketing_clicks', to='equipment.equipmentitem')),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='listing_clicks', to=settings.AUTH_USER_MODEL)),
|
||||
('vendor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='listing_clicks', to='accounts.vendorprofile')),
|
||||
],
|
||||
options={
|
||||
'ordering': ('-created_at',),
|
||||
'indexes': [models.Index(fields=['vendor', 'created_at'], name='marketing_l_vendor__341231_idx'), models.Index(fields=['vendor', 'traffic_type', 'created_at'], name='marketing_l_vendor__d03035_idx'), models.Index(fields=['utm_campaign', 'created_at'], name='marketing_l_utm_cam_f608ac_idx')],
|
||||
'constraints': [models.CheckConstraint(condition=models.Q(models.Q(('adventure_offering__isnull', True), ('equipment_item__isnull', False), ('listing_type', 'equipment')), models.Q(('adventure_offering__isnull', False), ('equipment_item__isnull', True), ('listing_type', 'adventure')), _connector='OR'), name='listing_click_exactly_one_listing')],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
marketing/migrations/__init__.py
Normal file
0
marketing/migrations/__init__.py
Normal file
25
marketing/mixins.py
Normal file
25
marketing/mixins.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from rest_framework.response import Response
|
||||
|
||||
from .services import record_adventure_listing_click, record_equipment_listing_click
|
||||
|
||||
|
||||
class EquipmentListingClickTrackingMixin:
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
click = record_equipment_listing_click(request, instance)
|
||||
serializer = self.get_serializer(instance)
|
||||
data = dict(serializer.data)
|
||||
data["marketing_click_id"] = click.id
|
||||
data["click_traffic_type"] = click.traffic_type
|
||||
return Response(data)
|
||||
|
||||
|
||||
class AdventureListingClickTrackingMixin:
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
click = record_adventure_listing_click(request, instance)
|
||||
serializer = self.get_serializer(instance)
|
||||
data = dict(serializer.data)
|
||||
data["marketing_click_id"] = click.id
|
||||
data["click_traffic_type"] = click.traffic_type
|
||||
return Response(data)
|
||||
67
marketing/models.py
Normal file
67
marketing/models.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
|
||||
|
||||
class ListingClick(models.Model):
|
||||
"""Server-side record of a public listing detail view with optional UTM / ad parameters."""
|
||||
|
||||
class ListingType(models.TextChoices):
|
||||
EQUIPMENT = "equipment", "Equipment"
|
||||
ADVENTURE = "adventure", "Adventure"
|
||||
|
||||
class TrafficType(models.TextChoices):
|
||||
ORGANIC = "organic", "Organic"
|
||||
MARKETING = "marketing", "Marketing"
|
||||
|
||||
vendor = models.ForeignKey("accounts.VendorProfile", on_delete=models.CASCADE, related_name="listing_clicks")
|
||||
listing_type = models.CharField(max_length=16, choices=ListingType.choices)
|
||||
equipment_item = models.ForeignKey(
|
||||
"equipment.EquipmentItem",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="marketing_clicks",
|
||||
)
|
||||
adventure_offering = models.ForeignKey(
|
||||
"adventrues.AdventureOffering",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="marketing_clicks",
|
||||
)
|
||||
traffic_type = models.CharField(max_length=16, choices=TrafficType.choices)
|
||||
utm_source = models.CharField(max_length=255, blank=True)
|
||||
utm_medium = models.CharField(max_length=255, blank=True)
|
||||
utm_campaign = models.CharField(max_length=255, blank=True)
|
||||
utm_term = models.CharField(max_length=255, blank=True)
|
||||
utm_content = models.CharField(max_length=255, blank=True)
|
||||
gclid = models.CharField(max_length=255, blank=True)
|
||||
fbclid = models.CharField(max_length=255, blank=True)
|
||||
referrer = models.TextField(blank=True)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="listing_clicks",
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ("-created_at",)
|
||||
indexes = [
|
||||
models.Index(fields=["vendor", "created_at"]),
|
||||
models.Index(fields=["vendor", "traffic_type", "created_at"]),
|
||||
models.Index(fields=["utm_campaign", "created_at"]),
|
||||
]
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
condition=Q(listing_type="equipment", equipment_item__isnull=False, adventure_offering__isnull=True)
|
||||
| Q(listing_type="adventure", equipment_item__isnull=True, adventure_offering__isnull=False),
|
||||
name="listing_click_exactly_one_listing",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"ListingClick #{self.pk} ({self.listing_type}, {self.traffic_type})"
|
||||
55
marketing/serializers.py
Normal file
55
marketing/serializers.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from adventrues.models import AdventureOffering
|
||||
from equipment.models import EquipmentItem
|
||||
|
||||
from .models import ListingClick
|
||||
|
||||
|
||||
class ListingClickSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ListingClick
|
||||
fields = (
|
||||
"id",
|
||||
"listing_type",
|
||||
"traffic_type",
|
||||
"utm_source",
|
||||
"utm_medium",
|
||||
"utm_campaign",
|
||||
"utm_term",
|
||||
"utm_content",
|
||||
"gclid",
|
||||
"fbclid",
|
||||
"equipment_item",
|
||||
"adventure_offering",
|
||||
"created_at",
|
||||
)
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class TrackListingClickSerializer(serializers.Serializer):
|
||||
listing_type = serializers.ChoiceField(choices=ListingClick.ListingType.choices)
|
||||
public_id = serializers.CharField(max_length=64)
|
||||
utm_source = serializers.CharField(required=False, allow_blank=True, default="", max_length=255)
|
||||
utm_medium = serializers.CharField(required=False, allow_blank=True, default="", max_length=255)
|
||||
utm_campaign = serializers.CharField(required=False, allow_blank=True, default="", max_length=255)
|
||||
utm_term = serializers.CharField(required=False, allow_blank=True, default="", max_length=255)
|
||||
utm_content = serializers.CharField(required=False, allow_blank=True, default="", max_length=255)
|
||||
gclid = serializers.CharField(required=False, allow_blank=True, default="", max_length=255)
|
||||
fbclid = serializers.CharField(required=False, allow_blank=True, default="", max_length=255)
|
||||
referrer = serializers.CharField(required=False, allow_blank=True, default="", max_length=2048)
|
||||
|
||||
def validate(self, attrs):
|
||||
listing_type = attrs["listing_type"]
|
||||
public_id = attrs["public_id"]
|
||||
if listing_type == ListingClick.ListingType.EQUIPMENT:
|
||||
item = EquipmentItem.objects.filter(public_id=public_id, is_active=True).select_related("vendor").first()
|
||||
if not item:
|
||||
raise serializers.ValidationError({"public_id": "No active equipment listing with this public_id."})
|
||||
attrs["_equipment_item"] = item
|
||||
else:
|
||||
offering = AdventureOffering.objects.filter(public_id=public_id, is_active=True).select_related("vendor").first()
|
||||
if not offering:
|
||||
raise serializers.ValidationError({"public_id": "No active adventure offering with this public_id."})
|
||||
attrs["_adventure_offering"] = offering
|
||||
return attrs
|
||||
182
marketing/services.py
Normal file
182
marketing/services.py
Normal file
@@ -0,0 +1,182 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import ListingClick
|
||||
|
||||
|
||||
def _clean_param(params, key: str, max_len: int = 255) -> str:
|
||||
raw = params.get(key)
|
||||
if raw is None:
|
||||
return ""
|
||||
s = str(raw).strip()
|
||||
return s[:max_len]
|
||||
|
||||
|
||||
def classify_traffic(
|
||||
*,
|
||||
utm_source: str,
|
||||
utm_medium: str,
|
||||
utm_campaign: str,
|
||||
utm_term: str,
|
||||
utm_content: str,
|
||||
gclid: str,
|
||||
fbclid: str,
|
||||
) -> str:
|
||||
if any(
|
||||
(
|
||||
utm_source,
|
||||
utm_medium,
|
||||
utm_campaign,
|
||||
utm_term,
|
||||
utm_content,
|
||||
gclid,
|
||||
fbclid,
|
||||
)
|
||||
):
|
||||
return ListingClick.TrafficType.MARKETING
|
||||
return ListingClick.TrafficType.ORGANIC
|
||||
|
||||
|
||||
def extract_attribution_from_request(request) -> dict:
|
||||
p = request.query_params if hasattr(request, "query_params") else request.GET
|
||||
utm_source = _clean_param(p, "utm_source")
|
||||
utm_medium = _clean_param(p, "utm_medium")
|
||||
utm_campaign = _clean_param(p, "utm_campaign")
|
||||
utm_term = _clean_param(p, "utm_term")
|
||||
utm_content = _clean_param(p, "utm_content")
|
||||
gclid = _clean_param(p, "gclid")
|
||||
fbclid = _clean_param(p, "fbclid")
|
||||
referrer = ""
|
||||
if hasattr(request, "META"):
|
||||
ref = request.META.get("HTTP_REFERER") or ""
|
||||
referrer = ref[:2048]
|
||||
traffic_type = classify_traffic(
|
||||
utm_source=utm_source,
|
||||
utm_medium=utm_medium,
|
||||
utm_campaign=utm_campaign,
|
||||
utm_term=utm_term,
|
||||
utm_content=utm_content,
|
||||
gclid=gclid,
|
||||
fbclid=fbclid,
|
||||
)
|
||||
return {
|
||||
"utm_source": utm_source,
|
||||
"utm_medium": utm_medium,
|
||||
"utm_campaign": utm_campaign,
|
||||
"utm_term": utm_term,
|
||||
"utm_content": utm_content,
|
||||
"gclid": gclid,
|
||||
"fbclid": fbclid,
|
||||
"referrer": referrer,
|
||||
"traffic_type": traffic_type,
|
||||
}
|
||||
|
||||
|
||||
def record_equipment_listing_click(request, equipment_item) -> ListingClick:
|
||||
attrs = extract_attribution_from_request(request)
|
||||
user = request.user if getattr(request, "user", None) and request.user.is_authenticated else None
|
||||
return ListingClick.objects.create(
|
||||
vendor=equipment_item.vendor,
|
||||
listing_type=ListingClick.ListingType.EQUIPMENT,
|
||||
equipment_item=equipment_item,
|
||||
adventure_offering=None,
|
||||
user=user,
|
||||
**attrs,
|
||||
)
|
||||
|
||||
|
||||
def record_adventure_listing_click(request, adventure_offering) -> ListingClick:
|
||||
attrs = extract_attribution_from_request(request)
|
||||
user = request.user if getattr(request, "user", None) and request.user.is_authenticated else None
|
||||
return ListingClick.objects.create(
|
||||
vendor=adventure_offering.vendor,
|
||||
listing_type=ListingClick.ListingType.ADVENTURE,
|
||||
equipment_item=None,
|
||||
adventure_offering=adventure_offering,
|
||||
user=user,
|
||||
**attrs,
|
||||
)
|
||||
|
||||
|
||||
def record_listing_click_from_payload(
|
||||
*,
|
||||
listing_type: str,
|
||||
equipment_item=None,
|
||||
adventure_offering=None,
|
||||
utm_source: str = "",
|
||||
utm_medium: str = "",
|
||||
utm_campaign: str = "",
|
||||
utm_term: str = "",
|
||||
utm_content: str = "",
|
||||
gclid: str = "",
|
||||
fbclid: str = "",
|
||||
referrer: str = "",
|
||||
user=None,
|
||||
) -> ListingClick:
|
||||
traffic_type = classify_traffic(
|
||||
utm_source=utm_source or "",
|
||||
utm_medium=utm_medium or "",
|
||||
utm_campaign=utm_campaign or "",
|
||||
utm_term=utm_term or "",
|
||||
utm_content=utm_content or "",
|
||||
gclid=gclid or "",
|
||||
fbclid=fbclid or "",
|
||||
)
|
||||
if listing_type == ListingClick.ListingType.EQUIPMENT:
|
||||
return ListingClick.objects.create(
|
||||
vendor=equipment_item.vendor,
|
||||
listing_type=ListingClick.ListingType.EQUIPMENT,
|
||||
equipment_item=equipment_item,
|
||||
adventure_offering=None,
|
||||
traffic_type=traffic_type,
|
||||
utm_source=utm_source[:255],
|
||||
utm_medium=utm_medium[:255],
|
||||
utm_campaign=utm_campaign[:255],
|
||||
utm_term=utm_term[:255],
|
||||
utm_content=utm_content[:255],
|
||||
gclid=gclid[:255],
|
||||
fbclid=fbclid[:255],
|
||||
referrer=referrer[:2048],
|
||||
user=user,
|
||||
)
|
||||
return ListingClick.objects.create(
|
||||
vendor=adventure_offering.vendor,
|
||||
listing_type=ListingClick.ListingType.ADVENTURE,
|
||||
equipment_item=None,
|
||||
adventure_offering=adventure_offering,
|
||||
traffic_type=traffic_type,
|
||||
utm_source=utm_source[:255],
|
||||
utm_medium=utm_medium[:255],
|
||||
utm_campaign=utm_campaign[:255],
|
||||
utm_term=utm_term[:255],
|
||||
utm_content=utm_content[:255],
|
||||
gclid=gclid[:255],
|
||||
fbclid=fbclid[:255],
|
||||
referrer=referrer[:2048],
|
||||
user=user,
|
||||
)
|
||||
|
||||
|
||||
ATTRIBUTION_MAX_AGE_DAYS = 90
|
||||
|
||||
|
||||
def listing_click_valid_for_booking(*, click, equipment_item=None, adventure_offering=None) -> bool:
|
||||
if click is None:
|
||||
return False
|
||||
cutoff = timezone.now() - timedelta(days=ATTRIBUTION_MAX_AGE_DAYS)
|
||||
if click.created_at < cutoff:
|
||||
return False
|
||||
if equipment_item is not None:
|
||||
return (
|
||||
click.listing_type == ListingClick.ListingType.EQUIPMENT
|
||||
and click.equipment_item_id == equipment_item.id
|
||||
and click.vendor_id == equipment_item.vendor_id
|
||||
)
|
||||
if adventure_offering is not None:
|
||||
return (
|
||||
click.listing_type == ListingClick.ListingType.ADVENTURE
|
||||
and click.adventure_offering_id == adventure_offering.id
|
||||
and click.vendor_id == adventure_offering.vendor_id
|
||||
)
|
||||
return False
|
||||
91
marketing/tests.py
Normal file
91
marketing/tests.py
Normal file
@@ -0,0 +1,91 @@
|
||||
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
|
||||
from marketing.models import ListingClick
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class MarketingTrackingTests(APITestCase):
|
||||
def setUp(self):
|
||||
self.vendor_user = User.objects.create_user(
|
||||
email="vendor-mkt@example.com",
|
||||
password="Pass123456!",
|
||||
is_vendor=True,
|
||||
is_customer=False,
|
||||
)
|
||||
self.vendor_profile = VendorProfile.objects.create(user=self.vendor_user, business_name="Mkt Vendor")
|
||||
self.customer = User.objects.create_user(
|
||||
email="customer-mkt@example.com",
|
||||
password="Pass123456!",
|
||||
is_vendor=False,
|
||||
is_customer=True,
|
||||
)
|
||||
category = EquipmentCategory.objects.create(name="Kayak", slug="kayak")
|
||||
self.item = EquipmentItem.objects.create(
|
||||
vendor=self.vendor_profile,
|
||||
category=category,
|
||||
title="Kayak 1",
|
||||
public_id="kayak-mkt-001",
|
||||
price_per_day="50.00",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
def test_equipment_detail_logs_click_and_returns_ids(self):
|
||||
res = self.client.get(f"/api/v1/equipment/items/{self.item.public_id}/")
|
||||
self.assertEqual(res.status_code, status.HTTP_200_OK)
|
||||
self.assertIn("marketing_click_id", res.data)
|
||||
self.assertIn("click_traffic_type", res.data)
|
||||
self.assertEqual(res.data["click_traffic_type"], ListingClick.TrafficType.ORGANIC)
|
||||
self.assertEqual(ListingClick.objects.count(), 1)
|
||||
|
||||
def test_equipment_detail_utm_classified_marketing(self):
|
||||
res = self.client.get(
|
||||
f"/api/v1/equipment/items/{self.item.public_id}/",
|
||||
{"utm_source": "google", "utm_medium": "cpc", "utm_campaign": "spring"},
|
||||
)
|
||||
self.assertEqual(res.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(res.data["click_traffic_type"], ListingClick.TrafficType.MARKETING)
|
||||
click = ListingClick.objects.get(pk=res.data["marketing_click_id"])
|
||||
self.assertEqual(click.utm_campaign, "spring")
|
||||
|
||||
def test_track_click_endpoint(self):
|
||||
res = self.client.post(
|
||||
"/api/v1/marketing/track/click/",
|
||||
{"listing_type": "equipment", "public_id": self.item.public_id, "utm_campaign": "email"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(res.status_code, status.HTTP_201_CREATED)
|
||||
self.assertIn("marketing_click_id", res.data)
|
||||
|
||||
def test_booking_with_marketing_click_id(self):
|
||||
detail = self.client.get(f"/api/v1/equipment/items/{self.item.public_id}/")
|
||||
click_id = detail.data["marketing_click_id"]
|
||||
starts = timezone.now() + timedelta(days=10)
|
||||
ends = starts + timedelta(days=1)
|
||||
self.client.force_authenticate(self.customer)
|
||||
res = self.client.post(
|
||||
"/api/v1/booking/bookings/request/",
|
||||
{
|
||||
"equipment_item_id": self.item.id,
|
||||
"starts_at": starts.isoformat(),
|
||||
"ends_at": ends.isoformat(),
|
||||
"marketing_click_id": click_id,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(res.status_code, status.HTTP_201_CREATED)
|
||||
booking = Booking.objects.get(pk=res.data["id"])
|
||||
self.assertEqual(booking.listing_click_id, click_id)
|
||||
|
||||
def test_vendor_summary_requires_range(self):
|
||||
self.client.force_authenticate(self.vendor_user)
|
||||
res = self.client.get("/api/v1/marketing/vendor/summary/")
|
||||
self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
9
marketing/urls.py
Normal file
9
marketing/urls.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import TrackListingClickView, VendorListingClickListView, VendorMarketingSummaryView
|
||||
|
||||
urlpatterns = [
|
||||
path("track/click/", TrackListingClickView.as_view(), name="marketing-track-click"),
|
||||
path("vendor/clicks/", VendorListingClickListView.as_view(), name="marketing-vendor-clicks"),
|
||||
path("vendor/summary/", VendorMarketingSummaryView.as_view(), name="marketing-vendor-summary"),
|
||||
]
|
||||
204
marketing/views.py
Normal file
204
marketing/views.py
Normal file
@@ -0,0 +1,204 @@
|
||||
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,
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user