diff --git a/app/integrations/google_workspace/google_drive.py b/app/integrations/google_workspace/google_drive.py index cf40e982..c18cae39 100644 --- a/app/integrations/google_workspace/google_drive.py +++ b/app/integrations/google_workspace/google_drive.py @@ -198,14 +198,26 @@ def create_file(name, folder, file_type): "create", body={"name": name, "parents": [folder], "mimeType": mime_type_value}, supportsAllDrives=True, - fields="id", + fields="id, name", ) - return result["id"] + return result + + +@handle_google_api_errors +def get_file_by_id(id): + return execute_google_api_call( + "drive", + "v3", + "files", + "get", + fileId=id, + supportsAllDrives=True, + ) @handle_google_api_errors -def get_file_by_name(name, folder_id=None): +def find_files_by_name(name, folder_id=None): """Get a file by name in a specific Google Drive folder. This function requires the caller to have the necessary permissions to access the file in Google Workspace. diff --git a/app/integrations/google_workspace/google_service.py b/app/integrations/google_workspace/google_service.py index f473a444..78e49aa1 100644 --- a/app/integrations/google_workspace/google_service.py +++ b/app/integrations/google_workspace/google_service.py @@ -86,6 +86,7 @@ def handle_google_api_errors(func: Callable[..., Any]) -> Callable[..., Any]: def wrapper(*args: Any, **kwargs: Any) -> Any: non_critical_errors = { "get_user": ["timed out"], + "get_sheet": ["Unable to parse range"], } argument_string = ", ".join( [str(arg) for arg in args] + [f"{k}={v}" for k, v in kwargs.items()] @@ -103,10 +104,19 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: ) return result except HttpError as e: - logging.error( - f"An HTTP error occurred in function '{func.__module__}:{func.__name__}': {e}" - ) - raise e + message = str(e) + func_name = func.__name__ + if func_name in non_critical_errors and any( + error in message for error in non_critical_errors[func_name] + ): + logging.warning( + f"A non critical error occurred in function '{func.__module__}:{func.__name__}{argument_string}': {e}" + ) + else: + logging.error( + f"An HTTP error occurred in function '{func.__module__}:{func.__name__}': {e}" + ) + raise e except ValueError as e: logging.error( f"A ValueError occurred in function '{func.__module__}:{func.__name__}': {e}" diff --git a/app/integrations/google_workspace/sheets.py b/app/integrations/google_workspace/sheets.py index fa8f364b..ef23777c 100644 --- a/app/integrations/google_workspace/sheets.py +++ b/app/integrations/google_workspace/sheets.py @@ -30,6 +30,50 @@ def get_values(spreadsheetId: str, range: str | None = None, fields=None): ) +@handle_google_api_errors +def get_sheet(spreadsheetId: str, ranges: str): + """Gets a Google Sheet. + + Args: + spreadsheetId (str): The id of the Google Sheet. + sheetId (int): The id of the sheet. + + Returns: + dict: The response from the Google Sheets API. + """ + return execute_google_api_call( + "sheets", + "v4", + "spreadsheets", + "get", + spreadsheetId=spreadsheetId, + ranges=ranges, + includeGridData=False, + ) + + +@handle_google_api_errors +def batch_update(spreadsheetId: str, body: dict): + """Updates a Google Sheet. + + Args: + spreadsheetId (str): The id of the Google Sheet. + body (dict): The request body. + + Returns: + dict: The response from the Google Sheets API. + """ + return execute_google_api_call( + "sheets", + "v4", + "spreadsheets", + "batchUpdate", + spreadsheetId=spreadsheetId, + body=body, + ) + + +@handle_google_api_errors def batch_update_values( spreadsheetId: str, range: str, diff --git a/app/modules/dev/google.py b/app/modules/dev/google.py index b4e82aae..452d3fcb 100644 --- a/app/modules/dev/google.py +++ b/app/modules/dev/google.py @@ -2,8 +2,9 @@ import os -from integrations.google_workspace import gmail -from integrations.slack import users as slack_users +from integrations.google_workspace import ( + google_directory, +) from dotenv import load_dotenv @@ -14,41 +15,14 @@ INCIDENT_TEMPLATE = os.environ.get("INCIDENT_TEMPLATE") -def open_modal(client, body, folders): - if not folders: - return - folder_names = [i["name"] for i in folders] - blocks = [ - {"type": "section", "text": {"type": "mrkdwn", "text": f"*{name}*"}} - for name in folder_names - ] - view = { - "type": "modal", - "title": {"type": "plain_text", "text": "Folder List"}, - "blocks": blocks, - } - client.views_open(trigger_id=body["trigger_id"], view=view) +def get_members(group): + members = google_directory.list_group_members(group) + return members def google_service_command(ack, client, body, respond, logger): ack() - post_content = test_content() - user_id = slack_users.get_user_email_from_body(client, body) - create_message = gmail.create_email_message( - "Test ATI message", post_content, user_id, "" - ) - - response = gmail.create_draft( - message=create_message, - user_id=user_id, - delegated_user_email=user_id, - ) - - logger.info(response) - if not response: - respond("No response") - else: - respond("Found users") + respond("Nothing to see here.") def test_content(): diff --git a/app/modules/incident/incident_roles.py b/app/modules/incident/incident_roles.py index f6bb786e..6993a033 100644 --- a/app/modules/incident/incident_roles.py +++ b/app/modules/incident/incident_roles.py @@ -34,7 +34,7 @@ def manage_roles(client: WebClient, body, ack, respond): channel_name.startswith("incident-") and len("incident-") : ] channel_name = channel_name[channel_name.startswith("dev-") and len("dev-") :] - documents = google_drive.get_file_by_name(channel_name) + documents = google_drive.find_files_by_name(channel_name) if len(documents) == 0: respond( diff --git a/app/modules/reports/__init__.py b/app/modules/reports/__init__.py new file mode 100644 index 00000000..1c5a97bf --- /dev/null +++ b/app/modules/reports/__init__.py @@ -0,0 +1,3 @@ +from . import core + +__all__ = ["core"] diff --git a/app/modules/reports/core.py b/app/modules/reports/core.py new file mode 100644 index 00000000..2f635b3e --- /dev/null +++ b/app/modules/reports/core.py @@ -0,0 +1,42 @@ +from slack_bolt import Ack, Respond +from slack_sdk import WebClient +from logging import Logger + +from . import google_groups # + + +help_text = """ +\n `/sre reports help | aide` +\n - show this help text +\n - montre le texte d'aide +\n `/sre reports google-groups` +\n - generate a Google Groups statistics report +\n - générer un rapport statistique sur les groupes Google +\n `/sre reports google-groups-members` +\n - generate a Google Groups Members report +\n - générer un rapport sur les membres des groupes Google""" + + +def reports_command( + args, ack: Ack, command, logger: Logger, respond: Respond, client: WebClient, body +): + ack() + if len(args) == 0: + respond(help_text) + return + logger.info("SRE reports command received: %s", command["text"]) + + action, *args = args + logger.info("SRE reports action: %s", action) + logger.info("SRE reports args: %s", args) + match action: + case "help" | "aide": + respond(help_text) + case "google-groups": + google_groups.generate_report(args, respond) + case "google-groups-members": + google_groups.generate_group_members_report(args, respond, logger) + case _: + respond( + "Unknown command. Type `/sre reports help` for a list of commands.\nCommande inconnue. Tapez `/sre reports aide` pour voir une liste de commandes." + ) diff --git a/app/modules/reports/google_groups.py b/app/modules/reports/google_groups.py new file mode 100644 index 00000000..2839778c --- /dev/null +++ b/app/modules/reports/google_groups.py @@ -0,0 +1,105 @@ +import os +import time +from datetime import datetime +from dotenv import load_dotenv +from integrations.google_workspace import ( + google_directory, + sheets, + google_drive, +) + +load_dotenv() + +DELEGATED_USER_EMAIL = os.environ.get("GOOGLE_DELEGATED_ADMIN_EMAIL") +FOLDER_REPORTS_GOOGLE_GROUPS = os.environ.get("FOLDER_REPORTS_GOOGLE_GROUPS", "") + + +def generate_report(args, respond): + respond("Generating Google Groups report is not implemented yet.") + + +def generate_group_members_report(args, respond, logger): + """Generate a report of Google Groups members.""" + if not FOLDER_REPORTS_GOOGLE_GROUPS: + respond("Google Drive folder for reports not set.") + return + exclude_groups = ["AWS-"] + logger.info("Generating Google Groups Members report...") + filename = f"groups_report_{datetime.now().strftime('%Y-%m-%d')}" + logger.info(f"Filename: {filename}") + files = google_drive.find_files_by_name(filename, FOLDER_REPORTS_GOOGLE_GROUPS) + + if len(files) == 0: + logger.info("No files found. Creating a new file.") + file = google_drive.create_file( + filename, FOLDER_REPORTS_GOOGLE_GROUPS, "spreadsheet" + ) + else: + logger.info("File found. Displaying the first file.") + file = files[0] + + logger.info(f"File: {file}") + + groups = google_directory.list_groups() + groups = [ + group + for group in groups + if not any(exclude in group["name"] for exclude in exclude_groups) + ] + + if not groups: + respond("No groups found.") + return + + groups_with_members = [] + for index, group in enumerate(groups): + logger.info(f"Processing group {index + 1}/{len(groups)}: {group['email']}") + members = google_directory.list_group_members(group["email"]) + group["members"] = members + groups_with_members.append(group) + + for group in groups_with_members: + range = f"{group['name']}" + logger.info(f"Creating sheet '{range}'") + if len(range) > 50: + range = range[:50] + + try: + sheet = sheets.get_sheet(file["id"], range) + except Exception: + sheet = None + if sheet: + logger.info(f"Sheet '{range}' already exists") + else: + try: + request = { + "requests": [ + { + "addSheet": { + "properties": { + "title": range, + } + } + } + ] + } + sheet = sheets.batch_update(file["id"], request) + if sheet: + logger.info(f"Sheet '{range}' created") + except Exception as e: + logger.error(e) + values = [["Group Name", range], ["Email", "Role"]] + range = f"{range}!A1" + for member in group["members"]: + values.append([member["email"], member["role"]]) + updated_sheet = sheets.batch_update_values( + file["id"], + range, + values, + ) + if updated_sheet: + logger.info(f"Sheet '{group['name']}' updated") + + time.sleep(1.1) + + respond("Google Groups Members report generated.") diff --git a/app/modules/sre/sre.py b/app/modules/sre/sre.py index 719156fa..480d7a4e 100644 --- a/app/modules/sre/sre.py +++ b/app/modules/sre/sre.py @@ -11,6 +11,7 @@ from modules.incident import incident_helper from modules.sre import geolocate_helper, webhook_helper from modules.dev import core as dev_core +from modules.reports import core as reports from integrations.slack import commands as slack_commands help_text = """ @@ -66,6 +67,8 @@ def sre_command( dev_core.dev_command(ack, logger, respond, client, body, args) case "version": respond(f"SRE Bot version: {os.environ.get('GIT_SHA', 'unknown')}") + case "reports": + reports.reports_command(args, ack, command, logger, respond, client, body) case _: respond( f"Unknown command: `{action}`. Type `/sre help` to see a list of commands. \nCommande inconnue: `{action}`. Entrez `/sre help` pour une liste des commandes valides" diff --git a/app/tests/integrations/google_workspace/test_google_drive.py b/app/tests/integrations/google_workspace/test_google_drive.py index 3720530f..0549812c 100644 --- a/app/tests/integrations/google_workspace/test_google_drive.py +++ b/app/tests/integrations/google_workspace/test_google_drive.py @@ -128,7 +128,7 @@ def test_create_folder_calls_api_with_fields(execute_google_api_call_mock): def test_create_file_with_valid_type_returns_file_id(execute_google_api_call_mock): execute_google_api_call_mock.return_value = {"id": "test_document_id"} result = google_drive.create_file("test_document", "folder_id", "document") - assert result == "test_document_id" + assert result == {"id": "test_document_id"} execute_google_api_call_mock.assert_called_once_with( "drive", "v3", @@ -140,7 +140,7 @@ def test_create_file_with_valid_type_returns_file_id(execute_google_api_call_moc "parents": ["folder_id"], }, supportsAllDrives=True, - fields="id", + fields="id, name", ) @@ -183,7 +183,7 @@ def test_create_file_from_template_returns_file(execute_google_api_call_mock): @patch("integrations.google_workspace.google_drive.execute_google_api_call") -def test_get_file_by_name_with_folder_id_returns_object(execute_google_api_call_mock): +def test_find_files_by_name_with_folder_id_returns_object(execute_google_api_call_mock): execute_google_api_call_mock.return_value = [ { "name": "test_document", @@ -191,7 +191,7 @@ def test_get_file_by_name_with_folder_id_returns_object(execute_google_api_call_ "appProperties": {}, } ] - result = google_drive.get_file_by_name("test_file_name", "folder_id") + result = google_drive.find_files_by_name("test_file_name", "folder_id") assert result == [ { "name": "test_document", @@ -216,7 +216,7 @@ def test_get_file_by_name_with_folder_id_returns_object(execute_google_api_call_ @patch("integrations.google_workspace.google_drive.execute_google_api_call") -def test_get_file_by_name_without_folder_id_returns_object( +def test_find_files_by_name_without_folder_id_returns_object( execute_google_api_call_mock, ): execute_google_api_call_mock.return_value = [ @@ -226,7 +226,7 @@ def test_get_file_by_name_without_folder_id_returns_object( "appProperties": {}, } ] - result = google_drive.get_file_by_name("test_file_name") + result = google_drive.find_files_by_name("test_file_name") assert result == [ { "name": "test_document", @@ -251,7 +251,7 @@ def test_get_file_by_name_without_folder_id_returns_object( @patch("integrations.google_workspace.google_drive.execute_google_api_call") -def test_get_file_by_name_with_empty_folder_id_returns_object( +def test_find_files_by_name_with_empty_folder_id_returns_object( execute_google_api_call_mock, ): execute_google_api_call_mock.return_value = [ @@ -261,7 +261,7 @@ def test_get_file_by_name_with_empty_folder_id_returns_object( "appProperties": {}, } ] - result = google_drive.get_file_by_name("test_file_name", "") + result = google_drive.find_files_by_name("test_file_name", "") assert result == [ { "name": "test_document", @@ -286,11 +286,11 @@ def test_get_file_by_name_with_empty_folder_id_returns_object( @patch("integrations.google_workspace.google_drive.execute_google_api_call") -def test_get_file_by_name_no_file_found_returns_empty_list( +def test_find_files_by_name_no_file_found_returns_empty_list( execute_google_api_call_mock, ): execute_google_api_call_mock.return_value = [] - result = google_drive.get_file_by_name("test_file_name", "folder_id") + result = google_drive.find_files_by_name("test_file_name", "folder_id") assert result == [] execute_google_api_call_mock.assert_called_once_with( "drive", diff --git a/app/tests/modules/incident/test_incident_roles.py b/app/tests/modules/incident/test_incident_roles.py index 1c7e6ee5..c7e6948e 100644 --- a/app/tests/modules/incident/test_incident_roles.py +++ b/app/tests/modules/incident/test_incident_roles.py @@ -3,7 +3,7 @@ from modules.incident import incident_roles as incident_helper -@patch("modules.incident.incident_roles.google_drive.get_file_by_name") +@patch("modules.incident.incident_roles.google_drive.find_files_by_name") def test_manage_roles(get_document_by_channel_name_mock): client = MagicMock() body = { @@ -22,7 +22,7 @@ def test_manage_roles(get_document_by_channel_name_mock): client.views_open.assert_called_once_with(trigger_id="trigger_id", view=ANY) -@patch("modules.incident.incident_roles.google_drive.get_file_by_name") +@patch("modules.incident.incident_roles.google_drive.find_files_by_name") def test_manage_roles_with_no_result(get_document_by_channel_name_mock): client = MagicMock() body = { @@ -40,7 +40,7 @@ def test_manage_roles_with_no_result(get_document_by_channel_name_mock): ) -@patch("modules.incident.incident_roles.google_drive.get_file_by_name") +@patch("modules.incident.incident_roles.google_drive.find_files_by_name") def test_manage_roles_with_dev_prefix(get_document_by_channel_name_mock): client = MagicMock() body = {