From 3f0b8df5750c043a5ce243afc1c1b96fb6f9e00b Mon Sep 17 00:00:00 2001 From: Jordan Prescott Date: Tue, 18 Jun 2024 16:02:06 +0100 Subject: [PATCH 1/5] feature start --- odins_spear/methods/get.py | 30 +++++++++++++++ odins_spear/reporter.py | 21 ++++++++++ odins_spear/reports/__init__.py | 4 +- .../reports/group_users_call_statistics.py | 37 ++++++++++++++++++ .../reports/report_utils/graphviz_module.py | 1 - .../reports/report_utils/report_entities.py | 38 ++++++++++++++++++- odins_spear/utils/formatting.py | 2 +- 7 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 odins_spear/reports/group_users_call_statistics.py diff --git a/odins_spear/methods/get.py b/odins_spear/methods/get.py index b8d254f..7f5c80d 100644 --- a/odins_spear/methods/get.py +++ b/odins_spear/methods/get.py @@ -229,6 +229,36 @@ def call_pickup_group_user(self, service_provider_id, group_id, user_id): # CALL PROCESSING POLICIES # CALL RECORDING # CALL RECORDS + + def users_stats(self, user_id: str, start_date:str, end_date: str = None, + start_time: str = "00:00:00", end_time:str = "23:59:59", time_zone: str = "Z"): + """Pulls a single users call statistics for a specified period of time. + + Args: + user_id (str): Target user ID you would like to pull call statistics for. + start_date (str): Start date of desired time period. Date must follow format 'YYYY-MM-DD' + end_date (str, optional): End date of desired time period. Date must follow format 'YYYY-MM-DD'.\ + If this date is the same as Start date you do not need this parameter. Defaults to None. + start_time (_type_, optional): Start time of desired time period. Time must follow formate 'HH:MM:SS'. \ + If you do not need to filter by time and want the whole day leave this parameter. Defaults to "00:00:00". + end_time (_type_, optional): End time of desired time period. Time must follow formate 'HH:MM:SS'. \ + If you do not need to filter by time and want the whole day leave this parameter. Defaults to "23:59:59". + time_zone (str, optional): A specified time you would like to see call records in. \ + Time zone must follow format 'GMT', 'EST', 'PST'. Defaults to "Z" (UTC Time Zone). + + Returns: + Dict: Users call record statistics for specified time period. + """ + + # checks if end_date has been left and therefore we assume user wants same date. + if not end_date: + end_date = start_date + + endpoint = f"/users/call-records/stats?userIds={user_id}&startTime={start_date}T{start_time}{time_zone} \ + &endTime={end_date}T{end_time}{time_zone}" + + return self.requester.get(endpoint) + # CALL TRANSFER # CALL WAITING # CALLING LINE ID BLOCKING OVERRIDE diff --git a/odins_spear/reporter.py b/odins_spear/reporter.py index 7c2f44e..e7cab1e 100644 --- a/odins_spear/reporter.py +++ b/odins_spear/reporter.py @@ -24,5 +24,26 @@ def call_flow(self, service_provider_id: str, group_id: str, number: str, number return reports.call_flow.main(self.api, service_provider_id, group_id, number, number_type, broadworks_entity_type) + + def group_users_call_statistics(self, service_provider_id: str, group_id: str, + start_date:str, end_date: str = None, + start_time: str = "00:00:00", end_time:str = "23:59:59", + time_zone: str = "Z"): + """_summary_ + + Args: + service_provider_id (str): _description_ + group_id (str): _description_ + start_date (str): _description_ + end_date (str, optional): _description_. Defaults to None. + start_time (_type_, optional): _description_. Defaults to "00:00:00". + end_time (_type_, optional): _description_. Defaults to "23:59:59". + time_zone (str, optional): _description_. Defaults to "Z". + + Returns: + _type_: _description_ + """ + return reports.group_users_call_statistics.main(self.api, service_provider_id, group_id, + start_date, end_date, start_time, end_time, time_zone) \ No newline at end of file diff --git a/odins_spear/reports/__init__.py b/odins_spear/reports/__init__.py index 16c4b6b..d392d68 100644 --- a/odins_spear/reports/__init__.py +++ b/odins_spear/reports/__init__.py @@ -1,5 +1,7 @@ __all__ = [ - "call_flow" + "call_flow", + "group_users_call_statistics" ] from .call_flow import main +from .group_users_call_statistics import main diff --git a/odins_spear/reports/group_users_call_statistics.py b/odins_spear/reports/group_users_call_statistics.py new file mode 100644 index 0000000..47b6055 --- /dev/null +++ b/odins_spear/reports/group_users_call_statistics.py @@ -0,0 +1,37 @@ +import csv + +from .report_utils.report_entities import call_records_statistics + +def main(api: object, service_provider_id: str, group_id: str, + start_date:str, end_date: str = None, start_time: str = "00:00:00", + end_time:str = "23:59:59", time_zone: str = "Z"): + + group_users_statistics = [] + users = api.get.users(service_provider_id, group_id) + + for user in users: + user_statistics = api.get.users_stats( + user["userId"], + start_date, + end_date, + start_time, + end_time, + time_zone + ) + + # Correction for API removing userId if no calls made by user + if user_statistics["userId"] == None: + user_statistics["userId"] = user["userId"] + + user_statistic_record = call_records_statistics.from_dict(user_statistics) + group_users_statistics.append(user_statistic_record) + + file_name = f"./os_reports/{group_id} User Call Statistics - {start_date} to {end_date}" + + with open(file_name, mode="w", newline="") as file: + writer = csv.DictWriter(file, fieldnames=group_users_statistics[0].keys()) + writer.writeheader() + + for user in group_users_statistics: + writer.writerow(user) + \ No newline at end of file diff --git a/odins_spear/reports/report_utils/graphviz_module.py b/odins_spear/reports/report_utils/graphviz_module.py index a9c9233..07a4af6 100644 --- a/odins_spear/reports/report_utils/graphviz_module.py +++ b/odins_spear/reports/report_utils/graphviz_module.py @@ -1,7 +1,6 @@ import graphviz from odins_spear.store import broadwork_entities as bre -from .report_entities import external_number class GraphvizModule: diff --git a/odins_spear/reports/report_utils/report_entities.py b/odins_spear/reports/report_utils/report_entities.py index 94375cb..b50f424 100644 --- a/odins_spear/reports/report_utils/report_entities.py +++ b/odins_spear/reports/report_utils/report_entities.py @@ -1,5 +1,5 @@ from dataclasses import dataclass, field -from typing import List, Type +from typing import List @dataclass class call_flow: @@ -9,4 +9,38 @@ class call_flow: @dataclass class external_number: - id: str \ No newline at end of file + id: str + +@dataclass +class call_records_statistics: + userId: str + total: int + totalAnsweredAndMissed: str + answeredTotal: str + missedTotal: str + busyTotal: str + redirectTotal: str + receivedTotal: str + receivedMissed: str + receivedAnswered: str + placedTotal: str + placedMissed: str + placedAnswered: str + + @classmethod + def from_dict(cls, data): + return cls( + userId= data.get("userId"), + total= data.get("total"), + totalAnsweredAndMissed= str(data.get("totalAnsweredAndMissed")), + answeredTotal= data.get("answeredTotal"), + missedTotal= data.get("missedTotal"), + busyTotal= data.get("busyTotal"), + redirectTotal= data.get("redirectTotal"), + receivedTotal= data.get("receivedTotal"), + receivedMissed= data.get("receivedMissed"), + receivedAnswered= data.get("receivedAnswered"), + placedTotal= data.get("placedTotal"), + placedMissed= data.get("placedMissed"), + placedAnswered= data.get("placedAnswered") + ) diff --git a/odins_spear/utils/formatting.py b/odins_spear/utils/formatting.py index 57708b7..c62279c 100644 --- a/odins_spear/utils/formatting.py +++ b/odins_spear/utils/formatting.py @@ -21,7 +21,7 @@ def format_filter(filter, type, value): elif type.lower() == "contains": return f"{filter}=*{value}*" else: - raise OAUnsupportedFilter + raise OSUnsupportedFilter def format_int_list_of_numbers(counrty_code: int, numbers: list): From 6e076f27b72759598a35d913a0cd36f98bc96a5f Mon Sep 17 00:00:00 2001 From: Jordan Prescott Date: Wed, 19 Jun 2024 16:01:17 +0100 Subject: [PATCH 2/5] v1 now spitting out a working csv - Big sites are taking an hour to run feature - The stats this writes don't fully make sense - need to clarify --- .../reports/group_users_call_statistics.py | 32 ++++++++++++++----- .../reports/report_utils/report_entities.py | 13 ++++++-- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/odins_spear/reports/group_users_call_statistics.py b/odins_spear/reports/group_users_call_statistics.py index 47b6055..5bf8f21 100644 --- a/odins_spear/reports/group_users_call_statistics.py +++ b/odins_spear/reports/group_users_call_statistics.py @@ -1,4 +1,7 @@ import csv +import os + +from tqdm import tqdm from .report_utils.report_entities import call_records_statistics @@ -6,10 +9,14 @@ def main(api: object, service_provider_id: str, group_id: str, start_date:str, end_date: str = None, start_time: str = "00:00:00", end_time:str = "23:59:59", time_zone: str = "Z"): + print("\nStart.") + group_users_statistics = [] + + print(f"Fetching list of users in {group_id}.") users = api.get.users(service_provider_id, group_id) - for user in users: + for user in tqdm(users, "Fetching individual stats for each user. This may take several minutes."): user_statistics = api.get.users_stats( user["userId"], start_date, @@ -20,18 +27,27 @@ def main(api: object, service_provider_id: str, group_id: str, ) # Correction for API removing userId if no calls made by user - if user_statistics["userId"] == None: + if user_statistics["userId"] is None: user_statistics["userId"] = user["userId"] - user_statistic_record = call_records_statistics.from_dict(user_statistics) + user_statistic_record = call_records_statistics.from_dict(user["extension"], user_statistics) group_users_statistics.append(user_statistic_record) - - file_name = f"./os_reports/{group_id} User Call Statistics - {start_date} to {end_date}" - + + output_directory = "./os_reports" + file_name = os.path.join(output_directory, f"{group_id} User Call Statistics - {start_date} to {end_date}.csv") + + # Ensure the directory exists + os.makedirs(output_directory, exist_ok=True) + with open(file_name, mode="w", newline="") as file: - writer = csv.DictWriter(file, fieldnames=group_users_statistics[0].keys()) + + fieldnames = [field.name for field in call_records_statistics.__dataclass_fields__.values()] + + writer = csv.DictWriter(file, fieldnames=fieldnames) writer.writeheader() for user in group_users_statistics: - writer.writerow(user) + writer.writerow(user.__dict__) + + print("\nEnd.") \ No newline at end of file diff --git a/odins_spear/reports/report_utils/report_entities.py b/odins_spear/reports/report_utils/report_entities.py index b50f424..6a33d31 100644 --- a/odins_spear/reports/report_utils/report_entities.py +++ b/odins_spear/reports/report_utils/report_entities.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass, field, fields from typing import List @dataclass @@ -13,6 +13,7 @@ class external_number: @dataclass class call_records_statistics: + extension: str userId: str total: int totalAnsweredAndMissed: str @@ -26,10 +27,18 @@ class call_records_statistics: placedTotal: str placedMissed: str placedAnswered: str + + def __post_init__(self): + for field in fields(self): + value = getattr(self, field.name) + # Replace None with 0 + if value is None: + setattr(self, field.name, 0) @classmethod - def from_dict(cls, data): + def from_dict(cls, extension, data): return cls( + extension = extension, userId= data.get("userId"), total= data.get("total"), totalAnsweredAndMissed= str(data.get("totalAnsweredAndMissed")), From 5e401e1fe5e991818591464616e869348c620931 Mon Sep 17 00:00:00 2001 From: Jordan Prescott Date: Thu, 20 Jun 2024 16:14:55 +0100 Subject: [PATCH 3/5] Adding in os media to each but moving file is failing --- odins_spear/assets/images/made_with_os.png | Bin 0 -> 41524 bytes .../reports/group_users_call_statistics.py | 56 +++++----- .../reports/report_utils/file_manager.py | 96 ++++++++++++++++++ .../reports/report_utils/report_entities.py | 2 +- 4 files changed, 126 insertions(+), 28 deletions(-) create mode 100644 odins_spear/assets/images/made_with_os.png create mode 100644 odins_spear/reports/report_utils/file_manager.py diff --git a/odins_spear/assets/images/made_with_os.png b/odins_spear/assets/images/made_with_os.png new file mode 100644 index 0000000000000000000000000000000000000000..24635c997491872b9bfc3cdbaa267ba45eaabbef GIT binary patch literal 41524 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 bool: + + if os.path.exists(directory_file_path): + return True + + return False ##TODO: return error or notify in someway + + +def json_fie_to_dict(file_path: str) -> dict: + """Takes path to JSON file and loads file into Python Dict Object. + + :param path: Path to file that will be loaded into Python Dict Object. + :return: Python Dict object. + """ + if check_directory_or_file_exists(file_path): + with open(file_path, 'r') as data: + return json.loads(data.read()) + + return False #TODO: return error or notify in someway + + +def make_directory(directory_path) -> bool: + """takes in a path and creates a new directory if dir does not already exist + + :param path: Path to directory. + :return: Returns True to indicate complete. + """ + + if not os.path.exists(directory_path): + os.mkdir(directory_path) + return True + + return False ##TODO: return error or notify in someway + + +def copy_all_directorys_files_to_target(source_dir, target_dir) -> bool: + """takes in source directory and new target directory which will copy all files from source, create new target + directory and paste all files into new directory. + + :param source_dir: Directory where source files are located. + :param target_dir: Directory which will be created and source files copied to. + :return: Returns True to indicate complete. + """ + + if check_directory_or_file_exists(source_dir) and check_directory_or_file_exists(target_dir): + shutil.copytree(source_dir, target_dir) + return True + + return False #TODO: return error or notify in someway + + +def copy_single_file_to_target_directory(source_dir, target_dir, file_name) -> bool: + """Copies a single targeted file from the source directory to the specified target directory. + + :param source_dir: Directory where the source file is located. + :param target_dir: Directory where the file will be copied to. + :param file_name: Name of the file to be copied. + :return: Returns True to indicate the file was successfully copied. + """ + + source_dir = os.path.normpath(source_dir) + + # Construct the full paths using os.path.join and normalize them + source_file = os.path.normpath(os.path.join(source_dir, file_name)) + target_file = os.path.normpath(os.path.join(target_dir, file_name)) + + if os.path.isfile(source_file): + # Create the target directory if it does not exist + os.makedirs(target_dir, exist_ok=True) + + # Copy the file to the target directory + shutil.copy2(source_file, target_file) + return True + else: + print(f"Source file {source_file} does not exist.") + return False + + return False # Return False if the source file does not exist + + +def remove_directory(directory_path) -> bool: + """takes in path to directory which will be removed if found and path given is a direactory. + If path given an error will be raised to indicate path cant be found. + """ + + if check_directory_or_file_exists(directory_path) and os.path.isdir(directory_path): + shutil.rmtree(directory_path) + return True + + return False \ No newline at end of file diff --git a/odins_spear/reports/report_utils/report_entities.py b/odins_spear/reports/report_utils/report_entities.py index 6a33d31..5fec6e2 100644 --- a/odins_spear/reports/report_utils/report_entities.py +++ b/odins_spear/reports/report_utils/report_entities.py @@ -32,7 +32,7 @@ def __post_init__(self): for field in fields(self): value = getattr(self, field.name) # Replace None with 0 - if value is None: + if value is None or value == 'None': setattr(self, field.name, 0) @classmethod From ccfded9a778425370ae9894d03aa83d353407b58 Mon Sep 17 00:00:00 2001 From: Jordan Prescott Date: Fri, 21 Jun 2024 10:55:16 +0100 Subject: [PATCH 4/5] v1 of feature complete - Feature complete - Added file_manager in report utils --- .../reports/group_users_call_statistics.py | 89 ++++++++----- .../reports/report_utils/file_manager.py | 123 ++++++++++++------ .../reports/report_utils/report_entities.py | 2 +- 3 files changed, 142 insertions(+), 72 deletions(-) diff --git a/odins_spear/reports/group_users_call_statistics.py b/odins_spear/reports/group_users_call_statistics.py index f2fb142..5128f2f 100644 --- a/odins_spear/reports/group_users_call_statistics.py +++ b/odins_spear/reports/group_users_call_statistics.py @@ -9,47 +9,70 @@ def main(api: object, service_provider_id: str, group_id: str, start_date:str, end_date: str = None, start_time: str = "00:00:00", end_time:str = "23:59:59", time_zone: str = "Z"): + """Generates a CSV deatiling each users incoming and outgoing call statistics over + a specified period for a single group. Each row contains user extension, user ID, and call stats. + + Args: + service_provider_id (str): Service Provider/ Enterprise where group is hosted. + group_id (str): Target Group you would like to know user statistics for. + start_date (str): Start date of desired time period. Date must follow format 'YYYY-MM-DD' + end_date (str, optional): End date of desired time period. Date must follow format 'YYYY-MM-DD'.\ + If this date is the same as Start date you do not need this parameter. Defaults to None. + start_time (_type_, optional): Start time of desired time period. Time must follow formate 'HH:MM:SS'. \ + If you do not need to filter by time and want the whole day leave this parameter. Defaults to "00:00:00". + end_time (_type_, optional): End time of desired time period. Time must follow formate 'HH:MM:SS'. \ + If you do not need to filter by time and want the whole day leave this parameter. Defaults to "23:59:59". + time_zone (str, optional): A specified time you would like to see call records in. \ + """ print("\nStart.") + # List of report_entities.call_records_statistics group_users_statistics = [] - # print(f"Fetching list of users in {group_id}.") - # users = api.get.users(service_provider_id, group_id) - - # for user in tqdm(users, "Fetching individual stats for each user. This may take several minutes."): - # user_statistics = api.get.users_stats( - # user["userId"], - # start_date, - # end_date, - # start_time, - # end_time, - # time_zone - # ) - - # # Correction for API removing userId if no calls made by user - # if user_statistics["userId"] is None: - # user_statistics["userId"] = user["userId"] + print(f"Fetching list of users in {group_id}.") + + # Fetches complete list of users in group + users = api.get.users(service_provider_id, group_id) + + # Pulls stats for each user, instantiates call_records_statistics, and append to group_users_statistics + for user in tqdm(users, "Fetching individual stats for each user. This may take several minutes"): + user_statistics = api.get.users_stats( + user["userId"], + start_date, + end_date, + start_time, + end_time, + time_zone + ) - # user_statistic_record = call_records_statistics.from_dict(user["extension"], user_statistics) - # group_users_statistics.append(user_statistic_record) - - # output_directory = "./os_reports" - # file_name = os.path.join(output_directory, f"{group_id} User Call Statistics - {start_date} to {end_date}.csv") - - # # Ensure the directory exists - # os.makedirs(output_directory, exist_ok=True) - - # with open(file_name, mode="w", newline="") as file: + # Correction for API removing userId if no calls made by user + if user_statistics["userId"] is None: + user_statistics["userId"] = user["userId"] - # fieldnames = [field.name for field in call_records_statistics.__dataclass_fields__.values()] + user_statistic_record = call_records_statistics.from_dict(user["extension"], user_statistics) + group_users_statistics.append(user_statistic_record) - # writer = csv.DictWriter(file, fieldnames=fieldnames) - # writer.writeheader() + # replace none with 0 if data returns None. Output is better if 0 allows user to make use of data better + for record in tqdm(group_users_statistics, "Formatting individual stats for each user"): + record.replace_none_with_0() + + output_directory = "./os_reports" + file_name = os.path.join(output_directory, f"{group_id} User Call Statistics - {start_date} to {end_date}.csv") + + # Ensure the directory exists + os.makedirs(output_directory, exist_ok=True) + + # Write statistics to csv + with open(file_name, mode="w", newline="") as file: + fieldnames = [field.name for field in call_records_statistics.__dataclass_fields__.values()] + writer = csv.DictWriter(file, fieldnames=fieldnames) + writer.writeheader() - # for user in group_users_statistics: - # writer.writerow(user.__dict__) - - copy_single_file_to_target_directory("./assets/images", "./os_reports", "made_with_os.png") + for user in group_users_statistics: + writer.writerow(user.__dict__) + + # Add made_with_os.png for output + copy_single_file_to_target_directory("./odins_spear/assets/images/", "./os_reports/", "made_with_os.png") print("\nEnd.") \ No newline at end of file diff --git a/odins_spear/reports/report_utils/file_manager.py b/odins_spear/reports/report_utils/file_manager.py index b65b819..14f512c 100644 --- a/odins_spear/reports/report_utils/file_manager.py +++ b/odins_spear/reports/report_utils/file_manager.py @@ -3,90 +3,137 @@ import shutil +from odins_spear.exceptions import OSFileNotFound + def check_directory_or_file_exists(directory_file_path: str) -> bool: + """Checks if a directory or file exists. + + Args: + directory_file_path (str): Path to the directory or file. + + Returns: + bool: If path exists return True else False. + """ if os.path.exists(directory_file_path): return True - return False ##TODO: return error or notify in someway + return False + + +def join_path(directory: str, file_name: str) -> str: + """Using os.path this method joins directory with file name and normalises path + for the OS running on. + + Args: + directory (str): Directory path. + file_name (str): Name of file. + + Returns: + str: Returns normalised string of joined path. + """ + + return os.path.normpath(os.path.join(directory, file_name)) def json_fie_to_dict(file_path: str) -> dict: - """Takes path to JSON file and loads file into Python Dict Object. + """Loads a json file into code as Python dict. + + Args: + file_path (str): Path to json file including file name. - :param path: Path to file that will be loaded into Python Dict Object. - :return: Python Dict object. + Returns: + dict: Python dict of json file. Returns False if file not found. + + Raises: + OSFileNotFound: Raised when file cant found. """ + if check_directory_or_file_exists(file_path): with open(file_path, 'r') as data: return json.loads(data.read()) - return False #TODO: return error or notify in someway + return OSFileNotFound -def make_directory(directory_path) -> bool: - """takes in a path and creates a new directory if dir does not already exist +def make_directory(directory_path: str) -> None: + """Checks if directory already exists if not it will create it. - :param path: Path to directory. - :return: Returns True to indicate complete. - """ + Args: + directory_path (str): Path to target directory. - if not os.path.exists(directory_path): - os.mkdir(directory_path) - return True - - return False ##TODO: return error or notify in someway + Returns: + None: Function builds directory. + """ + + return os.makedirs(directory_path, exist_ok=True) + def copy_all_directorys_files_to_target(source_dir, target_dir) -> bool: - """takes in source directory and new target directory which will copy all files from source, create new target - directory and paste all files into new directory. + """ + + Args: + source_dir (_type_): _description_ + target_dir (_type_): _description_ - :param source_dir: Directory where source files are located. - :param target_dir: Directory which will be created and source files copied to. - :return: Returns True to indicate complete. + Returns: + bool: Returns True if operation succeeded. + + Raises: + OSFileNotFound: Raised when source directoty can't be found. """ - if check_directory_or_file_exists(source_dir) and check_directory_or_file_exists(target_dir): + if check_directory_or_file_exists(source_dir): + # Check if target dir exists if not build it + make_directory(target_dir) + + # Copy files shutil.copytree(source_dir, target_dir) return True - return False #TODO: return error or notify in someway + return OSFileNotFound -def copy_single_file_to_target_directory(source_dir, target_dir, file_name) -> bool: - """Copies a single targeted file from the source directory to the specified target directory. +def copy_single_file_to_target_directory(source_dir: str, target_dir: str, file_name: str) -> bool: + """Copies a single targeted file from a source directory to a target directory. - :param source_dir: Directory where the source file is located. - :param target_dir: Directory where the file will be copied to. - :param file_name: Name of the file to be copied. - :return: Returns True to indicate the file was successfully copied. - """ + Args: + source_dir (str): Source directory path where target file is located. + target_dir (str): Target directory path where target file is to be copied to. + file_name (str): Name of source file + + Returns: + bool: Returns True if operation succeeded. - source_dir = os.path.normpath(source_dir) + Raises: + OSFileNotFound: Raised when source target file can't be found. + """ # Construct the full paths using os.path.join and normalize them source_file = os.path.normpath(os.path.join(source_dir, file_name)) target_file = os.path.normpath(os.path.join(target_dir, file_name)) - if os.path.isfile(source_file): + if check_directory_or_file_exists(source_file): # Create the target directory if it does not exist os.makedirs(target_dir, exist_ok=True) # Copy the file to the target directory shutil.copy2(source_file, target_file) return True - else: - print(f"Source file {source_file} does not exist.") - return False - return False # Return False if the source file does not exist + return OSFileNotFound + + +def remove_directory(directory_path: str) -> bool: + """Check if file exists and type is directory and if both are true the directory is removed. + Args: + directory_path (str): Path to target directory to be removed. -def remove_directory(directory_path) -> bool: - """takes in path to directory which will be removed if found and path given is a direactory. - If path given an error will be raised to indicate path cant be found. + Returns: + bool: _description_ """ if check_directory_or_file_exists(directory_path) and os.path.isdir(directory_path): diff --git a/odins_spear/reports/report_utils/report_entities.py b/odins_spear/reports/report_utils/report_entities.py index 5fec6e2..a74f297 100644 --- a/odins_spear/reports/report_utils/report_entities.py +++ b/odins_spear/reports/report_utils/report_entities.py @@ -28,7 +28,7 @@ class call_records_statistics: placedMissed: str placedAnswered: str - def __post_init__(self): + def replace_none_with_0(self): for field in fields(self): value = getattr(self, field.name) # Replace None with 0 From 01838cf8d93f671dd23ff1195c085d46fa637af5 Mon Sep 17 00:00:00 2001 From: Jordan Prescott Date: Fri, 21 Jun 2024 11:02:28 +0100 Subject: [PATCH 5/5] updated for max request. --- odins_spear/methods/get.py | 4 ++-- odins_spear/reporter.py | 24 ++++++++++--------- .../reports/group_users_call_statistics.py | 4 ++-- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/odins_spear/methods/get.py b/odins_spear/methods/get.py index 7f5c80d..dac93dc 100644 --- a/odins_spear/methods/get.py +++ b/odins_spear/methods/get.py @@ -240,9 +240,9 @@ def users_stats(self, user_id: str, start_date:str, end_date: str = None, end_date (str, optional): End date of desired time period. Date must follow format 'YYYY-MM-DD'.\ If this date is the same as Start date you do not need this parameter. Defaults to None. start_time (_type_, optional): Start time of desired time period. Time must follow formate 'HH:MM:SS'. \ - If you do not need to filter by time and want the whole day leave this parameter. Defaults to "00:00:00". + If you do not need to filter by time and want the whole day leave this parameter. Defaults to "00:00:00". MAX Request is 3 months. end_time (_type_, optional): End time of desired time period. Time must follow formate 'HH:MM:SS'. \ - If you do not need to filter by time and want the whole day leave this parameter. Defaults to "23:59:59". + If you do not need to filter by time and want the whole day leave this parameter. Defaults to "23:59:59". MAX Request is 3 months. time_zone (str, optional): A specified time you would like to see call records in. \ Time zone must follow format 'GMT', 'EST', 'PST'. Defaults to "Z" (UTC Time Zone). diff --git a/odins_spear/reporter.py b/odins_spear/reporter.py index e7cab1e..9afd31d 100644 --- a/odins_spear/reporter.py +++ b/odins_spear/reporter.py @@ -29,19 +29,21 @@ def group_users_call_statistics(self, service_provider_id: str, group_id: str, start_date:str, end_date: str = None, start_time: str = "00:00:00", end_time:str = "23:59:59", time_zone: str = "Z"): - """_summary_ + """Generates a CSV deatiling each users incoming and outgoing call statistics over + a specified period for a single group. Each row contains user extension, user ID, and call stats. Args: - service_provider_id (str): _description_ - group_id (str): _description_ - start_date (str): _description_ - end_date (str, optional): _description_. Defaults to None. - start_time (_type_, optional): _description_. Defaults to "00:00:00". - end_time (_type_, optional): _description_. Defaults to "23:59:59". - time_zone (str, optional): _description_. Defaults to "Z". - - Returns: - _type_: _description_ + service_provider_id (str): Service Provider/ Enterprise where group is hosted. + group_id (str): Target Group you would like to know user statistics for. + start_date (str): Start date of desired time period. Date must follow format 'YYYY-MM-DD' + end_date (str, optional): End date of desired time period. Date must follow format 'YYYY-MM-DD'.\ + If this date is the same as Start date you do not need this parameter. Defaults to None. + start_time (_type_, optional): Start time of desired time period. Time must follow formate 'HH:MM:SS'. \ + If you do not need to filter by time and want the whole day leave this parameter. Defaults to "00:00:00". MAX Request is 3 months. + end_time (_type_, optional): End time of desired time period. Time must follow formate 'HH:MM:SS'. \ + If you do not need to filter by time and want the whole day leave this parameter. Defaults to "23:59:59". MAX Request is 3 months. + time_zone (str, optional): A specified time you would like to see call records in. \ + Time zone must follow format 'GMT', 'EST', 'PST'. Defaults to "Z" (UTC Time Zone). """ return reports.group_users_call_statistics.main(self.api, service_provider_id, group_id, start_date, end_date, start_time, end_time, time_zone) diff --git a/odins_spear/reports/group_users_call_statistics.py b/odins_spear/reports/group_users_call_statistics.py index 5128f2f..fcdde03 100644 --- a/odins_spear/reports/group_users_call_statistics.py +++ b/odins_spear/reports/group_users_call_statistics.py @@ -19,9 +19,9 @@ def main(api: object, service_provider_id: str, group_id: str, end_date (str, optional): End date of desired time period. Date must follow format 'YYYY-MM-DD'.\ If this date is the same as Start date you do not need this parameter. Defaults to None. start_time (_type_, optional): Start time of desired time period. Time must follow formate 'HH:MM:SS'. \ - If you do not need to filter by time and want the whole day leave this parameter. Defaults to "00:00:00". + If you do not need to filter by time and want the whole day leave this parameter. Defaults to "00:00:00". MAX Request is 3 months. end_time (_type_, optional): End time of desired time period. Time must follow formate 'HH:MM:SS'. \ - If you do not need to filter by time and want the whole day leave this parameter. Defaults to "23:59:59". + If you do not need to filter by time and want the whole day leave this parameter. Defaults to "23:59:59". MAX Request is 3 months. time_zone (str, optional): A specified time you would like to see call records in. \ """