diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b3ee27a..0815b93 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,7 +2,7 @@ exclude: ^(ve/|venv/) repos: - repo: https://github.com/ambv/black - rev: 1fbf7251ccdb58ba93301622388615633ecc348a + rev: 24.8.0 hooks: - id: black language_version: python3.9 @@ -16,17 +16,17 @@ repos: additional_dependencies: ['flake8-print'] - repo: https://github.com/adrienverge/yamllint - rev: v1.16.0 + rev: v1.35.1 hooks: - id: yamllint files: ^.*\.(yml|yaml)$ - repo: https://github.com/asottile/seed-isort-config - rev: v1.9.1 + rev: v2.2.0 hooks: - id: seed-isort-config - repo: https://github.com/pre-commit/mirrors-isort - rev: v4.3.20 + rev: v5.10.1 hooks: - id: isort diff --git a/config/settings/base.py b/config/settings/base.py index e987a38..53c71b6 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -47,6 +47,7 @@ "rp_interceptors", "rp_yal", "randomisation", + "msisdn_utils", ] MIDDLEWARE = [ @@ -72,7 +73,8 @@ DATABASES = { "default": dj_database_url.config( default=os.environ.get( - "RP_SIDEKICK_DATABASE", "postgres://postgres@localhost/rp_sidekick" + "RP_SIDEKICK_DATABASE", + "postgres://postgres:dev_secret_key@localhost/rp_sidekick", ), engine="django_prometheus.db.backends.postgresql", ) diff --git a/config/urls.py b/config/urls.py index 81dac58..4e57c18 100644 --- a/config/urls.py +++ b/config/urls.py @@ -10,4 +10,5 @@ path("dtone/", include("rp_dtone.urls")), path("randomisation/", include("randomisation.urls")), path("yal/", include("rp_yal.urls"), name="rp_yal"), + path("msisdn_utils/", include("msisdn_utils.urls")), ] diff --git a/msisdn_utils/__init__.py b/msisdn_utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/msisdn_utils/admin.py b/msisdn_utils/admin.py new file mode 100644 index 0000000..4185d36 --- /dev/null +++ b/msisdn_utils/admin.py @@ -0,0 +1,3 @@ +# from django.contrib import admin + +# Register your models here. diff --git a/msisdn_utils/apps.py b/msisdn_utils/apps.py new file mode 100644 index 0000000..e64e9fa --- /dev/null +++ b/msisdn_utils/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TimezoneUtilsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "msisdn_utils" diff --git a/msisdn_utils/migrations/__init__.py b/msisdn_utils/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/msisdn_utils/models.py b/msisdn_utils/models.py new file mode 100644 index 0000000..0b4331b --- /dev/null +++ b/msisdn_utils/models.py @@ -0,0 +1,3 @@ +# from django.db import models + +# Create your models here. diff --git a/msisdn_utils/tests.py b/msisdn_utils/tests.py new file mode 100644 index 0000000..6e66553 --- /dev/null +++ b/msisdn_utils/tests.py @@ -0,0 +1,163 @@ +import json +from datetime import datetime +from unittest.mock import patch + +from django.contrib.auth.models import User +from rest_framework.authtoken.models import Token +from rest_framework.test import APIClient, APITestCase + + +class GetMsisdnTimezonesTest(APITestCase): + def setUp(self): + self.api_client = APIClient() + + self.admin_user = User.objects.create_superuser("adminuser", "admin_password") + + token = Token.objects.get(user=self.admin_user) + self.token = token.key + + self.api_client.credentials(HTTP_AUTHORIZATION="Token " + self.token) + + def test_auth_required_to_get_timezones(self): + response = self.api_client.post( + "/msisdn_utils/timezones/", + data=json.dumps({"whatsapp_id": "something"}), + content_type="application/json", + ) + + self.assertEqual(response.status_code, 401) + + def test_no_msisdn_returns_400(self): + self.client.force_authenticate(user=self.admin_user) + response = self.client.post( + "/msisdn_utils/timezones/", + data=json.dumps({}), + content_type="application/json", + ) + + self.assertEqual(response.data, {"whatsapp_id": ["This field is required."]}) + self.assertEqual(response.status_code, 400) + + def test_phonenumber_unparseable_returns_400(self): + self.client.force_authenticate(user=self.admin_user) + response = self.client.post( + "/msisdn_utils/timezones/", + data=json.dumps({"whatsapp_id": "something"}), + content_type="application/json", + ) + + self.assertEqual( + response.data, + { + "whatsapp_id": [ + "This value must be a phone number with a region prefix." + ] + }, + ) + self.assertEqual(response.status_code, 400) + + def test_not_possible_phonenumber_returns_400(self): + # If the length of a number doesn't match accepted length for it's region + self.client.force_authenticate(user=self.admin_user) + response = self.client.post( + "/msisdn_utils/timezones/", + data=json.dumps({"whatsapp_id": "120012301"}), + content_type="application/json", + ) + + self.assertEqual( + response.data, + { + "whatsapp_id": [ + "This value must be a phone number with a region prefix." + ] + }, + ) + self.assertEqual(response.status_code, 400) + + def test_invalid_phonenumber_returns_400(self): + # If a phone number is invalid for it's region + self.client.force_authenticate(user=self.admin_user) + response = self.client.post( + "/msisdn_utils/timezones/", + data=json.dumps({"whatsapp_id": "12001230101"}), + content_type="application/json", + ) + + self.assertEqual( + response.data, + { + "whatsapp_id": [ + "This value must be a phone number with a region prefix." + ] + }, + ) + self.assertEqual(response.status_code, 400) + + def test_phonenumber_with_plus(self): + self.client.force_authenticate(user=self.admin_user) + response = self.client.post( + "/msisdn_utils/timezones/", + data=json.dumps({"whatsapp_id": "+27345678910"}), + content_type="application/json", + ) + + self.assertEqual( + response.data, {"success": True, "timezones": ["Africa/Johannesburg"]} + ) + self.assertEqual(response.status_code, 200) + + def test_single_timezone_number(self): + self.client.force_authenticate(user=self.admin_user) + response = self.client.post( + "/msisdn_utils/timezones/", + data=json.dumps({"whatsapp_id": "27345678910"}), + content_type="application/json", + ) + + self.assertEqual( + response.data, {"success": True, "timezones": ["Africa/Johannesburg"]} + ) + self.assertEqual(response.status_code, 200) + + def test_multiple_timezone_number_returns_all(self): + self.client.force_authenticate(user=self.admin_user) + response = self.client.post( + "/msisdn_utils/timezones/", + data=json.dumps({"whatsapp_id": "61498765432"}), + content_type="application/json", + ) + + self.assertEqual( + response.data, + { + "success": True, + "timezones": [ + "Australia/Adelaide", + "Australia/Brisbane", + "Australia/Eucla", + "Australia/Lord_Howe", + "Australia/Perth", + "Australia/Sydney", + "Indian/Christmas", + "Indian/Cocos", + ], + }, + ) + self.assertEqual(response.status_code, 200) + + def test_return_one_flag_gives_middle_timezone(self): + self.client.force_authenticate(user=self.admin_user) + + with patch("msisdn_utils.views.datetime") as mock_datetime: + mock_datetime.utcnow.return_value = datetime(2022, 8, 8) + response = self.client.post( + "/msisdn_utils/timezones/?return_one=true", + data=json.dumps({"whatsapp_id": "61498765432"}), + content_type="application/json", + ) + + self.assertEqual( + response.data, {"success": True, "timezones": ["Australia/Adelaide"]} + ) + self.assertEqual(response.status_code, 200) diff --git a/msisdn_utils/urls.py b/msisdn_utils/urls.py new file mode 100644 index 0000000..dbf76e7 --- /dev/null +++ b/msisdn_utils/urls.py @@ -0,0 +1,11 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path( + "timezones/", + views.GetMsisdnTimezones.as_view(), + name="get-timezones", + ), +] diff --git a/msisdn_utils/views.py b/msisdn_utils/views.py new file mode 100644 index 0000000..2024842 --- /dev/null +++ b/msisdn_utils/views.py @@ -0,0 +1,74 @@ +import logging +from datetime import datetime +from math import floor + +import phonenumbers +import pytz +from phonenumbers import timezone as ph_timezone +from rest_framework import authentication, permissions +from rest_framework.exceptions import ValidationError +from rest_framework.response import Response +from rest_framework.views import APIView + +LOGGER = logging.getLogger(__name__) + + +def get_middle_tz(zones): + timezones = [] + for zone in zones: + offset = pytz.timezone(zone).utcoffset(datetime.utcnow()) + offset_seconds = (offset.days * 86400) + offset.seconds + timezones.append({"name": zone, "offset": offset_seconds / 3600}) + ordered_tzs = sorted(timezones, key=lambda k: k["offset"]) + + approx_tz = ordered_tzs[floor(len(ordered_tzs) / 2)]["name"] + + LOGGER.info( + "Available timezones: {}. Returned timezone: {}".format(ordered_tzs, approx_tz) + ) + return approx_tz + + +class GetMsisdnTimezones(APIView): + authentication_classes = [authentication.BasicAuthentication] + permission_classes = [permissions.IsAdminUser] + + def post(self, request, *args, **kwargs): + try: + msisdn = request.data["whatsapp_id"] + except KeyError: + raise ValidationError({"whatsapp_id": ["This field is required."]}) + + msisdn = msisdn if msisdn.startswith("+") else "+" + msisdn + + try: + msisdn = phonenumbers.parse(msisdn) + except phonenumbers.phonenumberutil.NumberParseException: + raise ValidationError( + { + "whatsapp_id": [ + "This value must be a phone number with a region prefix." + ] + } + ) + + if not ( + phonenumbers.is_possible_number(msisdn) + and phonenumbers.is_valid_number(msisdn) + ): + raise ValidationError( + { + "whatsapp_id": [ + "This value must be a phone number with a region prefix." + ] + } + ) + + zones = list(ph_timezone.time_zones_for_number(msisdn)) + if ( + len(zones) > 1 + and request.query_params.get("return_one", "false").lower() == "true" + ): + zones = [get_middle_tz(zones)] + + return Response({"success": True, "timezones": zones}, status=200) diff --git a/setup.cfg b/setup.cfg index 534c8a4..806f247 100644 --- a/setup.cfg +++ b/setup.cfg @@ -16,4 +16,4 @@ line_length = 88 multi_line_output = 3 include_trailing_comma = True skip = ve/,env/ -known_third_party = boto3,celery,dj_database_url,django,environ,freezegun,hashids,json2html,kombu,moto,phonenumber_field,pkg_resources,prometheus_client,pytest,raven,recommonmark,requests,responses,rest_framework,sentry_sdk,setuptools,six,temba_client +known_third_party = celery,dj_database_url,django,environ,freezegun,hashids,json2html,jsonschema,kombu,phonenumber_field,phonenumbers,pkg_resources,prometheus_client,pytest,pytz,raven,recommonmark,redis,requests,responses,rest_framework,sentry_sdk,setuptools,six,temba_client diff --git a/setup.py b/setup.py index 4cedc1c..25c35ca 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,7 @@ "django-prometheus==2.2.0", "djangorestframework==3.15.2", "json2html==1.3.0", - "phonenumbers==8.10.23", + "phonenumbers==8.13.45", "psycopg2-binary==2.8.6", "rapidpro-python==2.6.1", "redis==4.5.4",