diff --git a/admin_api/urls.py b/admin_api/urls.py index eea7a92..68840cd 100644 --- a/admin_api/urls.py +++ b/admin_api/urls.py @@ -30,6 +30,9 @@ urlpatterns = [ path('partners/me/events/', views.PartnerMeEventsView.as_view(), name='partner-me-events'), path('partners/me/events//', views.PartnerMeEventDetailView.as_view(), name='partner-me-event-detail'), path('partners/me/events//duplicate/', views.PartnerMeEventDuplicateView.as_view(), name='partner-me-event-duplicate'), + # Partner-Me: ticket tiers (Sprint 3) + path('partners/me/events//tiers/', views.PartnerMeEventTiersView.as_view(), name='partner-me-event-tiers'), + path('partners/me/events//tiers//', views.PartnerMeEventTierDetailView.as_view(), name='partner-me-event-tier-detail'), path('users/metrics/', views.UserMetricsView.as_view(), name='user-metrics'), path('users/', views.UserListView.as_view(), name='user-list'), path('users//', views.UserDetailView.as_view(), name='user-detail'), diff --git a/admin_api/views.py b/admin_api/views.py index bf481fd..b6adc93 100644 --- a/admin_api/views.py +++ b/admin_api/views.py @@ -3719,3 +3719,181 @@ 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)