From 1abdbca403f5248be89f1a2ee04ddd86b752f8e4 Mon Sep 17 00:00:00 2001 From: Sravan Reddy Date: Tue, 5 Nov 2024 20:36:54 +0530 Subject: [PATCH 1/8] Prefactor DRY --- users/models.py | 10 ++++++++++ users/views.py | 14 ++------------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/users/models.py b/users/models.py index 9712ffc..ce07158 100644 --- a/users/models.py +++ b/users/models.py @@ -9,6 +9,7 @@ from django.contrib.auth.models import AbstractUser from django.contrib.sites.models import Site from django.db import models +from django.http import HttpResponse from django.urls import reverse from django.utils.timezone import now from django_otp.models import SideChannelDevice @@ -101,6 +102,15 @@ def generate_challenge(self): send_sms(self.phone_number.as_e164, message, sender) return message + @classmethod + def send_otp_httpresponse(cls, phone_number, user): + # create otp device for user + # send otp code via twilio + otp_device, _ = cls.objects.get_or_create(phone_number=phone_number, user=user) + otp_device.save() + otp_device.generate_challenge() + return HttpResponse() + class Meta: constraints = [ models.UniqueConstraint(fields=['phone_number', 'user'], name='phone_number_user') diff --git a/users/views.py b/users/views.py index 6f4ead5..722a29b 100644 --- a/users/views.py +++ b/users/views.py @@ -64,13 +64,8 @@ def test(request): @api_view(['POST']) def validate_phone(request): - # create otp device for user - # send otp code via twilio user = request.user - otp_device, _ = PhoneDevice.objects.get_or_create(phone_number=user.phone_number, user=user) - otp_device.save() - otp_device.generate_challenge() - return HttpResponse() + return PhoneDevice.send_otp_httpresponse(phone_number=user.phone_number, user=user) @api_view(['POST']) @@ -90,13 +85,8 @@ def confirm_otp(request): @api_view(['POST']) def validate_secondary_phone(request): - # create otp device for user - # send otp code via twilio user = request.user - otp_device, _ = PhoneDevice.objects.get_or_create(phone_number=user.recovery_phone, user=user) - otp_device.save() - otp_device.generate_challenge() - return HttpResponse() + return PhoneDevice.send_otp_httpresponse(phone_number=user.recovery_phone, user=user) @api_view(['POST']) From 59236009ff2285b376aa9e60e80a228db396f7d4 Mon Sep 17 00:00:00 2001 From: Sravan Reddy Date: Thu, 7 Nov 2024 21:15:43 +0530 Subject: [PATCH 2/8] Capture payment phone numbers --- connectid/settings.py | 1 + payments/__init__.py | 0 payments/apps.py | 6 +++ payments/migrations/0001_initial.py | 31 +++++++++++++ payments/migrations/__init__.py | 0 payments/models.py | 21 +++++++++ payments/views.py | 71 +++++++++++++++++++++++++++++ users/urls.py | 4 ++ users/views.py | 32 ++++++++++--- 9 files changed, 160 insertions(+), 6 deletions(-) create mode 100644 payments/__init__.py create mode 100644 payments/apps.py create mode 100644 payments/migrations/0001_initial.py create mode 100644 payments/migrations/__init__.py create mode 100644 payments/models.py create mode 100644 payments/views.py diff --git a/connectid/settings.py b/connectid/settings.py index a02f993..22ee62e 100644 --- a/connectid/settings.py +++ b/connectid/settings.py @@ -35,6 +35,7 @@ 'users.apps.UsersConfig', 'messaging', 'oauth2_provider', + 'payments', 'rest_framework', 'axes', 'fcm_django', diff --git a/payments/__init__.py b/payments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/payments/apps.py b/payments/apps.py new file mode 100644 index 0000000..4886655 --- /dev/null +++ b/payments/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PaymentsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'payments' diff --git a/payments/migrations/0001_initial.py b/payments/migrations/0001_initial.py new file mode 100644 index 0000000..b5c68c4 --- /dev/null +++ b/payments/migrations/0001_initial.py @@ -0,0 +1,31 @@ +# Generated by Django 4.1.7 on 2024-11-06 16:50 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import phonenumber_field.modelfields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='PaymentProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('phone_number', phonenumber_field.modelfields.PhoneNumberField(max_length=128, region=None)), + ('telecom_provider', models.CharField(max_length=50,blank=True, null=True)), + ('is_verified', models.BooleanField(default=False)), + ('is_validated', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='payment_profile', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/payments/migrations/__init__.py b/payments/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/payments/models.py b/payments/models.py new file mode 100644 index 0000000..973d909 --- /dev/null +++ b/payments/models.py @@ -0,0 +1,21 @@ +from django.db import models + +from phonenumber_field.modelfields import PhoneNumberField +from users.models import ConnectUser + + +class PaymentProfile(models.Model): + user = models.OneToOneField( + ConnectUser, + on_delete=models.CASCADE, + related_name='payment_profile' + ) + phone_number = PhoneNumberField() + telecom_provider = models.CharField(max_length=50, blank=True, null=True) + # whether the number is verified using OTP + is_verified = models.BooleanField(default=False) + # whether the number is a valid payment receiver + is_validated = models.BooleanField(default=False) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) diff --git a/payments/views.py b/payments/views.py new file mode 100644 index 0000000..1538ef4 --- /dev/null +++ b/payments/views.py @@ -0,0 +1,71 @@ +from django.http import JsonResponse, HttpResponse, Http404 +from django.views.decorators.http import require_POST +from oauth2_provider.decorators import protected_resource +from utils.rest_framework import ClientProtectedResourceAuth +from rest_framework.decorators import api_view +from rest_framework.views import APIView +from users.models import ConnectUser, PhoneDevice +from utils.twilio import lookup_telecom_provider +from .models import PaymentProfile + + +@api_view(['POST']) +def update_payment_profile_phone(request): + user = request.user + phone_number = request.data.get('phone_number') + telecom_provider = lookup_telecom_provider(phone_number) + payment_profile, created = PaymentProfile.objects.update_or_create( + user=user, + defaults={ + 'phone_number': phone_number, + 'telecom_provider': telecom_provider, + 'is_verified': False, + 'is_validated': False + } + ) + return PhoneDevice.send_otp_httpresponse(phone_number=payment_profile.phone_number, user=payment_profile.user) + + +@api_view(['POST']) +def confirm_payment_profile_otp(request): + PaymentProfile.objects.get(user=request.user) + payment_profile = request.user.payment_profile + device = PhoneDevice.objects.get(phone_number=payment_profile.phone_number, user=payment_profile.user) + if not device.verify_token(request.data.get('token')): + return JsonResponse({"error": "OTP token is incorrect"}, status=401) + + payment_profile.is_verified = True + payment_profile.save() + return JsonResponse({"success": True}) + + +@require_POST +@protected_resource(scopes=[]) +def validate_payment_phone_number(request): + username = request.data["username"] + phone_number = request.data["phone_number"] + user = ConnectUser.objects.get(username=username) + profile = getattr(user, "payment_profile") + + if not profile or profile.phone_number != phone_number: + raise Http404("Payment number not found") + + profile.is_validated = True + return HttpResponse() + + +class ValidatePhoneNumber(APIView): + authentication_classes = [ClientProtectedResourceAuth] + + def post(self, request, *args, **kwargs): + username = request.data["username"] + phone_number = request.data["phone_number"] + user = ConnectUser.objects.get(username=username) + profile = getattr(user, "payment_profile") + + if not profile or profile.phone_number != phone_number: + raise Http404("Payment number not found") + + profile.is_validated = True + profile.save() + return HttpResponse() diff --git a/users/urls.py b/users/urls.py index b614f0c..bc34fde 100644 --- a/users/urls.py +++ b/users/urls.py @@ -1,6 +1,7 @@ from django.urls import path from . import views +from payments import views as payment_views urlpatterns = [ path('', views.test, name='test'), @@ -32,4 +33,7 @@ path('fetch_db_key', views.fetch_db_key, name='fetch_db_key'), path('recover/initiate_deactivation', views.initiate_deactivation, name='initiate_deactivation'), path('recover/confirm_deactivation', views.confirm_deactivation, name='confirm_deactivation'), + path('profile/payment_phone_number', payment_views.update_payment_profile_phone, name='update_payment_profile_phone'), + path('profile/confirm_payment_otp', payment_views.confirm_payment_profile_otp, name='confirm_payment_profile_otp'), + path('profile/validate_payment_phone_number', payment_views.ValidatePhoneNumber.as_view(), name='validate_payment_phone_number'), ] diff --git a/users/views.py b/users/views.py index 722a29b..90e440b 100644 --- a/users/views.py +++ b/users/views.py @@ -3,7 +3,7 @@ from django.contrib.auth.hashers import check_password from django.contrib.auth.password_validation import validate_password -from django.core.exceptions import ValidationError +from django.core.exceptions import ValidationError, ObjectDoesNotExist from django.http import HttpResponse, JsonResponse from django.utils.timezone import now from django.views import View @@ -180,7 +180,9 @@ def confirm_secondary_recovery_otp(request): status.step = RecoveryStatus.RecoverySteps.RESET_PASSWORD status.save() db_key = UserKey.get_or_create_key_for_user(user) - return JsonResponse({"name": user.name, "username": user.username, "db_key": db_key.key}) + user_data = {"name": user.name, "username": user.username, "db_key": db_key.key} + user_data.update(user_payment_profile(user)) + return JsonResponse(user_data) @api_view(['POST']) @@ -199,8 +201,7 @@ def confirm_password(request): if not check_password(password, user.password): return HttpResponse(status=401) status.delete() - db_key = UserKey.get_or_create_key_for_user(user) - return JsonResponse({"name": user.name, "username": user.username, "secondary_phone_validate_by": user.recovery_phone_validation_deadline, "db_key": db_key.key}) + return JsonResponse(user_data(user)) @api_view(['POST']) @@ -305,6 +306,26 @@ def set_recovery_pin(request): return HttpResponse() +def user_data(user): + db_key = UserKey.get_or_create_key_for_user(user) + user_data = {"name": user.name, "username": user.username, "secondary_phone_validate_by": user.recovery_phone_validation_deadline, "db_key": db_key.key} + user_data.update(user_payment_profile(user)) + return user_data + + +def user_payment_profile(user): + try: + profile = user.payment_profile + return {"payment_profile": { + "phone_number": profile.phone_number, + "telecom_provider": profile.telecom_provider, + "is_verified": profile.is_verified, + "is_validated": profile.is_validated, + }} + except ObjectDoesNotExist: + return {} + + @api_view(['POST']) @permission_classes([]) def confirm_recovery_pin(request): @@ -322,8 +343,7 @@ def confirm_recovery_pin(request): return JsonResponse({"error": "Recovery PIN is incorrect"}, status=401) status.step = RecoveryStatus.RecoverySteps.RESET_PASSWORD status.save() - db_key = UserKey.get_or_create_key_for_user(user) - return JsonResponse({"name": user.name, "username": user.username, "secondary_phone_validate_by": user.recovery_phone_validation_deadline, "db_key": db_key.key}) + return JsonResponse(user_data(user)) @api_view(['GET']) From bb300695bde36ba4951c281359860292e047b6db Mon Sep 17 00:00:00 2001 From: Sravan Reddy Date: Thu, 7 Nov 2024 21:53:02 +0530 Subject: [PATCH 3/8] send payment valid status message --- conftest.py | 29 ++++++- messaging/tests.py | 51 +++--------- payments/migrations/0001_initial.py | 6 +- payments/models.py | 18 +++- payments/tests.py | 95 +++++++++++++++++++++ payments/views.py | 123 ++++++++++++++++++++++------ users/urls.py | 3 +- users/views.py | 2 +- utils/twilio.py | 17 ++++ 9 files changed, 273 insertions(+), 71 deletions(-) create mode 100644 payments/tests.py create mode 100644 utils/twilio.py diff --git a/conftest.py b/conftest.py index 22439e4..c4958d8 100644 --- a/conftest.py +++ b/conftest.py @@ -1,10 +1,11 @@ import base64 import pytest +from oauth2_provider.models import Application from rest_framework.test import APIClient from users.factories import UserFactory, FCMDeviceFactory - +from messaging.factories import ServerFactory @pytest.fixture def user(db): @@ -32,3 +33,29 @@ def auth_device(user): client = APIClient() client.credentials(HTTP_AUTHORIZATION=cred) return client + +@pytest.fixture +def oauth_app(user): + application = Application( + name="Test Application", + redirect_uris="http://localhost", + user=user, + client_type=Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=Application.GRANT_CLIENT_CREDENTIALS, + ) + application.raw_client_secret = application.client_secret + application.save() + return application + + +@pytest.fixture +def server(oauth_app): + return ServerFactory(oauth_application=oauth_app) + + +@pytest.fixture +def authed_client(client, oauth_app): + auth = f'{oauth_app.client_id}:{oauth_app.raw_client_secret}'.encode('utf-8') + credentials = base64.b64encode(auth).decode('utf-8') + client.defaults['HTTP_AUTHORIZATION'] = 'Basic ' + credentials + return client diff --git a/messaging/tests.py b/messaging/tests.py index eb6376d..25d8a55 100644 --- a/messaging/tests.py +++ b/messaging/tests.py @@ -8,42 +8,17 @@ import pytest from django.urls import reverse from firebase_admin import messaging -from oauth2_provider.models import Application from rest_framework import status +from rest_framework.test import APITestCase from messaging.factories import ChannelFactory, MessageFactory, ServerFactory from messaging.models import Channel, Message, MessageStatus from messaging.serializers import MessageData -from users.factories import FCMDeviceFactory - -APPLICATION_JSON = "application/json" - - -@pytest.fixture -def oauth_app(user): - application = Application( - name="Test Application", - redirect_uris="http://localhost", - user=user, - client_type=Application.CLIENT_CONFIDENTIAL, - authorization_grant_type=Application.GRANT_CLIENT_CREDENTIALS, - ) - application.raw_client_secret = application.client_secret - application.save() - return application +from payments.models import PaymentProfile +from users.factories import FCMDeviceFactory, UserFactory -@pytest.fixture -def server(oauth_app): - return ServerFactory(oauth_application=oauth_app) - - -@pytest.fixture -def authed_client(client, oauth_app): - auth = f'{oauth_app.client_id}:{oauth_app.raw_client_secret}'.encode('utf-8') - credentials = base64.b64encode(auth).decode('utf-8') - client.defaults['HTTP_AUTHORIZATION'] = 'Basic ' + credentials - return client +APPLICATION_JSON = "application/json" def test_send_message(authed_client, fcm_device): @@ -54,7 +29,7 @@ def test_send_message(authed_client, fcm_device): "username": fcm_device.user.username, "body": "test message", "data": {"test": "data"}, - }, content_type="application/json") + }, content_type=APPLICATION_JSON) assert response.status_code == 200, response.content assert response.json() == { 'all_success': True, @@ -90,7 +65,7 @@ def test_send_message_bulk(authed_client, fcm_device): "data": {"test": "data2"}, } ] - }, content_type="application/json") + }, content_type=APPLICATION_JSON) assert response.status_code == 200, response.content assert mock_send_message.call_count == 2 @@ -270,7 +245,7 @@ def test_multiple_messages(self, auth_device, channel, server): response = auth_device.post( self.url, data=json.dumps(data), - content_type="application/json", + content_type=APPLICATION_JSON, ) json_data = response.json() @@ -356,7 +331,7 @@ def test_consent(self, auth_device, channel, server, consent=False, ): } json_data = json.dumps(data) response = auth_device.post( - self.url, json_data, content_type="application/json" + self.url, json_data, content_type=APPLICATION_JSON ) assert response.status_code == status.HTTP_200_OK @@ -383,7 +358,7 @@ def test_invalid_channel_id(self, auth_device): url = reverse("messaging:update_consent") data = {"channel": str(uuid4()), "consent": False} data = json.dumps(data) - response = auth_device.post(url, data, content_type="application/json") + response = auth_device.post(url, data, content_type=APPLICATION_JSON) assert response.status_code == status.HTTP_404_NOT_FOUND @@ -397,7 +372,7 @@ def test_update_received(self, auth_device, channel): data = {"messages": message_ids} data = json.dumps(data) - response = auth_device.post(self.url, data, content_type="application/json") + response = auth_device.post(self.url, data, content_type=APPLICATION_JSON) assert response.status_code == status.HTTP_200_OK @@ -408,7 +383,7 @@ def test_update_received(self, auth_device, channel): def test_empty_message_list(self, auth_device): data = {"messages": []} data = json.dumps(data) - response = auth_device.post(self.url, data, content_type="application/json") + response = auth_device.post(self.url, data, content_type=APPLICATION_JSON) assert response.status_code == status.HTTP_400_BAD_REQUEST assert Message.objects.filter(received__isnull=False).count() == 0 @@ -417,7 +392,7 @@ def test_invalid_message_ids(self, auth_device): invalid_message_ids = [str(uuid4()), str(uuid4())] data = {"messages": invalid_message_ids} data = json.dumps(data) - response = auth_device.post(self.url, data, content_type="application/json") + response = auth_device.post(self.url, data, content_type=APPLICATION_JSON) assert response.status_code == status.HTTP_404_NOT_FOUND assert Message.objects.filter(received__isnull=False).count() == 0 @@ -433,7 +408,7 @@ def test_grouped_channel_messages(self, mock_send_messages, auth_device): data = {"messages": message_ids} data = json.dumps(data) - response = auth_device.post(self.url, data, content_type="application/json") + response = auth_device.post(self.url, data, content_type=APPLICATION_JSON) assert response.status_code == status.HTTP_200_OK diff --git a/payments/migrations/0001_initial.py b/payments/migrations/0001_initial.py index b5c68c4..ef83c52 100644 --- a/payments/migrations/0001_initial.py +++ b/payments/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 4.1.7 on 2024-11-06 16:50 +# Generated by Django 4.1.7 on 2024-11-09 10:16 from django.conf import settings from django.db import migrations, models @@ -20,9 +20,9 @@ class Migration(migrations.Migration): fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('phone_number', phonenumber_field.modelfields.PhoneNumberField(max_length=128, region=None)), - ('telecom_provider', models.CharField(max_length=50,blank=True, null=True)), + ('telecom_provider', models.CharField(blank=True, max_length=50, null=True)), ('is_verified', models.BooleanField(default=False)), - ('is_validated', models.BooleanField(default=False)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('approved', 'Approved'), ('rejected', 'Rejected')], default='pending', max_length=10)), ('created_at', models.DateTimeField(auto_now_add=True)), ('updated_at', models.DateTimeField(auto_now=True)), ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='payment_profile', to=settings.AUTH_USER_MODEL)), diff --git a/payments/models.py b/payments/models.py index 973d909..d022601 100644 --- a/payments/models.py +++ b/payments/models.py @@ -5,6 +5,16 @@ class PaymentProfile(models.Model): + PENDING = 'pending' + APPROVED = 'approved' + REJECTED = 'rejected' + + STATUS_CHOICES = [ + (PENDING, 'Pending'), + (APPROVED, 'Approved'), + (REJECTED, 'Rejected'), + ] + user = models.OneToOneField( ConnectUser, on_delete=models.CASCADE, @@ -14,8 +24,10 @@ class PaymentProfile(models.Model): telecom_provider = models.CharField(max_length=50, blank=True, null=True) # whether the number is verified using OTP is_verified = models.BooleanField(default=False) - # whether the number is a valid payment receiver - is_validated = models.BooleanField(default=False) - + status = models.CharField( + max_length=10, + choices=STATUS_CHOICES, + default=PENDING, + ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) diff --git a/payments/tests.py b/payments/tests.py new file mode 100644 index 0000000..7b46118 --- /dev/null +++ b/payments/tests.py @@ -0,0 +1,95 @@ +import base64 +import pytest + +from django.urls import reverse +from rest_framework import status + +from messaging.tests import APPLICATION_JSON +from payments.models import PaymentProfile +from users.factories import UserFactory + + +@pytest.mark.parametrize( + "data, expected_status, expected_user1_status, expected_user2_status, result", + [ + # Scenario 1: Update both statuses successfully + ( + [ + {"username": "user1", "phone_number": "12345", "status": "approved"}, + {"username": "user2", "phone_number": "67890", "status": "rejected"}, + ], + status.HTTP_200_OK, + "approved", + "rejected", + {"approved": 1, "rejected": 1, "pending": 0} + ), + # Scenario 2: No change in status + ( + [ + {"username": "user2", "phone_number": "67890", "status": "approved"}, + ], + status.HTTP_200_OK, + "pending", # Should remain unchanged + "approved", # Should remain unchanged + {"approved": 0, "rejected": 0, "pending": 0} + ), + # Scenario 3: Invalid user (user doesn't exist) + ( + [ + {"username": "nonexistent_user", "phone_number": "00000", "status": "rejected"}, + ], + status.HTTP_404_NOT_FOUND, + "pending", # No change + "approved", # No change + {} + ), + # Scenario 4: Multiple users, one invalid + ( + [ + {"username": "user1", "phone_number": "12345", "status": "approved"}, + {"username": "nonexistent_user", "phone_number": "00000", "status": "rejected"}, + ], + status.HTTP_404_NOT_FOUND, + "pending", # No change + "approved", # No change + {} + ), + ] +) +def test_validate_phone_numbers(authed_client, data, expected_status, expected_user1_status, expected_user2_status, result): + user1 = UserFactory(username="user1") + user2 = UserFactory(username="user2") + PaymentProfile.objects.create(user=user1, phone_number="12345", status="pending") + PaymentProfile.objects.create(user=user2, phone_number="67890", status="approved") + + url = reverse("validate_payment_phone_numbers") + + response = authed_client.post(url, {"updates": data}, content_type=APPLICATION_JSON) + + assert response.status_code == expected_status + + profile1 = PaymentProfile.objects.get(user=user1) + profile2 = PaymentProfile.objects.get(user=user2) + + assert profile1.status == expected_user1_status + assert profile2.status == expected_user2_status + if response.status_code == 200: + assert response.json()["result"] == result + + +def test_fetch_phone_numbers(authed_client): + user1 = UserFactory(username="user1") + user2 = UserFactory(username="user2") + PaymentProfile.objects.create(user=user1, phone_number="12345", status="pending") + PaymentProfile.objects.create(user=user2, phone_number="67890", status="approved") + + url = reverse("fetch_payment_phone_numbers") + + response = authed_client.get(url, {"usernames": ["user1", "user2"]}) + assert len(response.json()['found_payment_numbers']) == 2 + + response = authed_client.get(url, {"usernames": ["user1", "user2"], "status": "pending"}) + assert len(response.json()['found_payment_numbers']) == 1 + + response = authed_client.get(url, {"usernames": ["user1"], "status": "approved"}) + assert len(response.json()['found_payment_numbers']) == 0 diff --git a/payments/views.py b/payments/views.py index 1538ef4..82d3c4f 100644 --- a/payments/views.py +++ b/payments/views.py @@ -1,9 +1,16 @@ -from django.http import JsonResponse, HttpResponse, Http404 +from django.db import transaction +from django.db.models import Q +from django.http import JsonResponse, HttpResponse from django.views.decorators.http import require_POST +from messaging.views import send_bulk_message +from messaging.serializers import MessageData from oauth2_provider.decorators import protected_resource from utils.rest_framework import ClientProtectedResourceAuth +from rest_framework import status as drf_status from rest_framework.decorators import api_view +from rest_framework.response import Response from rest_framework.views import APIView + from users.models import ConnectUser, PhoneDevice from utils.twilio import lookup_telecom_provider from .models import PaymentProfile @@ -20,7 +27,7 @@ def update_payment_profile_phone(request): 'phone_number': phone_number, 'telecom_provider': telecom_provider, 'is_verified': False, - 'is_validated': False + 'status': PaymentProfile.PENDING } ) return PhoneDevice.send_otp_httpresponse(phone_number=payment_profile.phone_number, user=payment_profile.user) @@ -39,33 +46,101 @@ def confirm_payment_profile_otp(request): return JsonResponse({"success": True}) -@require_POST -@protected_resource(scopes=[]) -def validate_payment_phone_number(request): - username = request.data["username"] - phone_number = request.data["phone_number"] - user = ConnectUser.objects.get(username=username) - profile = getattr(user, "payment_profile") - - if not profile or profile.phone_number != phone_number: - raise Http404("Payment number not found") +class FetchPhoneNumbers(APIView): + authentication_classes = [ClientProtectedResourceAuth] - profile.is_validated = True - return HttpResponse() + def get(self, request, *args, **kwargs): + usernames = request.GET.getlist('usernames') + status = request.GET.get("status") + results = {} + profiles = PaymentProfile.objects.filter( + user__username__in=usernames) + if status: + profiles = profiles.filter(status=status) + profiles = profiles.select_related("user") + results["found_payment_numbers"] = [ + { + "username": p.user.username, + "phone_number": str(p.phone_number), + "status": p.status, + } + for p in profiles + ] + return JsonResponse(results) -class ValidatePhoneNumber(APIView): +class ValidatePhoneNumbers(APIView): authentication_classes = [ClientProtectedResourceAuth] def post(self, request, *args, **kwargs): - username = request.data["username"] - phone_number = request.data["phone_number"] - user = ConnectUser.objects.get(username=username) - profile = getattr(user, "payment_profile") + # List of dictionaries: [{"username": ..., "phone_number": ..., "status": ...}, ...] + users_data = request.data["updates"] + + filter_conditions = Q() + status_map = {} + + for data in users_data: + username = data["username"] + phone_number = data["phone_number"] + status = data["status"] + + filter_conditions |= Q(user__username=username, phone_number=phone_number) + status_map[(username, phone_number)] = status + + profiles = PaymentProfile.objects.filter(filter_conditions).select_related("user") + if len(profiles) != len(users_data): + return Response(status=drf_status.HTTP_404_NOT_FOUND) - if not profile or profile.phone_number != phone_number: - raise Http404("Payment number not found") + profiles_to_update = [] - profile.is_validated = True - profile.save() - return HttpResponse() + usernames_by_states = { + "pending": [], + "approved": [], + "rejected": [], + } + with transaction.atomic(): + for profile in profiles: + key = (profile.user.username, profile.phone_number) + requested_status = status_map.get(key) + + if profile.status != requested_status: + profile.status = requested_status + profiles_to_update.append(profile) + + usernames_by_states[requested_status].append(profile.user.username) + + if profiles_to_update: + PaymentProfile.objects.bulk_update(profiles_to_update, ['status']) + + if usernames_by_states["approved"]: + send_bulk_message( + MessageData( + usernames=usernames_by_states["approved"], + title="Your Payment Phone Number is approved", + body="Your payment phone number is approved and future payments will be made to this number.", + data={"action": "ccc_payment_info_confirmation", "confirmation_status": "approved"} + ) + ) + if usernames_by_states["rejected"]: + send_bulk_message( + MessageData( + usernames=usernames_by_states["rejected"], + title="Your Payment Phone Number did not work", + body="Your payment number did not work. Please try to change to a different payment phone number", + data={"action": "ccc_payment_info_confirmation", "confirmation_status": "approved"} + ) + ) + if usernames_by_states["pending"]: + send_bulk_message( + MessageData( + usernames=usernames_by_states["pending"], + title="Your Payment Phone Number is pending review", + body="Your payment phone number is pending review. Please wait for further updates.", + data={"action": "ccc_payment_info_confirmation", "confirmation_status": "pending"} + ) + ) + result = { + state: len(usernames_by_states[state]) + for state in ["approved", "rejected", "pending"] + } + return JsonResponse({"success": True, "result": result}, status=200) diff --git a/users/urls.py b/users/urls.py index bc34fde..0be5c54 100644 --- a/users/urls.py +++ b/users/urls.py @@ -35,5 +35,6 @@ path('recover/confirm_deactivation', views.confirm_deactivation, name='confirm_deactivation'), path('profile/payment_phone_number', payment_views.update_payment_profile_phone, name='update_payment_profile_phone'), path('profile/confirm_payment_otp', payment_views.confirm_payment_profile_otp, name='confirm_payment_profile_otp'), - path('profile/validate_payment_phone_number', payment_views.ValidatePhoneNumber.as_view(), name='validate_payment_phone_number'), + path('fetch_payment_phone_numbers', payment_views.FetchPhoneNumbers.as_view(), name='fetch_payment_phone_numbers'), + path('validate_payment_phone_numbers', payment_views.ValidatePhoneNumbers.as_view(), name='validate_payment_phone_numbers'), ] diff --git a/users/views.py b/users/views.py index 90e440b..86ba951 100644 --- a/users/views.py +++ b/users/views.py @@ -320,7 +320,7 @@ def user_payment_profile(user): "phone_number": profile.phone_number, "telecom_provider": profile.telecom_provider, "is_verified": profile.is_verified, - "is_validated": profile.is_validated, + "status": profile.status, }} except ObjectDoesNotExist: return {} diff --git a/utils/twilio.py b/utils/twilio.py new file mode 100644 index 0000000..f52199e --- /dev/null +++ b/utils/twilio.py @@ -0,0 +1,17 @@ +from twilio.rest import Client +from django.conf import settings + +# Create the client instance only once +twilio_client = Client(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN) + +def get_twilio_client(): + return twilio_client + + +def lookup_telecom_provider(phone_number): + client = get_twilio_client() + try: + phone_info = client.lookups.v1.phone_numbers(phone_number).fetch(type="carrier") + return phone_info.carrier.get("name") + except Exception as e: + return None From d9b1316b959970263aaf04fb6d20430166878d88 Mon Sep 17 00:00:00 2001 From: Sravan Reddy Date: Thu, 14 Nov 2024 19:21:21 +0530 Subject: [PATCH 4/8] PR feedback --- payments/views.py | 20 +++++++++----------- utils/twilio.py | 6 ++++++ 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/payments/views.py b/payments/views.py index 82d3c4f..a509ac1 100644 --- a/payments/views.py +++ b/payments/views.py @@ -35,7 +35,6 @@ def update_payment_profile_phone(request): @api_view(['POST']) def confirm_payment_profile_otp(request): - PaymentProfile.objects.get(user=request.user) payment_profile = request.user.payment_profile device = PhoneDevice.objects.get(phone_number=payment_profile.phone_number, user=payment_profile.user) if not device.verify_token(request.data.get('token')): @@ -98,19 +97,18 @@ def post(self, request, *args, **kwargs): "approved": [], "rejected": [], } - with transaction.atomic(): - for profile in profiles: - key = (profile.user.username, profile.phone_number) - requested_status = status_map.get(key) + for profile in profiles: + key = (profile.user.username, profile.phone_number) + requested_status = status_map.get(key) - if profile.status != requested_status: - profile.status = requested_status - profiles_to_update.append(profile) + if profile.status != requested_status: + profile.status = requested_status + profiles_to_update.append(profile) - usernames_by_states[requested_status].append(profile.user.username) + usernames_by_states[requested_status].append(profile.user.username) - if profiles_to_update: - PaymentProfile.objects.bulk_update(profiles_to_update, ['status']) + if profiles_to_update: + PaymentProfile.objects.bulk_update(profiles_to_update, ['status']) if usernames_by_states["approved"]: send_bulk_message( diff --git a/utils/twilio.py b/utils/twilio.py index f52199e..e66b3dd 100644 --- a/utils/twilio.py +++ b/utils/twilio.py @@ -1,6 +1,11 @@ +import logging + from twilio.rest import Client from django.conf import settings + +logger = logging.getLogger(__name__) + # Create the client instance only once twilio_client = Client(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN) @@ -14,4 +19,5 @@ def lookup_telecom_provider(phone_number): phone_info = client.lookups.v1.phone_numbers(phone_number).fetch(type="carrier") return phone_info.carrier.get("name") except Exception as e: + logger.exception("Error occurred during twilio call: %s", str(e)) return None From 6b3377c8c6bcb436c9e1046a4e0e52409b449783 Mon Sep 17 00:00:00 2001 From: Sravan Reddy Date: Wed, 20 Nov 2024 14:49:18 +0530 Subject: [PATCH 5/8] Track payment number owner name --- payments/migrations/0001_initial.py | 1 + payments/models.py | 1 + payments/views.py | 2 ++ users/views.py | 3 ++- 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/payments/migrations/0001_initial.py b/payments/migrations/0001_initial.py index ef83c52..f4a8cbe 100644 --- a/payments/migrations/0001_initial.py +++ b/payments/migrations/0001_initial.py @@ -20,6 +20,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('phone_number', phonenumber_field.modelfields.PhoneNumberField(max_length=128, region=None)), + ('owner_name', models.TextField(max_length=150, blank=True)), ('telecom_provider', models.CharField(blank=True, max_length=50, null=True)), ('is_verified', models.BooleanField(default=False)), ('status', models.CharField(choices=[('pending', 'Pending'), ('approved', 'Approved'), ('rejected', 'Rejected')], default='pending', max_length=10)), diff --git a/payments/models.py b/payments/models.py index d022601..c4cebd5 100644 --- a/payments/models.py +++ b/payments/models.py @@ -21,6 +21,7 @@ class PaymentProfile(models.Model): related_name='payment_profile' ) phone_number = PhoneNumberField() + owner_name = models.TextField(max_length=150, blank=True) telecom_provider = models.CharField(max_length=50, blank=True, null=True) # whether the number is verified using OTP is_verified = models.BooleanField(default=False) diff --git a/payments/views.py b/payments/views.py index a509ac1..71fdbba 100644 --- a/payments/views.py +++ b/payments/views.py @@ -20,11 +20,13 @@ def update_payment_profile_phone(request): user = request.user phone_number = request.data.get('phone_number') + owner_name = request.data.get('owner_name') telecom_provider = lookup_telecom_provider(phone_number) payment_profile, created = PaymentProfile.objects.update_or_create( user=user, defaults={ 'phone_number': phone_number, + 'owner_name': owner_name, 'telecom_provider': telecom_provider, 'is_verified': False, 'status': PaymentProfile.PENDING diff --git a/users/views.py b/users/views.py index 86ba951..f64a176 100644 --- a/users/views.py +++ b/users/views.py @@ -318,12 +318,13 @@ def user_payment_profile(user): profile = user.payment_profile return {"payment_profile": { "phone_number": profile.phone_number, + "owner_name": profile.owner_name, "telecom_provider": profile.telecom_provider, "is_verified": profile.is_verified, "status": profile.status, }} except ObjectDoesNotExist: - return {} + return {"payment_profile": {}} @api_view(['POST']) From cf6d2f64fb95421ab942574c65858b3d070f025c Mon Sep 17 00:00:00 2001 From: Sravan Reddy Date: Fri, 22 Nov 2024 15:06:48 +0530 Subject: [PATCH 6/8] no need for singleton --- utils/twilio.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/utils/twilio.py b/utils/twilio.py index e66b3dd..3591143 100644 --- a/utils/twilio.py +++ b/utils/twilio.py @@ -6,18 +6,12 @@ logger = logging.getLogger(__name__) -# Create the client instance only once -twilio_client = Client(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN) - -def get_twilio_client(): - return twilio_client - def lookup_telecom_provider(phone_number): - client = get_twilio_client() + client = Client(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN) try: phone_info = client.lookups.v1.phone_numbers(phone_number).fetch(type="carrier") return phone_info.carrier.get("name") except Exception as e: - logger.exception("Error occurred during twilio call: %s", str(e)) + logger.exception("Error occurred during Twilio call for phone number %s: %s", phone_number, str(e)) return None From 97546fd44f97af7963f8bfde49cdb235861188f3 Mon Sep 17 00:00:00 2001 From: Sravan Reddy Date: Fri, 22 Nov 2024 15:28:21 +0530 Subject: [PATCH 7/8] Remove phone number param from post --- payments/tests.py | 12 ++++++------ payments/views.py | 24 ++++++++---------------- 2 files changed, 14 insertions(+), 22 deletions(-) diff --git a/payments/tests.py b/payments/tests.py index 7b46118..62466c6 100644 --- a/payments/tests.py +++ b/payments/tests.py @@ -15,8 +15,8 @@ # Scenario 1: Update both statuses successfully ( [ - {"username": "user1", "phone_number": "12345", "status": "approved"}, - {"username": "user2", "phone_number": "67890", "status": "rejected"}, + {"username": "user1", "status": "approved"}, + {"username": "user2", "status": "rejected"}, ], status.HTTP_200_OK, "approved", @@ -26,7 +26,7 @@ # Scenario 2: No change in status ( [ - {"username": "user2", "phone_number": "67890", "status": "approved"}, + {"username": "user2", "status": "approved"}, ], status.HTTP_200_OK, "pending", # Should remain unchanged @@ -36,7 +36,7 @@ # Scenario 3: Invalid user (user doesn't exist) ( [ - {"username": "nonexistent_user", "phone_number": "00000", "status": "rejected"}, + {"username": "nonexistent_user", "status": "rejected"}, ], status.HTTP_404_NOT_FOUND, "pending", # No change @@ -46,8 +46,8 @@ # Scenario 4: Multiple users, one invalid ( [ - {"username": "user1", "phone_number": "12345", "status": "approved"}, - {"username": "nonexistent_user", "phone_number": "00000", "status": "rejected"}, + {"username": "user1", "status": "approved"}, + {"username": "nonexistent_user", "status": "rejected"}, ], status.HTTP_404_NOT_FOUND, "pending", # No change diff --git a/payments/views.py b/payments/views.py index 71fdbba..ed96bca 100644 --- a/payments/views.py +++ b/payments/views.py @@ -77,40 +77,32 @@ def post(self, request, *args, **kwargs): # List of dictionaries: [{"username": ..., "phone_number": ..., "status": ...}, ...] users_data = request.data["updates"] - filter_conditions = Q() - status_map = {} + usernames = [data["username"] for data in users_data] + status_map = {data["username"]: data["status"] for data in users_data} - for data in users_data: - username = data["username"] - phone_number = data["phone_number"] - status = data["status"] - - filter_conditions |= Q(user__username=username, phone_number=phone_number) - status_map[(username, phone_number)] = status - - profiles = PaymentProfile.objects.filter(filter_conditions).select_related("user") + profiles = PaymentProfile.objects.filter(user__username__in=usernames).select_related("user") if len(profiles) != len(users_data): return Response(status=drf_status.HTTP_404_NOT_FOUND) profiles_to_update = [] - usernames_by_states = { "pending": [], "approved": [], "rejected": [], } + for profile in profiles: - key = (profile.user.username, profile.phone_number) - requested_status = status_map.get(key) + username = profile.user.username + requested_status = status_map.get(username) if profile.status != requested_status: profile.status = requested_status profiles_to_update.append(profile) - usernames_by_states[requested_status].append(profile.user.username) + usernames_by_states[requested_status].append(username) if profiles_to_update: - PaymentProfile.objects.bulk_update(profiles_to_update, ['status']) + PaymentProfile.objects.bulk_update(profiles_to_update, ["status"]) if usernames_by_states["approved"]: send_bulk_message( From efb609ef1722c5d7e9088ed1b0c405ea7e96210d Mon Sep 17 00:00:00 2001 From: Sravan Reddy Date: Fri, 29 Nov 2024 17:04:21 +0530 Subject: [PATCH 8/8] Use e164 format for phone --- users/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/users/views.py b/users/views.py index f64a176..fa81217 100644 --- a/users/views.py +++ b/users/views.py @@ -317,7 +317,7 @@ def user_payment_profile(user): try: profile = user.payment_profile return {"payment_profile": { - "phone_number": profile.phone_number, + "phone_number": profile.phone_number.as_e164, "owner_name": profile.owner_name, "telecom_provider": profile.telecom_provider, "is_verified": profile.is_verified,