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>
This commit is contained in:
@@ -30,6 +30,9 @@ urlpatterns = [
|
|||||||
path('partners/me/events/', views.PartnerMeEventsView.as_view(), name='partner-me-events'),
|
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>/', 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'),
|
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'),
|
||||||
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'),
|
||||||
|
|||||||
@@ -3719,3 +3719,181 @@ class PartnerMeEventDuplicateView(APIView):
|
|||||||
e.event_status = 'created' # always draft
|
e.event_status = 'created' # always draft
|
||||||
e.save()
|
e.save()
|
||||||
return Response(_serialize_event_detail(e), status=201)
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user