From 761b702e57ede716dfd02aca8b8dccdfacc315b6 Mon Sep 17 00:00:00 2001 From: Sicherhaven Date: Wed, 22 Apr 2026 11:02:34 +0530 Subject: [PATCH] =?UTF-8?q?feat(partner-portal):=20Sprint=201=20=E2=80=94?= =?UTF-8?q?=20partner-me=20settings=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 4 self-service endpoints under /api/v1/partners/me/: - GET/PUT /partners/me/profile/ → name, email, phone, website, bio - GET/PUT /partners/me/notifications/ → 4 boolean notification prefs - GET/PUT /partners/me/payout/ → bank account + payout schedule - POST /partners/me/change-password/ → current+new password change Model changes (partner/models.py + migration 0002): - Partner.bio TextField - Partner.payout_* fields (holder name, account number, IFSC, bank name, schedule) - Partner.notif_* boolean fields (new_booking, event_status, payout_update, weekly_report) Auth: simplejwt Bearer token (same as all admin_api views). Role guard: _require_partner() enforces partner/partner_manager/partner_staff and verifies user.partner FK is non-null. Co-Authored-By: Claude Sonnet 4.6 --- admin_api/urls.py | 5 + admin_api/views.py | 226 ++++++++++++++++++ ...02_partner_profile_payout_notifications.py | 70 ++++++ partner/models.py | 23 ++ 4 files changed, 324 insertions(+) create mode 100644 partner/migrations/0002_partner_profile_payout_notifications.py diff --git a/admin_api/urls.py b/admin_api/urls.py index 8705671..e9934d0 100644 --- a/admin_api/urls.py +++ b/admin_api/urls.py @@ -21,6 +21,11 @@ urlpatterns = [ path('partners//impersonate/', views.PartnerImpersonateView.as_view(), name='partner-impersonate'), path('partners/onboard/', views.PartnerOnboardView.as_view(), name='partner-onboard'), path('partners//staff/', views.PartnerStaffCreateView.as_view(), name='partner-staff-create'), + # Partner-Me: partner portal self-service (Sprint 1) + path('partners/me/profile/', views.PartnerMeProfileView.as_view(), name='partner-me-profile'), + path('partners/me/notifications/', views.PartnerMeNotificationsView.as_view(), name='partner-me-notifications'), + path('partners/me/payout/', views.PartnerMePayoutView.as_view(), name='partner-me-payout'), + path('partners/me/change-password/', views.PartnerMeChangePasswordView.as_view(), name='partner-me-change-password'), 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 c5e846c..df3f78f 100644 --- a/admin_api/views.py +++ b/admin_api/views.py @@ -3313,3 +3313,229 @@ class NotificationScheduleTestSendView(APIView): log('info', f'Test email sent for schedule #{pk} → {email}', request=request, user=request.user) return Response({'ok': True, 'sentTo': email}) + + +# =========================================================================== +# Partner-Me (Partner Portal self-service endpoints) +# Sprint 1 — Settings wiring +# Auth: simplejwt Bearer token (same as MeView / all admin_api views) +# =========================================================================== + +def _require_partner(request): + """Return (partner, None) or (None, error_response).""" + partner_roles = ['partner', 'partner_manager', 'partner_staff'] + if request.user.role not in partner_roles: + return None, Response( + {'error': 'Partner account required.'}, + status=status.HTTP_403_FORBIDDEN, + ) + partner = getattr(request.user, 'partner', None) + if partner is None: + return None, Response( + {'error': 'No partner organisation linked to this account.'}, + status=status.HTTP_403_FORBIDDEN, + ) + return partner, None + + +class PartnerMeProfileView(APIView): + """ + GET /api/v1/partners/me/profile/ — return partner profile + PUT /api/v1/partners/me/profile/ — update partner profile + """ + permission_classes = [IsAuthenticated] + + def get(self, request): + partner, err = _require_partner(request) + if err: + return err + return Response({ + 'name': partner.name or '', + 'email': request.user.email or '', + 'phone': request.user.phone_number or '', + 'website': partner.website_url or '', + 'bio': partner.bio or '', + }) + + def put(self, request): + partner, err = _require_partner(request) + if err: + return err + + data = request.data + user = request.user + user_changed = False + partner_changed = False + + if 'email' in data and data['email'] != user.email: + new_email = (data['email'] or '').strip() + if new_email: + # Uniqueness check — exclude self + from django.contrib.auth import get_user_model as _gum + _User = _gum() + if _User.objects.filter(email=new_email).exclude(pk=user.pk).exists(): + return Response({'error': 'Email already in use by another account.'}, status=400) + user.email = new_email + user_changed = True + + if 'phone' in data: + user.phone_number = (data['phone'] or '').strip() or None + user_changed = True + + if 'name' in data and data['name']: + partner.name = data['name'].strip() + partner_changed = True + + if 'website' in data: + partner.website_url = (data['website'] or '').strip() or None + partner_changed = True + + if 'bio' in data: + partner.bio = (data['bio'] or '').strip() or None + partner_changed = True + + if user_changed: + user.save(update_fields=[f for f in ['email', 'phone_number'] if f]) + if partner_changed: + partner.save(update_fields=[f for f in ['name', 'website_url', 'bio'] if getattr(partner, f, None) is not None or f in data]) + + return Response({ + 'name': partner.name or '', + 'email': user.email or '', + 'phone': user.phone_number or '', + 'website': partner.website_url or '', + 'bio': partner.bio or '', + }) + + +class PartnerMeNotificationsView(APIView): + """ + GET /api/v1/partners/me/notifications/ — return notification prefs + PUT /api/v1/partners/me/notifications/ — update notification prefs + """ + permission_classes = [IsAuthenticated] + + def get(self, request): + partner, err = _require_partner(request) + if err: + return err + return Response({ + 'newBooking': partner.notif_new_booking, + 'eventStatus': partner.notif_event_status, + 'payoutUpdate': partner.notif_payout_update, + 'weeklyReport': partner.notif_weekly_report, + }) + + def put(self, request): + partner, err = _require_partner(request) + if err: + return err + + data = request.data + if 'newBooking' in data: + partner.notif_new_booking = bool(data['newBooking']) + if 'eventStatus' in data: + partner.notif_event_status = bool(data['eventStatus']) + if 'payoutUpdate' in data: + partner.notif_payout_update = bool(data['payoutUpdate']) + if 'weeklyReport' in data: + partner.notif_weekly_report = bool(data['weeklyReport']) + partner.save(update_fields=[ + 'notif_new_booking', 'notif_event_status', + 'notif_payout_update', 'notif_weekly_report', + ]) + return Response({ + 'newBooking': partner.notif_new_booking, + 'eventStatus': partner.notif_event_status, + 'payoutUpdate': partner.notif_payout_update, + 'weeklyReport': partner.notif_weekly_report, + }) + + +class PartnerMePayoutView(APIView): + """ + GET /api/v1/partners/me/payout/ — return payout settings + PUT /api/v1/partners/me/payout/ — update payout settings + """ + permission_classes = [IsAuthenticated] + + def get(self, request): + partner, err = _require_partner(request) + if err: + return err + return Response({ + 'accountHolderName': partner.payout_account_holder_name or '', + 'accountNumber': partner.payout_account_number or '', + 'ifscCode': partner.payout_ifsc_code or '', + 'bankName': partner.payout_bank_name or '', + 'payoutSchedule': partner.payout_schedule or 'monthly', + }) + + def put(self, request): + partner, err = _require_partner(request) + if err: + return err + + data = request.data + valid_schedules = ['weekly', 'biweekly', 'monthly'] + + if 'accountHolderName' in data: + partner.payout_account_holder_name = (data['accountHolderName'] or '').strip() or None + if 'accountNumber' in data: + partner.payout_account_number = (data['accountNumber'] or '').strip() or None + if 'ifscCode' in data: + partner.payout_ifsc_code = (data['ifscCode'] or '').strip().upper() or None + if 'bankName' in data: + partner.payout_bank_name = (data['bankName'] or '').strip() or None + if 'payoutSchedule' in data: + sched = data['payoutSchedule'] + if sched not in valid_schedules: + return Response( + {'error': f'payoutSchedule must be one of: {", ".join(valid_schedules)}'}, + status=400, + ) + partner.payout_schedule = sched + + partner.save(update_fields=[ + 'payout_account_holder_name', 'payout_account_number', + 'payout_ifsc_code', 'payout_bank_name', 'payout_schedule', + ]) + return Response({ + 'accountHolderName': partner.payout_account_holder_name or '', + 'accountNumber': partner.payout_account_number or '', + 'ifscCode': partner.payout_ifsc_code or '', + 'bankName': partner.payout_bank_name or '', + 'payoutSchedule': partner.payout_schedule or 'monthly', + }) + + +class PartnerMeChangePasswordView(APIView): + """ + POST /api/v1/partners/me/change-password/ + Body: { current_password, new_password } + """ + permission_classes = [IsAuthenticated] + + def post(self, request): + partner_roles = ['partner', 'partner_manager', 'partner_staff'] + if request.user.role not in partner_roles: + return Response({'error': 'Partner account required.'}, status=403) + + current_password = request.data.get('current_password', '') + new_password = request.data.get('new_password', '') + + if not current_password or not new_password: + return Response( + {'error': 'current_password and new_password are required.'}, + status=400, + ) + + if not request.user.check_password(current_password): + return Response({'error': 'Current password is incorrect.'}, status=400) + + if len(new_password) < 8: + return Response({'error': 'New password must be at least 8 characters.'}, status=400) + + request.user.set_password(new_password) + request.user.save(update_fields=['password']) + return Response({'success': True}) diff --git a/partner/migrations/0002_partner_profile_payout_notifications.py b/partner/migrations/0002_partner_profile_payout_notifications.py new file mode 100644 index 0000000..28d96c2 --- /dev/null +++ b/partner/migrations/0002_partner_profile_payout_notifications.py @@ -0,0 +1,70 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('partner', '0001_initial'), + ] + + operations = [ + # Profile extras + migrations.AddField( + model_name='partner', + name='bio', + field=models.TextField(blank=True, null=True), + ), + + # Payout settings + migrations.AddField( + model_name='partner', + name='payout_account_holder_name', + field=models.CharField(blank=True, max_length=250, null=True), + ), + migrations.AddField( + model_name='partner', + name='payout_account_number', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AddField( + model_name='partner', + name='payout_ifsc_code', + field=models.CharField(blank=True, max_length=20, null=True), + ), + migrations.AddField( + model_name='partner', + name='payout_bank_name', + field=models.CharField(blank=True, max_length=250, null=True), + ), + migrations.AddField( + model_name='partner', + name='payout_schedule', + field=models.CharField( + choices=[('weekly', 'Weekly'), ('biweekly', 'Bi-weekly'), ('monthly', 'Monthly')], + default='monthly', + max_length=20, + ), + ), + + # Notification preferences + migrations.AddField( + model_name='partner', + name='notif_new_booking', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='partner', + name='notif_event_status', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='partner', + name='notif_payout_update', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='partner', + name='notif_weekly_report', + field=models.BooleanField(default=False), + ), + ] diff --git a/partner/models.py b/partner/models.py index 94524f4..2227fe1 100644 --- a/partner/models.py +++ b/partner/models.py @@ -65,5 +65,28 @@ class Partner(models.Model): kyc_compliance_document_file = models.FileField(upload_to='kyc_documents/', blank=True, null=True) kyc_compliance_document_number = models.CharField(max_length=250, blank=True, null=True) + # Profile extras + bio = models.TextField(blank=True, null=True) + + # Payout settings + PAYOUT_SCHEDULE_CHOICES = ( + ('weekly', 'Weekly'), + ('biweekly', 'Bi-weekly'), + ('monthly', 'Monthly'), + ) + payout_account_holder_name = models.CharField(max_length=250, blank=True, null=True) + payout_account_number = models.CharField(max_length=50, blank=True, null=True) + payout_ifsc_code = models.CharField(max_length=20, blank=True, null=True) + payout_bank_name = models.CharField(max_length=250, blank=True, null=True) + payout_schedule = models.CharField( + max_length=20, choices=PAYOUT_SCHEDULE_CHOICES, default='monthly' + ) + + # Notification preferences + notif_new_booking = models.BooleanField(default=True) + notif_event_status = models.BooleanField(default=True) + notif_payout_update = models.BooleanField(default=True) + notif_weekly_report = models.BooleanField(default=False) + def __str__(self): return self.name