diff --git a/docs/index.rst b/docs/index.rst
index 093849d15a..aed7ccca3a 100755
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -205,6 +205,7 @@ Indices and tables
google
hustle
mailchimp
+ mobilecommons
mobilize_america
nation_builder
newmode
diff --git a/docs/mobilecommons.rst b/docs/mobilecommons.rst
new file mode 100644
index 0000000000..ebdc4e1236
--- /dev/null
+++ b/docs/mobilecommons.rst
@@ -0,0 +1,49 @@
+MobileCommons
+==========
+
+********
+Overview
+********
+
+`MobileCommons `_ is a broadcast text messaging tool that helps orgranizations
+mobilize supporters and fundraise by building opt-ed in audiences. You can read more about the product
+`here `_.
+
+***********
+Quick Start
+***********
+
+To instantiate a class you must pass the username and password of a MobileCommons account as an argument
+or store the username and password into environmental variables called ``MOBILECOMMONS_USERNAME`` and
+``MOBILECOMMONS_PASSWORD``, respectively. If you MobileCommons account has access to various MobileCommons
+companies (i.e. organizations), you'll need to specify which MobileCommons company you'd like to interact
+with by specifying the Company ID in the ``company_id`` parameter. To find the Company ID, navigate to the
+`Company and Users page `_.
+
+.. code-block:: python
+ from parsons import MobileCommons
+
+ # Pass credentials via environmental variables for account has access to only one MobileCommons company
+ mc = MobileCommons()
+
+ # Pass credentials via environmental variables for account has access to multiple MobileCommons companies
+ mc = MobileCommons(company_id='EXAMPLE78363BOCA483954419EB70986A68888')
+
+ # Pass credentials via argument for account has access to only one MobileCommons company
+ mc = MobileCommons(username='octavia.b@scifi.net', password='badpassword123')
+
+Then you can call various endpoints:
+
+.. code-block:: python
+ # Return all MobileCommons subscribers in a table
+ subscribers = get_campaign_subscribers(campaign_id=1234567)
+
+ # Create a new profile, return profile_id
+ new_profile=create_profile(phone=3073991987, first_name='Jane', last_name='Fonda')
+
+
+***
+API
+***
+.. autoclass :: parsons.MobileCommons
+ :inherited-members:
\ No newline at end of file
diff --git a/parsons/__init__.py b/parsons/__init__.py
index 132d90db75..b8793ec649 100644
--- a/parsons/__init__.py
+++ b/parsons/__init__.py
@@ -65,6 +65,7 @@
("parsons.google.google_sheets", "GoogleSheets"),
("parsons.hustle.hustle", "Hustle"),
("parsons.mailchimp.mailchimp", "Mailchimp"),
+ ("parsons.mobilecommons.mobilecommons", "MobileCommons"),
("parsons.mobilize_america.ma", "MobilizeAmerica"),
("parsons.nation_builder.nation_builder", "NationBuilder"),
("parsons.newmode.newmode", "Newmode"),
diff --git a/parsons/mobilecommons/__init__.py b/parsons/mobilecommons/__init__.py
new file mode 100644
index 0000000000..377c3e6319
--- /dev/null
+++ b/parsons/mobilecommons/__init__.py
@@ -0,0 +1,3 @@
+from parsons.mobilecommons.mobilecommons import MobileCommons
+
+__all__ = ["MobileCommons"]
diff --git a/parsons/mobilecommons/mobilecommons.py b/parsons/mobilecommons/mobilecommons.py
new file mode 100644
index 0000000000..41acf31386
--- /dev/null
+++ b/parsons/mobilecommons/mobilecommons.py
@@ -0,0 +1,428 @@
+from parsons.utilities import check_env
+from parsons.utilities.api_connector import APIConnector
+from parsons.utilities.datetime import parse_date
+from parsons import Table
+from bs4 import BeautifulSoup
+from requests import HTTPError
+import xmltodict
+import logging
+
+logger = logging.getLogger(__name__)
+
+MC_URI = "https://secure.mcommons.com/api/"
+DATE_FMT = "%Y-%m-%d"
+
+
+def _format_date(user_entered_date):
+ if user_entered_date:
+ formatted_date = parse_date(user_entered_date).strftime(DATE_FMT)
+ else:
+ formatted_date = None
+ return formatted_date
+
+
+class MobileCommons:
+ """
+ Instantiate the MobileCommons class.
+
+ `Args:`
+ username: str
+ A valid email address connected to a MobileCommons account. Not required if
+ ``MOBILECOMMONS_USERNAME`` env variable is set.
+ password: str
+ Password associated with Zoom account. Not required if ``MOBILECOMMONS_PASSWORD``
+ env variable set.
+ company_id: str
+ The company id of the MobileCommons organization to connect to. Not required if
+ username and password are for an account associated with only one MobileCommons
+ organization.
+ """
+
+ def __init__(self, username=None, password=None, company_id=None):
+ self.username = check_env.check("MOBILECOMMONS_USERNAME", username)
+ self.password = check_env.check("MOBILECOMMONS_PASSWORD", password)
+ self.default_params = {"company": company_id} if company_id else {}
+ self.client = APIConnector(uri=MC_URI, auth=(self.username, self.password))
+
+ def _mc_get_request(
+ self,
+ endpoint,
+ first_data_key,
+ second_data_key,
+ params,
+ elements_to_unpack=None,
+ limit=None,
+ ):
+ """
+ A function for GET requests that handles MobileCommons xml responses and pagination
+
+ `Args:`
+ endpoint: str
+ The endpoint, which will be appended to the base URL for each request
+ first_data_key: str
+ The first key used to extract the desired data from the response dictionary derived
+ from the xml response. E.g. 'broadcasts'
+ second_data_key: str
+ The second key used to extract the desired data from the response dictionary derived
+ from the xml response. The value of this key is a list of values. E.g. 'broadcast'
+ params: dict
+ Parameters to be passed into GET request
+ elements_to_unpack: list
+ A list of elements that contain dictionaries to be unpacked into new columns in the
+ final table
+ limit: int
+ The maximum number of rows to return
+ `Returns:`
+ Parsons table with requested data
+ """
+
+ # Create a table to compile results from different pages in
+ final_table = Table()
+ # Max page_limit is 1000 for MC
+ page_limit = min((limit or 1000), 1000)
+
+ # Set get request params
+ params = {"limit": page_limit, **self.default_params, **params}
+
+ logger.info(
+ f"Working on fetching first {page_limit} rows. This can take a long time."
+ )
+
+ # Make get call and parse XML into list of dicts
+ page = 1
+ response_dict = self._parse_get_request(endpoint=endpoint, params=params)
+
+ # If there's only one row, then it is returned as a dict, otherwise as a list
+ data = response_dict["response"][first_data_key][second_data_key]
+ if isinstance(data, dict):
+ data = [data]
+
+ # Convert to parsons table
+ response_table = Table(data)
+
+ # empty_page used for pagination below
+ if response_table.num_rows > 0:
+ empty_page = False
+ else:
+ raise ValueError("There are no records for specified resource")
+
+ # Unpack any specified elements
+ if elements_to_unpack:
+ for col in elements_to_unpack:
+ response_table.unpack_dict(col)
+
+ # Append to final table
+ final_table.concat(response_table)
+ final_table.materialize()
+
+ # Now must paginate to get more records
+ # MC GET responses sometimes includes a page_count parameter to indicate how many pages
+ # are available. Other times, the response includes a 'num' parameter that, when
+ # you reach an empty page, equals 0. In the first scenario we know to stop paginating when
+ # we reach the last page. In the second, we know to stop paginating when we encounter
+ # and empty page
+
+ # First scenario
+ try:
+ avail_pages = int(response_dict["response"][first_data_key]["page_count"])
+ total_records = avail_pages * page_limit
+ page_indicator = "page_count"
+
+ # Second scenario
+ except KeyError:
+ response_dict["response"][first_data_key]["num"]
+ page_indicator = "num"
+ # If page_count is not available, we cannot calculate total_records and will paginate
+ # until we hit user defined limit or an empty page
+ total_records = float("inf")
+
+ # Go fetch other pages of data
+ while final_table.num_rows < (limit or total_records) and not empty_page:
+ page += 1
+ page_params = {"page": str(page), **params}
+ logger.info(
+ f"Fetching rows {(page - 1) * page_limit + 1} - {(page) * page_limit} "
+ f"of {limit}"
+ )
+ # Send get request
+ response_dict = self._parse_get_request(
+ endpoint=endpoint, params=page_params
+ )
+ # Check to see if page was empty if num parameter is available
+ if page_indicator == "num":
+ empty_page = int(response_dict["response"][first_data_key]["num"]) > 0
+
+ if not empty_page:
+ # Extract data
+ response_table = Table(
+ response_dict["response"][first_data_key][second_data_key]
+ )
+ # Append to final table
+ final_table.concat(response_table)
+ final_table.materialize()
+
+ return Table(final_table[:limit])
+
+ def _check_response_status_code(self, response):
+ """
+ A helper function that checks the status code of a response and raises an error if the
+ response code is not 200
+
+ `Args:`
+ response: requests package response object
+ """
+ if response.status_code != 200:
+ error = f"Response Code {str(response.status_code)}"
+ error_html = BeautifulSoup(response.text, features="html.parser")
+ error += "\n" + error_html.h4.next
+ error += "\n" + error_html.p.next
+ raise HTTPError(error)
+
+ def _parse_get_request(self, endpoint, params):
+ """
+ A helper function that sends a get request to MobileCommons and then parses XML responses in
+ order to return the response as a dictionary
+
+ `Args:`
+ endpoint: str
+ The endpoint, which will be appended to the base URL for each request
+ params: dict
+ Parameters to be passed into GET request
+ `Returns:`
+ xml response parsed into list or dictionary
+ """
+ response = self.client.request(endpoint, "GET", params=params)
+
+ # If there's an error with initial response, raise error
+ self._check_response_status_code(response)
+
+ # If good response, compile data into final_table
+ # Parse xml to nested dictionary and load to parsons table
+ response_dict = xmltodict.parse(
+ response.text, attr_prefix="", cdata_key="", dict_constructor=dict
+ )
+ return response_dict
+
+ def _mc_post_request(self, endpoint, params):
+ """
+ A function for POST requests that handles MobileCommons xml responses
+
+ `Args:`
+ endpoint: str
+ The endpoint, which will be appended to the base URL for each request
+ params: dict
+ Parameters to be passed into GET request
+ `Returns:`
+ xml response parsed into list or dictionary
+ """
+
+ response = self.client.request(endpoint, "POST", params=params)
+
+ response_dict = xmltodict.parse(
+ response.text, attr_prefix="", cdata_key="", dict_constructor=dict
+ )
+ if response_dict["response"]["success"] == "true":
+ return response_dict["response"]
+ else:
+ raise HTTPError(response_dict["response"]["error"])
+
+ def get_broadcasts(
+ self, first_date=None, last_date=None, status=None, campaign_id=None, limit=None
+ ):
+ """
+ A function for get broadcasts
+
+ `Args:`
+ first_date: str
+ The date of the earliest possible broadcast you'd like returned. All common date
+ format should work (e.g. mm/dd/yy or yyyy-mm-dd)
+ last_date: str
+ The date of the latest possible broadcast you'd like returned. All common date
+ format should work (e.g. mm/dd/yy or yyyy-mm-dd)
+ status: str
+ 'draft', 'scheduled', or 'generated'
+ campaign_id: int
+ Specify to return broadcasts from a specific campaign
+ limit: int
+ Max rows you want returned
+
+ `Returns:`
+ Parsons table with requested broadcasts
+ """
+
+ params = {
+ "start_time": _format_date(first_date),
+ "end_time": _format_date(last_date),
+ "campaign_id": campaign_id,
+ "status": status,
+ **self.default_params,
+ }
+
+ return self._mc_get_request(
+ endpoint="broadcasts",
+ first_data_key="broadcasts",
+ second_data_key="broadcast",
+ params=params,
+ elements_to_unpack=["campaign"],
+ limit=limit,
+ )
+
+ def get_campaign_subscribers(
+ self,
+ campaign_id: int,
+ first_date: str = None,
+ last_date: str = None,
+ opt_in_path_id: int = None,
+ limit: int = None,
+ ):
+ """
+ A function for getting subscribers of a specified campaign
+
+ `Args:`
+ campaign_id: int
+ The campaign for which you'd like to get subscribers. You can get this from the url
+ of the campaign's page after select a campaign at
+ https://secure.mcommons.com/campaigns
+ first_date: str
+ The date of the earliest possible subscription returned. All common date
+ format should work (e.g. mm/dd/yy or yyyy-mm-dd)
+ last_date: str
+ The date of the latest possible subscription you'd like returned. All common date
+ format should work (e.g. mm/dd/yy or yyyy-mm-dd)
+ opt_in_path_id: int
+ Optional parameter to narrow results to on particular opt-in path. You can get this
+ from the url of the opt in paths page https://secure.mcommons.com/opt_in_paths
+ limit: int
+ Max rows you want returned
+
+ `Returns:`
+ Parsons table with requested broadcasts
+ """
+
+ params = {
+ "campaign_id": campaign_id,
+ "from": _format_date(first_date),
+ "to": _format_date(last_date),
+ "opt_in_path_id": opt_in_path_id,
+ **self.default_params,
+ }
+
+ return self._mc_get_request(
+ endpoint="campaign_subscribers",
+ first_data_key="subscriptions",
+ second_data_key="sub",
+ params=params,
+ limit=limit,
+ )
+
+ def get_profiles(
+ self,
+ phones: list = None,
+ first_date: str = None,
+ last_date: str = None,
+ include_custom_columns: bool = False,
+ include_subscriptions: bool = False,
+ limit: int = None,
+ ):
+ """
+ A function for getting profiles, which are MobileCommons people records
+
+ `Args:`
+ phones: list
+ A list of phone numbers including country codes for which you want profiles returned
+ MobileCommons claims to recognize most formats.
+ first_date: str
+ The date of the earliest possible subscription returned. All common date
+ format should work (e.g. mm/dd/yy or yyyy-mm-dd).
+ last_date: str
+ The date of the latest possible subscription you'd like returned. All common date
+ format should work (e.g. mm/dd/yy or yyyy-mm-dd).
+ include_custom_columns: bool
+ Optional parameter to that, if set to True, will return custom column values for
+ profiles as a list of dictionaries contained within a column.
+ include_subscriptions: bool
+ Optional parameter to that, if set to True, will return a list of campaigns a
+ given profile is subscribed to in a single column
+ limit: int
+ Max rows you want returned
+
+ `Returns:`
+ Parsons table with requested broadcasts
+ """
+
+ custom_cols = "true" if include_custom_columns else "false"
+ subscriptions = "true" if include_subscriptions else "false"
+
+ params = {
+ "phone_number": phones,
+ "from": _format_date(first_date),
+ "to": _format_date(last_date),
+ "include_custom_columns": custom_cols,
+ "include_subscriptions": subscriptions,
+ **self.default_params,
+ }
+
+ return self._mc_get_request(
+ endpoint="profiles",
+ first_data_key="profiles",
+ second_data_key="profile",
+ elements_to_unpack=["source", "address"],
+ params=params,
+ limit=limit,
+ )
+
+ def create_profile(
+ self,
+ phone,
+ first_name=None,
+ last_name=None,
+ zip=None,
+ addressline1=None,
+ addressline2=None,
+ city=None,
+ state=None,
+ opt_in_path_id=None,
+ ):
+ """
+ A function for creating a single MobileCommons profile
+
+ `Args:`
+ phone: str
+ Phone number to assign profile
+ first_name: str
+ Profile first name
+ last_name: str
+ Profile last name
+ zip: str
+ Profile 5-digit postal code
+ addressline1: str
+ Profile address line 1
+ addressline2: str
+ Profile address line 2
+ city: str
+ Profile city
+ state: str
+ Profile state
+ opt_in_path_id: str
+ ID of the opt-in path to send new profile through. This will determine the welcome
+ text they receive.
+
+ `Returns:`
+ ID of created profile
+ """
+
+ params = {
+ "phone_number": phone,
+ "first_name": first_name,
+ "last_name": last_name,
+ "postal_code": zip,
+ "street1": addressline1,
+ "street2": addressline2,
+ "city": city,
+ "state": state,
+ "opt_in_path_id": opt_in_path_id,
+ **self.default_params,
+ }
+
+ response = self._mc_post_request("profile_update", params=params)
+ return response["profile"]["id"]
diff --git a/requirements.txt b/requirements.txt
index d1f416949b..2c171c56a8 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -38,6 +38,7 @@ surveygizmo==1.2.3
PyJWT==2.4.0 # Otherwise `import jwt` would refer to python-jwt package
SQLAlchemy==1.3.23
requests_oauthlib==1.3.0
+bs4==0.0.1
# Testing Requirements
requests-mock==1.5.2
diff --git a/test/test_mobilecommons/mobilecommons_responses.py b/test/test_mobilecommons/mobilecommons_responses.py
new file mode 100644
index 0000000000..f9dd87c69e
--- /dev/null
+++ b/test/test_mobilecommons/mobilecommons_responses.py
@@ -0,0 +1,250 @@
+class get_profiles_response:
+ status_code = 200
+ text = """
+
+
+
+ James
+ Holden
+ 13073997994
+ james.holden.boo@gmail.com
+ Undeliverable
+ 2022-06-29 17:28:24 UTC
+ 2023-03-14 21:46:58 UTC
+ 2022-09-16 01:07:50 UTC
+ Opt-out from API
+
+
+ 2430 Douglas Dr
+
+ Salt Lake City
+ UT
+
+ US
+
+
+ 41.692249
+ -112.854408
+ rooftop
+ Salt Lake City
+ UT
+ 84106
+ US
+
+
+ UT-2
+ UT-13
+ UT-32
+ No
+
+
+
+
+
+
+
+ Cormac
+
+
+
+ Martinez del Rio
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+
+class get_broadcasts_response:
+ status_code = 200
+ text = """
+
+
+
+ Test Round 2
+ Test :) http://lil.ms/m9c2
+
+ TEST DH DD
+
+ 2023-06-23 18:45:00 UTC
+ true
+ false
+ false
+ false
+ 2
+ 2
+ 0
+
+
+
+
+
+ 808sandheartbreaks@gmail.com
+
+
+
+ Test 6/6 DD DH
+ Hey {{first_name}}!! On a scale of 1-5, how is your day going?
+Reply STOP to quit. Msg&DataRatesMayApply
+
+ TEST DH DD
+
+ 2023-06-06 20:08:56 UTC
+ true
+ false
+ false
+ false
+ 2
+ 2
+ 0
+
+
+
+
+
+ how.to.eat.water.with.a.fork@gmail.com
+
+
+
+ Broadcast 04/10/2023 01:34PM test
+ hi name, what's your email? Recurring Msgs. Reply STOP to quit, HELP for info.
+ Msg&DataRatesMayApply
+
+ Dog King 2024
+
+
+ false
+ false
+ false
+ false
+
+ 0
+ 0
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+
+class post_profile_response:
+ status_code = 200
+ text = """
+
+
+ Hardcoremac
+ Del Sangre
+ 13073997990
+ hardcore.smack@gmail.com
+ Undeliverable
+ 2022-06-29 17:28:24 UTC
+ 2023-03-14 21:46:58 UTC
+ 2022-09-16 01:07:50 UTC
+ Opt-out from API
+
+
+ 2430 Douglas Dr
+
+ Salt Lake City
+ UT
+
+ US
+
+
+ 41.692249
+ -112.854408
+ rooftop
+ Salt Lake City
+ UT
+ 84106
+ US
+
+
+ UT-2
+ UT-13
+ UT-32
+ No
+
+
+
+
+
+
+
+ Cormac
+
+
+
+ Martinez del Rio
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"""
diff --git a/test/test_mobilecommons/test_mobilecommons.py b/test/test_mobilecommons/test_mobilecommons.py
new file mode 100644
index 0000000000..dd80d8a562
--- /dev/null
+++ b/test/test_mobilecommons/test_mobilecommons.py
@@ -0,0 +1,127 @@
+import unittest
+import requests_mock
+from parsons.mobilecommons import MobileCommons
+from parsons.etl import Table
+from mobilecommons_responses import (
+ get_profiles_response,
+ get_broadcasts_response,
+ post_profile_response,
+)
+
+
+MOBILECOMMONS_USERNAME = "MOBILECOMMONS_USERNAME"
+MOBILECOMMONS_PASSWORD = "MOBILECOMMONS_PASSWORD"
+DEFAULT_GET_PARAMS = {"page": 1, "limit": 1000}
+DEFAULT_GET_ENDPOINT = "broadcasts"
+DEFAULT_FIRST_KEY = "broadcasts"
+DEFAULT_SECOND_KEY = "broadcast"
+DEFAULT_POST_PARAMS = {"phone": 13073997994}
+DEFAULT_POST_ENDPOINT = "profile_update"
+
+
+class TestMobileCommons(unittest.TestCase):
+ @requests_mock.Mocker()
+ def setUp(self, m):
+ self.base_uri = "https://secure.mcommons.com/api/"
+
+ self.mc = MobileCommons(
+ username=MOBILECOMMONS_USERNAME, password=MOBILECOMMONS_PASSWORD
+ )
+
+ @requests_mock.Mocker()
+ def test_parse_get_request(self, m):
+ m.get(
+ self.base_uri + DEFAULT_GET_ENDPOINT,
+ status_code=get_profiles_response.status_code,
+ text=get_profiles_response.text,
+ )
+ parsed_get_request_text = self.mc._parse_get_request(
+ endpoint=DEFAULT_GET_ENDPOINT, params=DEFAULT_GET_PARAMS
+ )
+ self.assertIsInstance(parsed_get_request_text, dict)
+
+ @requests_mock.Mocker()
+ def test_mc_get_request(self, m):
+ m.get(
+ self.base_uri + DEFAULT_GET_ENDPOINT,
+ status_code=get_broadcasts_response.status_code,
+ text=get_broadcasts_response.text,
+ )
+ parsed_get_response_text = self.mc._mc_get_request(
+ params=DEFAULT_GET_PARAMS,
+ endpoint=DEFAULT_GET_ENDPOINT,
+ first_data_key=DEFAULT_FIRST_KEY,
+ second_data_key=DEFAULT_SECOND_KEY,
+ )
+ self.assertIsInstance(
+ parsed_get_response_text,
+ Table,
+ "MobileCommons.mc_get_request does output parsons table",
+ )
+
+ @requests_mock.Mocker()
+ def test_get_profiles(self, m):
+ m.get(
+ self.base_uri + "profiles",
+ status_code=get_profiles_response.status_code,
+ text=get_profiles_response.text,
+ )
+ profiles = self.mc.get_profiles(limit=1000)
+ self.assertIsInstance(
+ profiles,
+ Table,
+ "MobileCommons.get_profiles method did not return a parsons Table",
+ )
+ self.assertEqual(
+ profiles[0]["first_name"],
+ "James",
+ "MobileCommons.get_profiles method not returning a table structured"
+ "as expected",
+ )
+
+ @requests_mock.Mocker()
+ def test_get_broadcasts(self, m):
+ m.get(
+ self.base_uri + "broadcasts",
+ status_code=get_broadcasts_response.status_code,
+ text=get_broadcasts_response.text,
+ )
+ broadcasts = self.mc.get_broadcasts(limit=1000)
+ self.assertIsInstance(
+ broadcasts,
+ Table,
+ "MobileCommons.get_broadcasts method did not return a parsons Table",
+ )
+ self.assertEqual(
+ broadcasts[0]["id"],
+ "2543129",
+ "MobileCommons.get_broadcasts method not returning a table structured"
+ "as expected",
+ )
+
+ @requests_mock.Mocker()
+ def test_mc_post_request(self, m):
+ m.post(self.base_uri + "profile_update", text=post_profile_response.text)
+ response_dict = self.mc._mc_post_request(
+ endpoint=DEFAULT_POST_ENDPOINT, params=DEFAULT_POST_PARAMS
+ )
+ self.assertIsInstance(
+ response_dict,
+ dict,
+ "MobileCommons.mc_post_request output not expected type dictionary",
+ )
+ self.assertEqual(
+ response_dict["profile"]["id"],
+ "602169563",
+ "MobileCommons.mc_post_request output value not expected",
+ )
+
+ @requests_mock.Mocker()
+ def test_create_profile(self, m):
+ m.post(self.base_uri + "profile_update", text=post_profile_response.text)
+ profile_id = self.mc.create_profile(phone=13073997994)
+ self.assertEqual(
+ profile_id,
+ "602169563",
+ "MobileCommons.create_profile does not return expected profile_id",
+ )