diff --git a/.coveragerc b/.coveragerc index 2fa1253..741e45a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -5,3 +5,5 @@ omit = */python?.?/* */site-packages/* */unittest2/* + */tests/* + *_flymake.py diff --git a/.travis.yml b/.travis.yml index c29eea1..ee17d2e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,6 @@ language: python python: - '2.7' - - '3.4' - '3.5' install: diff --git a/membersuite_api_client/exceptions.py b/membersuite_api_client/exceptions.py index e8aa1a4..d46f550 100644 --- a/membersuite_api_client/exceptions.py +++ b/membersuite_api_client/exceptions.py @@ -16,8 +16,11 @@ def __str__(self): concierge_error=concierge_error) def get_concierge_error(self): - return (self.result["body"][self.result_type] - ["Errors"]["ConciergeError"]) + try: + return (self.result["body"][self.result_type] + ["Errors"]["ConciergeError"]) + except KeyError: + return (self.result["Errors"]) class LoginToPortalError(MemberSuiteAPIError): @@ -25,6 +28,11 @@ class LoginToPortalError(MemberSuiteAPIError): result_type = "LoginToPortalResult" +class LogoutError(MemberSuiteAPIError): + + result_type = "LogoutResult" + + class ExecuteMSQLError(MemberSuiteAPIError): result_type = "ExecuteMSQLResult" diff --git a/membersuite_api_client/memberships/models.py b/membersuite_api_client/memberships/models.py index ab26981..d4471fa 100644 --- a/membersuite_api_client/memberships/models.py +++ b/membersuite_api_client/memberships/models.py @@ -1,7 +1,7 @@ class Membership(object): def __init__(self, membership): - """ Create a Membership model from MemberSuite Member object + """ Create a Membership model from MemberSuite Membership object """ self.id = membership["ID"] self.owner = membership["Owner"] diff --git a/membersuite_api_client/memberships/services.py b/membersuite_api_client/memberships/services.py index 7faeec0..87eceb3 100644 --- a/membersuite_api_client/memberships/services.py +++ b/membersuite_api_client/memberships/services.py @@ -4,10 +4,10 @@ http://api.docs.membersuite.com/#References/Objects/Membership.htm """ +from zeep.exceptions import TransportError from .models import Membership, MembershipProduct from ..utils import convert_ms_object -from zeep.exceptions import TransportError class MembershipService(object): diff --git a/membersuite_api_client/organizations/models.py b/membersuite_api_client/organizations/models.py index 9dda452..42976c7 100644 --- a/membersuite_api_client/organizations/models.py +++ b/membersuite_api_client/organizations/models.py @@ -4,8 +4,11 @@ def __init__(self, org): """Create an Organization model from MemberSuite Organization object """ self.account_num = org["ID"] + self.id = self.account_num self.membersuite_id = org["LocalID"] + self.local_id = self.membersuite_id self.org_name = org["Name"] + self.name = self.org_name self.picklist_name = org["SortName"] or '' self.address = org["Mailing_Address"] @@ -44,4 +47,4 @@ def __init__(self, org_type): '6faf90e4-01f3-c0f1-4593-0b3c3ca7ff6c': 'Deceased', '6faf90e4-01f3-c7ad-174c-0b3c52b7f497': 'Defunct', '6faf90e4-01f3-cd50-ffed-0b3c3ca7f4fd': 'Retired', -} \ No newline at end of file +} diff --git a/membersuite_api_client/security/models.py b/membersuite_api_client/security/models.py index f99603e..2cec28f 100644 --- a/membersuite_api_client/security/models.py +++ b/membersuite_api_client/security/models.py @@ -4,6 +4,8 @@ from ..exceptions import ExecuteMSQLError from ..models import MemberSuiteObject +from ..memberships import services as membership_services +from ..organizations.models import Organization from ..utils import convert_ms_object @@ -36,6 +38,9 @@ def __str__(self): session_id=self.session_id)) def get_username(self): + """Return a username suitable for storing in auth.User.username. + + """ return "_membersuite_id_{}".format(self.id) def get_individual(self, client): @@ -55,11 +60,12 @@ def get_individual(self, client): if msql_result["Success"]: membersuite_object_data = (msql_result["ResultValue"] ["SingleObject"]) - return Individual(membersuite_object_data=membersuite_object_data, - portal_user=self) else: raise ExecuteMSQLError(result=result) + return Individual(membersuite_object_data=membersuite_object_data, + portal_user=self) + @python_2_unicode_compatible class Individual(MemberSuiteObject): @@ -76,6 +82,9 @@ def __init__(self, membersuite_object_data, portal_user=None): self.first_name = self.fields["FirstName"] self.last_name = self.fields["LastName"] + self.primary_organization__rtg = ( + self.fields["PrimaryOrganization__rtg"]) + self.portal_user = portal_user def __str__(self): @@ -85,3 +94,60 @@ def __str__(self): email_address=self.email_address, first_name=self.first_name, last_name=self.last_name)) + + def is_member(self, client): + """Is this Individual a member? + + Assumptions: + + - a "primary organization" in MemberSuite is the "current" + Organization for an Individual + + - get_memberships_for_org() returns Memberships ordered such + that the first one returned is the "current" one. + + """ + if not client.session_id: + client.request_session() + + primary_organization = self.get_primary_organization(client=client) + + membership_service = membership_services.MembershipService( + client=client) + + try: + membership = membership_service.get_memberships_for_org( + account_num=primary_organization.id)[0] + except IndexError: + return False + + return membership.receives_member_benefits + + def get_primary_organization(self, client): + """Return the primary Organization for this Individual. + + """ + if self.primary_organization__rtg is None: + return None + + if not client.session_id: + client.request_session() + + query = "SELECT OBJECT() FROM ORGANIZATION WHERE ID = '{}'".format( + self.primary_organization__rtg) + + result = client.runSQL(query) + + msql_result = result["body"]["ExecuteMSQLResult"] + + if msql_result["Success"]: + membersuite_object_data = (msql_result["ResultValue"] + ["SingleObject"]) + else: + raise ExecuteMSQLError(result=result) + + # Could omit this step if Organization inherits from MemberSuiteObject. + organization = convert_ms_object( + membersuite_object_data["Fields"]["KeyValueOfstringanyType"]) + + return Organization(org=organization) diff --git a/membersuite_api_client/security/services.py b/membersuite_api_client/security/services.py index b048ea4..60feb45 100644 --- a/membersuite_api_client/security/services.py +++ b/membersuite_api_client/security/services.py @@ -1,14 +1,15 @@ from .models import PortalUser -from ..exceptions import LoginToPortalError +from ..exceptions import LoginToPortalError, LogoutError from ..utils import get_session_id -def login_to_portal(username, password, client): +def login_to_portal(username, password, client, retries=2): """Log `username` into the MemberSuite Portal. Returns a PortalUser object if successful, raises LoginToPortalError if not. + Will retry logging in if a GeneralException occurs, up to `retries`. """ if not client.session_id: client.request_session() @@ -17,19 +18,49 @@ def login_to_portal(username, password, client): url=("http://membersuite.com/contracts/IConciergeAPIService/" "LoginToPortal")) - result = client.client.service.LoginToPortal( - _soapheaders=[concierge_request_header], - portalUserName=username, - portalPassword=password) + attempts = 0 + while attempts < retries: + result = client.client.service.LoginToPortal( + _soapheaders=[concierge_request_header], + portalUserName=username, + portalPassword=password) - login_to_portal_result = result["body"]["LoginToPortalResult"] + login_to_portal_result = result["body"]["LoginToPortalResult"] - if login_to_portal_result["Success"]: - portal_user = login_to_portal_result["ResultValue"]["PortalUser"] + if login_to_portal_result["Success"]: + portal_user = login_to_portal_result["ResultValue"]["PortalUser"] - session_id = get_session_id(result=result) + session_id = get_session_id(result=result) - return PortalUser(membersuite_object_data=portal_user, - session_id=session_id) - else: - raise LoginToPortalError(result=result) + return PortalUser(membersuite_object_data=portal_user, + session_id=session_id) + else: + attempts += 1 + + raise LoginToPortalError(result=result) + + +def logout(client): + """Log out the currently logged-in user. + + There's a really crappy side-effect here - the session_id + attribute of the `client` passed in will be reset to None if the + logout succeeds, which is going to be almost always, let's hope. + + """ + if not client.session_id: + client.request_session() + + concierge_request_header = client.construct_concierge_header( + url=("http://membersuite.com/contracts/IConciergeAPIService/" + "Logout")) + + logout_result = client.client.service.Logout( + _soapheaders=[concierge_request_header]) + + result = logout_result["body"]["LogoutResult"] + + if result["SessionID"] is None: # Success! + client.session_id = None + else: # Failure . . . + raise LogoutError(result=result) diff --git a/membersuite_api_client/tests/test_client.py b/membersuite_api_client/tests/test_client.py index 4decff2..410cae4 100644 --- a/membersuite_api_client/tests/test_client.py +++ b/membersuite_api_client/tests/test_client.py @@ -1,4 +1,3 @@ -import datetime import os import unittest diff --git a/membersuite_api_client/tests/test_exceptions.py b/membersuite_api_client/tests/test_exceptions.py new file mode 100644 index 0000000..b5b22f4 --- /dev/null +++ b/membersuite_api_client/tests/test_exceptions.py @@ -0,0 +1,27 @@ +import unittest + +from ..exceptions import ExecuteMSQLError +from ..utils import (get_new_client, + submit_msql_query) + + +client = get_new_client() + + +class MemberSuiteAPIErrorTestCase(unittest.TestCase): + + @classmethod + def setUpClass(cls): + client.request_session() + try: + submit_msql_query(query="SELECT OBJECT() FROM BOB", + client=client) + except ExecuteMSQLError as exc: + cls.exc = exc + + def test_get_concierge_error(self): + concierge_error = self.exc.get_concierge_error() + self.assertTrue(concierge_error is not None) + + def test___str__(self): + self.assertTrue(str(self.exc) > '') diff --git a/membersuite_api_client/tests/test_memberships.py b/membersuite_api_client/tests/test_memberships.py index a612b2e..969d021 100644 --- a/membersuite_api_client/tests/test_memberships.py +++ b/membersuite_api_client/tests/test_memberships.py @@ -1,3 +1,5 @@ +import unittest + from .base import BaseTestCase from ..memberships.services import MembershipService from ..memberships.models import Membership, MembershipProduct @@ -9,6 +11,7 @@ def setUp(self): super(MembershipServiceTestCase, self).setUp() self.service = MembershipService(self.client) + @unittest.skip("Need an Organization ID for a non-member org") def test_get_membership_for_org(self): """ Get membership info for a test org diff --git a/membersuite_api_client/tests/test_security.py b/membersuite_api_client/tests/test_security.py index 07245ee..61078f6 100644 --- a/membersuite_api_client/tests/test_security.py +++ b/membersuite_api_client/tests/test_security.py @@ -1,20 +1,32 @@ import os import unittest -from ..client import ConciergeClient +from ..exceptions import LoginToPortalError, MemberSuiteAPIError from ..security import models -from ..security.services import (login_to_portal, - LoginToPortalError) +from ..security.services import login_to_portal, logout +from ..utils import get_new_client + + +def get_portal_user(client, member=True): + if client.session_id is None: + client.request_session() + if member: + return login_to_portal( + username=os.environ["TEST_MS_PORTAL_USER_ID"], + password=os.environ["TEST_MS_PORTAL_USER_PASS"], + client=client) + else: + return login_to_portal( + username=os.environ["TEST_NON_MEMBER_MS_PORTAL_USER_ID"], + password=os.environ["TEST_NON_MEMBER_MS_PORTAL_USER_PASS"], + client=client) class SecurityServicesTestCase(unittest.TestCase): @classmethod def setUpClass(cls): - cls.client = ConciergeClient( - access_key=os.environ["MS_ACCESS_KEY"], - secret_key=os.environ["MS_SECRET_KEY"], - association_id=os.environ["MS_ASSOCIATION_ID"]) + cls.client = get_new_client() def test_login_to_portal(self): """Can we log in to the portal?""" @@ -31,20 +43,102 @@ def test_login_to_portal_failure(self): password="wrong password", client=self.client) + def test_logout(self): + """Can we logout? + + This logs out from the API client session, not the MemberSuite + Portal. + + """ + self.client.session_id = None + + self.client.request_session() # A fresh session. Yum! + self.assertTrue(self.client.session_id) + + logout(self.client) + + self.assertIsNone(self.client.session_id) + class PortalUserTestCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.client = get_new_client() + + def setUp(self): + self.portal_user = get_portal_user(client=self.client) + + def test_get_username(self): + """Does get_username() work? + + """ + self.portal_user.id = "fake-membersuite-id" + self.assertEqual("_membersuite_id_fake-membersuite-id", + self.portal_user.get_username()) + def test_get_individual(self): - """Does get_individual() work?""" - client = ConciergeClient( - access_key=os.environ["MS_ACCESS_KEY"], - secret_key=os.environ["MS_SECRET_KEY"], - association_id=os.environ["MS_ASSOCIATION_ID"]) - portal_user = login_to_portal( - username=os.environ["TEST_MS_PORTAL_USER_ID"], - password=os.environ["TEST_MS_PORTAL_USER_PASS"], + """Does get_individual() work? + + """ + individual = self.portal_user.get_individual(client=self.client) + self.assertEqual(self.portal_user.first_name, individual.first_name) + self.assertEqual(self.portal_user.last_name, individual.last_name) + self.assertEqual(self.portal_user.owner, individual.id) + + +class IndividualTestCase(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.client = get_new_client() + + def setUp(self): + self.client.request_session() + member_portal_user = get_portal_user(client=self.client) + self.individual_member = member_portal_user.get_individual( + client=self.client) + + def test_is_member_for_member(self): + """Does is_member() work for members? + + """ + is_member = self.individual_member.is_member(client=self.client) + self.assertTrue(is_member) + + # test_is_member_for_nonmember() below can't succeed, because it + # doesn't know about any non-member to use. Once non-member data + # (at least a non-member Organization and a connected Individudal + # with Portal Access) is available, push it into the env in + # TEST_NON_MEMBER_MS_PORTAL_USER_ID and + # TEST_NON_MEMBER_MS_PORTAL_USER_PASS and unskip this test. + @unittest.skip("Because it can't succeed") + def test_is_member_for_nonmember(self): + """Does is_member() work for non-members? + + """ + client = get_new_client() + client.request_session() + non_member_portal_user = get_portal_user(client=client, + member=False) + individual_non_member = non_member_portal_user.get_individual( client=client) - individual = portal_user.get_individual(client=client) - self.assertEqual(portal_user.first_name, individual.first_name) - self.assertEqual(portal_user.last_name, individual.last_name) - self.assertEqual(portal_user.owner, individual.id) + is_member = individual_non_member.is_member(client=client) + self.assertFalse(is_member) + + def test_get_primary_organization(self): + """Does get_primary_organization() work? + + """ + organization = self.individual_member.get_primary_organization( + client=self.client) + self.assertIsInstance(organization, models.Organization) + + def test_get_primary_organization_fails(self): + """What happens when get_primary_organization() fails? + + """ + with self.assertRaises(MemberSuiteAPIError): + self.individual_member.primary_organization__rtg = "bogus ID" + self.individual_member.get_primary_organization( + client=self.client) diff --git a/membersuite_api_client/tests/test_utils.py b/membersuite_api_client/tests/test_utils.py new file mode 100644 index 0000000..4d6ddcd --- /dev/null +++ b/membersuite_api_client/tests/test_utils.py @@ -0,0 +1,69 @@ +import unittest + +from ..exceptions import ExecuteMSQLError +from ..models import MemberSuiteObject +from ..security.models import Individual +from ..utils import (get_new_client, + submit_msql_query) + + +client = get_new_client() + + +class SubmitMSQLQueryTestCase(unittest.TestCase): + + @classmethod + def setUpClass(cls): + client.request_session() + + def test_class_familiar_to_factory(self): + individual = submit_msql_query( + query="SELECT OBJECT() FROM INDIVIDUAL", + client=client)[0] + self.assertIsInstance(individual, Individual) + + # test_class_not_familiar_to_factory below needs a MemberSuite + # object for which at least one instance is available, and which + # is not modeled in membersuite_api_client. Don't know of one + # of those that's reliably (or even now and then-ly) available, + # that's why this test gets skipped. + @unittest.skip("Needs data fixture") + def test_class_not_familiar_to_factory(self): + """Is a base MemberSuiteObject returned when the class is unfamiliar? + + Test will fail when MemberSuite Task is modeled (and added to + membersuite_object_factory.klasses). + """ + results = submit_msql_query(query="SELECT OBJECT() FROM ???", + client=client) + self.assertEqual(type(results[0]), MemberSuiteObject) + + def test_unpermitted_query(self): + with self.assertRaises(ExecuteMSQLError): + submit_msql_query(query="SELECT OBJECT() FROM TermsOfService", + client=client) + + def test_well_formed_but_invalid_msql(self): + with self.assertRaises(ExecuteMSQLError): + submit_msql_query(query="SELECT OBJECT() FROM BOB", + client=client) + + def test_query_with_no_results(self): + results = submit_msql_query( + query=("SELECT OBJECT() FROM INDIVIDUAL " + "WHERE LASTNAME = 'bo-o-o-ogus'"), + client=client) + self.assertEqual(0, len(results)) + + def test_query_with_multiple_results(self): + """Does submit_msql_query work with multiple results? + + NOTE: This test depends on multiple Individuals with LastName + of 'User' being available. + + """ + results = submit_msql_query( + query=("SELECT OBJECTS() FROM INDIVIDUAL " + "WHERE LASTNAME = 'User'"), + client=client) + self.assertTrue(len(results)) diff --git a/membersuite_api_client/utils.py b/membersuite_api_client/utils.py index 590a1fd..f62a67b 100644 --- a/membersuite_api_client/utils.py +++ b/membersuite_api_client/utils.py @@ -1,3 +1,8 @@ +import os + +from .exceptions import ExecuteMSQLError + + def convert_ms_object(ms_object): """ Converts the list of dictionaries with keys "key" and "value" into @@ -34,3 +39,55 @@ def membersuite_object_factory(membersuite_object_data): membersuite_object_data=membersuite_object_data) else: return klass(membersuite_object_data=membersuite_object_data) + + +def get_new_client(): + """Return a new ConciergeClient, pulling secrets from the environment. + + """ + from .client import ConciergeClient + return ConciergeClient(access_key=os.environ["MS_ACCESS_KEY"], + secret_key=os.environ["MS_SECRET_KEY"], + association_id=os.environ["MS_ASSOCIATION_ID"]) + + +def submit_msql_query(query, client=None): + """Submit `query` to MemberSuite, returning .models.MemberSuiteObjects. + + So this is a converter from MSQL to .models.MemberSuiteObjects. + + Returns query results as a list of MemberSuiteObjects. + + """ + if client is None: + client = get_new_client() + if not client.session_id: + client.request_session() + + result = client.runSQL(query) + execute_msql_result = result["body"]["ExecuteMSQLResult"] + + membersuite_object_list = [] + + if execute_msql_result["Success"]: + result_value = execute_msql_result["ResultValue"] + if result_value["ObjectSearchResult"]["Objects"]: + # Multiple results. + membersuite_object_list = [] + for obj in (result_value["ObjectSearchResult"]["Objects"] + ["MemberSuiteObject"]): + membersuite_object = membersuite_object_factory(obj) + membersuite_object_list.append(membersuite_object) + elif result_value["SingleObject"]["ClassType"]: + # Only one result. + membersuite_object = membersuite_object_factory( + execute_msql_result["ResultValue"]["SingleObject"]) + membersuite_object_list.append(membersuite_object) + elif (result_value["ObjectSearchResult"]["Objects"] is None and + result_value["SingleObject"]["ClassType"] is None): + # No results, I guess. + pass + return membersuite_object_list + else: + # @TODO Fix - exposing only the first of possibly many errors here. + raise ExecuteMSQLError(result=execute_msql_result) diff --git a/setup.py b/setup.py index 5073cef..b2036ef 100644 --- a/setup.py +++ b/setup.py @@ -26,9 +26,6 @@ def read(fname): 'Programming Language :: Python :: 3.4.3', ], include_package_data=True, - install_requires=[ - "zeep>=0.26", - "future==0.16.0", - "lxml==3.7.0" - ], + install_requires=["future==0.16.0", + "zeep>=0.26"] )