diff --git a/.travis.yml b/.travis.yml index b10fc8e..c29eea1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,17 +4,11 @@ python: - '3.4' - '3.5' -env: - - DJANGO_VERSION=1.4.3 - - DJANGO_VERSION=1.8 - - DJANGO_VERSION=1.10 - install: - pip install -r requirements.txt - pip install -r requirements_test.txt - - pip install Django==$DJANGO_VERSION -script: nosetests --with-coverage +script: nosetests --with-coverage --nologcapture after_success: coveralls diff --git a/membersuite_api_client/client.py b/membersuite_api_client/client.py index 11353c5..56111aa 100644 --- a/membersuite_api_client/client.py +++ b/membersuite_api_client/client.py @@ -95,49 +95,14 @@ def construct_concierge_header(self, url): return concierge_request_header - def query_orgs(self, parameters=None, since_when=None): - """ - Constructs request to MemberSuite to query organization objects - based on parameters provided. - - parameters: A dictionary of key-value pairs (field name: value) - """ - concierge_request_header = self.construct_concierge_header( - url="http://membersuite.com/contracts/" - "IConciergeAPIService/ExecuteMSQL") - - query = "SELECT Objects() FROM Organization " - if parameters: - query += "WHERE" - for key in parameters: - query += " %s = '%s' AND" % (key, parameters[key]) - query = query[:-4] - - if since_when: - query += " AND LastModifiedDate > '{since_when} 00:00:00'"\ - .format(since_when=since_when.isoformat()) - elif since_when: - query += "WHERE LastModifiedDate > '{since_when} 00:00:00'".format( - since_when=since_when.isoformat()) - - result = self.client.service.ExecuteMSQL( - _soapheaders=[concierge_request_header], - msqlStatement=query, - startRecord=0 - ) - if result["body"]["ExecuteMSQLResult"]["ResultValue"]["ObjectSearchResult"]["Objects"]: - return(result["body"]["ExecuteMSQLResult"]["ResultValue"] - ["ObjectSearchResult"]["Objects"]["MemberSuiteObject"]) - else: - return None - - def runSQL(self, query, start_record=0): + def runSQL(self, query, start_record=0, limit_to=400): concierge_request_header = self.construct_concierge_header( url="http://membersuite.com/contracts/" "IConciergeAPIService/ExecuteMSQL") result = self.client.service.ExecuteMSQL( _soapheaders=[concierge_request_header], msqlStatement=query, - startRecord=start_record + startRecord=start_record, + maximumNumberOfRecordsToReturn=limit_to, ) return result diff --git a/membersuite_api_client/memberships/__init__.py b/membersuite_api_client/memberships/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/membersuite_api_client/memberships/models.py b/membersuite_api_client/memberships/models.py new file mode 100644 index 0000000..ab26981 --- /dev/null +++ b/membersuite_api_client/memberships/models.py @@ -0,0 +1,49 @@ +class Membership(object): + + def __init__(self, membership): + """ Create a Membership model from MemberSuite Member object + """ + self.id = membership["ID"] + self.owner = membership["Owner"] + self.membership_directory_opt_out = \ + membership["MembershipDirectoryOptOut"] + self.receives_member_benefits = membership["ReceivesMemberBenefits"] + self.current_dues_amount = membership["CurrentDuesAmount"] + self.expiration_date = membership["ExpirationDate"] + self.type = membership["Type"] + self.product = membership["Product"] + self.last_modified_date = membership["LastModifiedDate"] + + self.status = membership["Status"] + self.join_date = membership["JoinDate"] + self.termination_date = membership["TerminationDate"] + self.renewal_date = membership["RenewalDate"] + + +class MembershipProduct(object): + + def __init__(self, membership_type): + """ Create a MembershipType model from MembershipDuesProduct object + """ + self.id = membership_type["ID"] + self.name = membership_type["Name"] + +STATUSES = { + '6faf90e4-0069-cf2c-650f-0b3c15a7d3aa': 'Expired', + '6faf90e4-0069-cd19-6a43-0b3c15a7c287': 'New Member', + '6faf90e4-0069-c450-a9c8-0b3c6a781755': 'Pending', + '6faf90e4-0069-c7d5-2e88-0b3c15a7cf8a': 'Reinstated', + '6faf90e4-0069-c2ef-6d51-0b3c15a7cb7f': 'Renewed', + '6faf90e4-0069-cebb-b501-0b3c15a7d742': 'Terminated', +} + +TYPES = { + '6faf90e4-006a-c1e7-1ac8-0b3c2f7cb3bc': 'Business', + '6faf90e4-006a-c2ae-40f5-0b3c5c99549d': 'International Institution', + '6faf90e4-006a-c242-d6ab-0b3c5c994f57': 'North American Institution', + '6faf90e4-006a-c6b0-10c0-0b3c2f7cc1c5': 'Other', + '6faf90e4-006a-c71f-2f32-0b3c2f7cbbee': 'Campus', + '6faf90e4-006a-c85d-2efa-0b3c2f7ca9cc': 'HEASC Membership', + '6faf90e4-006a-c9c7-5aa2-0b3bc8775e74': 'New Member', + '6faf90e4-006a-c904-255d-0b3bc8774c95': 'Regained Membership', +} diff --git a/membersuite_api_client/memberships/services.py b/membersuite_api_client/memberships/services.py new file mode 100644 index 0000000..7faeec0 --- /dev/null +++ b/membersuite_api_client/memberships/services.py @@ -0,0 +1,148 @@ +""" + The service for connecting to MemberSuite for MembershipService + + http://api.docs.membersuite.com/#References/Objects/Membership.htm + +""" + +from .models import Membership, MembershipProduct +from ..utils import convert_ms_object +from zeep.exceptions import TransportError + + +class MembershipService(object): + + def __init__(self, client): + """ + Accepts a ConciergeClient to connect with MemberSuite + """ + self.client = client + + def get_memberships_for_org(self, account_num): + """ + Retrieve all memberships associated with an organization + """ + if not self.client.session_id: + self.client.request_session() + + query = "SELECT Objects() FROM Membership " \ + "WHERE Owner = '%s'" % account_num + result = self.client.runSQL(query) + msql_result = result["body"]["ExecuteMSQLResult"] + obj_result = msql_result["ResultValue"]["ObjectSearchResult"] + if obj_result['Objects']: + objects = obj_result['Objects']['MemberSuiteObject'] + if not msql_result["Errors"] and len(objects): + return self.package_memberships(objects) + return None + + def get_all_memberships(self, since_when=None, results=None, + start_record=0, limit_to=200, + depth=1, max_depth=None): + """ + Retrieve all memberships updated since "since_when" + + Loop over queries of size limit_to until either a non-full queryset + is returned, or max_depth is reached (used in tests). Then the + recursion collapses to return a single concatenated list. + """ + if not self.client.session_id: + self.client.request_session() + + query = "SELECT Objects() FROM Membership" + if since_when: + query += " WHERE LastModifiedDate > '{since_when} 00:00:00'" \ + " ORDER BY LocalID"\ + .format(since_when=since_when.isoformat()) + else: + query += " ORDER BY LocalID" + + try: + result = self.client.runSQL( + query=query, + start_record=start_record, + limit_to=limit_to, + ) + + except TransportError: + # API Intermittently fails and kicks a 504, + # this is a way to retry if that happens. + result = self.get_all_memberships( + since_when=since_when, + results=results, + start_record=start_record, + limit_to=limit_to, + depth=depth, + max_depth=max_depth, + ) + + msql_result = result['body']["ExecuteMSQLResult"] + if (not msql_result['Errors'] and msql_result["ResultValue"] + ["ObjectSearchResult"]["Objects"]): + new_results = self.package_memberships(msql_result + ["ResultValue"] + ["ObjectSearchResult"] + ["Objects"] + ["MemberSuiteObject"] + ) + (results or []) + + # Check if the queryset was completely full. If so, there may be + # More results we need to query + if len(new_results) >= limit_to and depth < max_depth: + new_results = self.get_all_memberships( + since_when=since_when, + results=new_results, + start_record=start_record + limit_to, + limit_to=limit_to, + depth=depth+1, + max_depth=max_depth + ) + return new_results + else: + return results + + def get_all_membership_products(self): + """ + Retrieves membership product objects + """ + if not self.client.session_id: + self.client.request_session() + + query = "SELECT Objects() FROM MembershipDuesProduct" + result = self.client.runSQL(query) + msql_result = result['body']['ExecuteMSQLResult'] + if not msql_result['Errors']: + return self.package_membership_products(msql_result) + else: + return None + + def package_memberships(self, object_list): + """ + Loops through MS objects returned from queries to turn them into + Membership objects and pack them into a list for later use. + """ + membership_list = [] + for obj in object_list: + if type(obj) != str: + sane_obj = convert_ms_object( + obj['Fields']['KeyValueOfstringanyType']) + membership = Membership(sane_obj) + membership_list.append(membership) + + return membership_list + + def package_membership_products(self, msql_result): + """ + Loops through MS objects returned from queries to turn them into + MembershipProduct objects and pack them into a list for later use. + """ + obj_result = msql_result['ResultValue']['ObjectSearchResult'] + objects = obj_result['Objects']['MemberSuiteObject'] + product_list = [] + for obj in objects: + sane_obj = convert_ms_object( + obj['Fields']['KeyValueOfstringanyType'] + ) + product = MembershipProduct(sane_obj) + product_list.append(product) + return product_list diff --git a/membersuite_api_client/organizations/__init__.py b/membersuite_api_client/organizations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/membersuite_api_client/organizations/models.py b/membersuite_api_client/organizations/models.py new file mode 100644 index 0000000..4a51904 --- /dev/null +++ b/membersuite_api_client/organizations/models.py @@ -0,0 +1,49 @@ +class Organization(object): + + def __init__(self, org): + """Create an Organization model from MemberSuite Organization object + """ + self.account_num = org["ID"] + self.membersuite_id = org["LocalID"] + self.org_name = org["Name"] + self.picklist_name = org["SortName"] or '' + + address = org["Mailing_Address"] + if address: + self.street1 = address["Line1"] or '' + self.street2 = address["Line2"] or '' + self.city = address["City"] or '' + self.state = address["State"] or '' + self.country = address["Country"] + self.postal_code = address["PostalCode"] or '' + self.latitude = address["GeocodeLat"] or '' + self.longitude = address["GeocodeLong"] or '' + + self.website = org["WebSite"] or '' + self.exclude_from_website = False + self.is_defunct = (org["Status"] == 'Defunct') + + self.org_type = org["Type"] + + self.stars_participant_status = ( + 'STARS participant' if org["STARSCharterParticipant__c"] else '' + ) + + self.primary_email = org['EmailAddress'] or '' + + +class OrganizationType(object): + + def __init__(self, org_type): + """Create an OrganizationType model + from MemberSuite OrganizationType object + """ + self.id = org_type["ID"] + self.Name = org_type["Name"] + +STATUSES = { + '6faf90e4-01f3-c54c-f01a-0b3bc87640ab': 'Active', + '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/organizations/services.py b/membersuite_api_client/organizations/services.py new file mode 100644 index 0000000..a476702 --- /dev/null +++ b/membersuite_api_client/organizations/services.py @@ -0,0 +1,135 @@ +""" + The service for connecting to MemberSuite for OrganizationService + + http://api.docs.membersuite.com/#References/Objects/Organization.htm + +""" + +from .models import Organization, OrganizationType +from ..utils import convert_ms_object +from zeep.exceptions import TransportError + + +class OrganizationService(object): + + def __init__(self, client): + """ + Accepts a ConciergeClient to connect with MemberSuite + """ + self.client = client + + def get_orgs(self, parameters=None, get_all=False, since_when=None, + results=None, start_record=0, limit_to=200, depth=1, + max_depth=None): + """ + Constructs request to MemberSuite to query organization objects + based on parameters provided. + + Loop over queries of size limit_to until either a non-full queryset + is returned, or max_depth is reached (used in tests). Then the + recursion collapses to return a single concatenated list. + """ + if not self.client.session_id: + self.client.request_session() + + query = "SELECT Objects() FROM Organization " + if parameters and not get_all: + query += "WHERE" + for key in parameters: + query += " %s = '%s' AND" % (key, parameters[key]) + query = query[:-4] + + if since_when: + query += " AND LastModifiedDate > '{since_when} 00:00:00'" \ + .format(since_when=since_when.isoformat()) + elif since_when and not get_all: + query += "WHERE LastModifiedDate > '{since_when} 00:00:00'".format( + since_when=since_when.isoformat()) + try: + result = self.client.runSQL( + query=query, + start_record=start_record, + limit_to=limit_to, + ) + + except TransportError: + # API Intermittently fails and kicks a 504, + # this is a way to retry if that happens. + result = self.get_orgs( + parameters=parameters, + get_all=get_all, + since_when=since_when, + results=results, + start_record=start_record, + limit_to=limit_to, + depth=depth, + max_depth=max_depth, + ) + + msql_result = result['body']["ExecuteMSQLResult"] + if not msql_result['Errors'] and \ + msql_result["ResultValue"]["ObjectSearchResult"]["Objects"]: + new_results = self.package_organizations(msql_result["ResultValue"] + ["ObjectSearchResult"] + ["Objects"] + ["MemberSuiteObject"] + ) + (results or []) + # Check if the queryset was completely full. If so, there may be + # More results we need to query + if len(new_results) >= limit_to and depth < max_depth: + new_results = self.get_orgs( + parameters=parameters, + get_all=get_all, + since_when=since_when, + results=new_results, + start_record=start_record + limit_to, + limit_to=limit_to, + depth=depth + 1, + max_depth=max_depth + ) + return new_results + else: + return None + + def get_org_types(self): + """ + Retrieves all current OrganizationType objects + """ + if not self.client.session_id: + self.client.request_session() + + query = "SELECT Objects() FROM OrganizationType" + result = self.client.runSQL(query=query) + msql_result = result['body']["ExecuteMSQLResult"] + return self.package_org_types(msql_result["ResultValue"] + ["ObjectSearchResult"] + ["Objects"]["MemberSuiteObject"] + ) + + def package_organizations(self, obj_list): + """ + Loops through MS objects returned from queries to turn them into + Organization objects and pack them into a list for later use. + """ + org_list = [] + for obj in obj_list: + sane_obj = convert_ms_object( + obj['Fields']['KeyValueOfstringanyType'] + ) + org = Organization(sane_obj) + org_list.append(org) + return org_list + + def package_org_types(self, obj_list): + """ + Loops through MS objects returned from queries to turn them into + OrganizationType objects and pack them into a list for later use. + """ + org_type_list = [] + for obj in obj_list: + sane_obj = convert_ms_object( + obj['Fields']['KeyValueOfstringanyType'] + ) + org = OrganizationType(sane_obj) + org_type_list.append(org) + return org_type_list diff --git a/membersuite_api_client/tests/test_client.py b/membersuite_api_client/tests/test_client.py index 5d2d9ed..4decff2 100644 --- a/membersuite_api_client/tests/test_client.py +++ b/membersuite_api_client/tests/test_client.py @@ -57,31 +57,6 @@ def test_request_session(self): self.assertEqual(get_session_id(result=response), client.session_id) - def test_query_orgs(self): - """ - Can we call the search method and receive an org object back? - """ - client = ConciergeClient(access_key=MS_ACCESS_KEY, - secret_key=MS_SECRET_KEY, - association_id=MS_ASSOCIATION_ID) - - # Send a login request to receive a session id - session_id = client.request_session() - self.assertTrue(session_id) - parameters = { - 'Name': 'AASHE Test Campus', - } - response = client.query_orgs(parameters) - self.assertEqual(response[0]["Fields"]["KeyValueOfstringanyType"] - [28]["Value"], - 'AASHE Test Campus') - - # Test querying orgs modified in the last day - # (there should be zero in our sandbox) - since_when = datetime.date.today() - datetime.timedelta(1) - response = client.query_orgs(parameters, since_when) - # self.assertFalse(response) - if __name__ == '__main__': unittest.main() diff --git a/membersuite_api_client/tests/test_memberships.py b/membersuite_api_client/tests/test_memberships.py new file mode 100644 index 0000000..eaa3a18 --- /dev/null +++ b/membersuite_api_client/tests/test_memberships.py @@ -0,0 +1,45 @@ +from .base import BaseTestCase +from ..memberships.services import MembershipService +from ..memberships.models import Membership, MembershipProduct + + +class MembershipServiceTestCase(BaseTestCase): + + def setUp(self): + super(MembershipServiceTestCase, self).setUp() + self.service = MembershipService(self.client) + + def test_get_membership_for_org(self): + """ + Get membership info for a test org + """ + # Test org with a membership + test_org_id = "6faf90e4-0007-c578-8310-0b3c53985743" + membership_list = self.service.get_memberships_for_org(test_org_id) + self.assertEqual(type(membership_list[0]), Membership) + self.assertEqual(membership_list[0].id, + '6faf90e4-0074-cbb5-c1d2-0b3c539859ef') + + # Test org without a membership + test_org_id = "6faf90e4-0007-c9dc-98b7-0b3c53985743" + membership_list = self.service.get_memberships_for_org(test_org_id) + self.assertFalse(membership_list) + + def test_get_all_memberships(self): + """ + Does the get_all_memberships() method work? + """ + membership_list = self.service.get_all_memberships( + limit_to=1, max_depth=2 + ) + self.assertEqual(len(membership_list), 2) + self.assertEqual(type(membership_list[0]), Membership) + + def test_get_all_membership_products(self): + """ + Test if we can retrieve all 103 MembershipProduct objects + """ + membership_product_list = self.service.get_all_membership_products() + self.assertTrue(len(membership_product_list) == 103) + self.assertEqual(type(membership_product_list[0]), + MembershipProduct) diff --git a/membersuite_api_client/tests/test_organizations.py b/membersuite_api_client/tests/test_organizations.py new file mode 100644 index 0000000..b0a6007 --- /dev/null +++ b/membersuite_api_client/tests/test_organizations.py @@ -0,0 +1,38 @@ +from .base import BaseTestCase +from ..organizations.services import OrganizationService +from ..organizations.models import Organization, OrganizationType + + +class OrganizationServiceTestCase(BaseTestCase): + + def setUp(self): + super(OrganizationServiceTestCase, self).setUp() + self.service = OrganizationService(self.client) + + def test_get_orgs(self): + """ + Can we call the get_orgs method and receive an org object back? + """ + # Fetch just one org by name + parameters = { + 'Name': 'AASHE Test Campus', + } + org_list = self.service.get_orgs(parameters) + self.assertEqual(len(org_list), 1) + self.assertEqual(type(org_list[0]), Organization) + + # Fetch all orgs using get_all=True + # But limit to 1 result per iteration, 2 iterations + org_list = self.service.get_orgs(get_all=True, + limit_to=1, + max_depth=2) + self.assertEqual(len(org_list), 2) + self.assertEqual(type(org_list[0]), Organization) + + def test_get_org_types(self): + """ + Test fetching all org type objects + """ + org_type_list = self.service.get_org_types() + self.assertTrue(len(org_type_list)) + self.assertTrue(type(org_type_list[0]), OrganizationType) diff --git a/membersuite_api_client/tests/test_utils.py b/membersuite_api_client/tests/test_utils.py deleted file mode 100644 index e3c13bf..0000000 --- a/membersuite_api_client/tests/test_utils.py +++ /dev/null @@ -1,40 +0,0 @@ -import os -import unittest - -from ..client import ConciergeClient -from ..utils import convert_ms_object - - -MS_ACCESS_KEY = os.environ["MS_ACCESS_KEY"] -MS_SECRET_KEY = os.environ["MS_SECRET_KEY"] -MS_ASSOCIATION_ID = os.environ["MS_ASSOCIATION_ID"] - - -class UtilsTestCase(unittest.TestCase): - - def test_convert_ms_object(self): - """ - Can we parse the list of dicts for org attributes into a dict? - """ - client = ConciergeClient(access_key=MS_ACCESS_KEY, - secret_key=MS_SECRET_KEY, - association_id=MS_ASSOCIATION_ID) - - # Send a login request to receive a session id - session_id = client.request_session() - self.assertTrue(session_id) - parameters = { - 'Name': 'AASHE Test Campus', - } - response = client.query_orgs(parameters) - self.assertEqual(response[0]["Fields"]["KeyValueOfstringanyType"] - [28]["Value"], - 'AASHE Test Campus') - converted_dict = convert_ms_object( - response[0]["Fields"]["KeyValueOfstringanyType"]) - - self.assertEqual(converted_dict["Name"], "AASHE Test Campus") - - -if __name__ == '__main__': - unittest.main()