Skip to content

Commit f14126a

Browse files
authored
Merge pull request #156 from unicef/feature/phone-number-field
Feature/phone number field
2 parents 5e8b960 + ff6dde6 commit f14126a

File tree

7 files changed

+1320
-1124
lines changed

7 files changed

+1320
-1124
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ dependencies = [
5050
"numpy>=2.3",
5151
"openpyxl>=3.1.5",
5252
"pandas>=2.3",
53+
"phonenumbers>=9.0.8",
5354
"pillow>=11.2.1",
5455
"pre-commit>=4.2",
5556
"psycopg2-binary>=2.9.9",

src/country_workspace/contrib/hope/apps.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from .geo import Admin1Choice, Admin2Choice, Admin3Choice, Admin4Choice, CountryChoice
44
from .lookups import FinancialInstitutionChoice
5+
from .phone_numbers import PhoneNumberField
56

67

78
class Config(AppConfig):
@@ -21,6 +22,7 @@ def ready(self) -> None:
2122
field_registry.register(Admin3Choice)
2223
field_registry.register(Admin4Choice)
2324
field_registry.register(FinancialInstitutionChoice)
25+
field_registry.register(PhoneNumberField)
2426

2527
from country_workspace.contrib.hope.validators import FullHouseholdValidator
2628
from country_workspace.validators.registry import beneficiary_validator_registry
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from django import forms
2+
3+
from country_workspace.validators.phone_number import is_right_phone_number_format
4+
5+
6+
class PhoneNumberField(forms.CharField):
7+
def clean(self, value: str) -> str:
8+
value = super().clean(value)
9+
10+
if value and not is_right_phone_number_format(value):
11+
raise forms.ValidationError("Invalid phone number")
12+
13+
return value
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import phonenumbers
2+
3+
from typing import Any
4+
5+
6+
def is_right_phone_number_format(phone_number: str | Any) -> bool:
7+
# from phonenumbers.parse method description:
8+
# This method will throw a NumberParseException if the number is not
9+
# considered to be a possible number.
10+
#
11+
# so if `parse` does not throw, we may assume it's ok
12+
if not isinstance(phone_number, str): # pragma: no cover
13+
phone_number = str(phone_number)
14+
15+
phone_number = phone_number.strip()
16+
if phone_number.startswith("00"):
17+
phone_number = f"+{phone_number[2:]}"
18+
19+
try:
20+
phonenumbers.parse(phone_number)
21+
except phonenumbers.NumberParseException:
22+
return False
23+
24+
return True
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from concurrency.utils import fqn
2+
from hope_flex_fields.models import FieldDefinition
3+
from hope_flex_fields.registry import field_registry
4+
from hope_flex_fields.utils import get_kwargs_from_field_class, get_common_attrs
5+
from packaging.version import Version
6+
7+
from country_workspace.contrib.hope.phone_numbers import PhoneNumberField
8+
9+
_script_for_version = Version("0.1.0")
10+
11+
PHONE_NUMBER_FIELD = "phone_number"
12+
13+
14+
def forward() -> None:
15+
field_registry.register(PhoneNumberField)
16+
FieldDefinition.objects.get_or_create(
17+
name=PhoneNumberField.__name__,
18+
field_type=fqn(PhoneNumberField),
19+
defaults={"attrs": get_kwargs_from_field_class(PhoneNumberField, get_common_attrs())},
20+
)
21+
22+
23+
def backward() -> None:
24+
FieldDefinition.objects.filter(
25+
name=PhoneNumberField.__name__,
26+
field_type=fqn(PhoneNumberField),
27+
).delete()
28+
29+
30+
class Scripts:
31+
requires = []
32+
operations = [(forward, backward)]
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import pytest
2+
from django.core.exceptions import ValidationError
3+
4+
from country_workspace.contrib.hope.phone_numbers import PhoneNumberField
5+
6+
7+
def test_valid_international_format_with_plus():
8+
field = PhoneNumberField()
9+
10+
valid_numbers = [
11+
"+12345678901", # US number (10 digits)
12+
"+442071234567", # UK number
13+
"+33123456789", # French number
14+
"+61412345678", # Australian number
15+
"+123456789012345", # Long international number
16+
]
17+
18+
for number in valid_numbers:
19+
result = field.clean(number)
20+
assert result == number
21+
22+
23+
def test_valid_international_format_with_00_prefix():
24+
field = PhoneNumberField()
25+
26+
valid_numbers = [
27+
"0012345678901", # US number with 00
28+
"00442071234567", # UK number with 00
29+
"0033123456789", # French number with 00
30+
]
31+
32+
for number in valid_numbers:
33+
result = field.clean(number)
34+
assert result == number
35+
36+
37+
def test_empty_value():
38+
field = PhoneNumberField(required=False)
39+
40+
result = field.clean("")
41+
assert result == ""
42+
43+
result = field.clean(None)
44+
assert result == ""
45+
46+
47+
def test_whitespace_handling():
48+
field = PhoneNumberField()
49+
50+
result = field.clean(" +12345678901 ")
51+
assert result == "+12345678901"
52+
53+
result = field.clean("+1 234 567 8901")
54+
assert result == "+1 234 567 8901"
55+
56+
57+
def test_invalid_phone_numbers():
58+
field = PhoneNumberField()
59+
60+
invalid_numbers = [
61+
"123",
62+
"+",
63+
"+99",
64+
]
65+
66+
for number in invalid_numbers:
67+
with pytest.raises(ValidationError, match="Invalid phone number"):
68+
field.clean(number)
69+
70+
71+
def test_edge_cases():
72+
field = PhoneNumberField()
73+
74+
with pytest.raises(ValidationError, match="Invalid phone number"):
75+
field.clean("1")
76+
77+
with pytest.raises(ValidationError, match="Invalid phone number"):
78+
field.clean("0000000000")
79+
80+
with pytest.raises(ValidationError, match="Invalid phone number"):
81+
field.clean("123abc456")
82+
83+
84+
def test_required_field_validation():
85+
field = PhoneNumberField(required=True)
86+
87+
with pytest.raises(ValidationError):
88+
field.clean("")
89+
90+
with pytest.raises(ValidationError):
91+
field.clean(None)
92+
93+
94+
def test_field_inheritance():
95+
field = PhoneNumberField(max_length=20)
96+
97+
long_number = "123456789012345678901234567890"
98+
with pytest.raises(ValidationError):
99+
field.clean(long_number)
100+
101+
valid_number = "+12345678901"
102+
result = field.clean(valid_number)
103+
assert result == valid_number
104+
105+
106+
def test_00_prefix_conversion_logic():
107+
field = PhoneNumberField()
108+
109+
result = field.clean("0012345678901")
110+
assert result == "0012345678901"
111+
112+
result = field.clean("00 123 456 7890")
113+
assert result == "00 123 456 7890"

0 commit comments

Comments
 (0)