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