diff --git a/CHANGELOG b/CHANGELOG index 36efd0b..6aeda67 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -2,6 +2,7 @@ - Adds iOS platform options for: interruption_level, relevance_score, and target_content_id - Adds amazon platform options for: notification_tag, notification_channel, icon, icon_color +- Adds audience selectors for date_attribute, text_attribute, and number_attribute - Adds attributes, device_attributes, named_user_id, commercial_opted_in, commercial_opted_out, transactional_opted_in, transactional_opted_out to channel look up and listing. - Adds support for setting attributes on channel ids and named user ids - Adds support for removing attributes from channel ids and named user ids diff --git a/tests/push/test_audience.py b/tests/push/test_audience.py index 9c2fe2f..a2d7c44 100644 --- a/tests/push/test_audience.py +++ b/tests/push/test_audience.py @@ -1,3 +1,4 @@ +import datetime import unittest import urbanairship as ua @@ -26,11 +27,7 @@ def test_basic_selectors(self): "074e84a2-9ed9-4eee-9ca4-cc597bfdbef3", {"open_channel": "074e84a2-9ed9-4eee-9ca4-cc597bfdbef3"}, ), - ( - ua.device_token, - "f" * 64, - {"device_token": "F" * 64}, - ), + (ua.device_token, "f" * 64, {"device_token": "F" * 64}), (ua.device_token, "0" * 64, {"device_token": "0" * 64}), ( ua.apid, @@ -118,3 +115,160 @@ def test_location_selector(self): self.assertRaises(ValueError, ua.location) self.assertRaises(ValueError, ua.location, alias=1, id=1) self.assertRaises(ValueError, ua.location, date=None, id="foobar") + + +class TestAttributeSelectors(unittest.TestCase): + def test_date_incorrect_operator_raises(self): + with self.assertRaises(ValueError) as err_ctx: + ua.date_attribute(attribute="test_attribute", operator="the_way_we_wont") + + self.assertEqual( + err_ctx.message, + "operator must be one of: 'is_empty', 'before', 'after', 'range', 'equals'", + ) + + def test_date_is_empty(self): + selector = ua.date_attribute(attribute="test_attribute", operator="is_empty") + + self.assertEqual( + selector, {"attribute": "test_attribute", "operator": "is_empty"} + ) + + # testing exception raising only on before. after and equals use same codepath + def test_date_before_no_value_raises(self): + with self.assertRaises(ValueError) as err_ctx: + ua.date_attribute( + attribute="test_attribute", operator="before", precision="years" + ) + + self.assertEqual( + err_ctx.message, + "value must be included when using the 'before' operator", + ) + + def test_date_before_no_precision_raises(self): + with self.assertRaises(ValueError) as err_ctx: + ua.date_attribute( + attribute="test_attribute", + operator="before", + value="2021-11-03 12:00:00", + ) + + self.assertEqual( + err_ctx.message, + "precision must be included when using the 'before' operator", + ) + + def test_date_before(self): + selector = ua.date_attribute( + attribute="test_attribute", + operator="before", + value="2021-11-03 12:00:00", + precision="years", + ) + + self.assertEqual( + selector, + { + "attribute": "test_attribute", + "operator": "before", + "value": "2021-11-03 12:00:00", + "precision": "years", + }, + ) + + def test_date_after(self): + selector = ua.date_attribute( + attribute="test_attribute", + operator="after", + value="2021-11-03 12:00:00", + precision="years", + ) + + self.assertEqual( + selector, + { + "attribute": "test_attribute", + "operator": "after", + "value": "2021-11-03 12:00:00", + "precision": "years", + }, + ) + + def test_date_equals(self): + selector = ua.date_attribute( + attribute="test_attribute", + operator="equals", + value="2021-11-03 12:00:00", + precision="years", + ) + + self.assertEqual( + selector, + { + "attribute": "test_attribute", + "operator": "equals", + "value": "2021-11-03 12:00:00", + "precision": "years", + }, + ) + + def test_text(self): + selector = ua.text_attribute( + attribute="test_attribute", operator="equals", value="test_value" + ) + + self.assertEqual( + selector, + { + "attribute": "test_attribute", + "operator": "equals", + "value": "test_value", + }, + ) + + def test_text_incorrect_operator_raises(self): + with self.assertRaises(ValueError) as err_ctx: + ua.text_attribute( + attribute="test_attribute", operator="am_180", value="test_value" + ) + + self.assertEqual( + err_ctx.message, + "operator must be one of 'equals', 'contains', 'less', 'greater', 'is_empty'", + ) + + def test_text_incorrect_value_type_raises(self): + with self.assertRaises(ValueError) as err_ctx: + ua.text_attribute(attribute="test_attribute", operator="am_180", value=2001) + + self.assertEqual(err_ctx.message, "value must be a string") + + def test_number(self): + selector = ua.number_attribute( + attribute="test_attribute", operator="equals", value=100 + ) + + self.assertEqual( + selector, + {"attribute": "test_attribute", "operator": "equals", "value": 100}, + ) + + def test_number_incorrect_operator_raises(self): + with self.assertRaises(ValueError) as err_ctx: + ua.number_attribute( + attribute="test_attribute", operator="am_180", value="test_value" + ) + + self.assertEqual( + err_ctx.message, + "operator must be one of 'equals', 'contains', 'less', 'greater', 'is_empty'", + ) + + def test_number_incorrect_value_type_raises(self): + with self.assertRaises(ValueError) as err_ctx: + ua.number_attribute( + attribute="test_attribute", operator="equals", value="ive_got_it" + ) + + self.assertEqual(err_ctx.message, "value must be an integer") diff --git a/tests/push/test_push.py b/tests/push/test_push.py index 740d41b..a0a2a7f 100644 --- a/tests/push/test_push.py +++ b/tests/push/test_push.py @@ -4,7 +4,6 @@ import mock import requests - import urbanairship as ua from tests import TEST_KEY, TEST_SECRET @@ -350,10 +349,7 @@ def test_sms_overrides(self): p.audience = ua.all_ p.notification = ua.notification( alert="top level alert", - sms=ua.sms( - alert="sms override alert", - expiry="2018-04-01T12:00:00", - ), + sms=ua.sms(alert="sms override alert", expiry="2018-04-01T12:00:00"), ) p.device_types = ua.device_types("sms") @@ -459,10 +455,7 @@ def test_standard_ios_opts(self): p.audience = ua.all_ p.notification = ua.notification( alert="Top level alert", - ios=ua.ios( - alert="iOS override alert", - sound="cat.caf", - ), + ios=ua.ios(alert="iOS override alert", sound="cat.caf"), ) p.device_types = ua.device_types("ios") @@ -539,7 +532,7 @@ def test_ios_overrides(self): "collapse_id": "nugent sand", "interruption_level": "critical", "relevance_score": 0.75, - "target_content_id": "big day coming" + "target_content_id": "big day coming", } }, "device_types": "ios", @@ -1011,18 +1004,15 @@ def test_amazon_overrides(self): "icon": "icon.img", "icon_color": "#1234ff", } - } - } + }, + }, ) def test_standard_amazon_push(self): p = ua.Push(None) p.audience = ua.all_ p.notification = ua.notification( - alert="top level alert", - amazon=ua.amazon( - alert="amazon override alert" - ) + alert="top level alert", amazon=ua.amazon(alert="amazon override alert") ) p.device_types = ua.device_types("amazon") @@ -1033,9 +1023,7 @@ def test_standard_amazon_push(self): "device_types": ["amazon"], "notification": { "alert": "top level alert", - "amazon": { - "alert": "amazon override alert" - } - } - } - ) \ No newline at end of file + "amazon": {"alert": "amazon override alert"}, + }, + }, + ) diff --git a/urbanairship/__init__.py b/urbanairship/__init__.py index 98890f2..3dcf2df 100644 --- a/urbanairship/__init__.py +++ b/urbanairship/__init__.py @@ -1,55 +1,90 @@ """Python package for using the Urban Airship API""" -from .core import Airship +import logging + +from .automation import Automation, Pipeline from .common import AirshipFailure, Unauthorized +from .core import Airship +from .devices import ( + APIDList, + ChannelInfo, + ChannelList, + ChannelTags, + ChannelUninstall, + DeviceInfo, + DeviceTokenList, + Email, + EmailTags, + LocationFinder, + NamedUser, + NamedUserList, + NamedUserTags, + OpenChannel, + OpenChannelTags, + Segment, + SegmentList, + Sms, + StaticList, + StaticLists, +) + +# Silence urllib3 INFO logging by default +from .experiments import ABTest, Experiment, Variant from .push import ( + CreateAndSendPush, Push, - ScheduledPush, ScheduledList, - TemplatePush, + ScheduledPush, Template, TemplateList, - CreateAndSendPush, + TemplatePush, + absolute_date, + actions, + alias, all_, - ios_channel, - android_channel, + amazon, amazon_channel, + and_, + android, + android_channel, + apid, + best_time, + campaigns, channel, - open_channel, - sms_id, - sms_sender, + date_attribute, device_token, - apid, - wns, - tag, - tag_group, - alias, - segment, - and_, - or_, - not_, + device_types, + email, + in_app, + interactive, + ios, + ios_channel, + local_scheduled_time, location, - recent_date, - absolute_date, + merge_data, + message, + named_user, + not_, notification, - ios, - android, - amazon, - web, - sms, - email, - wns_payload, + number_attribute, + open_channel, open_platform, - message, - in_app, - device_types, options, - campaigns, - actions, - interactive, - wearable, + or_, public_notification, - style, + recent_date, scheduled_time, + segment, + sms, + sms_id, + sms_sender, + style, + tag, + tag_group, + text_attribute, + wearable, + web, + wns, + wns_payload, local_scheduled_time, best_time, named_user, @@ -83,18 +118,17 @@ ModifyAttributes, AttributeResponse ) - from .reports import ( - IndividualResponseStats, - ResponseList, + AppOpensList, + CustomEventsList, DevicesReport, + IndividualResponseStats, OptInList, OptOutList, PushList, + ResponseList, ResponseReportList, - AppOpensList, TimeInAppList, - CustomEventsList, ) __all__ = [ @@ -183,6 +217,9 @@ Email, EmailTags, CreateAndSendPush, + date_attribute, + text_attribute, + number_attribute, ChannelTags, OpenChannelTags, Attribute, @@ -190,9 +227,5 @@ ModifyAttributes, ] -# Silence urllib3 INFO logging by default -from .experiments import Experiment, Variant, ABTest - -import logging logging.getLogger("requests.packages.urllib3.connectionpool").setLevel(logging.WARNING) diff --git a/urbanairship/push/__init__.py b/urbanairship/push/__init__.py index 4e9a84c..cce1a9b 100644 --- a/urbanairship/push/__init__.py +++ b/urbanairship/push/__init__.py @@ -1,68 +1,53 @@ -from .core import ( - Push, - ScheduledPush, - TemplatePush, - CreateAndSendPush, -) - from .audience import ( - ios_channel, - android_channel, + absolute_date, + alias, amazon_channel, + and_, + android_channel, + apid, channel, + date_attribute, + device_token, + ios_channel, + location, + named_user, + not_, + number_attribute, open_channel, + or_, + recent_date, + segment, sms_id, sms_sender, - device_token, - apid, - wns, tag, tag_group, - alias, - segment, - and_, - or_, - not_, - location, - recent_date, - absolute_date, - named_user, + text_attribute, + wns, ) - +from .core import CreateAndSendPush, Push, ScheduledPush, TemplatePush from .payload import ( - notification, - ios, - android, + actions, amazon, - wns_payload, - web, - sms, + android, + campaigns, + device_types, email, - open_platform, + in_app, + interactive, + ios, message, - device_types, + notification, + open_platform, options, - campaigns, - actions, - interactive, - in_app, - wearable, - style, public_notification, + sms, + style, + wearable, + web, + wns_payload, ) - -from .schedule import ( - scheduled_time, - local_scheduled_time, - best_time, - ScheduledList, -) - -from .template import ( - merge_data, - Template, - TemplateList, -) +from .schedule import ScheduledList, best_time, local_scheduled_time, scheduled_time +from .template import Template, TemplateList, merge_data # Common selector for audience & device_types @@ -81,6 +66,7 @@ TemplatePush, Template, TemplateList, + CreateAndSendPush, ios_channel, android_channel, amazon_channel, @@ -117,4 +103,15 @@ local_scheduled_time, in_app, named_user, + location, + date_attribute, + email, + campaigns, + wearable, + style, + public_notification, + best_time, + merge_data, + text_attribute, + number_attribute, ] diff --git a/urbanairship/push/audience.py b/urbanairship/push/audience.py index 129f6f6..e96cc3c 100644 --- a/urbanairship/push/audience.py +++ b/urbanairship/push/audience.py @@ -1,3 +1,4 @@ +import datetime import re import sys @@ -19,8 +20,6 @@ string_type = basestring # Value selectors; device IDs, aliases, tags, etc. - - def ios_channel(uuid): """Select a single iOS Channel""" if not UUID_FORMAT.match(uuid): @@ -113,9 +112,98 @@ def segment(segment): return {"segment": segment} -# Compound selectors +def named_user(name): + return {"named_user": name} + + +# Attribute selectors +def date_attribute(attribute, operator, precision=None, value=None): + """ + Select an audience to send to based on an attribute object with a DATE schema type, + including predefined and device attributes. + Please refer to https://docs.airship.com/api/ua/?http#schemas-dateattribute for + more information about using this selector, including information about required + data formatting for values. + Custom attributes must be defined in the Airship UI prior to use. + """ + if operator not in ["is_empty", "before", "after", "range", "equals"]: + raise ValueError( + "operator must be one of: 'is_empty', 'before', 'after', 'range', 'equals'" + ) + + selector = {"attribute": attribute, "operator": operator} + + if operator == "range": + if value is None: + raise ValueError( + "value must be included when using the '{0}' operator".format(operator) + ) + + selector["value"] = value + + if operator in ["before", "after", "equals"]: + if value is None: + raise ValueError( + "value must be included when using the '{0}' operator".format(operator) + ) + if precision is None: + raise ValueError( + "precision must be included when using the '{0}' operator".format( + operator + ) + ) + + selector["value"] = value + selector["precision"] = precision + + return selector + + +def text_attribute(attribute, operator, value): + """ + Select an audience to send to based on an attribute object with a TEXT schema type, + including predefined and device attributes. + + Please refer to https://docs.airship.com/api/ua/?http#schemas-textattribute for + more information about using this selector, including information about required + data formatting for values. + Custom attributes must be defined in the Airship UI prior to use. + """ + if operator not in ["equals", "contains", "less", "greater", "is_empty"]: + raise ValueError( + "operator must be one of 'equals', 'contains', 'less', 'greater', 'is_empty'" + ) + + if type(value) is not str: + raise ValueError("value must be a string") + + return {"attribute": attribute, "operator": operator, "value": value} + + +def number_attribute(attribute, operator, value): + """ + Select an audience to send to based on an attribute object with a INTEGER schema + type, including predefined and device attributes. + + Please refer to https://docs.airship.com/api/ua/?http#schemas-numberattribute for + more information about using this selector, including information about required + data formatting for values. + + Custom attributes must be defined in the Airship UI prior to use. + """ + if operator not in ["equals", "contains", "less", "greater", "is_empty"]: + raise ValueError( + "operator must be one of 'equals', 'contains', 'less', 'greater', 'is_empty'" + ) + + if type(value) is not int: + raise ValueError("value must be an integer") + return {"attribute": attribute, "operator": operator, "value": value} + + +# Compound selectors def or_(*children): """Select devices that match at least one of the given selectors. @@ -147,8 +235,6 @@ def not_(child): # Location selectors - - def location(date=None, **kwargs): """Select a location expression. @@ -227,7 +313,3 @@ def absolute_date(resolution, start, end): payload = {resolution: {"start": start, "end": end}} return payload - - -def named_user(name): - return {"named_user": name} diff --git a/urbanairship/push/payload.py b/urbanairship/push/payload.py index d1ba365..43841c1 100644 --- a/urbanairship/push/payload.py +++ b/urbanairship/push/payload.py @@ -1,7 +1,7 @@ # coding=utf-8 +import collections import re import sys -import collections import warnings # Python coarse version differentiation @@ -166,8 +166,8 @@ def ios( with iOS 12. :keyword interruption_level: Optional, a string. Indicates the importance and delivery timing of a notification. Must be one of: passive, active, - time-sensitive, critical. Note: Use of the 'critical' levels requires an - entitlement grant from Apple. Once this grant has been enabled, contact Airship + time-sensitive, critical. Note: Use of the 'critical' levels requires an + entitlement grant from Apple. Once this grant has been enabled, contact Airship Support to enable support with our APIs. :keyword relevance_score: Optional, a number from 0.0 to 1.0. Used to sort notifications for an app. The notification with highest score is featured in @@ -768,10 +768,7 @@ def message( 10 'categories' """ - payload = { - "title": title, - "body": body, - } + payload = {"title": title, "body": body} if content_type is not None: payload["content_type"] = content_type if content_encoding is not None: @@ -1054,7 +1051,7 @@ def media_attachment(url, content=None, options=None): def content(title=None, subtitle=None, body=None): """iOS content builder. Each argument describes the portions of the - notifcation that should be modified if the media_attachment succeeds. + notification that should be modified if the media_attachment succeeds. :keyword title: String. :keyword subtitle: String.