6 Commits

Author SHA1 Message Date
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
fee67385d5 Sprint 6: partner staff CRUD endpoints
- admin_api/views.py: PartnerMeStaffListView (GET list + POST invite with
  auto-generated temp password), PartnerMeStaffDetailView (PATCH role +
  DELETE/deactivate); role mapping admin/manager→partner_manager,
  analyst/scanner→partner_staff; soft-delete (is_active=False)
- admin_api/urls.py: wire partners/me/staff/ and .../staff/{pk}/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 11:41:52 +05:30
f587c4dd24 Sprint 5: PartnerCustomerListView — partner-scoped customer list
- admin_api/views.py: PartnerCustomerListView — distinct users who've
  booked partner's events, annotated with bookings_count + total_spent
  aggregates, search by email/name, paginated [1,200]
- admin_api/urls.py: wire partners/me/customers/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 11:38:39 +05:30
4669907a02 Sprint 4: PartnerBookingListView for partner-scoped booking list
- admin_api/views.py: PartnerBookingListView — filters Booking rows by
  partner via ticket_meta__event__partner, supports search (booking_id,
  user email/name), payment_status filter, event_id filter, pagination
  with page_size bound [1,200]
- admin_api/urls.py: wire partners/me/bookings/ endpoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 11:34:57 +05:30
611d653938 Sprint 3: partner ticket tier CRUD endpoints
- admin_api/views.py: add _serialize_tier(), PartnerMeEventTiersView
  (GET list + POST create with get_or_create TicketMeta),
  PartnerMeEventTierDetailView (PATCH update + DELETE)
- admin_api/urls.py: wire partners/me/events/{event_pk}/tiers/ and
  .../tiers/{tier_pk}/ with named routes partner-me-event-tiers,
  partner-me-event-tier-detail
- Deployed to eventify-backend + eventify-django containers

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

View File

@@ -30,6 +30,20 @@ urlpatterns = [
path('partners/me/events/', views.PartnerMeEventsView.as_view(), name='partner-me-events'),
path('partners/me/events/<int:pk>/', views.PartnerMeEventDetailView.as_view(), name='partner-me-event-detail'),
path('partners/me/events/<int:pk>/duplicate/', views.PartnerMeEventDuplicateView.as_view(), name='partner-me-event-duplicate'),
# Partner-Me: ticket tiers (Sprint 3)
path('partners/me/events/<int:event_pk>/tiers/', views.PartnerMeEventTiersView.as_view(), name='partner-me-event-tiers'),
path('partners/me/events/<int:event_pk>/tiers/<int:tier_pk>/', views.PartnerMeEventTierDetailView.as_view(), name='partner-me-event-tier-detail'),
# Partner-Me: bookings (Sprint 4)
path('partners/me/bookings/', views.PartnerBookingListView.as_view(), name='partner-me-bookings'),
# Partner-Me: customers (Sprint 5)
path('partners/me/customers/', views.PartnerCustomerListView.as_view(), name='partner-me-customers'),
# 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'),
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

@@ -3719,3 +3719,698 @@ class PartnerMeEventDuplicateView(APIView):
e.event_status = 'created' # always draft
e.save()
return Response(_serialize_event_detail(e), status=201)
# ===========================================================================
# Partner-Me Ticket Tiers (Sprint 3)
# ===========================================================================
def _serialize_tier(tt, sold=0):
"""Serialize TicketType (tier) for partner portal."""
capacity = tt.ticket_meta.maximum_quantity if tt.ticket_meta_id else tt.ticket_type_quantity
return {
'id': str(tt.id),
'name': tt.ticket_type,
'description': tt.ticket_type_description or '',
'price': str(tt.price),
'capacity': tt.ticket_type_quantity,
'totalCapacity': capacity,
'sold': sold,
'isActive': tt.is_active,
'isOffer': tt.is_offer,
'offerPrice': str(tt.offer_price) if tt.is_offer else None,
}
class PartnerMeEventTiersView(APIView):
"""
GET /api/v1/partners/me/events/{event_pk}/tiers/ — list tiers
POST /api/v1/partners/me/events/{event_pk}/tiers/ — create tier
"""
permission_classes = [IsAuthenticated]
def _get_event_and_meta(self, request, event_pk):
from events.models import Event
from bookings.models import TicketMeta
from django.shortcuts import get_object_or_404
partner, err = _require_partner(request)
if err:
return None, None, None, err
event = get_object_or_404(Event, pk=event_pk)
if event.partner_id != partner.id:
return None, None, None, Response(
{'error': 'Event not found or access denied.'}, status=404
)
# Get or create the event's single TicketMeta
meta, _ = TicketMeta.objects.get_or_create(
event=event,
defaults={
'ticket_name': event.title or 'Tickets',
'maximum_quantity': 0,
'available_quantity': 0,
},
)
return partner, event, meta, None
def get(self, request, event_pk):
from bookings.models import TicketType, Booking
from django.db.models import Sum
_partner, _event, meta, err = self._get_event_and_meta(request, event_pk)
if err:
return err
tiers = TicketType.objects.filter(ticket_meta=meta).order_by('id')
# Aggregate sold count per tier
sold_map = dict(
Booking.objects.filter(ticket_meta=meta)
.values('ticket_type_id')
.annotate(total=Sum('quantity'))
.values_list('ticket_type_id', 'total')
)
return Response([_serialize_tier(t, sold_map.get(t.id, 0)) for t in tiers])
def post(self, request, event_pk):
from bookings.models import TicketType
_partner, _event, meta, err = self._get_event_and_meta(request, event_pk)
if err:
return err
data = request.data
name = (data.get('name') or '').strip()
if not name:
return Response({'error': 'name is required'}, status=400)
try:
price = float(data.get('price', 0))
except (ValueError, TypeError):
return Response({'error': 'price must be numeric'}, status=400)
try:
capacity = int(data.get('capacity', 0))
except (ValueError, TypeError):
return Response({'error': 'capacity must be integer'}, status=400)
tt = TicketType.objects.create(
ticket_meta=meta,
ticket_type=name,
ticket_type_description=data.get('description', ''),
ticket_type_quantity=capacity,
price=price,
is_active=True,
)
# Update meta total capacity
from django.db.models import Sum as _Sum
total = TicketType.objects.filter(ticket_meta=meta).aggregate(
total=_Sum('ticket_type_quantity')
)['total'] or 0
meta.maximum_quantity = total
meta.available_quantity = total
meta.save(update_fields=['maximum_quantity', 'available_quantity'])
return Response(_serialize_tier(tt), status=201)
class PartnerMeEventTierDetailView(APIView):
"""
PATCH /api/v1/partners/me/events/{event_pk}/tiers/{tier_pk}/
DELETE /api/v1/partners/me/events/{event_pk}/tiers/{tier_pk}/
"""
permission_classes = [IsAuthenticated]
def _get_tier(self, request, event_pk, tier_pk):
from events.models import Event
from bookings.models import TicketType
from django.shortcuts import get_object_or_404
partner, err = _require_partner(request)
if err:
return None, err
event = get_object_or_404(Event, pk=event_pk)
if event.partner_id != partner.id:
return None, Response({'error': 'Event not found or access denied.'}, status=404)
tt = get_object_or_404(TicketType, pk=tier_pk, ticket_meta__event=event)
return tt, None
def patch(self, request, event_pk, tier_pk):
tt, err = self._get_tier(request, event_pk, tier_pk)
if err:
return err
data = request.data
updated = []
if 'name' in data:
tt.ticket_type = (data['name'] or '').strip()
updated.append('ticket_type')
if 'description' in data:
tt.ticket_type_description = data['description'] or ''
updated.append('ticket_type_description')
if 'price' in data:
try:
tt.price = float(data['price'])
except (ValueError, TypeError):
return Response({'error': 'price must be numeric'}, status=400)
updated.append('price')
if 'capacity' in data:
try:
tt.ticket_type_quantity = int(data['capacity'])
except (ValueError, TypeError):
return Response({'error': 'capacity must be integer'}, status=400)
updated.append('ticket_type_quantity')
if 'isActive' in data:
tt.is_active = bool(data['isActive'])
updated.append('is_active')
if updated:
tt.save(update_fields=updated)
return Response(_serialize_tier(tt))
def delete(self, request, event_pk, tier_pk):
tt, err = self._get_tier(request, event_pk, tier_pk)
if err:
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: 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,
})