3 Commits

Author SHA1 Message Date
03ac2f25ae feat(sprint9): add 5 partner analytics endpoints
revenue-timeseries, ticket-type-breakdown, marketing-funnel,
traffic-sources, retention-heatmap. Funnel/sources return
trackingAvailable=false with notes where no tracking exists.
Heatmap computed from booking created_date by day × time slot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 11:58:41 +05:30
8bc176b2f6 feat(sprint8): add PartnerDashboardView + URL for partner me dashboard
Returns KPIs (revenue/tickets/events) with 30d vs prev-30d % change,
last 5 bookings, and next 5 upcoming events with capacity progress.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 11:50:07 +05:30
b6c2b93fd0 Sprint 7: PartnerMeCheckInView — JWT-authenticated ticket check-in
- admin_api/views.py: PartnerMeCheckInView — validates ticket belongs to
  partner's event, marks is_checked_in=True, returns name/ticket/event/
  alreadyCheckedIn; uses IsAuthenticated (Bearer JWT, not body token)
- admin_api/urls.py: wire partners/me/check-in/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 11:45:32 +05:30
2 changed files with 377 additions and 0 deletions

View File

@@ -40,6 +40,16 @@ urlpatterns = [
# Partner-Me: staff CRUD (Sprint 6)
path('partners/me/staff/', views.PartnerMeStaffListView.as_view(), name='partner-me-staff-list'),
path('partners/me/staff/<int:pk>/', views.PartnerMeStaffDetailView.as_view(), name='partner-me-staff-detail'),
# Partner-Me: check-in (Sprint 7)
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/<int:pk>/', views.UserDetailView.as_view(), name='user-detail'),

View File

@@ -4223,3 +4223,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: 3160 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 0100 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})