@@ -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 ( {
@@ -4070,3 +4071,523 @@ class PartnerCustomerListView(APIView):
' pageSize ' : page_size ,
' results ' : results ,
} )
# ============================================================
# Sprint 6 — Partner Staff CRUD
# ============================================================
def _serialize_staff_member ( u ) :
""" Serialize a partner staff user for the partner portal. """
display_name = f " { u . first_name } { u . last_name } " . strip ( ) or u . username
# Map backend role to frontend label
frontend_role = ' manager ' if u . role == ' partner_manager ' else ' scanner '
return {
' id ' : str ( u . id ) ,
' name ' : display_name ,
' email ' : u . email ,
' role ' : frontend_role ,
' backendRole ' : u . role ,
' status ' : ' active ' if u . is_active else ' suspended ' ,
' lastActive ' : u . last_login . isoformat ( ) if u . last_login else None ,
' joinedAt ' : u . date_joined . isoformat ( ) if u . date_joined else None ,
}
class PartnerMeStaffListView ( APIView ) :
"""
GET /api/v1/partners/me/staff/ — list partner staff
POST /api/v1/partners/me/staff/ — invite new staff member
"""
permission_classes = [ IsAuthenticated ]
def get ( self , request ) :
partner , err = _require_partner ( request )
if err :
return err
staff = User . objects . filter (
partner = partner ,
role__in = [ ' partner_manager ' , ' partner_staff ' ] ,
) . order_by ( ' id ' )
search = request . query_params . get ( ' search ' , ' ' ) . strip ( )
if search :
from django . db . models import Q
staff = staff . filter (
Q ( email__icontains = search ) |
Q ( first_name__icontains = search ) |
Q ( last_name__icontains = search )
)
return Response ( [ _serialize_staff_member ( u ) for u in staff ] )
def post ( self , request ) :
import uuid as _uuid_mod
partner , err = _require_partner ( request )
if err :
return err
data = request . data
name = ( data . get ( ' name ' ) or ' ' ) . strip ( )
email = ( data . get ( ' email ' ) or ' ' ) . strip ( ) . lower ( )
frontend_role = ( data . get ( ' role ' ) or ' scanner ' ) . strip ( ) . lower ( )
if not name :
return Response ( { ' error ' : ' name is required ' } , status = 400 )
if not email :
return Response ( { ' error ' : ' email is required ' } , status = 400 )
# Map frontend role to backend role
# admin/manager → partner_manager; analyst/scanner → partner_staff
backend_role = ' partner_manager ' if frontend_role in ( ' admin ' , ' manager ' ) else ' partner_staff '
# Check email uniqueness
if User . objects . filter ( email = email ) . exists ( ) :
return Response ( { ' error ' : f " Email ' { email } ' already exists " } , status = 409 )
# Generate username from email
base_username = email . split ( ' @ ' ) [ 0 ]
username = base_username
counter = 1
while User . objects . filter ( username = username ) . exists ( ) :
username = f " { base_username } { counter } "
counter + = 1
# Generate temporary password
temp_password = _uuid_mod . uuid4 ( ) . hex [ : 12 ]
# Split name
parts = name . split ( ' ' , 1 )
first_name = parts [ 0 ]
last_name = parts [ 1 ] if len ( parts ) > 1 else ' '
try :
staff_user = User (
username = username ,
email = email ,
first_name = first_name ,
last_name = last_name ,
role = backend_role ,
is_customer = False ,
partner = partner ,
)
staff_user . set_password ( temp_password )
staff_user . save ( )
except Exception as e :
return Response ( { ' error ' : f ' Failed to create staff: { str ( e ) } ' } , status = 500 )
result = _serialize_staff_member ( staff_user )
result [ ' tempPassword ' ] = temp_password # Exposed once — partner shares with invitee
return Response ( result , status = 201 )
class PartnerMeStaffDetailView ( APIView ) :
"""
PATCH /api/v1/partners/me/staff/ {pk} / — update role
DELETE /api/v1/partners/me/staff/ {pk} / — revoke (deactivate)
"""
permission_classes = [ IsAuthenticated ]
def _get_staff ( self , request , pk ) :
from django . shortcuts import get_object_or_404
partner , err = _require_partner ( request )
if err :
return None , err
staff_user = get_object_or_404 (
User , pk = pk , partner = partner ,
role__in = [ ' partner_manager ' , ' partner_staff ' ] ,
)
return staff_user , None
def patch ( self , request , pk ) :
staff_user , err = self . _get_staff ( request , pk )
if err :
return err
data = request . data
updated = [ ]
if ' role ' in data :
frontend_role = ( data [ ' role ' ] or ' ' ) . strip ( ) . lower ( )
backend_role = ' partner_manager ' if frontend_role in ( ' admin ' , ' manager ' ) else ' partner_staff '
staff_user . role = backend_role
updated . append ( ' role ' )
if updated :
staff_user . save ( update_fields = updated )
return Response ( _serialize_staff_member ( staff_user ) )
def delete ( self , request , pk ) :
staff_user , err = self . _get_staff ( request , pk )
if err :
return err
# Deactivate rather than hard delete to preserve booking records
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 } )