@@ -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 } )