1 Commits

Author SHA1 Message Date
03ac2f25ae feat(sprint9): add 5 partner analytics endpoints
revenue-timeseries, ticket-type-breakdown, marketing-funnel,
traffic-sources, retention-heatmap. Funnel/sources return
trackingAvailable=false with notes where no tracking exists.
Heatmap computed from booking created_date by day × time slot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 11:58:41 +05:30
2 changed files with 182 additions and 0 deletions

View File

@@ -44,6 +44,12 @@ urlpatterns = [
path('partners/me/check-in/', views.PartnerMeCheckInView.as_view(), name='partner-me-check-in'),
# Partner-Me: dashboard (Sprint 8)
path('partners/me/dashboard/', views.PartnerDashboardView.as_view(), name='partner-me-dashboard'),
# Partner-Me: analytics (Sprint 9)
path('partners/me/analytics/revenue-timeseries/', views.PartnerAnalyticsRevenueView.as_view(), name='partner-me-analytics-revenue'),
path('partners/me/analytics/ticket-type-breakdown/', views.PartnerAnalyticsTicketTypeView.as_view(), name='partner-me-analytics-ticket-type'),
path('partners/me/analytics/marketing-funnel/', views.PartnerAnalyticsFunnelView.as_view(), name='partner-me-analytics-funnel'),
path('partners/me/analytics/traffic-sources/', views.PartnerAnalyticsTrafficSourcesView.as_view(), name='partner-me-analytics-traffic'),
path('partners/me/analytics/retention-heatmap/', views.PartnerAnalyticsHeatmapView.as_view(), name='partner-me-analytics-heatmap'),
path('users/metrics/', views.UserMetricsView.as_view(), name='user-metrics'),
path('users/', views.UserListView.as_view(), name='user-list'),
path('users/<int:pk>/', views.UserDetailView.as_view(), name='user-detail'),

View File

@@ -4414,3 +4414,179 @@ class PartnerDashboardView(APIView):
'recentBookings': recent_bookings,
'upcomingEvents': upcoming_events,
})
# ─── Sprint 9: Partner Analytics Views ───────────────────────────────────────
class PartnerAnalyticsRevenueView(APIView):
"""GET /api/v1/partners/me/analytics/revenue-timeseries/
Query params: days=30 (default), granularity=daily|weekly
Returns daily/weekly revenue + tickets sold for partner's paid bookings.
"""
permission_classes = [IsAuthenticated]
def get(self, request):
partner = _require_partner(request)
from django.utils import timezone as tz
from django.db.models.functions import TruncDay, TruncWeek
try:
days = max(1, min(int(request.query_params.get('days', 30)), 365))
except (ValueError, TypeError):
days = 30
granularity = request.query_params.get('granularity', 'daily')
end = tz.now().date()
start = end - timedelta(days=days - 1)
qs = Booking.objects.filter(
ticket_meta__event__partner=partner,
payment_status='paid',
created_date__date__gte=start,
created_date__date__lte=end,
)
if granularity == 'weekly':
qs = qs.annotate(period=TruncWeek('created_date')).values('period')
else:
qs = qs.annotate(period=TruncDay('created_date')).values('period')
qs = qs.annotate(
revenue=Sum(
ExpressionWrapper(F('price') * F('quantity'), output_field=DecimalField(max_digits=14, decimal_places=2))
),
tickets=Sum('quantity'),
).order_by('period')
result = [
{
'label': row['period'].strftime('%b %d') if granularity == 'daily' else row['period'].strftime('W%W'),
'revenue': float(row['revenue'] or 0),
'tickets': int(row['tickets'] or 0),
}
for row in qs
]
return Response({'data': result, 'days': days, 'granularity': granularity})
class PartnerAnalyticsTicketTypeView(APIView):
"""GET /api/v1/partners/me/analytics/ticket-type-breakdown/
Returns ticket type distribution by quantity sold + revenue.
"""
permission_classes = [IsAuthenticated]
def get(self, request):
partner = _require_partner(request)
qs = Booking.objects.filter(
ticket_meta__event__partner=partner,
payment_status='paid',
).values('ticket_type__ticket_type').annotate(
quantity=Sum('quantity'),
revenue=Sum(
ExpressionWrapper(F('price') * F('quantity'), output_field=DecimalField(max_digits=14, decimal_places=2))
),
).order_by('-quantity')
result = [
{
'name': row['ticket_type__ticket_type'] or 'Unknown',
'value': int(row['quantity'] or 0),
'revenue': float(row['revenue'] or 0),
}
for row in qs
]
return Response({'data': result})
class PartnerAnalyticsFunnelView(APIView):
"""GET /api/v1/partners/me/analytics/marketing-funnel/
Returns booking funnel. Page-view + cart tracking not available; only
purchase data is real. Frontend should render 'pending' state for upper funnel.
"""
permission_classes = [IsAuthenticated]
def get(self, request):
partner = _require_partner(request)
total_bookings = Booking.objects.filter(
ticket_meta__event__partner=partner,
).count()
paid_bookings = Booking.objects.filter(
ticket_meta__event__partner=partner,
payment_status='paid',
).count()
failed_bookings = Booking.objects.filter(
ticket_meta__event__partner=partner,
payment_status__in=['failed', 'cancelled'],
).count()
return Response({
'trackingAvailable': False,
'note': 'Page-view and cart tracking not yet enabled. Purchase data is real.',
'data': [
{'stage': 'Initiated', 'value': total_bookings},
{'stage': 'Paid', 'value': paid_bookings},
{'stage': 'Dropped', 'value': failed_bookings},
],
})
class PartnerAnalyticsTrafficSourcesView(APIView):
"""GET /api/v1/partners/me/analytics/traffic-sources/
Referrer tracking not yet enabled — returns empty with tracking flag.
"""
permission_classes = [IsAuthenticated]
def get(self, request):
return Response({
'trackingAvailable': False,
'note': 'Referrer tracking not yet enabled.',
'data': [],
})
class PartnerAnalyticsHeatmapView(APIView):
"""GET /api/v1/partners/me/analytics/retention-heatmap/
Returns booking intensity by day-of-week × time-slot (Morning/Afternoon/Evening/Night).
Values are normalised to 0100 relative to the busiest cell.
"""
permission_classes = [IsAuthenticated]
def get(self, request):
partner = _require_partner(request)
from django.db.models.functions import ExtractWeekDay, ExtractHour
qs = Booking.objects.filter(
ticket_meta__event__partner=partner,
payment_status='paid',
).annotate(
wday=ExtractWeekDay('created_date'), # 1=Sun, 2=Mon, ..., 7=Sat
hour=ExtractHour('created_date'),
).values('wday', 'hour').annotate(cnt=Count('id'))
# Build a 7x4 grid: days Mon-Sun, slots Morning/Afternoon/Evening/Night
# ExtractWeekDay: 1=Sunday,2=Mon,...,7=Sat — reorder to Mon-Sun
DAY_MAP = {2: 0, 3: 1, 4: 2, 5: 3, 6: 4, 7: 5, 1: 6}
DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
def hour_to_slot(h):
if 6 <= h < 12: return 0 # Morning
if 12 <= h < 18: return 1 # Afternoon
if 18 <= h < 22: return 2 # Evening
return 3 # Night
grid = [[0] * 4 for _ in range(7)]
for row in qs:
day_idx = DAY_MAP.get(row['wday'], 0)
slot_idx = hour_to_slot(row['hour'])
grid[day_idx][slot_idx] += row['cnt']
# Normalise to 0-100
max_val = max((v for row in grid for v in row), default=1) or 1
result = [
{
'day': DAY_LABELS[i],
'slots': [round(v / max_val * 100) for v in row],
}
for i, row in enumerate(grid)
]
return Response({'data': result})