diff --git a/admin_api/urls.py b/admin_api/urls.py index 32f894f..e18927e 100644 --- a/admin_api/urls.py +++ b/admin_api/urls.py @@ -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//', views.UserDetailView.as_view(), name='user-detail'), diff --git a/admin_api/views.py b/admin_api/views.py index 3c24926..39045fb 100644 --- a/admin_api/views.py +++ b/admin_api/views.py @@ -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 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})