Skip to content

Commit 8e3b7cb

Browse files
authored
[TOOLSLIBS-2437] SMS Field Updates (#220)
* add new fields * new version updates
1 parent e3d8205 commit 8e3b7cb

File tree

4 files changed

+268
-14
lines changed

4 files changed

+268
-14
lines changed

CHANGELOG

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
# urbanairship changelog
22

3+
## 7.2.0
4+
5+
- Adds new fields to the SMS class for enhanced opt-in management:
6+
- `opted_out`: Optional datetime for tracking when users opt out of SMS messages
7+
- `opt_in_mode`: Optional string ("classic" or "double") for opt-in mode configuration
8+
- `properties`: Optional dict for event properties used with double opt-in
9+
- Updates SMS payload structure to properly handle different API contexts:
10+
- Registration payloads exclude opt_in_mode, properties, and opted_out
11+
- Update payloads include opted_out but exclude opt_in_mode and properties
12+
- Create-and-send audience includes all fields including ua_opted_out
13+
314
## 7.1.1
415

516
- Fixes a bug where the EmailAttachment class was not properly encoding file data.

tests/devices/test_sms.py

Lines changed: 154 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,7 @@ def test_sms_channel_reg(self):
1717

1818
with mock.patch.object(ua.Airship, "_request") as mock_request:
1919
response = requests.Response()
20-
response._content = json.dumps({"ok": True, "status": "pending"}).encode(
21-
"utf-8"
22-
)
20+
response._content = json.dumps({"ok": True, "status": "pending"}).encode("utf-8")
2321
response.status_code = 202
2422
mock_request.return_value = response
2523

@@ -38,9 +36,7 @@ def test_sms_channel_reg_with_optin(self):
3836

3937
with mock.patch.object(ua.Airship, "_request") as mock_request:
4038
response = requests.Response()
41-
response._content = json.dumps(
42-
{"ok": True, "channel_id": channel_id}
43-
).encode("utf-8")
39+
response._content = json.dumps({"ok": True, "channel_id": channel_id}).encode("utf-8")
4440
response.status_code = 201
4541
mock_request.return_value = response
4642

@@ -90,7 +86,7 @@ def test_sms_registration_payload_property(self):
9086
airship=ua.Airship(TEST_KEY, TEST_SECRET),
9187
sender="12345",
9288
msisdn="15035556789",
93-
opted_in="2018-02-13T11:58:59",
89+
opted_in=datetime(2018, 2, 13, 11, 58, 59),
9490
locale_country="us",
9591
locale_language="en",
9692
timezone="America/Los_Angeles",
@@ -146,6 +142,157 @@ def test_sms_lookup(self):
146142

147143
self.assertTrue(r.ok)
148144

145+
def test_sms_registration_payload_with_new_fields(self):
146+
"""Test SMS registration payload with new fields: opted_out, opt_in_mode, properties."""
147+
sms = ua.Sms(
148+
airship=ua.Airship(TEST_KEY, TEST_SECRET),
149+
sender="12345",
150+
msisdn="15035556789",
151+
opted_in=datetime(2018, 2, 13, 11, 58, 59),
152+
opted_out=datetime(2018, 3, 15, 14, 30, 0),
153+
opt_in_mode="double",
154+
properties={"source": "web_form", "campaign": "summer_2023"},
155+
locale_country="us",
156+
locale_language="en",
157+
timezone="America/Los_Angeles",
158+
)
159+
160+
self.assertEqual(
161+
sms._registration_payload,
162+
{
163+
"sender": "12345",
164+
"msisdn": "15035556789",
165+
"locale_language": "en",
166+
"locale_country": "us",
167+
"timezone": "America/Los_Angeles",
168+
"opted_in": "2018-02-13T11:58:59",
169+
},
170+
)
171+
172+
def test_sms_update_payload(self):
173+
"""Test SMS update payload excludes opt_in_mode and properties."""
174+
sms = ua.Sms(
175+
airship=ua.Airship(TEST_KEY, TEST_SECRET),
176+
sender="12345",
177+
msisdn="15035556789",
178+
opted_in=datetime(2018, 2, 13, 11, 58, 59),
179+
opted_out=datetime(2018, 3, 15, 14, 30, 0),
180+
opt_in_mode="double",
181+
properties={"source": "web_form"},
182+
locale_country="us",
183+
locale_language="en",
184+
timezone="America/Los_Angeles",
185+
)
186+
187+
self.assertEqual(
188+
sms._update_payload,
189+
{
190+
"sender": "12345",
191+
"msisdn": "15035556789",
192+
"locale_language": "en",
193+
"locale_country": "us",
194+
"timezone": "America/Los_Angeles",
195+
"opted_in": "2018-02-13T11:58:59",
196+
"opted_out": "2018-03-15T14:30:00",
197+
},
198+
)
199+
200+
def test_sms_create_and_send_audience_with_new_fields(self):
201+
"""Test SMS create and send audience includes new fields."""
202+
sms = ua.Sms(
203+
airship=ua.Airship(TEST_KEY, TEST_SECRET),
204+
sender="12345",
205+
msisdn="15035556789",
206+
opted_in=datetime(2018, 2, 13, 11, 58, 59),
207+
opted_out=datetime(2018, 3, 15, 14, 30, 0),
208+
template_fields={"name": "John Doe", "preference": "text"},
209+
)
210+
211+
audience = sms.create_and_send_audience
212+
self.assertEqual(audience["ua_sender"], "12345")
213+
self.assertEqual(audience["ua_msisdn"], "15035556789")
214+
self.assertEqual(audience["ua_opted_in"], datetime(2018, 2, 13, 11, 58, 59))
215+
self.assertEqual(audience["ua_opted_out"], datetime(2018, 3, 15, 14, 30, 0))
216+
self.assertEqual(audience["name"], "John Doe")
217+
self.assertEqual(audience["preference"], "text")
218+
219+
def test_sms_create_and_send_audience_without_opted_in_raises(self):
220+
"""Test SMS create and send audience raises error without opted_in."""
221+
sms = ua.Sms(
222+
airship=ua.Airship(TEST_KEY, TEST_SECRET),
223+
sender="12345",
224+
msisdn="15035556789",
225+
)
226+
227+
with self.assertRaises(ValueError) as context:
228+
sms.create_and_send_audience
229+
230+
self.assertIn("must include opt-in datestamps", str(context.exception))
231+
232+
def test_sms_opt_in_mode_validation(self):
233+
"""Test SMS opt_in_mode validation."""
234+
# Valid values
235+
sms = ua.Sms(
236+
airship=ua.Airship(TEST_KEY, TEST_SECRET),
237+
sender="12345",
238+
msisdn="15035556789",
239+
opt_in_mode="classic",
240+
)
241+
self.assertEqual(sms.opt_in_mode, "classic")
242+
243+
sms.opt_in_mode = "double"
244+
self.assertEqual(sms.opt_in_mode, "double")
245+
246+
sms.opt_in_mode = None
247+
self.assertIsNone(sms.opt_in_mode)
248+
249+
# Invalid value
250+
with self.assertRaises(ValueError) as context:
251+
sms.opt_in_mode = "invalid"
252+
self.assertIn("must be one of: 'classic' or 'double'", str(context.exception))
253+
254+
def test_sms_properties_validation(self):
255+
"""Test SMS properties validation."""
256+
# Valid dict
257+
sms = ua.Sms(
258+
airship=ua.Airship(TEST_KEY, TEST_SECRET),
259+
sender="12345",
260+
msisdn="15035556789",
261+
properties={"key": "value"},
262+
)
263+
self.assertEqual(sms.properties, {"key": "value"})
264+
265+
# None value
266+
sms.properties = None
267+
self.assertIsNone(sms.properties)
268+
269+
# Invalid type
270+
with self.assertRaises(TypeError) as context:
271+
sms.properties = "not_a_dict"
272+
self.assertIn("must be a dict", str(context.exception))
273+
274+
def test_sms_opted_out_datetime_handling(self):
275+
"""Test SMS opted_out datetime handling."""
276+
sms = ua.Sms(
277+
airship=ua.Airship(TEST_KEY, TEST_SECRET),
278+
sender="12345",
279+
msisdn="15035556789",
280+
)
281+
282+
# Set opted_out
283+
opted_out_dt = datetime(2018, 3, 15, 14, 30, 0)
284+
sms.opted_out = opted_out_dt
285+
self.assertEqual(sms.opted_out, opted_out_dt)
286+
287+
# Clear opted_out
288+
sms.opted_out = None
289+
self.assertIsNone(sms.opted_out)
290+
291+
# Test in update payload (opted_out is only included in updates, not registration)
292+
sms.opted_out = opted_out_dt
293+
update_payload = sms._update_payload
294+
self.assertEqual(update_payload["opted_out"], "2018-03-15T14:30:00")
295+
149296

150297
class TestSmsKeywordInteraction(unittest.TestCase):
151298
def setUp(self):

urbanairship/__about__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "7.1.1"
1+
__version__ = "7.2.0"

urbanairship/devices/sms.py

Lines changed: 102 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,16 @@ class Sms(object):
2727
:param opted_in: A datetime.datetime object that represents the
2828
date and time when explicit permission was received from the user to
2929
receive messages. This is required for use with CreateAndSend.
30+
:param opted_out: Optional. A datetime.datetime object that represents the
31+
date and time when a user opted out of SMS messages.
32+
:param opt_in_mode: Optional. The opt-in mode for registering the channel.
33+
`classic` is the default when unspecified, `double` creates a
34+
`double_opt_in` event.
35+
:param properties: Optional. An object containing event properties. You can
36+
use these properties to filter the double opt-in event and reference
37+
them in your message content by using handlebars. Items in the
38+
`properties` object are limited to a 255-character maximum string
39+
length.
3040
:param template_fields: For use with CreateAndSend with inline templates.
3141
A dict of template field names and their substitution values.
3242
:param locale_country: The ISO 3166 two-character country code. The value for this
@@ -43,6 +53,9 @@ def __init__(
4353
sender: str,
4454
msisdn: str,
4555
opted_in: Optional[datetime] = None,
56+
opted_out: Optional[datetime] = None,
57+
opt_in_mode: Optional[str] = None,
58+
properties: Optional[Dict] = None,
4659
template_fields: Optional[Dict] = None,
4760
locale_country: Optional[str] = None,
4861
locale_language: Optional[str] = None,
@@ -52,6 +65,9 @@ def __init__(
5265
self.sender = sender
5366
self.msisdn = msisdn
5467
self.opted_in = opted_in
68+
self.opted_out = opted_out
69+
self.opt_in_mode = opt_in_mode
70+
self.properties = properties
5571
self.template_fields = template_fields
5672
self.locale_country = locale_country
5773
self.locale_language = locale_language
@@ -81,6 +97,40 @@ def opted_in(self, value: Optional[datetime]) -> None:
8197

8298
self._opted_in = value
8399

100+
@property
101+
def opted_out(self) -> Optional[datetime]:
102+
return self._opted_out
103+
104+
@opted_out.setter
105+
def opted_out(self, value: Optional[datetime]) -> None:
106+
if not value:
107+
self._opted_out = None
108+
return
109+
110+
self._opted_out = value
111+
112+
@property
113+
def opt_in_mode(self) -> Optional[str]:
114+
return self._opt_in_mode
115+
116+
@opt_in_mode.setter
117+
def opt_in_mode(self, value: Optional[str]) -> None:
118+
if value not in ["classic", "double"] and value is not None:
119+
raise ValueError("opt_in_mode must be one of: 'classic' or 'double'")
120+
121+
self._opt_in_mode = value
122+
123+
@property
124+
def properties(self) -> Optional[Dict]:
125+
return self._properties
126+
127+
@properties.setter
128+
def properties(self, value: Optional[Dict]) -> None:
129+
if not isinstance(value, (dict, type(None))):
130+
raise TypeError("properties must be a dict")
131+
132+
self._properties = value
133+
84134
@property
85135
def locale_language(self) -> Optional[str]:
86136
return self._locale_language
@@ -135,22 +185,66 @@ def msisdn(self, value: str) -> None:
135185
def common_payload(self) -> Dict:
136186
return {"sender": self.sender, "msisdn": self.msisdn}
137187

188+
@property
189+
def _full_payload(self) -> Dict[str, Any]:
190+
payload: Dict[str, Any] = {"sender": self.sender, "msisdn": self.msisdn}
191+
192+
reg_payload_keys = [
193+
"locale_language",
194+
"locale_country",
195+
"timezone",
196+
"opted_in",
197+
"opt_in_mode",
198+
"properties",
199+
]
200+
201+
for key in reg_payload_keys:
202+
if getattr(self, key) is not None:
203+
if isinstance(getattr(self, key), datetime):
204+
payload[key] = getattr(self, key).strftime("%Y-%m-%dT%H:%M:%S")
205+
else:
206+
payload[key] = getattr(self, key)
207+
208+
return payload
209+
138210
@property
139211
def _registration_payload(self) -> Dict:
212+
# SMS API registration only supports basic fields
140213
payload = self.common_payload
141214

142215
reg_payload_keys = [
143216
"locale_language",
144217
"locale_country",
145218
"timezone",
146-
"template_fields",
147219
"opted_in",
148220
]
149221

150222
for key in reg_payload_keys:
151223
if getattr(self, key) is not None:
152224
if isinstance(getattr(self, key), datetime):
153-
payload[key] = datetime.strptime(getattr(self, key), "%Y-%m-%dT%H:%M:%S")
225+
payload[key] = getattr(self, key).strftime("%Y-%m-%dT%H:%M:%S")
226+
else:
227+
payload[key] = getattr(self, key)
228+
229+
return payload
230+
231+
@property
232+
def _update_payload(self) -> Dict:
233+
# SMS API update supports opted_out but not opt_in_mode/properties
234+
payload = self.common_payload
235+
236+
update_payload_keys = [
237+
"locale_language",
238+
"locale_country",
239+
"timezone",
240+
"opted_in",
241+
"opted_out",
242+
]
243+
244+
for key in update_payload_keys:
245+
if getattr(self, key) is not None:
246+
if isinstance(getattr(self, key), datetime):
247+
payload[key] = getattr(self, key).strftime("%Y-%m-%dT%H:%M:%S")
154248
else:
155249
payload[key] = getattr(self, key)
156250

@@ -160,12 +254,14 @@ def _registration_payload(self) -> Dict:
160254
def create_and_send_audience(self) -> Dict:
161255
audience: Dict[str, Any] = {"ua_sender": self.sender, "ua_msisdn": self.msisdn}
162256

257+
if self.opted_in:
258+
audience["ua_opted_in"] = self.opted_in
259+
if self.opted_out:
260+
audience["ua_opted_out"] = self.opted_out
163261
if self.template_fields:
164262
audience.update(self.template_fields)
165263

166-
if self.opted_in:
167-
audience["ua_opted_in"] = self.opted_in
168-
else:
264+
if not self.opted_in:
169265
raise ValueError("sms objects for create and send must include opt-in datestamps")
170266
return audience
171267

@@ -219,7 +315,7 @@ def update(self, channel_id: Optional[str] = None) -> Response:
219315

220316
response = self.airship.request(
221317
method="PUT",
222-
body=json.dumps(self._registration_payload).encode("utf-8"),
318+
body=json.dumps(self._update_payload).encode("utf-8"),
223319
url=self.airship.urls.get("sms_url") + self.channel_id,
224320
version=3,
225321
)

0 commit comments

Comments
 (0)