From c35fc54e2189c1523fbf2967794d2896aa63dad7 Mon Sep 17 00:00:00 2001 From: Ankur Naik Date: Wed, 12 Jun 2024 19:19:58 -0700 Subject: [PATCH 1/5] Airmeet Event Details connector with tests and documentation --- docs/airmeet.rst | 63 +++++ parsons/__init__.py | 1 + parsons/airmeet/__init__.py | 3 + parsons/airmeet/airmeet.py | 415 +++++++++++++++++++++++++++++++ test/test_airmeet.py | 477 ++++++++++++++++++++++++++++++++++++ 5 files changed, 959 insertions(+) create mode 100644 docs/airmeet.rst create mode 100644 parsons/airmeet/__init__.py create mode 100644 parsons/airmeet/airmeet.py create mode 100644 test/test_airmeet.py diff --git a/docs/airmeet.rst b/docs/airmeet.rst new file mode 100644 index 0000000000..627c2fdb76 --- /dev/null +++ b/docs/airmeet.rst @@ -0,0 +1,63 @@ +Airmeet +======= + +******** +Overview +******** + +`Airmeet `_ is a webinar platform. This connector supports +fetching events ("Airmeets"), sessions, participants, and other event data via the +`Airmeet Public API for Event Details `_. + +.. note:: + Authentication + You must create an Access Key and Secret Key via the Airmeet website. These are used by the ``Airmeet`` class to fetch + an access token which is used for subsequent interactions with the API. There are three region-based API endpoints; see + the `Airmeet API documentation `_ for details. + +*********** +Quick Start +*********** + +To instantiate the ``Airmeet`` class, you can either store your API endpoint, access key, and secret key as environmental +variables (``AIRMEET_URI``, ``AIRMEET_ACCESS_KEY``, ``AIRMEET_SECRET_KEY``) or pass them in as arguments. + +.. code-block:: python + + from parsons import Airmeet + + # First approach: Use API credentials via environmental variables + airmeet = Airmeet() + + # Second approach: Pass API credentials as arguments (airmeet_uri is optional) + airmeet = Airmeet( + airmeet_uri='https://api-gateway.airmeet.com/prod', + airmeet_access_key="my_access_key", + airmeet_secret_key="my_secret_key + ) + +You can then call various endpoints: + +.. code-block:: python + + # Fetch the list of Airmeets. + events_tbl = airmeet.list_airmeets() + + # Fetch the list of sessions in an Airmeet. + sessions_tbl = airmeet.fetch_airmeet_sessions("my_airmeet_id") + + # Fetch the list of registrations for an Airmeet, sorted in order + # of registration date. + participants_tbl = airmeet.fetch_airmeet_participants( + "my_airmeet_id", sorting_direction="ASC" + ) + + # Fetch the list of session attendees. + session_attendees_tbl = airmeet.fetch_session_attendance("my_session_id") + +*** +API +*** + +.. autoclass :: parsons.Airmeet + :inherited-members: diff --git a/parsons/__init__.py b/parsons/__init__.py index ebc743e37f..6432ac0224 100644 --- a/parsons/__init__.py +++ b/parsons/__init__.py @@ -31,6 +31,7 @@ ("parsons.action_kit.action_kit", "ActionKit"), ("parsons.action_builder.action_builder", "ActionBuilder"), ("parsons.action_network.action_network", "ActionNetwork"), + ("parsons.airmeet.airmeet", "Airmeet"), ("parsons.airtable.airtable", "Airtable"), ("parsons.alchemer.alchemer", "Alchemer"), ("parsons.alchemer.alchemer", "SurveyGizmo"), diff --git a/parsons/airmeet/__init__.py b/parsons/airmeet/__init__.py new file mode 100644 index 0000000000..0bdbec4253 --- /dev/null +++ b/parsons/airmeet/__init__.py @@ -0,0 +1,3 @@ +from parsons.airmeet.airmeet import Airmeet + +__all__ = ["Airmeet"] diff --git a/parsons/airmeet/airmeet.py b/parsons/airmeet/airmeet.py new file mode 100644 index 0000000000..48a113673f --- /dev/null +++ b/parsons/airmeet/airmeet.py @@ -0,0 +1,415 @@ +from parsons.utilities import check_env +from parsons.etl.table import Table +from parsons.utilities.api_connector import APIConnector + +AIRMEET_DEFAULT_URI = "https://api-gateway.airmeet.com/prod/" + + +class Airmeet(object): + """ + Instantiate class. + + `Args:` + airmeet_uri: string + The URI of the Airmeet API endpoint. Not required. The default + is https://api-gateway.airmeet.com/prod/. You can set an + ``AIRMEET_URI`` env variable or use this parameter when + instantiating the class. + airmeet_access_key: string + The Airmeet API access key. + airmeet_secret_key: string + The Airmeet API secret key. + + For instructions on how to generate an access key and secret key set, + see `Airmeet's Event Details API documentation + `_. + """ + + def __init__(self, airmeet_uri=None, airmeet_access_key=None, airmeet_secret_key=None): + """ + Authenticate with the Airmeet API and update the connection headers + with the access token. + + `Args:` + airmeet_uri: string + The Airmeet API endpoint. + airmeet_access_key: string + The Airmeet API access key. + airmeet_secret_key: string + The Airmeet API secret key. + `Returns:` + ``None`` + """ + self.uri = check_env.check("AIRMEET_URI", airmeet_uri, optional=True) or AIRMEET_DEFAULT_URI + self.client = APIConnector(self.uri) + self.airmeet_client_key = check_env.check("AIRMEET_ACCESS_KEY", airmeet_access_key) + self.airmeet_client_secret = check_env.check("AIRMEET_SECRET_KEY", airmeet_secret_key) + self.client.headers = { + "X-Airmeet-Access-Key": self.airmeet_client_key, + "X-Airmeet-Secret-Key": self.airmeet_client_secret, + } + response = self.client.post_request(url="auth", success_codes=[200]) + self.token = response["token"] + + # API calls expect the token in the header. + self.client.headers = { + "Content-Type": "application/json", + "X-Airmeet-Access-Token": self.token, + } + + def _get_all_pages(self, url, page_size=50, **kwargs) -> Table: + """ + Get all the results from an Airmeet API url, handling pagination based + on the returned pageCount. + + `Args:` + page_size: 50 + The number of items to get per page. The max allowed varies by + API call. For details, see `Airmeet's Event Details API + documentation + `_. + **kwargs: + Additional parameters to include in the request. + `Returns:` + ``None`` + """ + results = [] + cursor_after = "" # For getting the next set of results + kwargs["size"] = page_size + + # Initial API call to get the first page of data + response = self.client.get_request(url=url, params=kwargs) + + # Some APIs are asynchronous and will return a 202 if the request + # should be tried again after five minutes, because the results + # set needs to be built. + if "statusCode" in response and response["statusCode"] != 200: + raise Exception(response) + else: + results.extend(response["data"]) + + if "cursors" in response and response["cursors"]["pageCount"] > 1: + cursor_after = response["cursors"]["after"] + + # Fetch subsequent pages if needed + for _ in range(2, response["cursors"]["pageCount"] + 1): + kwargs["after"] = cursor_after + response = self.client.get_request(url=url, params=kwargs) + results.extend(response["data"]) + cursor_after = response["cursors"]["after"] + + return Table(results) + + def list_airmeets(self) -> Table: + """ + Get the list of Airmeets. The API excludes any Airmeets that are + Archived (Deleted). + + `Returns:` + Parsons.Table + List of Airmeets + """ + return self._get_all_pages(url="airmeets", page_size=500) + + def fetch_airmeet_participants( + self, airmeet_id, sorting_key="registrationDate", sorting_direction="DESC" + ) -> Table: + """ + Get all participants (registrations) for a specific Airmeet, handling + pagination based on the returned totalUserCount. This API doesn't use + cursors for paging, so we can't use _get_all_pages() here. + + `Args:` + airmeet_id: string + The id of the Airmeet. + sorting_key: string + The key to sort the participants by. Can be 'name', 'email', or + 'registrationDate' (the default). + sorting_direction: string + Can be either 'ASC' or 'DESC' (the default). + `Returns:` + Parsons.Table + List of participants for the Airmeet event + """ + participants = [] # List to hold all participants + page_size = 1000 # Maximum number of results per page + + # Initial API call to get the total user count and first page of data + response = self.client.get_request( + url=f"airmeet/{airmeet_id}/participants", + params={ + "pageNumber": 1, + "resultSize": page_size, + "sortingKey": sorting_key, + "sortingDirection": sorting_direction, + }, + ) + participants.extend(response["participants"]) + + # Calculate total pages needed based on totalUserCount. + total_count = response["totalUserCount"] + total_pages = (total_count + page_size - 1) // page_size # This rounds up the division. + + # Fetch subsequent pages if needed. + for page in range(2, total_pages + 1): + response = self.client.get_request( + url=f"airmeet/{airmeet_id}/participants", + params={ + "pageNumber": page, + "resultSize": page_size, + "sortingKey": sorting_key, + "sortingDirection": sorting_direction, + }, + ) + participants.extend(response["participants"]) + + return Table(participants) + + def fetch_airmeet_sessions(self, airmeet_id) -> Table: + """ + Get the list of sessions for an Airmeet. + + `Args:` + airmeet_id: string + The id of the Airmeet. + `Returns:` + Parsons.Table + List of sessions for this Airmeet event + """ + response = self.client.get_request(url=f"airmeet/{airmeet_id}/info") + return Table(response["sessions"]) + + def fetch_airmeet_info(self, airmeet_id, lists_to_tables=False): + """ + Get the data for an Airmeet (event), which include the list of + sessions, session hosts/cohosts, and various other info. + + `Args:` + airmeet_id: string + The id of the Airmeet. + lists_to_tables: bool + If True, will convert any dictionary values that are lists + to Tables. + `Returns:` + Dict containing the Airmeet data + """ + response = self.client.get_request(url=f"airmeet/{airmeet_id}/info") + if lists_to_tables: + for k in response: + if isinstance(response[k], list): + response[k] = Table(response[k]) + return response + + def fetch_airmeet_custom_registration_fields(self, airmeet_id) -> Table: + """ + Get the list of custom registration fields for an Airmeet. + + `Args:` + airmeet_id: string + The id of the Airmeet. + `Returns:` + Parsons.Table + List of custom registration fields for this Airmeet event + """ + response = self.client.get_request(url=f"airmeet/{airmeet_id}/custom-fields") + return Table(response["customFields"]) + + def fetch_event_attendance(self, airmeet_id) -> Table: + """ + Get all attendees for an Airmeet, handling pagination based on the + returned pageCount. + + Results are available only for events with a status of `FINISHED`. + Maximum number of results per page = 50. + + "This is an Asynchronous API. If you get a 202 code in response, + please try again after 5 minutes." + + `Args:` + airmeet_id: string + The id of the Airmeet. + `Returns:` + Parsons.Table + List of attendees for this Airmeet event + """ + return self._get_all_pages(url=f"airmeet/{airmeet_id}/attendees", page_size=50) + + def fetch_session_attendance(self, session_id) -> Table: + """ + Get all attendees for a specific Airmeet session, handling pagination + based on the returned pageCount. + + Results are available only for sessions with a status of `FINISHED`. + Maximum number of results per page = 50. + + "This is an Asynchronous API. If you get a 202 code in response, + please try again after 5 minutes." + + `Args:` + session_id: string + The id of the session. + `Returns:` + Parsons.Table + List of attendees for this session + """ + return self._get_all_pages(url=f"session/{session_id}/attendees", page_size=50) + + def fetch_airmeet_booths(self, airmeet_id) -> Table: + """ + Get the list of booths for a specific Airmeet by ID. + + `Args:` + airmeet_id: string + The id of the Airmeet. + `Returns:` + Parsons.Table + List of booths for this Airmeet + """ + response = self.client.get_request(url=f"airmeet/{airmeet_id}/booths") + return Table(response["booths"]) + + def fetch_booth_attendance(self, airmeet_id, booth_id) -> Table: + """ + Get all attendees for a specific Airmeet booth, handling pagination + based on the returned pageCount. + + Results are available only for events with a status of `FINISHED`. + Maximum number of results per page = 50. + + "This is an Asynchronous API. If you get a 202 code in response, + please try again after 5 minutes." + + `Args:` + airmeet_id: string + The id of the Airmeet. + booth_id: string + The id of the booth. + `Returns:` + Parsons.Table + List of attendees for this booth + """ + return self._get_all_pages( + url=f"airmeet/{airmeet_id}/booth/{booth_id}/booth-attendance", page_size=50 + ) + + def fetch_poll_responses(self, airmeet_id) -> Table: + """ + Get a list of the poll responses in an Airmeet, handling pagination + based on the returned pageCount. + + Maximum number of results per page = 50. + + `Args:` + airmeet_id: string + The id of the Airmeet. + `Returns:` + Parsons.Table + List of users. For each user, the value for the "polls" + key is a list of poll questions and answers for that user. + """ + return self._get_all_pages(url=f"airmeet/{airmeet_id}/polls", page_size=50) + + def fetch_questions_asked(self, airmeet_id) -> Table: + """ + Get a list of the questions asked in an Airmeet. + + `Args:` + airmeet_id: string + The id of the Airmeet. + `Returns:` + Parsons.Table + List of users. For each user, the value for the "questions" + key is a list of that user's questions. + """ + response = self.client.get_request(url=f"airmeet/{airmeet_id}/questions") + return Table(response["data"]) + + def fetch_event_tracks(self, airmeet_id) -> Table: + """ + Get a list of the tracks in a specific Airmeet by ID. + + `Args:` + airmeet_id: string + The id of the Airmeet. + `Returns:` + Parsons.Table + List of event tracks + """ + response = self.client.get_request(url=f"airmeet/{airmeet_id}/tracks") + return Table(response["tracks"]) + + def fetch_registration_utms(self, airmeet_id) -> Table: + """ + Get all the UTM parameters captured during registration, handling + pagination based on the returned pageCount. + + Maximum number of results per page = ?? (documentation doesn't say, + but assume 50 like the other asynchronous APIs). + + "This is an Asynchronous API. If you get a 202 code in response, + please try again after 5 minutes." + + `Args:` + airmeet_id: string + The id of the Airmeet. + `Returns:` + Parsons.Table + List of UTM parameters captured during registration + """ + return self._get_all_pages(url=f"airmeet/{airmeet_id}/utms", page_size=50) + + def download_session_recordings(self, airmeet_id, session_id=None) -> Table: + """ + Get a list of recordings for a specific Airmeet (and optionally a + specific session in that Airmeet). The data for each recording + includes a download link which is valid for 6 hours. + + The API returns "recordingsCount" and "totalCount", which implies + that the results could be paged like in fetch_airmeet_participants(). + The API docs don't specify if that's the case, but this method will + need to be updated if it is. + + `Args:` + airmeet_id: string + The id of the Airmeet. + session_id: string + (optional) If provided, limits results to only the recording + of the specified session. + `Returns:` + Parsons.Table + List of session recordings + """ + kwargs = {} + if session_id: + kwargs["sessionIds"] = session_id + response = self.client.get_request(url=f"airmeet/{airmeet_id}/session-recordings", **kwargs) + return Table(response["recordings"]) + + def fetch_event_replay_attendance(self, airmeet_id, session_id=None) -> Table: + """ + Get all replay attendees for a specific Airmeet (and optionally a + specific session in that Airmeet), handling pagination based on the + returned pageCount. + + Results are available only for events with a status of `FINISHED`. + Maximum number of results per page = 50. + + "This is an Asynchronous API. If you get a 202 code in response, + please try again after 5 minutes." + + `Args:` + airmeet_id: string + The id of the Airmeet. + session_id: string + (optional) If provided, limits results to only attendees of + the specified session. + `Returns:` + Parsons.Table + List of event replay attendees + """ + attendees = self._get_all_pages( + url=f"airmeet/{airmeet_id}/event-replay-attendees", page_size=50 + ) + if session_id is not None: + attendees = attendees.select_rows("{session_id} == '" + session_id + "'") + return attendees diff --git a/test/test_airmeet.py b/test/test_airmeet.py new file mode 100644 index 0000000000..6df85eb49a --- /dev/null +++ b/test/test_airmeet.py @@ -0,0 +1,477 @@ +import os +import pytest +import requests_mock +import unittest +from unittest import mock +from parsons import Airmeet, Table + +ENV_PARAMETERS = { + "AIRMEET_URI": "https://env_api_endpoint", + "AIRMEET_ACCESS_KEY": "env_access_key", + "AIRMEET_SECRET_KEY": "env_secret_key", +} + + +class TestAirmeet(unittest.TestCase): + @requests_mock.Mocker() + def setUp(self, m): + m.post("https://api-gateway.airmeet.com/prod/auth", json={"token": "test_token"}) + self.airmeet = Airmeet(airmeet_access_key="fake_key", airmeet_secret_key="fake_secret") + self.airmeet.client = mock.MagicMock() + + def tearDown(self): + pass + + @requests_mock.Mocker() + @mock.patch.dict(os.environ, ENV_PARAMETERS) + def test_from_environ(self, m): + m.post("https://env_api_endpoint/auth", json={"token": "test_token"}) + airmeet = Airmeet() + self.assertEqual(airmeet.uri, "https://env_api_endpoint") + self.assertEqual(airmeet.airmeet_client_key, "env_access_key") + self.assertEqual(airmeet.airmeet_client_secret, "env_secret_key") + self.assertEqual(airmeet.token, "test_token") + + def test_get_all_pages_single_page(self): + # Simulate API response for a single page without further cursors. + self.airmeet.client.get_request = mock.MagicMock( + return_value={ + "data": [{"id": "1", "name": "Item 1"}], + "cursors": {"pageCount": 1, "after": None}, + } + ) + + url = "airmeet/some_endpoint" + result = self.airmeet._get_all_pages(url) + + self.airmeet.client.get_request.assert_called_once_with(url=url, params={"size": 50}) + assert isinstance(result, Table), "The result should be a Table" + assert len(result) == 1, "Table should contain exactly one record" + + def test_get_all_pages_multiple_pages(self): + # Simulate API responses for multiple pages. + responses = [ + { + "data": [{"id": "1", "name": "Item 1"}], + "cursors": {"pageCount": 2, "after": "abc123"}, + }, + { + "data": [{"id": "2", "name": "Item 2"}], + "cursors": {"pageCount": 2, "after": None}, + }, # Last page + ] + self.airmeet.client.get_request = mock.MagicMock( + side_effect=lambda *args, **kwargs: responses.pop(0) + ) + + url = "airmeet/some_endpoint" + result = self.airmeet._get_all_pages(url) + + calls = [ + mock.call(url=url, params={"size": 50, "after": "abc123"}), + mock.call(url=url, params={"size": 50, "after": "abc123"}), + ] + self.airmeet.client.get_request.assert_has_calls(calls, any_order=True) + assert isinstance(result, Table), "The result should be a Table" + assert len(result) == 2, "Table should contain records from both pages" + + def test_list_airmeets(self): + # Test get the list of Airmeets. + self.airmeet.client = mock.MagicMock() + + result = self.airmeet.list_airmeets() + + self.airmeet.client.get_request.assert_called_with( + url="airmeets", + params={"size": 500}, + ) + assert isinstance(result, Table), "The result should be a Table" + + def test_fetch_airmeet_participants_single_page(self): + # Simulate API response for a single page of participants. This + # particular API doesn't use cursors like the other ones that can have + # multiple pages, which is why this is a separate test. + self.airmeet.client.get_request = mock.MagicMock( + return_value={ + "participants": [{"user_id": "abc123", "name": "Test User 1"}], + "userCount": 1, + "totalUserCount": 1, + } + ) + + result = self.airmeet.fetch_airmeet_participants("test_airmeet_id") + + self.airmeet.client.get_request.assert_called_once_with( + url="airmeet/test_airmeet_id/participants", + params={ + "pageNumber": 1, + "resultSize": 1000, + "sortingKey": "registrationDate", + "sortingDirection": "DESC", + }, + ) + assert isinstance(result, Table), "The result should be a Table" + assert len(result) == 1, "Table should contain exactly one record" + + def test_fetch_airmeet_participants_multiple_pages(self): + # Simulate API responses for multiple pages of participants. This + # particular API doesn't use cursors like the other ones that can have + # multiple pages, which is why this is a separate test. + + # The connector requests 1000 at a time, so we return a totalUserCount + # of 2000 here to make it request a second page. + responses = [ + { + "participants": [{"user_id": "abc123", "name": "Test User 1"}], + "userCount": 1, + "totalUserCount": 2000, + }, + { + "participants": [{"user_id": "def456", "name": "Test User 1"}], + "userCount": 1, + "totalUserCount": 2000, + }, # Last page + ] + self.airmeet.client.get_request = mock.MagicMock( + side_effect=lambda *args, **kwargs: responses.pop(0) + ) + + result = self.airmeet.fetch_airmeet_participants("test_airmeet_id") + + calls = [ + mock.call( + url="airmeet/test_airmeet_id/participants", + params={ + "pageNumber": 1, + "resultSize": 1000, + "sortingKey": "registrationDate", + "sortingDirection": "DESC", + }, + ), + mock.call( + url="airmeet/test_airmeet_id/participants", + params={ + "pageNumber": 2, + "resultSize": 1000, + "sortingKey": "registrationDate", + "sortingDirection": "DESC", + }, + ), + ] + self.airmeet.client.get_request.assert_has_calls(calls, any_order=True) + assert isinstance(result, Table), "The result should be a Table" + assert len(result) == 2, "Table should contain records from both pages" + + def test_fetch_airmeet_sessions(self): + # Test get the list of sessions for an Airmeet. + self.airmeet.client.get_request = mock.MagicMock( + return_value={ + "name": "Test Event", + "sessions": [ + {"sessionid": "test_session_id_1", "name": "Test Session 1"}, + {"sessionid": "test_session_id_2", "name": "Test Session 2"}, + ], + } + ) + + result = self.airmeet.fetch_airmeet_sessions("test_airmeet_id") + + self.airmeet.client.get_request.assert_called_once_with(url="airmeet/test_airmeet_id/info") + assert isinstance(result, Table), "The result should be a Table" + assert len(result) == 2, "Table should contain both records" + + def test_fetch_airmeet_info(self): + # Test get the Airmeet info. + self.airmeet.client.get_request = mock.MagicMock( + return_value={ + "name": "Test Event", + "sessions": [ + {"sessionid": "test_session_id_1", "name": "Test Session 1"}, + {"sessionid": "test_session_id_2", "name": "Test Session 2"}, + ], + "session_hosts": [{"id": "abc123", "name": "Test Host 1"}], + } + ) + + result = self.airmeet.fetch_airmeet_info("test_airmeet_id", lists_to_tables=True) + + self.airmeet.client.get_request.assert_called_once_with(url="airmeet/test_airmeet_id/info") + assert isinstance(result, dict), "The result should be a Table" + assert isinstance(result["sessions"], Table), "The sessions should be a Table" + assert isinstance(result["session_hosts"], Table), "The session hosts should be a Table" + assert len(result["sessions"]) == 2, "Sessions Table should contain exactly two records" + assert len(result["session_hosts"]) == 1, "Session hosts Table should contain exactly one record" + + def test_fetch_airmeet_custom_registration_fields(self): + # Test get the custom registration fields for an Airmeet. + self.airmeet.client.get_request = mock.MagicMock( + return_value={ + "customFields": [ + {"fieldId": "test_field_id_1", "label": "Test Label 1"}, + {"fieldId": "test_field_id_2", "label": "Test Label 2"}, + ], + } + ) + + result = self.airmeet.fetch_airmeet_custom_registration_fields("test_airmeet_id") + + self.airmeet.client.get_request.assert_called_once_with( + url="airmeet/test_airmeet_id/custom-fields" + ) + assert isinstance(result, Table), "The result should be a Table" + assert len(result) == 2, "Table should contain exactly two records" + + def test_fetch_event_attendance(self): + # Test get the attendees for an Airmeet. + self.airmeet.client.get_request = mock.MagicMock( + return_value={ + "data": [ + { + "name": "Test User 1", + "user_id": "abc123", + } + ], + } + ) + + result = self.airmeet.fetch_event_attendance("test_airmeet_id") + + self.airmeet.client.get_request.assert_called_once_with( + url="airmeet/test_airmeet_id/attendees", + params={"size": 50}, + ) + assert isinstance(result, Table), "The result should be a Table" + assert len(result) == 1, "The result should contain exactly one record" + + def test_fetch_session_attendance(self): + # Test get the attendees for a session. + self.airmeet.client.get_request = mock.MagicMock( + return_value={ + "data": [ + { + "name": "Test User 1", + "user_id": "abc123", + } + ], + } + ) + + result = self.airmeet.fetch_session_attendance("test_session_id") + + self.airmeet.client.get_request.assert_called_once_with( + url="session/test_session_id/attendees", + params={"size": 50}, + ) + assert isinstance(result, Table), "The result should be a Table" + assert len(result) == 1, "The result should contain exactly one record" + + def test_fetch_session_attendance_exception(self): + # Test that an asynchronous API raises an exception if it returns + # a statusCode != 200. + self.airmeet.client.get_request = mock.MagicMock( + return_value={ + "statusCode": 202, + "statusMessage": "Preparing your results. Try after 5 minutes to get the updated results", + } + ) + + with pytest.raises(Exception): + self.airmeet.fetch_session_attendance("test_session_id") + + def test_fetch_airmeet_booths(self): + # Test get the booths for an Airmeet. + self.airmeet.client.get_request = mock.MagicMock( + return_value={ + "booths": [ + { + "uid": "test_booth_uid_1", + "name": "Test Booth 1", + } + ], + } + ) + + result = self.airmeet.fetch_airmeet_booths("test_airmeet_id") + + self.airmeet.client.get_request.assert_called_once_with( + url="airmeet/test_airmeet_id/booths" + ) + assert isinstance(result, Table), "The result should be a Table" + assert len(result) == 1, "The result should contain exactly one record" + + def test_fetch_booth_attendance(self): + # Test get the attendees for a booth. + self.airmeet.client.get_request = mock.MagicMock( + return_value={ + "data": [ + { + "name": "Test User 1", + "user_id": "abc123", + } + ], + } + ) + + result = self.airmeet.fetch_booth_attendance( + "test_airmeet_id", + booth_id="test_booth_id", + ) + + self.airmeet.client.get_request.assert_called_once_with( + url="airmeet/test_airmeet_id/booth/test_booth_id/booth-attendance", + params={"size": 50}, + ) + assert isinstance(result, Table), "The result should be a Table" + assert len(result) == 1, "The result should contain exactly one record" + + def test_fetch_poll_responses(self): + # Test get the poll responses for an Airmeet. + self.airmeet.client.get_request = mock.MagicMock( + return_value={ + "data": [ + { + "name": "Test User 1", + "polls": [ + {"question": "Poll Question 1", "answer": "Poll Answer 1"}, + {"question": "Poll Question 2", "answer": "Poll Answer 2"}, + ], + } + ], + } + ) + + result = self.airmeet.fetch_poll_responses("test_airmeet_id") + + self.airmeet.client.get_request.assert_called_once_with( + url="airmeet/test_airmeet_id/polls", + params={"size": 50}, + ) + assert isinstance(result, Table), "The result should be a Table" + assert len(result[0]["polls"]) == 2, "The record should contain exactly two poll responses" + + def test_fetch_questions_asked(self): + # Test get the questions asked for an Airmeet. + self.airmeet.client.get_request = mock.MagicMock( + return_value={ + "data": [ + { + "name": "Test User 1", + "questions": [ + {"question": "Question 1", "session_id": "session_id_1"}, + {"question": "Question 2", "session_id": "session_id_2"}, + ], + } + ], + } + ) + + result = self.airmeet.fetch_questions_asked("test_airmeet_id") + + self.airmeet.client.get_request.assert_called_once_with( + url="airmeet/test_airmeet_id/questions" + ) + assert isinstance(result, Table), "The result should be a Table" + assert len(result[0]["questions"]) == 2, "The record should contain exactly two questions" + + def test_fetch_event_tracks(self): + # Test get the tracks for an Airmeet. + self.airmeet.client.get_request = mock.MagicMock( + return_value={ + "tracks": [ + { + "uid": "test_track_uid_1", + "name": "Test Track 1", + "sessions": [ + "session_id_1", + "session_id_2", + ], + } + ], + } + ) + + result = self.airmeet.fetch_event_tracks("test_airmeet_id") + + self.airmeet.client.get_request.assert_called_once_with( + url="airmeet/test_airmeet_id/tracks" + ) + assert isinstance(result, Table), "The result should be a Table" + assert len(result[0]["sessions"]) == 2, "The record should contain exactly two session ids" + + def test_fetch_registration_utms(self): + # Test get the registration UTMs for an Airmeet. + self.airmeet.client.get_request = mock.MagicMock( + return_value={ + "data": [ + { + "airmeetId": "test_airmeet_id", + "email": "test@example.com", + "id": 1, + "utms": { + "utm_campaign": "test_utm_campaign", + "utm_medium": None, + "utm_source": "test_utm_source", + }, + } + ], + } + ) + + result = self.airmeet.fetch_registration_utms("test_airmeet_id") + + self.airmeet.client.get_request.assert_called_once_with( + url="airmeet/test_airmeet_id/utms", + params={"size": 50}, + ) + assert isinstance(result, Table), "The result should be a Table" + assert len(result) == 1, "The result should contain exactly one record" + assert len(result[0]["utms"]) == 3, "The record should contain exactly three UTMs" + + def test_download_session_recordings(self): + # Test get the session recordings for an Airmeet. + self.airmeet.client.get_request = mock.MagicMock( + return_value={ + "recordings": [ + { + "session_id": "test_session_id_1", + "session_name": "Test Session 1", + "download_link": "https://example.com/test_download_link", + } + ], + } + ) + + result = self.airmeet.download_session_recordings( + "test_airmeet_id", + session_id="test_session_id", + ) + + self.airmeet.client.get_request.assert_called_once_with( + url="airmeet/test_airmeet_id/session-recordings", + sessionIds="test_session_id", + ) + assert isinstance(result, Table), "The result should be a Table" + assert len(result) == 1, "The result should contain exactly one record" + + def test_fetch_event_replay_attendance(self): + # Test get the replay attendees for an Airmeet. + self.airmeet.client.get_request = mock.MagicMock( + return_value={ + "data": [ + { + "id": 1, + "name": "Test User 1", + "session_id": "test_session_id", + } + ], + } + ) + + result = self.airmeet.fetch_event_replay_attendance("test_airmeet_id") + + self.airmeet.client.get_request.assert_called_once_with( + url="airmeet/test_airmeet_id/event-replay-attendees", + params={"size": 50}, + ) + assert isinstance(result, Table), "The result should be a Table" + assert len(result) == 1, "The result should contain exactly one record" From 22a4a5f4e3a207aa1f311de8a00c4b0007dde979 Mon Sep 17 00:00:00 2001 From: Ankur Naik Date: Thu, 13 Jun 2024 11:24:23 -0700 Subject: [PATCH 2/5] Handle when list of booths is null. Add tests for session and replay attendance exceptions. --- parsons/airmeet/airmeet.py | 6 +++--- test/test_airmeet.py | 30 +++++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/parsons/airmeet/airmeet.py b/parsons/airmeet/airmeet.py index 48a113673f..7f775356e1 100644 --- a/parsons/airmeet/airmeet.py +++ b/parsons/airmeet/airmeet.py @@ -219,8 +219,8 @@ def fetch_event_attendance(self, airmeet_id) -> Table: Get all attendees for an Airmeet, handling pagination based on the returned pageCount. - Results are available only for events with a status of `FINISHED`. - Maximum number of results per page = 50. + Results include attendance only from sessions with a status of + `FINISHED`. Maximum number of results per page = 50. "This is an Asynchronous API. If you get a 202 code in response, please try again after 5 minutes." @@ -266,7 +266,7 @@ def fetch_airmeet_booths(self, airmeet_id) -> Table: List of booths for this Airmeet """ response = self.client.get_request(url=f"airmeet/{airmeet_id}/booths") - return Table(response["booths"]) + return Table(response["booths"] or []) def fetch_booth_attendance(self, airmeet_id, booth_id) -> Table: """ diff --git a/test/test_airmeet.py b/test/test_airmeet.py index 6df85eb49a..8cc205ee98 100644 --- a/test/test_airmeet.py +++ b/test/test_airmeet.py @@ -265,9 +265,23 @@ def test_fetch_session_attendance(self): assert isinstance(result, Table), "The result should be a Table" assert len(result) == 1, "The result should contain exactly one record" + def test_fetch_session_attendance_exception(self): + # Test that the sessions attendees API raises an exception if it returns + # a statusCode == 400. + self.airmeet.client.get_request = mock.MagicMock( + return_value={ + "data": {}, + "statusCode": 400, + "statusMessage": "Session status is not valid", + } + ) + + with pytest.raises(Exception): + self.airmeet.fetch_session_attendance("test_session_id") + def test_fetch_session_attendance_exception(self): # Test that an asynchronous API raises an exception if it returns - # a statusCode != 200. + # a statusCode == 202. self.airmeet.client.get_request = mock.MagicMock( return_value={ "statusCode": 202, @@ -475,3 +489,17 @@ def test_fetch_event_replay_attendance(self): ) assert isinstance(result, Table), "The result should be a Table" assert len(result) == 1, "The result should contain exactly one record" + + def test_fetch_event_replay_attendance_exception(self): + # Test that the replay attendees API raises an exception if it returns + # a statusCode == 400. + self.airmeet.client.get_request = mock.MagicMock( + return_value={ + "data": {}, + "statusCode": 400, + "statusMessage": "Airmeet status is not valid", + } + ) + + with pytest.raises(Exception): + self.airmeet.fetch_event_replay_attendance("test_airmeet_id") From ad3a71edd1fae398fda32fd5a319566bda12c6c5 Mon Sep 17 00:00:00 2001 From: Ankur Naik Date: Thu, 13 Jun 2024 11:35:02 -0700 Subject: [PATCH 3/5] Fix naming and formatting --- test/test_airmeet.py | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/test/test_airmeet.py b/test/test_airmeet.py index 8cc205ee98..65469517ba 100644 --- a/test/test_airmeet.py +++ b/test/test_airmeet.py @@ -200,7 +200,9 @@ def test_fetch_airmeet_info(self): assert isinstance(result["sessions"], Table), "The sessions should be a Table" assert isinstance(result["session_hosts"], Table), "The session hosts should be a Table" assert len(result["sessions"]) == 2, "Sessions Table should contain exactly two records" - assert len(result["session_hosts"]) == 1, "Session hosts Table should contain exactly one record" + assert ( + len(result["session_hosts"]) == 1 + ), "Session hosts Table should contain exactly one record" def test_fetch_airmeet_custom_registration_fields(self): # Test get the custom registration fields for an Airmeet. @@ -265,27 +267,27 @@ def test_fetch_session_attendance(self): assert isinstance(result, Table), "The result should be a Table" assert len(result) == 1, "The result should contain exactly one record" - def test_fetch_session_attendance_exception(self): - # Test that the sessions attendees API raises an exception if it returns - # a statusCode == 400. + def test_fetch_session_attendance_exception_202(self): + # Test that an asynchronous API raises an exception if it returns + # a statusCode == 202. self.airmeet.client.get_request = mock.MagicMock( return_value={ - "data": {}, - "statusCode": 400, - "statusMessage": "Session status is not valid", + "statusCode": 202, + "statusMessage": "Preparing your results. Try after 5 minutes to get the updated results", } ) with pytest.raises(Exception): self.airmeet.fetch_session_attendance("test_session_id") - def test_fetch_session_attendance_exception(self): - # Test that an asynchronous API raises an exception if it returns - # a statusCode == 202. + def test_fetch_session_attendance_exception_400(self): + # Test that the sessions attendees API raises an exception if it returns + # a statusCode == 400. self.airmeet.client.get_request = mock.MagicMock( return_value={ - "statusCode": 202, - "statusMessage": "Preparing your results. Try after 5 minutes to get the updated results", + "data": {}, + "statusCode": 400, + "statusMessage": "Session status is not valid", } ) @@ -490,7 +492,7 @@ def test_fetch_event_replay_attendance(self): assert isinstance(result, Table), "The result should be a Table" assert len(result) == 1, "The result should contain exactly one record" - def test_fetch_event_replay_attendance_exception(self): + def test_fetch_event_replay_attendance_exception_400(self): # Test that the replay attendees API raises an exception if it returns # a statusCode == 400. self.airmeet.client.get_request = mock.MagicMock( From 2306d33be285e22ef526cd0af3b7a12bd4ce51a2 Mon Sep 17 00:00:00 2001 From: Ankur Naik Date: Thu, 13 Jun 2024 11:43:14 -0700 Subject: [PATCH 4/5] Fix long lines. --- test/test_airmeet.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/test_airmeet.py b/test/test_airmeet.py index 65469517ba..13369bbe9e 100644 --- a/test/test_airmeet.py +++ b/test/test_airmeet.py @@ -273,7 +273,8 @@ def test_fetch_session_attendance_exception_202(self): self.airmeet.client.get_request = mock.MagicMock( return_value={ "statusCode": 202, - "statusMessage": "Preparing your results. Try after 5 minutes to get the updated results", + "statusMessage": "Preparing your results. Try after 5 minutes" + + "to get the updated results", } ) @@ -281,8 +282,8 @@ def test_fetch_session_attendance_exception_202(self): self.airmeet.fetch_session_attendance("test_session_id") def test_fetch_session_attendance_exception_400(self): - # Test that the sessions attendees API raises an exception if it returns - # a statusCode == 400. + # Test that the sessions attendees API raises an exception if it + # returns a statusCode == 400. self.airmeet.client.get_request = mock.MagicMock( return_value={ "data": {}, From 642425be9e9ca7560e2c918f4fa00706c3b3b18c Mon Sep 17 00:00:00 2001 From: Cody Gordon Date: Wed, 26 Jun 2024 12:36:17 -0400 Subject: [PATCH 5/5] add airmeet docs to sidebar --- docs/index.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/index.rst b/docs/index.rst index bb057fbca3..2df26096cb 100755 --- a/docs/index.rst +++ b/docs/index.rst @@ -182,6 +182,7 @@ Indices and tables action_kit action_builder action_network + airmeet airtable alchemer auth0