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>
This commit is contained in:
@@ -37,6 +37,9 @@ 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'),
|
||||
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'),
|
||||
|
||||
@@ -4070,3 +4070,156 @@ 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)
|
||||
|
||||
Reference in New Issue
Block a user