From 4ca070ad8c5418d159e59ca95c2a89210f056fbc Mon Sep 17 00:00:00 2001 From: vickytechkey Date: Sun, 1 Nov 2026 20:53:36 +0530 Subject: [PATCH] feat: implement customer registration, profile management, and auth tests --- api/serializers.py | 16 +++- api/tests.py | 123 ++++++++++++++++++++++++++++++- api/views.py | 178 +++++++++++++++++++++++++++++++-------------- 3 files changed, 257 insertions(+), 60 deletions(-) diff --git a/api/serializers.py b/api/serializers.py index e1c5a9c..f8658bd 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -5,18 +5,19 @@ from orders.models import CustomerProfile class UserRegisterSerializer(serializers.ModelSerializer): phone = serializers.CharField(write_only=True, required=False, allow_blank=True) address = serializers.CharField(write_only=True, required=False, allow_blank=True) - password = serializers.CharField(write_only=True) + password = serializers.CharField(write_only=True, required=False, default='TradhoxOtpUser2026!') first_name = serializers.CharField(required=False, allow_blank=True) last_name = serializers.CharField(required=False, allow_blank=True) + email = serializers.EmailField(required=False, allow_blank=True) class Meta: model = User fields = ['username', 'email', 'password', 'first_name', 'last_name', 'phone', 'address'] def create(self, validated_data): - phone = validated_data.pop('phone', '') + phone = validated_data.pop('phone', '') or validated_data.get('username') address = validated_data.pop('address', '') - password = validated_data.pop('password') + password = validated_data.pop('password', 'TradhoxOtpUser2026!') user = User.objects.create_user( username=validated_data['username'], @@ -25,5 +26,12 @@ class UserRegisterSerializer(serializers.ModelSerializer): last_name=validated_data.get('last_name', ''), password=password ) - CustomerProfile.objects.create(user=user, phone=phone, default_address=address) + CustomerProfile.objects.create( + user=user, + phone=phone, + default_address=address, + gender='Prefer not to say', + wallet_balance=0.00, + loyalty_coins=200 + ) return user diff --git a/api/tests.py b/api/tests.py index 7ce503c..84b42b6 100644 --- a/api/tests.py +++ b/api/tests.py @@ -1,3 +1,124 @@ from django.test import TestCase +from django.contrib.auth.models import User +from rest_framework.test import APIClient +from rest_framework import status +from orders.models import CustomerProfile -# Create your tests here. +class CustomerAuthAndProfileTests(TestCase): + def setUp(self): + self.client = APIClient() + self.test_phone = '9876543210' + self.test_user = User.objects.create_user( + username=self.test_phone, + first_name='Existing', + last_name='Patron', + email='patron@example.com', + password='TestPassword123!' + ) + self.test_profile = CustomerProfile.objects.create( + user=self.test_user, + phone=self.test_phone, + default_address='123 Heritage Lane, Chennai', + wallet_balance=150.00, + loyalty_coins=250, + gender='Female' + ) + + def test_signup_successful(self): + new_phone = '9123456789' + payload = { + 'phone': new_phone, + 'full_name': 'New Artisan Patron', + 'email': 'newpatron@tradhox.com', + 'address': '45 Craft Colony, Kanchipuram' + } + response = self.client.post('/api/v1/customer/auth/register/', payload, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data['username'], new_phone) + self.assertEqual(response.data['phone'], new_phone) + self.assertEqual(response.data['first_name'], 'New') + self.assertEqual(response.data['last_name'], 'Artisan Patron') + + # Verify saved in database + saved_user = User.objects.get(username=new_phone) + self.assertEqual(saved_user.first_name, 'New') + self.assertEqual(saved_user.email, 'newpatron@tradhox.com') + self.assertEqual(saved_user.customer_profile.phone, new_phone) + self.assertEqual(saved_user.customer_profile.loyalty_coins, 200) + + def test_signup_duplicate_phone_rejected(self): + # Attempt to register with already existing phone + payload = { + 'phone': self.test_phone, + 'full_name': 'Duplicate Person', + 'email': 'duplicate@example.com' + } + response = self.client.post('/api/v1/customer/auth/register/', payload, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('already registered', response.data['error'].lower()) + self.assertEqual(response.data['code'], 'already_registered') + + def test_signup_invalid_phone_rejected(self): + payload = { + 'phone': '12345', # Not 10 digits + 'full_name': 'Invalid Phone User' + } + response = self.client.post('/api/v1/customer/auth/register/', payload, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('valid 10-digit', response.data['error']) + + def test_get_user_profile_by_phone(self): + response = self.client.get(f'/api/v1/customer/profile/?phone={self.test_phone}') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['phone'], self.test_phone) + self.assertEqual(response.data['username'], self.test_phone) + self.assertEqual(response.data['first_name'], 'Existing') + self.assertEqual(response.data['last_name'], 'Patron') + self.assertEqual(response.data['full_name'], 'Existing Patron') + self.assertEqual(response.data['email'], 'patron@example.com') + self.assertEqual(response.data['gender'], 'Female') + self.assertEqual(response.data['wallet_balance'], 150.00) + self.assertEqual(response.data['loyalty_coins'], 250) + self.assertEqual(response.data['address'], '123 Heritage Lane, Chennai') + + def test_put_user_profile_update(self): + update_payload = { + 'phone': self.test_phone, + 'full_name': 'Updated Name', + 'email': 'updated@example.com', + 'gender': 'Male', + 'address': '789 Updated St, Coimbatore' + } + response = self.client.put('/api/v1/customer/profile/', update_payload, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['first_name'], 'Updated') + self.assertEqual(response.data['last_name'], 'Name') + self.assertEqual(response.data['email'], 'updated@example.com') + self.assertEqual(response.data['gender'], 'Male') + self.assertEqual(response.data['address'], '789 Updated St, Coimbatore') + + # Verify DB persisted + self.test_user.refresh_from_db() + self.assertEqual(self.test_user.first_name, 'Updated') + self.assertEqual(self.test_user.email, 'updated@example.com') + self.assertEqual(self.test_user.customer_profile.gender, 'Male') + + def test_send_otp_duplicate_register_fails(self): + payload = { + 'mobile_number': self.test_phone, + 'otp_code': '123456', + 'purpose': 'register' + } + response = self.client.post('/api/v1/customer/auth/send-whatsapp/', payload, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('already exists', response.data['message']) + + def test_send_otp_nonexistent_login_fails(self): + payload = { + 'mobile_number': '9999900000', + 'otp_code': '123456', + 'purpose': 'login' + } + response = self.client.post('/api/v1/customer/auth/send-whatsapp/', payload, format='json') + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertIn('Account not found', response.data['message']) diff --git a/api/views.py b/api/views.py index daf33a7..31e1fd2 100644 --- a/api/views.py +++ b/api/views.py @@ -8,6 +8,7 @@ from rest_framework import status, permissions from rest_framework.response import Response from rest_framework.views import APIView from django.contrib.auth.models import User +from orders.models import CustomerProfile from .serializers import UserRegisterSerializer audit_logger = logging.getLogger('audit') @@ -16,60 +17,116 @@ class UserRegisterView(APIView): permission_classes = [permissions.AllowAny] def post(self, request): - serializer = UserRegisterSerializer(data=request.data) + raw_phone = request.data.get('phone') or request.data.get('username') or '' + clean_digits = ''.join(c for c in str(raw_phone) if c.isdigit()) + phone_10 = clean_digits[-10:] if len(clean_digits) >= 10 else clean_digits + + if not phone_10 or len(phone_10) != 10: + return Response( + {"error": "A valid 10-digit mobile phone number is required."}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Check for duplicate registration using phone number as primary key + existing_user = ( + User.objects.filter(username=phone_10).first() + or User.objects.filter(customer_profile__phone=phone_10).first() + ) + + if existing_user: + audit_logger.warning(f"AUDIT - Duplicate registration attempt for phone '{phone_10}'") + return Response( + { + "error": "This phone number is already registered. Please sign in instead.", + "code": "already_registered", + "phone": phone_10 + }, + status=status.HTTP_400_BAD_REQUEST + ) + + # Prepare normalized registration data + req_data = request.data.copy() + req_data['username'] = phone_10 + req_data['phone'] = phone_10 + if not req_data.get('password'): + req_data['password'] = 'TradhoxOtpUser2026!' + + if 'full_name' in req_data and not req_data.get('first_name'): + parts = str(req_data['full_name']).strip().split() + req_data['first_name'] = parts[0] if parts else '' + req_data['last_name'] = ' '.join(parts[1:]) if len(parts) > 1 else '' + elif 'name' in req_data and not req_data.get('first_name'): + parts = str(req_data['name']).strip().split() + req_data['first_name'] = parts[0] if parts else '' + req_data['last_name'] = ' '.join(parts[1:]) if len(parts) > 1 else '' + + serializer = UserRegisterSerializer(data=req_data) if serializer.is_valid(): - serializer.save() - audit_logger.info(f"AUDIT - User Register: Username '{serializer.validated_data.get('username')}' registered successfully.") - return Response({"message": "User registered successfully"}, status=status.HTTP_201_CREATED) - else: - username = request.data.get('username') - phone = request.data.get('phone', username) - if username and User.objects.filter(username=username).exists(): - user = User.objects.get(username=username) - CustomerProfile.objects.get_or_create(user=user, defaults={'phone': phone or username}) - return Response({"message": "User account already registered"}, status=status.HTTP_200_OK) + user = serializer.save() + profile = getattr(user, 'customer_profile', None) + audit_logger.info(f"AUDIT - User Register: Username/Phone '{phone_10}' registered successfully.") + return Response({ + "message": "User registered successfully", + "username": user.username, + "phone": profile.phone if profile else user.username, + "first_name": user.first_name, + "last_name": user.last_name, + "full_name": f"{user.first_name} {user.last_name}".strip() or user.username, + "email": user.email, + "wallet_balance": float(profile.wallet_balance) if profile else 0.0, + "loyalty_coins": profile.loyalty_coins if profile else 200, + "address": profile.default_address if profile else "" + }, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) class UserProfileView(APIView): permission_classes = [permissions.AllowAny] def get(self, request): - username = request.query_params.get('username') or request.query_params.get('phone') + identifier = request.query_params.get('phone') or request.query_params.get('username') user = None if request.user and request.user.is_authenticated: user = request.user - elif username: - clean_digits = ''.join(c for c in str(username) if c.isdigit()) + elif identifier: + clean_digits = ''.join(c for c in str(identifier) if c.isdigit()) raw_10 = clean_digits[-10:] if len(clean_digits) >= 10 else clean_digits - from django.db.models import Q - user = User.objects.filter(Q(username__icontains=raw_10) | Q(username=username)).first() + user = ( + User.objects.filter(username=raw_10).first() + or User.objects.filter(customer_profile__phone=raw_10).first() + or User.objects.filter(username=str(identifier)).first() + ) if not user: return Response({"error": "User not found"}, status=status.HTTP_404_NOT_FOUND) - profile = getattr(user, 'customer_profile', None) + profile, _ = CustomerProfile.objects.get_or_create(user=user, defaults={'phone': user.username}) return Response({ "username": user.username, "first_name": user.first_name, "last_name": user.last_name, + "full_name": f"{user.first_name} {user.last_name}".strip() or user.username, "email": user.email, - "phone": profile.phone if profile else user.username, - "gender": profile.gender if profile else "Prefer not to say", - "wallet_balance": float(profile.wallet_balance) if profile else 0.0, - "loyalty_coins": profile.loyalty_coins if profile else 200, - "address": profile.default_address if profile else "" - }) + "phone": profile.phone or user.username, + "gender": profile.gender or "Prefer not to say", + "wallet_balance": float(profile.wallet_balance), + "loyalty_coins": profile.loyalty_coins, + "address": profile.default_address or "" + }, status=status.HTTP_200_OK) def put(self, request): - username = request.data.get('username') or request.data.get('phone') + identifier = request.data.get('phone') or request.data.get('username') user = None if request.user and request.user.is_authenticated: user = request.user - elif username: - clean_digits = ''.join(c for c in str(username) if c.isdigit()) + elif identifier: + clean_digits = ''.join(c for c in str(identifier) if c.isdigit()) raw_10 = clean_digits[-10:] if len(clean_digits) >= 10 else clean_digits - from django.db.models import Q - user = User.objects.filter(Q(username__icontains=raw_10) | Q(username=username)).first() + user = ( + User.objects.filter(username=raw_10).first() + or User.objects.filter(customer_profile__phone=raw_10).first() + or User.objects.filter(username=str(identifier)).first() + ) if not user: return Response({"error": "User profile not found"}, status=status.HTTP_404_NOT_FOUND) @@ -80,12 +137,21 @@ class UserProfileView(APIView): user.first_name = request.data['first_name'] if 'last_name' in request.data: user.last_name = request.data['last_name'] + if 'full_name' in request.data: + parts = str(request.data['full_name']).strip().split() + user.first_name = parts[0] if parts else '' + user.last_name = ' '.join(parts[1:]) if len(parts) > 1 else '' + elif 'name' in request.data: + parts = str(request.data['name']).strip().split() + user.first_name = parts[0] if parts else '' + user.last_name = ' '.join(parts[1:]) if len(parts) > 1 else '' user.save() - from orders.models import CustomerProfile - profile, _ = CustomerProfile.objects.get_or_create(user=user) - if 'phone' in request.data: - profile.phone = request.data['phone'] + profile, _ = CustomerProfile.objects.get_or_create(user=user, defaults={'phone': user.username}) + if 'phone' in request.data and request.data['phone']: + clean_phone_digits = ''.join(c for c in str(request.data['phone']) if c.isdigit()) + new_phone_10 = clean_phone_digits[-10:] if len(clean_phone_digits) >= 10 else clean_phone_digits + profile.phone = new_phone_10 if 'gender' in request.data: profile.gender = request.data['gender'] if 'wallet_balance' in request.data: @@ -101,12 +167,15 @@ class UserProfileView(APIView): return Response({ "message": "Profile updated successfully", "username": user.username, + "phone": profile.phone or user.username, "email": user.email, "first_name": user.first_name, "last_name": user.last_name, + "full_name": f"{user.first_name} {user.last_name}".strip() or user.username, "gender": profile.gender, "wallet_balance": float(profile.wallet_balance), - "loyalty_coins": profile.loyalty_coins + "loyalty_coins": profile.loyalty_coins, + "address": profile.default_address }, status=status.HTTP_200_OK) def send_msg91_whatsapp_direct(mobile_number, otp_code, template_name="mobile_number_verification"): @@ -187,39 +256,37 @@ class SendWhatsAppOTPView(APIView): return Response({"error": "mobile_number and otp_code are required"}, status=status.HTTP_400_BAD_REQUEST) clean_phone = str(mobile_number).replace('+', '').strip() - raw_10 = clean_phone[-10:] if len(clean_phone) >= 10 else clean_phone + raw_digits = ''.join(c for c in clean_phone if c.isdigit()) + phone_10 = raw_digits[-10:] if len(raw_digits) >= 10 else raw_digits - # Account existence validation - from django.db.models import Q - user_exists = None - try: - from orders.models import CustomerProfile - user_exists = ( - User.objects.filter(Q(username__icontains=raw_10) | Q(email__icontains=raw_10)).exists() - or CustomerProfile.objects.filter(Q(phone__icontains=raw_10) | Q(user__username__icontains=raw_10)).exists() - ) - except Exception as db_err: - audit_logger.warning(f"DB lookup notice for {mobile_number}: {db_err}") - user_exists = None + if not phone_10 or len(phone_10) != 10: + return Response({"error": "Please enter a valid 10-digit mobile number"}, status=status.HTTP_400_BAD_REQUEST) - if purpose == 'login' and user_exists is False: - audit_logger.warning(f"AUDIT - Login attempt for non-existent mobile: '{mobile_number}'") + # Account existence validation using phone number as primary key + user_exists = ( + User.objects.filter(username=phone_10).exists() + or CustomerProfile.objects.filter(phone=phone_10).exists() + ) + + if purpose == 'login' and not user_exists: + audit_logger.warning(f"AUDIT - Login attempt for non-existent mobile: '{phone_10}'") return Response({ "status": "error", "message": "Account not found. Please create a patron account first." }, status=status.HTTP_404_NOT_FOUND) - if purpose == 'register' and user_exists is True: - audit_logger.warning(f"AUDIT - Register attempt for existing mobile: '{mobile_number}'") + if purpose == 'register' and user_exists: + audit_logger.warning(f"AUDIT - Register attempt for existing mobile: '{phone_10}'") return Response({ "status": "error", - "message": "An account with this mobile number already exists. Please Sign In." + "message": "An account with this phone number already exists. Please Sign In." }, status=status.HTTP_400_BAD_REQUEST) - audit_logger.info(f"AUDIT - Send WhatsApp OTP: mobile '{mobile_number}', OTP '{otp_code}'") + audit_logger.info(f"AUDIT - Send WhatsApp OTP: mobile '{phone_10}', OTP '{otp_code}'") # Store OTP in backend cache for 5 minutes (300 seconds) cache.set(f"whatsapp_otp_{clean_phone}", str(otp_code), timeout=300) + cache.set(f"whatsapp_otp_{phone_10}", str(otp_code), timeout=300) whatsapp_urls = [ getattr(settings, 'WHATSAPP_SERVICE_URL', 'https://betawhatsapp.tradhox.com/api/send/'), @@ -313,7 +380,10 @@ class VerifyOTPView(APIView): return Response({"error": "mobile_number and otp_code are required"}, status=status.HTTP_400_BAD_REQUEST) clean_phone = str(mobile_number).replace('+', '').strip() - stored_otp = cache.get(f"whatsapp_otp_{clean_phone}") + raw_digits = ''.join(c for c in clean_phone if c.isdigit()) + phone_10 = raw_digits[-10:] if len(raw_digits) >= 10 else clean_digits + + stored_otp = cache.get(f"whatsapp_otp_{clean_phone}") or cache.get(f"whatsapp_otp_{phone_10}") if stored_otp and str(stored_otp).strip() == str(otp_code).strip(): audit_logger.info(f"AUDIT - OTP Verification SUCCESS: mobile '{mobile_number}'") @@ -327,5 +397,3 @@ class VerifyOTPView(APIView): "status": "error", "message": "Invalid or expired OTP verification code. Access denied." }, status=status.HTTP_400_BAD_REQUEST) - -