@@ -3897,3 +3897,520 @@ class PartnerMeEventTierDetailView(APIView):
return err
tt . delete ( )
return Response ( { ' status ' : ' deleted ' } , status = 204 )
# ============================================================
# Sprint 4 — Partner Bookings
# ============================================================
class PartnerBookingListView ( APIView ) :
"""
GET /api/v1/partners/me/bookings/
Query params: search, status (payment_status), event_id, page, page_size
"""
permission_classes = [ IsAuthenticated ]
def get ( self , request ) :
from bookings . models import Booking
from django . db . models import Q
partner , err = _require_partner ( request )
if err :
return err
qs = Booking . objects . filter (
ticket_meta__event__partner = partner
) . select_related (
' user ' , ' ticket_meta__event ' , ' ticket_type '
) . order_by ( ' -created_date ' , ' -id ' )
# Search: booking_id, user email, user first/last name
search = request . query_params . get ( ' search ' , ' ' ) . strip ( )
if search :
qs = qs . filter (
Q ( booking_id__icontains = search ) |
Q ( user__email__icontains = search ) |
Q ( user__first_name__icontains = search ) |
Q ( user__last_name__icontains = search )
)
# Filter by payment_status
status = request . query_params . get ( ' status ' , ' ' ) . strip ( )
if status :
qs = qs . filter ( payment_status = status )
# Filter by event_id
event_id = request . query_params . get ( ' event_id ' , ' ' ) . strip ( )
if event_id :
qs = qs . filter ( ticket_meta__event_id = event_id )
# Pagination
try :
page_size = max ( 1 , min ( int ( request . query_params . get ( ' page_size ' , 20 ) ) , 200 ) )
except ( ValueError , TypeError ) :
page_size = 20
try :
page = max ( 1 , int ( request . query_params . get ( ' page ' , 1 ) ) )
except ( ValueError , TypeError ) :
page = 1
total = qs . count ( )
start = ( page - 1 ) * page_size
bookings = qs [ start : start + page_size ]
results = [ ]
for b in bookings :
user = b . user
customer_name = f " { user . first_name } { user . last_name } " . strip ( ) or user . username
event = b . ticket_meta . event if b . ticket_meta_id else None
results . append ( {
' id ' : str ( b . id ) ,
' bookingId ' : b . booking_id or f ' BKG- { b . id } ' ,
' customerName ' : customer_name ,
' customerEmail ' : user . email ,
' eventTitle ' : event . title if event else ' — ' ,
' eventId ' : str ( event . id ) if event else None ,
' ticketType ' : b . ticket_type . ticket_type if b . ticket_type_id else ' — ' ,
' quantity ' : b . quantity ,
' amount ' : str ( b . price ) ,
' totalAmount ' : str ( b . price * b . quantity ) ,
' paymentStatus ' : b . payment_status ,
' transactionId ' : b . transaction_id or ' ' ,
' createdDate ' : b . created_date . isoformat ( ) if b . created_date else None ,
} )
return Response ( {
' count ' : total ,
' page ' : page ,
' pageSize ' : page_size ,
' results ' : results ,
} )
# ============================================================
# Sprint 5 — Partner Customers (Users who booked partner events)
# ============================================================
class PartnerCustomerListView ( APIView ) :
"""
GET /api/v1/partners/me/customers/
Returns distinct users who have made bookings for this partner ' s events.
Query params: search, page, page_size
"""
permission_classes = [ IsAuthenticated ]
def get ( self , request ) :
from bookings . models import Booking
from accounts . models import User
from django . db . models import Count , Sum , F , Q , DecimalField , ExpressionWrapper
partner , err = _require_partner ( request )
if err :
return err
# Annotate users with per-partner booking stats
user_qs = User . objects . filter (
booking__ticket_meta__event__partner = partner
) . annotate (
bookings_count = Count (
' booking ' ,
filter = Q ( booking__ticket_meta__event__partner = partner ) ,
) ,
total_spent = Sum (
ExpressionWrapper (
F ( ' booking__price ' ) * F ( ' booking__quantity ' ) ,
output_field = DecimalField ( max_digits = 12 , decimal_places = 2 ) ,
) ,
filter = Q ( booking__ticket_meta__event__partner = partner ) ,
) ,
) . distinct ( ) . order_by ( ' -bookings_count ' , ' id ' )
# Search by email / name
search = request . query_params . get ( ' search ' , ' ' ) . strip ( )
if search :
user_qs = user_qs . filter (
Q ( email__icontains = search ) |
Q ( first_name__icontains = search ) |
Q ( last_name__icontains = search ) |
Q ( username__icontains = search )
)
# Pagination
try :
page_size = max ( 1 , min ( int ( request . query_params . get ( ' page_size ' , 20 ) ) , 200 ) )
except ( ValueError , TypeError ) :
page_size = 20
try :
page = max ( 1 , int ( request . query_params . get ( ' page ' , 1 ) ) )
except ( ValueError , TypeError ) :
page = 1
total = user_qs . count ( )
start = ( page - 1 ) * page_size
users = user_qs [ start : start + page_size ]
results = [ ]
for u in users :
display_name = f " { u . first_name } { u . last_name } " . strip ( ) or u . username
results . append ( {
' id ' : str ( u . id ) ,
' name ' : display_name ,
' email ' : u . email ,
' phone ' : u . phone if hasattr ( u , ' phone ' ) else ' ' ,
' bookingsCount ' : u . bookings_count or 0 ,
' totalSpent ' : str ( u . total_spent or 0 ) ,
' joinedAt ' : u . date_joined . isoformat ( ) if u . date_joined else None ,
' lastLogin ' : u . last_login . isoformat ( ) if u . last_login else None ,
' isActive ' : u . is_active ,
} )
return Response ( {
' count ' : total ,
' page ' : page ,
' 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 ,
} )