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>
This commit is contained in:
@@ -44,6 +44,12 @@ urlpatterns = [
|
|||||||
path('partners/me/check-in/', views.PartnerMeCheckInView.as_view(), name='partner-me-check-in'),
|
path('partners/me/check-in/', views.PartnerMeCheckInView.as_view(), name='partner-me-check-in'),
|
||||||
# Partner-Me: dashboard (Sprint 8)
|
# Partner-Me: dashboard (Sprint 8)
|
||||||
path('partners/me/dashboard/', views.PartnerDashboardView.as_view(), name='partner-me-dashboard'),
|
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/metrics/', views.UserMetricsView.as_view(), name='user-metrics'),
|
||||||
path('users/', views.UserListView.as_view(), name='user-list'),
|
path('users/', views.UserListView.as_view(), name='user-list'),
|
||||||
path('users/<int:pk>/', views.UserDetailView.as_view(), name='user-detail'),
|
path('users/<int:pk>/', views.UserDetailView.as_view(), name='user-detail'),
|
||||||
|
|||||||
@@ -4414,3 +4414,179 @@ class PartnerDashboardView(APIView):
|
|||||||
'recentBookings': recent_bookings,
|
'recentBookings': recent_bookings,
|
||||||
'upcomingEvents': upcoming_events,
|
'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 0–100 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})
|
||||||
|
|||||||
Reference in New Issue
Block a user