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

0
marketing/__init__.py Normal file
View File

11
marketing/admin.py Normal file
View 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
View 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"

View 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')],
},
),
]

View File

25
marketing/mixins.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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,
}
)