Compare commits
5 Commits
main
...
sprint/7-c
| Author | SHA1 | Date | |
|---|---|---|---|
| b6c2b93fd0 | |||
| fee67385d5 | |||
| f587c4dd24 | |||
| 4669907a02 | |||
| 611d653938 |
@@ -30,6 +30,18 @@ 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'),
|
||||||
|
# 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'),
|
||||||
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,566 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 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,
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user