From 64aaf647499614690e6f6488c26fcac841759836 Mon Sep 17 00:00:00 2001 From: Tre Wilkins <90734292+TreWilkins@users.noreply.github.com> Date: Fri, 14 Feb 2025 13:33:53 -0700 Subject: [PATCH 1/4] Refactor `get_params_from_kwargs` to handle kwargs as a dictionary and add tests for telephony log retrieval with various parameters --- duo_client/util.py | 2 ++ tests/admin/test_telephony.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/duo_client/util.py b/duo_client/util.py index 72b02b04..d8c4851a 100644 --- a/duo_client/util.py +++ b/duo_client/util.py @@ -4,6 +4,8 @@ def get_params_from_kwargs(valid_params: Sequence[str], **kwargs) -> Dict: params = {} + if isinstance(kwargs, dict): + kwargs = kwargs["kwargs"] if kwargs.get("kwargs") else kwargs for k in kwargs: if kwargs[k] is not None and k in valid_params: params[k] = kwargs[k] diff --git a/tests/admin/test_telephony.py b/tests/admin/test_telephony.py index 20a6d0f4..a7087130 100644 --- a/tests/admin/test_telephony.py +++ b/tests/admin/test_telephony.py @@ -34,9 +34,38 @@ def test_get_telephony_logs_v2_no_args(self): self.assertEqual(param_dict["mintime"], [expected_mintime]) self.assertEqual(param_dict["maxtime"], [expected_maxtime]) + @freeze_time("2022-10-01") + def test_get_telephony_logs_v2_with_kwargs(self): + mintime = datetime(2022, 9, 1, 0, 0, 0, tzinfo=pytz.utc) + expected_mintime = str(int(mintime.timestamp() * 1000)) + maxtime = datetime(2022, 10, 1, 0, 0, 0, tzinfo=pytz.utc) + expected_maxtime = str(int(maxtime.timestamp() * 1000) - 120) + params = {"mintime": expected_mintime} + response = self.items_response_client.get_telephony_log(api_version=2, + kwargs=params) + uri, args = response["uri"].split("?") + param_dict = util.params_to_dict(args) + self.assertEqual(response["method"], "GET") + self.assertEqual(uri, "/admin/v2/logs/telephony") + self.assertEqual(param_dict["mintime"], [expected_mintime]) + self.assertEqual(param_dict["maxtime"], [expected_maxtime]) + @freeze_time("2022-10-01") def test_get_telephony_logs_v1_no_args(self): response = self.client_list.get_telephony_log() uri, args = response[0]["uri"].split("?") self.assertEqual(response[0]["method"], "GET") self.assertEqual(uri, "/admin/v1/logs/telephony") + + @freeze_time("2022-10-01") + def test_get_telephony_logs_v1_with_mintime_arg(self): + freezed_time = datetime(2022, 9, 1, 0, 0, 0, tzinfo=pytz.utc) + expected_mintime = str( + int((freezed_time - timedelta(days=180)).timestamp() * 1000) + ) + response = self.client_list.get_telephony_log(mintime=expected_mintime) + uri, args = response[0]["uri"].split("?") + param_dict = util.params_to_dict(args) + self.assertEqual(response[0]["method"], "GET") + self.assertEqual(uri, "/admin/v1/logs/telephony") + self.assertEqual(param_dict["mintime"], [expected_mintime]) \ No newline at end of file From 48f2a9abc14ec9981de3f219801fcb7d8e1973b3 Mon Sep 17 00:00:00 2001 From: Tre Wilkins <90734292+TreWilkins@users.noreply.github.com> Date: Fri, 14 Feb 2025 20:50:33 -0700 Subject: [PATCH 2/4] Improve get_telephony_log method for API v2 support and clean up Telephony module --- duo_client/admin.py | 63 ++++++++++++++++++++++++++++---- duo_client/logs/__init__.py | 5 --- duo_client/logs/telephony.py | 65 ---------------------------------- duo_client/util.py | 23 ------------ examples/Admin/log_examples.py | 2 +- tests/admin/test_telephony.py | 23 ++++++++++-- 6 files changed, 79 insertions(+), 102 deletions(-) delete mode 100644 duo_client/logs/__init__.py delete mode 100644 duo_client/logs/telephony.py delete mode 100644 duo_client/util.py diff --git a/duo_client/admin.py b/duo_client/admin.py index 7c3d3165..e18b4a28 100644 --- a/duo_client/admin.py +++ b/duo_client/admin.py @@ -178,11 +178,10 @@ import time import urllib.parse import warnings +from typing import Optional from datetime import datetime, timedelta, timezone from . import Accounts, client -from .logs.telephony import Telephony - USER_STATUS_ACTIVE = "active" USER_STATUS_BYPASS = "bypass" USER_STATUS_DISABLED = "disabled" @@ -212,6 +211,15 @@ VALID_ACTIVITY_REQUEST_PARAMS = ["mintime", "maxtime", "limit", "sort", "next_offset"] +VALID_TELEPHONY_V2_REQUEST_PARAMS = [ + "filters", + "mintime", + "maxtime", + "limit", + "sort", + "next_offset", + "account_id", +] class Admin(client.Client): account_id = None @@ -628,12 +636,21 @@ def get_activity_logs(self, **kwargs): row['host'] = self.host return response - def get_telephony_log(self, mintime=0, api_version=1, **kwargs): + def get_telephony_log(self, mintime=0, api_version=1, maxtime:int = 0, + limit: Optional[int] = 100, sort: Optional[str] = 'desc', + next_offset: Optional[str] = None, account_id = None, + filters = None, **kwargs): """ Returns telephony log events. mintime - Fetch events only >= mintime (to avoid duplicate records that have already been fetched) + maxtime - Fetch events only <= maxtime. (API Version 2 only) + limit - Number of results to limit to, default 100, max 1000. (API Version 2 only) + sort - Sort order to be applied, default 'desc'. (API Version 2 only) + next_offset - Used to grab the next set of results from a previous response. (API Version 2 only) + account_id - Filter by account_id. (API Version 2 only) Type undocumented. + filters - Filter by filters. (API Version 2 only) Type undocumented. api_version - The API version of the handler to use. Currently, the default api version is v1, but the v1 API will be deprecated in a future version of the Duo Admin API. @@ -682,11 +699,45 @@ def get_telephony_log(self, mintime=0, api_version=1, **kwargs): if api_version not in [1,2]: raise ValueError("Invalid API Version") + + path = f"/admin/v{api_version}/logs/telephony" + + params = {} - if api_version == 2: - return Telephony.get_telephony_logs_v2(self.json_api_call, self.host, **kwargs) - return Telephony.get_telephony_logs_v1(self.json_api_call, self.host, mintime=mintime) + today = datetime.now(tz=timezone.utc) + # If mintime is not provided, the script defaults it to 180 days in past + mintime = int((today - timedelta(days=180)).timestamp() * 1000) if not mintime else mintime + params["mintime"] = f"{int(mintime)}" + + if api_version == 2: # Add additional parameters for API Version 2 + if limit > 1000: + limit = 1000 # Limit is capped at 1000 + params["limit"] = f"{int(limit)}" + + params["sort"] = 'desc' if sort.lower() == 'desc' else 'asc' + # if maxtime is not provided, the script defaults it to now + maxtime = int(today.timestamp() * 1000) - 120 if not maxtime else maxtime + params["maxtime"] = f"{int(maxtime)}" + if next_offset: + params["next_offset"] = next_offset + if account_id: + params["account_id"] = account_id + if filters: + params["filters"] = filters + + response = self.json_api_call("GET", path, params) + if api_version == 1: + for row in response: + row["eventtype"] = "telephony" + row["host"] = self.host + else: + for row in response["items"]: + row["eventtype"] = "telephony" + row["host"] = self.host + + return response + def get_users_iterator(self): """ Returns iterator of user objects. diff --git a/duo_client/logs/__init__.py b/duo_client/logs/__init__.py deleted file mode 100644 index 730060e1..00000000 --- a/duo_client/logs/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .telephony import Telephony - -__all__ = [ - 'Telephony' -] diff --git a/duo_client/logs/telephony.py b/duo_client/logs/telephony.py deleted file mode 100644 index 54abd3b9..00000000 --- a/duo_client/logs/telephony.py +++ /dev/null @@ -1,65 +0,0 @@ -from typing import Callable -from duo_client.util import ( - get_params_from_kwargs, - get_log_uri, - get_default_request_times, -) - -VALID_TELEPHONY_V2_REQUEST_PARAMS = [ - "filters", - "mintime", - "maxtime", - "limit", - "sort", - "next_offset", - "account_id", -] - -LOG_TYPE = "telephony" - - -class Telephony: - @staticmethod - def get_telephony_logs_v1(json_api_call: Callable, host: str, mintime=0): - # Sanity check mintime as unix timestamp, then transform to string - mintime = f"{int(mintime)}" - params = { - "mintime": mintime, - } - response = json_api_call( - "GET", - get_log_uri(LOG_TYPE, 1), - params, - ) - for row in response: - row["eventtype"] = LOG_TYPE - row["host"] = host - return response - - @staticmethod - def get_telephony_logs_v2(json_api_call: Callable, host: str, **kwargs): - params = {} - default_mintime, default_maxtime = get_default_request_times() - - params = get_params_from_kwargs(VALID_TELEPHONY_V2_REQUEST_PARAMS, **kwargs) - - if "mintime" not in params: - # If mintime is not provided, the script defaults it to 180 days in past - params["mintime"] = default_mintime - params["mintime"] = f"{int(params['mintime'])}" - if "maxtime" not in params: - # if maxtime is not provided, the script defaults it to now - params["maxtime"] = default_maxtime - params["maxtime"] = f"{int(params['maxtime'])}" - if "limit" in params: - params["limit"] = f"{int(params['limit'])}" - - response = json_api_call( - "GET", - get_log_uri(LOG_TYPE, 2), - params, - ) - for row in response["items"]: - row["eventtype"] = LOG_TYPE - row["host"] = host - return response diff --git a/duo_client/util.py b/duo_client/util.py deleted file mode 100644 index d8c4851a..00000000 --- a/duo_client/util.py +++ /dev/null @@ -1,23 +0,0 @@ -from typing import Dict, Sequence, Tuple -from datetime import datetime, timedelta, timezone - - -def get_params_from_kwargs(valid_params: Sequence[str], **kwargs) -> Dict: - params = {} - if isinstance(kwargs, dict): - kwargs = kwargs["kwargs"] if kwargs.get("kwargs") else kwargs - for k in kwargs: - if kwargs[k] is not None and k in valid_params: - params[k] = kwargs[k] - return params - - -def get_log_uri(log_type: str, version: int = 1) -> str: - return f"/admin/v{version}/logs/{log_type}" - - -def get_default_request_times() -> Tuple[int, int]: - today = datetime.now(tz=timezone.utc) - mintime = int((today - timedelta(days=180)).timestamp() * 1000) - maxtime = int(today.timestamp() * 1000) - 120 - return mintime, maxtime diff --git a/examples/Admin/log_examples.py b/examples/Admin/log_examples.py index e467d04c..43286ef8 100644 --- a/examples/Admin/log_examples.py +++ b/examples/Admin/log_examples.py @@ -79,7 +79,7 @@ def get_next_arg(prompt, default=None): ] ) if log_type == "telephony_v2": - telephony_logs = admin_api.get_telephony_log(api_version=2, kwargs=params) + telephony_logs = admin_api.get_telephony_log(api_version=2, **params) reporter.writerow(("telephony_id", "txid", "credits", "context", "phone", "type")) for log in telephony_logs["items"]: diff --git a/tests/admin/test_telephony.py b/tests/admin/test_telephony.py index a7087130..4a491c7c 100644 --- a/tests/admin/test_telephony.py +++ b/tests/admin/test_telephony.py @@ -33,6 +33,8 @@ def test_get_telephony_logs_v2_no_args(self): self.assertEqual(uri, "/admin/v2/logs/telephony") self.assertEqual(param_dict["mintime"], [expected_mintime]) self.assertEqual(param_dict["maxtime"], [expected_maxtime]) + self.assertAlmostEqual(param_dict["sort"], ["desc"]) + self.assertEqual(param_dict["limit"], ["100"]) @freeze_time("2022-10-01") def test_get_telephony_logs_v2_with_kwargs(self): @@ -40,15 +42,32 @@ def test_get_telephony_logs_v2_with_kwargs(self): expected_mintime = str(int(mintime.timestamp() * 1000)) maxtime = datetime(2022, 10, 1, 0, 0, 0, tzinfo=pytz.utc) expected_maxtime = str(int(maxtime.timestamp() * 1000) - 120) - params = {"mintime": expected_mintime} + params = {"mintime": expected_mintime, "sort": "asc", "limit": 900} response = self.items_response_client.get_telephony_log(api_version=2, - kwargs=params) + **params) uri, args = response["uri"].split("?") param_dict = util.params_to_dict(args) self.assertEqual(response["method"], "GET") self.assertEqual(uri, "/admin/v2/logs/telephony") self.assertEqual(param_dict["mintime"], [expected_mintime]) self.assertEqual(param_dict["maxtime"], [expected_maxtime]) + self.assertEqual(param_dict["sort"], ["asc"]) + self.assertEqual(param_dict["limit"], ["900"]) + + @freeze_time("2022-10-01") + def test_get_telephony_logs_v2_with_unsupported_kwargs(self): + params = { + "unsupported": "argument", + "non_existent": "argument" + } + response = self.items_response_client.get_telephony_log(api_version=2, + **params) + uri, args = response["uri"].split("?") + param_dict = util.params_to_dict(args) + self.assertEqual(response["method"], "GET") + self.assertEqual(uri, "/admin/v2/logs/telephony") + self.assertNotIn("unsupported", param_dict) + self.assertNotIn("non_existent", param_dict) @freeze_time("2022-10-01") def test_get_telephony_logs_v1_no_args(self): From 32ea2d8a64e2885f016dda3cb4461b90d4a579c8 Mon Sep 17 00:00:00 2001 From: Tre Wilkins <90734292+TreWilkins@users.noreply.github.com> Date: Fri, 14 Feb 2025 20:59:29 -0700 Subject: [PATCH 3/4] rename tests --- tests/admin/test_telephony.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/admin/test_telephony.py b/tests/admin/test_telephony.py index 4a491c7c..56c55397 100644 --- a/tests/admin/test_telephony.py +++ b/tests/admin/test_telephony.py @@ -37,7 +37,7 @@ def test_get_telephony_logs_v2_no_args(self): self.assertEqual(param_dict["limit"], ["100"]) @freeze_time("2022-10-01") - def test_get_telephony_logs_v2_with_kwargs(self): + def test_get_telephony_logs_v2_with_args(self): mintime = datetime(2022, 9, 1, 0, 0, 0, tzinfo=pytz.utc) expected_mintime = str(int(mintime.timestamp() * 1000)) maxtime = datetime(2022, 10, 1, 0, 0, 0, tzinfo=pytz.utc) @@ -55,7 +55,7 @@ def test_get_telephony_logs_v2_with_kwargs(self): self.assertEqual(param_dict["limit"], ["900"]) @freeze_time("2022-10-01") - def test_get_telephony_logs_v2_with_unsupported_kwargs(self): + def test_get_telephony_logs_v2_with_unsupported_args(self): params = { "unsupported": "argument", "non_existent": "argument" From 2a819f19723e67bfbb64a77607bfd3ead897a6f1 Mon Sep 17 00:00:00 2001 From: Tre Wilkins <90734292+TreWilkins@users.noreply.github.com> Date: Fri, 14 Feb 2025 22:22:52 -0700 Subject: [PATCH 4/4] Fix sort arg for v2, update v1 and v2 tests, default to ms for v2 and seconds for v1. --- duo_client/admin.py | 7 +++---- tests/admin/test_telephony.py | 26 +++++++++++++++++++++----- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/duo_client/admin.py b/duo_client/admin.py index e18b4a28..fdb195e7 100644 --- a/duo_client/admin.py +++ b/duo_client/admin.py @@ -706,7 +706,7 @@ def get_telephony_log(self, mintime=0, api_version=1, maxtime:int = 0, today = datetime.now(tz=timezone.utc) # If mintime is not provided, the script defaults it to 180 days in past - mintime = int((today - timedelta(days=180)).timestamp() * 1000) if not mintime else mintime + mintime = int((today - timedelta(days=180)).timestamp() * (1000 if api_version == 2 else 1)) if not mintime else mintime params["mintime"] = f"{int(mintime)}" if api_version == 2: # Add additional parameters for API Version 2 @@ -714,7 +714,7 @@ def get_telephony_log(self, mintime=0, api_version=1, maxtime:int = 0, limit = 1000 # Limit is capped at 1000 params["limit"] = f"{int(limit)}" - params["sort"] = 'desc' if sort.lower() == 'desc' else 'asc' + params["sort"] = 'ts:desc' if sort.lower() == 'desc' else 'ts:asc' # if maxtime is not provided, the script defaults it to now maxtime = int(today.timestamp() * 1000) - 120 if not maxtime else maxtime params["maxtime"] = f"{int(maxtime)}" @@ -724,7 +724,6 @@ def get_telephony_log(self, mintime=0, api_version=1, maxtime:int = 0, params["account_id"] = account_id if filters: params["filters"] = filters - response = self.json_api_call("GET", path, params) if api_version == 1: @@ -3842,4 +3841,4 @@ def set_telephony_credits(self, credits): } return self.json_api_call('POST', '/admin/v1/billing/telephony_credits', - params) + params) \ No newline at end of file diff --git a/tests/admin/test_telephony.py b/tests/admin/test_telephony.py index 56c55397..5fdc9b26 100644 --- a/tests/admin/test_telephony.py +++ b/tests/admin/test_telephony.py @@ -33,7 +33,7 @@ def test_get_telephony_logs_v2_no_args(self): self.assertEqual(uri, "/admin/v2/logs/telephony") self.assertEqual(param_dict["mintime"], [expected_mintime]) self.assertEqual(param_dict["maxtime"], [expected_maxtime]) - self.assertAlmostEqual(param_dict["sort"], ["desc"]) + self.assertAlmostEqual(param_dict["sort"], ["ts:desc"]) self.assertEqual(param_dict["limit"], ["100"]) @freeze_time("2022-10-01") @@ -51,7 +51,7 @@ def test_get_telephony_logs_v2_with_args(self): self.assertEqual(uri, "/admin/v2/logs/telephony") self.assertEqual(param_dict["mintime"], [expected_mintime]) self.assertEqual(param_dict["maxtime"], [expected_maxtime]) - self.assertEqual(param_dict["sort"], ["asc"]) + self.assertEqual(param_dict["sort"], ["ts:asc"]) self.assertEqual(param_dict["limit"], ["900"]) @freeze_time("2022-10-01") @@ -77,14 +77,30 @@ def test_get_telephony_logs_v1_no_args(self): self.assertEqual(uri, "/admin/v1/logs/telephony") @freeze_time("2022-10-01") - def test_get_telephony_logs_v1_with_mintime_arg(self): + def test_get_telephony_logs_v1_with_args(self): freezed_time = datetime(2022, 9, 1, 0, 0, 0, tzinfo=pytz.utc) expected_mintime = str( - int((freezed_time - timedelta(days=180)).timestamp() * 1000) + int((freezed_time - timedelta(days=180)).timestamp()) ) response = self.client_list.get_telephony_log(mintime=expected_mintime) uri, args = response[0]["uri"].split("?") param_dict = util.params_to_dict(args) self.assertEqual(response[0]["method"], "GET") self.assertEqual(uri, "/admin/v1/logs/telephony") - self.assertEqual(param_dict["mintime"], [expected_mintime]) \ No newline at end of file + self.assertEqual(param_dict["mintime"], [expected_mintime]) + + @freeze_time("2022-10-01") + def test_get_telephony_logs_v1_ignore_v2_args(self): + freezed_time = datetime(2022, 9, 1, 0, 0, 0, tzinfo=pytz.utc) + expected_mintime = str( + int((freezed_time - timedelta(days=180)).timestamp()) + ) + params = {"mintime": expected_mintime, "limit": 20, "sort": "ts:asc"} + response = self.client_list.get_telephony_log(**params) + uri, args = response[0]["uri"].split("?") + param_dict = util.params_to_dict(args) + self.assertEqual(response[0]["method"], "GET") + self.assertEqual(uri, "/admin/v1/logs/telephony") + self.assertEqual(param_dict["mintime"], [expected_mintime]) + self.assertNotIn(param_dict["limit"], ["limit"]) + self.assertNotIn(param_dict["sort"], ["sort"]) \ No newline at end of file