feat: implement customer registration, profile management, and auth tests
All checks were successful
Deploy Beta (NATIVE) / deploy (push) Successful in 35s
All checks were successful
Deploy Beta (NATIVE) / deploy (push) Successful in 35s
This commit is contained in:
parent
d32c653013
commit
4ca070ad8c
3 changed files with 257 additions and 60 deletions
|
|
@ -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
|
||||
|
|
|
|||
123
api/tests.py
123
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'])
|
||||
|
|
|
|||
178
api/views.py
178
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)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue