@@ -3922,7 +3922,7 @@ class PartnerBookingListView(APIView):
ticket_meta__event__partner = partner
) . select_related (
' user ' , ' ticket_meta__event ' , ' ticket_type '
) . order_by ( ' -created_date ' , ' -id ' )
) . prefetch_related ( ' ticket_set ' ) . order_by( ' -created_date ' , ' -id ' )
# Search: booking_id, user email, user first/last name
search = request . query_params . get ( ' search ' , ' ' ) . strip ( )
@@ -3977,6 +3977,7 @@ class PartnerBookingListView(APIView):
' paymentStatus ' : b . payment_status ,
' transactionId ' : b . transaction_id or ' ' ,
' createdDate ' : b . created_date . isoformat ( ) if b . created_date else None ,
' checkedIn ' : any ( t . is_checked_in for t in b . ticket_set . all ( ) ) ,
} )
return Response ( {
@@ -4223,3 +4224,370 @@ class PartnerMeStaffDetailView(APIView):
staff_user . is_active = False
staff_user . save ( update_fields = [ ' is_active ' ] )
return Response ( { ' status ' : ' revoked ' } , status = 204 )
# ============================================================
# Sprint 7 — Partner Check-in (JWT-authenticated)
# ============================================================
class PartnerMeCheckInView ( APIView ) :
"""
POST /api/v1/partners/me/check-in/
Body: { " ticket_id " : " <ticket_id> " }
Validates the ticket belongs to a partner-owned event, marks checked-in.
"""
permission_classes = [ IsAuthenticated ]
def post ( self , request ) :
from bookings . models import Ticket
partner , err = _require_partner ( request )
if err :
return err
ticket_id = ( request . data . get ( ' ticket_id ' ) or ' ' ) . strip ( )
if not ticket_id :
return Response ( { ' valid ' : False , ' error ' : ' ticket_id is required ' } , status = 400 )
try :
ticket = Ticket . objects . select_related (
' booking__user ' ,
' booking__ticket_meta__event ' ,
' booking__ticket_type ' ,
) . get ( ticket_id = ticket_id )
except Ticket . DoesNotExist :
return Response ( { ' valid ' : False , ' error ' : ' Ticket not found ' } , status = 404 )
# Verify the ticket's event belongs to this partner
event = ticket . booking . ticket_meta . event if ticket . booking . ticket_meta_id else None
if not event or event . partner_id != partner . id :
return Response ( { ' valid ' : False , ' error ' : ' Ticket not found ' } , status = 404 )
user = ticket . booking . user
customer_name = f " { user . first_name } { user . last_name } " . strip ( ) or user . username
ticket_type = ticket . booking . ticket_type . ticket_type if ticket . booking . ticket_type_id else ' — '
already_checked_in = ticket . is_checked_in
if not already_checked_in :
from datetime import datetime , timezone as _tz
ticket . is_checked_in = True
ticket . checked_in_date_time = datetime . now ( _tz . utc )
ticket . save ( update_fields = [ ' is_checked_in ' , ' checked_in_date_time ' ] )
return Response ( {
' valid ' : True ,
' alreadyCheckedIn ' : already_checked_in ,
' name ' : customer_name ,
' ticket ' : ticket_type ,
' event ' : event . title if event else ' — ' ,
' ticketId ' : ticket . ticket_id ,
} )
# ============================================================
# Sprint 8 — Partner Dashboard Aggregate
# ============================================================
class PartnerDashboardView ( APIView ) :
"""
GET /api/v1/partners/me/dashboard/
Returns partner KPIs, recent bookings, and upcoming events in one call.
"""
permission_classes = [ IsAuthenticated ]
def get ( self , request ) :
from bookings . models import Booking
from django . db . models import Sum , Count , F , Q , DecimalField , ExpressionWrapper
from datetime import date , timedelta
partner , err = _require_partner ( request )
if err :
return err
today = date . today ( )
# Current period: last 30 days
period_start = today - timedelta ( days = 30 )
# Previous period: 31– 60 days ago (for change %)
prev_start = today - timedelta ( days = 60 )
prev_end = today - timedelta ( days = 31 )
def booking_revenue_qs ( start , end ) :
return Booking . objects . filter (
ticket_meta__event__partner = partner ,
payment_status = ' paid ' ,
created_date__gte = start ,
created_date__lte = end ,
) . aggregate (
total = Sum (
ExpressionWrapper (
F ( ' price ' ) * F ( ' quantity ' ) ,
output_field = DecimalField ( max_digits = 14 , decimal_places = 2 ) ,
)
) ,
tickets = Sum ( ' quantity ' ) ,
)
curr = booking_revenue_qs ( period_start , today )
prev = booking_revenue_qs ( prev_start , prev_end )
curr_rev = float ( curr [ ' total ' ] or 0 )
prev_rev = float ( prev [ ' total ' ] or 0 )
curr_tickets = int ( curr [ ' tickets ' ] or 0 )
prev_tickets = int ( prev [ ' tickets ' ] or 0 )
def pct_change ( curr_v , prev_v ) :
if prev_v == 0 :
return ' +0 % ' if curr_v == 0 else ' +100 % '
delta = ( ( curr_v - prev_v ) / prev_v ) * 100
sign = ' + ' if delta > = 0 else ' '
return f ' { sign } { delta : .1f } % '
from events . models import Event
active_events = Event . objects . filter (
partner = partner , event_status__in = [ ' published ' , ' live ' ]
) . count ( )
total_events = Event . objects . filter ( partner = partner ) . count ( )
# KPIs
kpis = {
' totalRevenue ' : f ' \u20b9 { curr_rev : ,.0f } ' ,
' revenueChange ' : pct_change ( curr_rev , prev_rev ) ,
' ticketsSold ' : f ' { curr_tickets : , } ' ,
' ticketsChange ' : pct_change ( curr_tickets , prev_tickets ) ,
' activeEvents ' : str ( active_events ) ,
' totalEvents ' : str ( total_events ) ,
}
# Recent bookings (last 5 paid + pending, newest first)
recent_qs = Booking . objects . filter (
ticket_meta__event__partner = partner ,
) . select_related ( ' user ' , ' ticket_meta__event ' , ' ticket_type ' ) . order_by (
' -created_date ' , ' -id '
) [ : 5 ]
recent_bookings = [ ]
for b in recent_qs :
user = b . user
name = f " { user . first_name } { user . last_name } " . strip ( ) or user . username
event = b . ticket_meta . event if b . ticket_meta_id else None
recent_bookings . append ( {
' id ' : b . booking_id or f ' BKG- { b . id } ' ,
' event ' : event . title if event else ' — ' ,
' customer ' : name ,
' date ' : b . created_date . isoformat ( ) if b . created_date else None ,
' amount ' : f ' \u20b9 { float ( b . price * b . quantity ) : ,.0f } ' ,
' status ' : b . payment_status ,
} )
# Upcoming events (next 3 by date, have a future start date or are published)
upcoming_qs = Event . objects . filter (
partner = partner ,
event_status__in = [ ' published ' , ' live ' , ' draft ' ] ,
) . order_by ( ' event_date ' , ' id ' ) [ : 5 ]
upcoming_events = [ ]
for ev in upcoming_qs :
# Count tickets sold for this event
sold = Booking . objects . filter (
ticket_meta__event = ev , payment_status = ' paid '
) . aggregate ( s = Sum ( ' quantity ' ) ) [ ' s ' ] or 0
# Get total capacity from TicketMeta
from bookings . models import TicketMeta
try :
meta = TicketMeta . objects . get ( event = ev )
total_capacity = meta . maximum_quantity or 0
except TicketMeta . DoesNotExist :
total_capacity = 0
upcoming_events . append ( {
' id ' : str ( ev . id ) ,
' name ' : ev . title ,
' date ' : ev . event_date . isoformat ( ) if ev . event_date else None ,
' sold ' : sold ,
' total ' : total_capacity ,
' status ' : ev . event_status ,
} )
return Response ( {
' kpis ' : kpis ,
' 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 } )