2 Commits

Author SHA1 Message Date
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
2 changed files with 217 additions and 0 deletions

View File

@@ -37,6 +37,11 @@ urlpatterns = [
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/', views.UserListView.as_view(), name='user-list'),
path('users/<int:pk>/', views.UserDetailView.as_view(), name='user-detail'),

View File

@@ -4070,3 +4070,215 @@ class PartnerCustomerListView(APIView):
'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,
})