Skip to content

Commit

Permalink
Merge pull request #194 from praekeltfoundation/timezone_utils
Browse files Browse the repository at this point in the history
Timezone utils
  • Loading branch information
MatthewWeppenaar authored Sep 12, 2024
2 parents 772c2c5 + a8752d4 commit 448cbd8
Show file tree
Hide file tree
Showing 13 changed files with 270 additions and 7 deletions.
8 changes: 4 additions & 4 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
4 changes: 3 additions & 1 deletion config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"rp_interceptors",
"rp_yal",
"randomisation",
"msisdn_utils",
]

MIDDLEWARE = [
Expand All @@ -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",
)
Expand Down
1 change: 1 addition & 0 deletions config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
]
Empty file added msisdn_utils/__init__.py
Empty file.
3 changes: 3 additions & 0 deletions msisdn_utils/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# from django.contrib import admin

# Register your models here.
6 changes: 6 additions & 0 deletions msisdn_utils/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class TimezoneUtilsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "msisdn_utils"
Empty file.
3 changes: 3 additions & 0 deletions msisdn_utils/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# from django.db import models

# Create your models here.
163 changes: 163 additions & 0 deletions msisdn_utils/tests.py
Original file line number Diff line number Diff line change
@@ -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)
11 changes: 11 additions & 0 deletions msisdn_utils/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from django.urls import path

from . import views

urlpatterns = [
path(
"timezones/",
views.GetMsisdnTimezones.as_view(),
name="get-timezones",
),
]
74 changes: 74 additions & 0 deletions msisdn_utils/views.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down

0 comments on commit 448cbd8

Please sign in to comment.