Compare commits
4 Commits
sprint/6-s
...
sprint/pos
| Author | SHA1 | Date | |
|---|---|---|---|
| d1f43c957c | |||
| 03ac2f25ae | |||
| 8bc176b2f6 | |||
| b6c2b93fd0 |
@@ -40,6 +40,16 @@ urlpatterns = [
|
|||||||
# Partner-Me: staff CRUD (Sprint 6)
|
# Partner-Me: staff CRUD (Sprint 6)
|
||||||
path('partners/me/staff/', views.PartnerMeStaffListView.as_view(), name='partner-me-staff-list'),
|
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'),
|
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/metrics/', views.UserMetricsView.as_view(), name='user-metrics'),
|
||||||
path('users/', views.UserListView.as_view(), name='user-list'),
|
path('users/', views.UserListView.as_view(), name='user-list'),
|
||||||
path('users/<int:pk>/', views.UserDetailView.as_view(), name='user-detail'),
|
path('users/<int:pk>/', views.UserDetailView.as_view(), name='user-detail'),
|
||||||
|
|||||||
@@ -3922,7 +3922,7 @@ class PartnerBookingListView(APIView):
|
|||||||
ticket_meta__event__partner=partner
|
ticket_meta__event__partner=partner
|
||||||
).select_related(
|
).select_related(
|
||||||
'user', 'ticket_meta__event', 'ticket_type'
|
'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: booking_id, user email, user first/last name
|
||||||
search = request.query_params.get('search', '').strip()
|
search = request.query_params.get('search', '').strip()
|
||||||
@@ -3977,6 +3977,7 @@ class PartnerBookingListView(APIView):
|
|||||||
'paymentStatus': b.payment_status,
|
'paymentStatus': b.payment_status,
|
||||||
'transactionId': b.transaction_id or '',
|
'transactionId': b.transaction_id or '',
|
||||||
'createdDate': b.created_date.isoformat() if b.created_date else None,
|
'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({
|
return Response({
|
||||||
@@ -4223,3 +4224,370 @@ class PartnerMeStaffDetailView(APIView):
|
|||||||
staff_user.is_active = False
|
staff_user.is_active = False
|
||||||
staff_user.save(update_fields=['is_active'])
|
staff_user.save(update_fields=['is_active'])
|
||||||
return Response({'status': 'revoked'}, status=204)
|
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})
|
||||||
|
|||||||
Reference in New Issue
Block a user