1"""
2.. note::
3
4 The `phonenumbers`_ package must be installed to use PhoneNumber types.
5
6.. _phonenumbers: https://github.com/daviddrysdale/python-phonenumbers
7"""
8
9from sqlalchemy import exc, types
10
11from ..exceptions import ImproperlyConfigured
12from ..utils import str_coercible
13from .scalar_coercible import ScalarCoercible
14
15try:
16 import phonenumbers
17 from phonenumbers.phonenumber import PhoneNumber as BasePhoneNumber
18 from phonenumbers.phonenumberutil import NumberParseException
19except ImportError:
20 phonenumbers = None
21 BasePhoneNumber = object
22 NumberParseException = Exception
23
24
25class PhoneNumberParseException(NumberParseException, exc.DontWrapMixin):
26 """
27 Wraps exceptions from phonenumbers with SQLAlchemy's DontWrapMixin
28 so we get more meaningful exceptions on validation failure instead of the
29 StatementException
30
31 Clients can catch this as either a PhoneNumberParseException or
32 NumberParseException from the phonenumbers library.
33 """
34
35 pass
36
37
38@str_coercible
39class PhoneNumber(BasePhoneNumber):
40 """
41 Extends a PhoneNumber class from `Python phonenumbers library`_. Adds
42 different phone number formats to attributes, so they can be easily used
43 in templates. Phone number validation method is also implemented.
44
45 Takes the raw phone number and country code as params and parses them
46 into a PhoneNumber object.
47
48 .. _Python phonenumbers library:
49 https://github.com/daviddrysdale/python-phonenumbers
50
51
52 ::
53
54 from sqlalchemy_utils import PhoneNumber
55
56
57 class User(self.Base):
58 __tablename__ = 'user'
59 id = sa.Column(sa.Integer, autoincrement=True, primary_key=True)
60 name = sa.Column(sa.Unicode(255))
61 _phone_number = sa.Column(sa.Unicode(20))
62 country_code = sa.Column(sa.Unicode(8))
63
64 phone_number = sa.orm.composite(
65 PhoneNumber,
66 _phone_number,
67 country_code
68 )
69
70
71 user = User(phone_number=PhoneNumber('0401234567', 'FI'))
72
73 user.phone_number.e164 # '+358401234567'
74 user.phone_number.international # '+358 40 1234567'
75 user.phone_number.national # '040 1234567'
76 user.country_code # 'FI'
77
78
79 :param raw_number:
80 String representation of the phone number.
81 :param region:
82 Region of the phone number.
83 :param check_region:
84 Whether to check the supplied region parameter;
85 should always be True for external callers.
86 Can be useful for short codes or toll free
87 """
88
89 def __init__(self, raw_number, region=None, check_region=True):
90 # Bail if phonenumbers is not found.
91 if phonenumbers is None:
92 raise ImproperlyConfigured(
93 "The 'phonenumbers' package is required to use 'PhoneNumber'"
94 )
95
96 try:
97 self._phone_number = phonenumbers.parse(
98 raw_number, region, _check_region=check_region
99 )
100 except NumberParseException as e:
101 # Wrap exception so SQLAlchemy doesn't swallow it as a
102 # StatementError
103 #
104 # Worth noting that if -1 shows up as the error_type
105 # it's likely because the API has changed upstream and these
106 # bindings need to be updated.
107 raise PhoneNumberParseException(getattr(e, 'error_type', -1), str(e))
108
109 super().__init__(
110 country_code=self._phone_number.country_code,
111 national_number=self._phone_number.national_number,
112 extension=self._phone_number.extension,
113 italian_leading_zero=self._phone_number.italian_leading_zero,
114 raw_input=self._phone_number.raw_input,
115 country_code_source=self._phone_number.country_code_source,
116 preferred_domestic_carrier_code=(
117 self._phone_number.preferred_domestic_carrier_code
118 ),
119 )
120 self.region = region
121 self.national = phonenumbers.format_number(
122 self._phone_number, phonenumbers.PhoneNumberFormat.NATIONAL
123 )
124 self.international = phonenumbers.format_number(
125 self._phone_number, phonenumbers.PhoneNumberFormat.INTERNATIONAL
126 )
127 self.e164 = phonenumbers.format_number(
128 self._phone_number, phonenumbers.PhoneNumberFormat.E164
129 )
130
131 def __composite_values__(self):
132 return self.national, self.region
133
134 def is_valid_number(self):
135 return phonenumbers.is_valid_number(self._phone_number)
136
137 def __unicode__(self):
138 return self.national
139
140 def __hash__(self):
141 return hash(self.e164)
142
143
144class PhoneNumberType(ScalarCoercible, types.TypeDecorator):
145 """
146 Changes PhoneNumber objects to a string representation on the way in and
147 changes them back to PhoneNumber objects on the way out. If E164 is used
148 as storing format, no country code is needed for parsing the database
149 value to PhoneNumber object.
150
151 ::
152
153 class User(self.Base):
154 __tablename__ = 'user'
155 id = sa.Column(sa.Integer, autoincrement=True, primary_key=True)
156 name = sa.Column(sa.Unicode(255))
157 phone_number = sa.Column(PhoneNumberType())
158
159
160 user = User(phone_number='+358401234567')
161
162 user.phone_number.e164 # '+358401234567'
163 user.phone_number.international # '+358 40 1234567'
164 user.phone_number.national # '040 1234567'
165 """
166
167 STORE_FORMAT = 'e164'
168 impl = types.Unicode(20)
169 python_type = PhoneNumber
170 cache_ok = True
171
172 def __init__(self, region='US', max_length=20, *args, **kwargs):
173 # Bail if phonenumbers is not found.
174 if phonenumbers is None:
175 raise ImproperlyConfigured(
176 "The 'phonenumbers' package is required to use 'PhoneNumberType'"
177 )
178
179 super().__init__(*args, **kwargs)
180 self.region = region
181 self.impl = types.Unicode(max_length)
182
183 def process_bind_param(self, value, dialect):
184 if value:
185 if not isinstance(value, PhoneNumber):
186 value = PhoneNumber(value, region=self.region)
187
188 if self.STORE_FORMAT == 'e164' and value.extension:
189 return f'{value.e164};ext={value.extension}'
190
191 return getattr(value, self.STORE_FORMAT)
192
193 return value
194
195 def process_result_value(self, value, dialect):
196 if value:
197 return PhoneNumber(value, self.region)
198 return value
199
200 def _coerce(self, value):
201 if value and not isinstance(value, PhoneNumber):
202 value = PhoneNumber(value, region=self.region)
203
204 return value or None