feat(partner-portal): Sprint 1 — partner-me settings endpoints
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 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,11 @@ urlpatterns = [
|
|||||||
path('partners/<int:pk>/impersonate/', views.PartnerImpersonateView.as_view(), name='partner-impersonate'),
|
path('partners/<int:pk>/impersonate/', views.PartnerImpersonateView.as_view(), name='partner-impersonate'),
|
||||||
path('partners/onboard/', views.PartnerOnboardView.as_view(), name='partner-onboard'),
|
path('partners/onboard/', views.PartnerOnboardView.as_view(), name='partner-onboard'),
|
||||||
path('partners/<int:partner_id>/staff/', views.PartnerStaffCreateView.as_view(), name='partner-staff-create'),
|
path('partners/<int:partner_id>/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/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'),
|
||||||
|
|||||||
@@ -3313,3 +3313,229 @@ class NotificationScheduleTestSendView(APIView):
|
|||||||
log('info', f'Test email sent for schedule #{pk} → {email}',
|
log('info', f'Test email sent for schedule #{pk} → {email}',
|
||||||
request=request, user=request.user)
|
request=request, user=request.user)
|
||||||
return Response({'ok': True, 'sentTo': email})
|
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})
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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_file = models.FileField(upload_to='kyc_documents/', blank=True, null=True)
|
||||||
kyc_compliance_document_number = models.CharField(max_length=250, 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):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|||||||
Reference in New Issue
Block a user