Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Timezone utils #194

Merged
merged 3 commits into from
Sep 12, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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",
"timezone_utils",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you change the app to be msisdn_utils? I think an app where we can put small tools for handling phone numbers will be more useful in the long run than a timezone app

This change will probably require quite a bit of shifting things around I'm afraid

]

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("timezone_utils/", include("timezone_utils.urls")),
]
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MatthewWeppenaar did you update these or did an automated action update them?
I'm concerned about the removal of some of the libraries (especially boto3) so just want to check if something prompted you to do it or if it's automated.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Im not sure why this was removed, i didn't manually edit that line in setup.cfg so it must have been removed when i ran a function in the command line🤷‍♂️ Shall i add that line back?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok no that's fine. Just wanted to understand the reason behind it but if it was an automated process then it was probably removed in a previous change and the setup.cfg file wasn't updated.

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
Empty file added timezone_utils/__init__.py
Empty file.
3 changes: 3 additions & 0 deletions timezone_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 timezone_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 = "timezone_utils"
Empty file.
3 changes: 3 additions & 0 deletions timezone_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 timezone_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(
"/timezone_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(
"/timezone_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(
"/timezone_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(
"/timezone_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(
"/timezone_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(
"/timezone_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(
"/timezone_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(
"/timezone_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("timezone_utils.views.datetime") as mock_datetime:
mock_datetime.utcnow.return_value = datetime(2022, 8, 8)
response = self.client.post(
"/timezone_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 timezone_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 timezone_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)
Loading