From fee67385d531a240f2efd5cf1a44c48065407f9b Mon Sep 17 00:00:00 2001 From: Sicherhaven Date: Wed, 22 Apr 2026 11:41:52 +0530 Subject: [PATCH] Sprint 6: partner staff CRUD endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- admin_api/urls.py | 3 + admin_api/views.py | 153 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) diff --git a/admin_api/urls.py b/admin_api/urls.py index d94d448..35751fc 100644 --- a/admin_api/urls.py +++ b/admin_api/urls.py @@ -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//', 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//', views.UserDetailView.as_view(), name='user-detail'), diff --git a/admin_api/views.py b/admin_api/views.py index 24e06c8..bf9a495 100644 --- a/admin_api/views.py +++ b/admin_api/views.py @@ -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)