diff --git a/.gitignore b/.gitignore index 8d6520e18e..a48fb8f277 100644 --- a/.gitignore +++ b/.gitignore @@ -125,4 +125,4 @@ bill_com_credentials.* docs/html docs/dirhtml -*.sw* +*.sw* \ No newline at end of file diff --git a/docs/google.rst b/docs/google.rst index 9590d51f20..72363bc131 100644 --- a/docs/google.rst +++ b/docs/google.rst @@ -51,7 +51,6 @@ API .. autoclass:: parsons.google.google_admin.GoogleAdmin :inherited-members: - ******** BigQuery ******** @@ -255,7 +254,60 @@ You can also retrieve represntative information such as offices, officals, etc. API === -.. autoclass :: parsons.google.google_civic.GoogleCivic +.. autoclass:: parsons.google.google_civic.GoogleCivic + :inherited-members: + +************* +Google Drive +************* + +======== +Overview +======== + +The GoogleDrive class allows you to interact with Google Drive. You can update permissions with this connector. + +In order to instantiate the class, you must pass Google service account credentials as a dictionary, or store the credentials as a JSON file locally and pass the path to the file as a string in the ``GOOGLE_DRIVE_CREDENTIALS`` environment variable. You can follow these steps: + +- Go to the `Google Developer Console `_ and make sure the "Google Drive API" is enabled. +- Go to the credentials page via the lefthand sidebar. On the credentials page, click "create credentials". +- Choose the "Service Account" option and fill out the form provided. This should generate your credentials. +- Select your newly created Service Account on the credentials main page. +- select "keys", then "add key", then "create new key". Pick the key type JSON. The credentials should start to automatically download. + +You can now copy and paste the data from the key into your script or (recommended) save it locally as a JSON file. + +========== +Quickstart +========== + +To instantiate the GoogleSheets class, you can either pass the constructor a dict containing your Google service account credentials or define the environment variable ``GOOGLE_DRIVE_CREDENTIALS`` to contain a path to the JSON file containing the dict. + +.. code-block:: python + + from parsons import GoogleDrive + + # First approach: Use API credentials via environmental variables + drive = GoogleDrive() + + # Second approach: Pass API credentials as argument + credential_filename = 'google_drive_service_credentials.json' + credentials = json.load(open(credential_filename)) + drive = GoogleDrive(google_keyfile_dict=credentials) + +You can then retreive and edit the permissions for Google Drive objects. + +.. code-block:: python + + email_addresses = ["bob@bob.com"] + shared = drive.share_object(file_id, email_addresses) + + +=== +API +=== + +.. autoclass:: parsons.google.google_drive.GoogleDrive :inherited-members: @@ -267,7 +319,7 @@ Google Sheets Overview ======== -The GoogleSheets class allows you to interact with Google service account spreadsheets, called "Google Sheets." You can create, modify, read, format, share and delete sheets with this connector. +The GoogleSlides class allows you to interact with Google Slides. You can create and modify Google Slides with this connector. In order to instantiate the class, you must pass Google service account credentials as a dictionary, or store the credentials as a JSON file locally and pass the path to the file as a string in the ``GOOGLE_DRIVE_CREDENTIALS`` environment variable. You can follow these steps: @@ -314,3 +366,57 @@ API .. autoclass:: parsons.google.google_sheets.GoogleSheets :inherited-members: +************* +Google Slides +************* + +======== +Overview +======== + +The GoogleSheets class allows you to interact with Google service account spreadsheets, called "Google Sheets." You can create, modify, read, format, share and delete sheets with this connector. + +In order to instantiate the class, you must pass Google service account credentials as a dictionary, or store the credentials as a JSON file locally and pass the path to the file as a string in the ``GOOGLE_DRIVE_CREDENTIALS`` environment variable. You can follow these steps: + +- Go to the `Google Developer Console `_ and make sure the "Google Drive API" and the "Google Sheets API" are both enabled. +- Go to the credentials page via the lefthand sidebar. On the credentials page, click "create credentials". +- Choose the "Service Account" option and fill out the form provided. This should generate your credentials. +- Select your newly created Service Account on the credentials main page. +- select "keys", then "add key", then "create new key". Pick the key type JSON. The credentials should start to automatically download. + +You can now copy and paste the data from the key into your script or (recommended) save it locally as a JSON file. + +========== +Quickstart +========== + +To instantiate the GoogleSheets class, you can either pass the constructor a dict containing your Google service account credentials or define the environment variable ``GOOGLE_DRIVE_CREDENTIALS`` to contain a path to the JSON file containing the dict. + +.. code-block:: python + + from parsons import GoogleSlides + + # First approach: Use API credentials via environmental variables + slides = GoogleSlides() + + # Second approach: Pass API credentials as argument + credential_filename = 'google_drive_service_credentials.json' + credentials = json.load(open(credential_filename)) + slides = GoogleSlides(google_keyfile_dict=credentials) + +You can then create/modify/retrieve slides using instance methods: + +.. code-block:: python + + slides.create_presentation("Parsons is Fun") + + +You can use the GoogleDrive() connector to share the slide deck. + +=== +API +=== + +.. autoclass:: parsons.google.google_slides.GoogleSlides + :inherited-members: + diff --git a/parsons/__init__.py b/parsons/__init__.py index ebc743e37f..c32702127b 100644 --- a/parsons/__init__.py +++ b/parsons/__init__.py @@ -64,7 +64,9 @@ ("parsons.google.google_bigquery", "GoogleBigQuery"), ("parsons.google.google_civic", "GoogleCivic"), ("parsons.google.google_cloud_storage", "GoogleCloudStorage"), + ("parsons.google.google_drive", "GoogleDrive"), ("parsons.google.google_sheets", "GoogleSheets"), + ("parsons.google.google_slides", "GoogleSlides"), ("parsons.hustle.hustle", "Hustle"), ("parsons.mailchimp.mailchimp", "Mailchimp"), ("parsons.mobilecommons.mobilecommons", "MobileCommons"), diff --git a/parsons/google/google_drive.py b/parsons/google/google_drive.py new file mode 100644 index 0000000000..abf319ade9 --- /dev/null +++ b/parsons/google/google_drive.py @@ -0,0 +1,153 @@ +import os +import json +import logging + +from parsons.google.utitities import setup_google_application_credentials +from googleapiclient.discovery import build +from google.oauth2.service_account import Credentials + +logger = logging.getLogger(__name__) + + +class GoogleDrive: + """ + A connector for Google Drive, largely handling permissions. + + `Args:` + google_keyfile_dict: dict + A dictionary of Google Drive API credentials, parsed from JSON provided + by the Google Developer Console. Required if env variable + ``GOOGLE_DRIVE_CREDENTIALS`` is not populated. + subject: string + In order to use account impersonation, pass in the email address of the account to be + impersonated as a string. + """ + + def __init__(self, google_keyfile_dict=None, subject=None): + + scope = ["https://www.googleapis.com/auth/drive"] + + setup_google_application_credentials( + google_keyfile_dict, "GOOGLE_DRIVE_CREDENTIALS" + ) + google_credential_file = open(os.environ["GOOGLE_DRIVE_CREDENTIALS"]) + credentials_dict = json.load(google_credential_file) + + credentials = Credentials.from_service_account_info( + credentials_dict, scopes=scope, subject=subject + ) + + self.client = build("drive", "v3", credentials=credentials) + + def get_permissions(self, file_id): + """ + `Args:` + file_id: str + this is the ID of the object you are hoping to share + `Returns:` + permission dict + """ + + p = self.client.permissions().list(fileId=file_id).execute() + + return p + + def _share_object(self, file_id, permission_dict): + + # Send the request to share the file + p = ( + self.client.permissions() + .create(fileId=file_id, body=permission_dict) + .execute() + ) + + return p + + def share_object(self, file_id, email_addresses=None, role="reader", type="user"): + """ + `Args:` + file_id: str + this is the ID of the object you are hoping to share + email_addresses: list + this is the list of the email addresses you want to share; + set to a list of domains like `['domain']` if you choose `type='domain'`; + set to `None` if you choose `type='anyone'` + role: str + Options are -- owner, organizer, fileOrganizer, writer, commenter, reader + https://developers.google.com/drive/api/guides/ref-roles + type: str + Options are -- user, group, domain, anyone + `Returns:` + List of permission objects + """ + if role not in [ + "owner", + "organizer", + "fileOrganizer", + "writer", + "commenter", + "reader", + ]: + raise Exception( + f"{role} not from the allowed list of: \ + owner, organizer, fileOrganizer, writer, commenter, reader" + ) + + if type not in ["user", "group", "domain", "anyone"]: + raise Exception( + f"{type} not from the allowed list of: \ + user, group, domain, anyone" + ) + + if type == "domain": + permissions = [ + {"type": type, "role": role, "domain": email} + for email in email_addresses + ] + else: + permissions = [ + {"type": type, "role": role, "emailAddress": email} + for email in email_addresses + ] + + new_permissions = [] + for permission in permissions: + p = self._share_object(file_id, permission) + new_permissions.append(p) + + return new_permissions + + def transfer_ownership(self, file_id, new_owner_email): + """ + `Args:` + file_id: str + this is the ID of the object you are hoping to share + new_owner_email: str + the email address of the intended new owner + `Returns:` + None + """ + permissions = self.client.permissions().list(fileId=file_id).execute() + + # Find the current owner + current_owner_permission = next( + (p for p in permissions.get("permissions", []) if "owner" in p), None + ) + + if current_owner_permission: + # Update the permission to transfer ownership + new_owner_permission = { + "type": "user", + "role": "owner", + "emailAddress": new_owner_email, + } + self.client.permissions().update( + fileId=file_id, + permissionId=current_owner_permission["id"], + body=new_owner_permission, + ).execute() + logger.info(f"Ownership transferred successfully to {new_owner_email}.") + else: + logger.info("File does not have a current owner.") + + return None diff --git a/parsons/google/google_slides.py b/parsons/google/google_slides.py new file mode 100644 index 0000000000..92ff6584ce --- /dev/null +++ b/parsons/google/google_slides.py @@ -0,0 +1,242 @@ +import os +import json +import logging + +from parsons.google.utitities import setup_google_application_credentials +from googleapiclient.discovery import build +from google.oauth2.service_account import Credentials + +logger = logging.getLogger(__name__) + + +class GoogleSlides: + """ + A connector for Google Slides, handling slide creation. + + `Args:` + google_keyfile_dict: dict + A dictionary of Google Drive API credentials, parsed from JSON provided + by the Google Developer Console. Required if env variable + ``GOOGLE_DRIVE_CREDENTIALS`` is not populated. + subject: string + In order to use account impersonation, pass in the email address of the account to be + impersonated as a string. + """ + + def __init__(self, google_keyfile_dict=None, subject=None): + + scope = [ + "https://www.googleapis.com/auth/presentations", + "https://www.googleapis.com/auth/drive", + ] + + setup_google_application_credentials( + google_keyfile_dict, "GOOGLE_DRIVE_CREDENTIALS" + ) + google_credential_file = open(os.environ["GOOGLE_DRIVE_CREDENTIALS"]) + credentials_dict = json.load(google_credential_file) + + credentials = Credentials.from_service_account_info( + credentials_dict, scopes=scope, subject=subject + ) + + self.client = build("slides", "v1", credentials=credentials) + + def create_presentation(self, title): + """ + `Args:` + title: str + this is the title you'd like to give your presentation + `Returns:` + the presentation object + """ + + body = {"title": title} + presentation = self.client.presentations().create(body=body).execute() + logger.info( + f"Created presentation with ID:" f"{(presentation.get('presentationId'))}" + ) + return presentation + + def get_presentation(self, presentation_id): + """ + `Args:` + presentation_id: str + this is the ID of the presentation to put the duplicated slide + `Returns:` + the presentation object + """ + + presentation = ( + self.client.presentations().get(presentationId=presentation_id).execute() + ) + + return presentation + + def duplicate_slide(self, presentation_id, source_slide_number): + """ + `Args:` + presentation_id: str + this is the ID of the presentation to put the duplicated slide + source_slide_number: int + this should reflect the slide # (e.g. 2 = 2nd slide) + `Returns:` + the duplicated slide object + """ + source_slide = self.get_slide(presentation_id, source_slide_number) + source_slide_id = source_slide["objectId"] + + batch_request = { + "requests": [{"duplicateObject": {"objectId": source_slide_id}}] + } + response = ( + self.client.presentations() + .batchUpdate(presentationId=presentation_id, body=batch_request) + .execute() + ) + + duplicated_slide = response["replies"][0]["duplicateObject"] + + return duplicated_slide + + def get_slide(self, presentation_id, slide_number): + """ + `Args:` + presentation_id: str + this is the ID of the presentation to put the duplicated slide + slide_number: int + the slide number you are seeking + `Returns:` + the slide object + """ + + presentation = self.get_presentation(presentation_id) + slide = presentation["slides"][slide_number - 1] + + return slide + + def delete_slide(self, presentation_id, slide_number): + """ + `Args:` + presentation_id: str + this is the ID of the presentation to put the duplicated slide + slide_number: int + the slide number you are seeking + `Returns:` + None + """ + slide_object_id = self.get_slide(presentation_id, slide_number)['objectId'] + + requests = [ + { + 'deleteObject': { + 'objectId': slide_object_id + } + } + ] + + # Execute the request + body = { + 'requests': requests + } + self.client.presentations().batchUpdate( + presentationId=presentation_id, + body=body + ).execute() + + return None + + def replace_slide_text( + self, presentation_id, slide_number, original_text, replace_text + ): + """ + `Args:` + presentation_id: str + this is the ID of the presentation to put the duplicated slide + slide_number: int + this should reflect the slide # (e.g. 2 = 2nd slide) + origianl_text: str + the text to be replaced + replace_text: str + the desired new text + `Returns:` + None + """ + + slide = self.get_slide(presentation_id, slide_number) + slide_id = slide["objectId"] + + reqs = [ + { + "replaceAllText": { + "containsText": {"text": original_text}, + "replaceText": replace_text, + "pageObjectIds": [slide_id], + } + }, + ] + + self.client.presentations().batchUpdate( + body={"requests": reqs}, presentationId=presentation_id + ).execute() + + return None + + def get_slide_images(self, presentation_id, slide_number): + """ + `Args:` + presentation_id: str + this is the ID of the presentation to put the duplicated slide + slide_number: int + this should reflect the slide # (e.g. 2 = 2nd slide) + `Returns:` + a list of object dicts for image objects + """ + + slide = self.get_slide(presentation_id, slide_number) + + images = [] + for x in slide["pageElements"]: + if "image" in x.keys(): + images.append(x) + + return images + + def replace_slide_image( + self, presentation_id, slide_number, image_obj, new_image_url + ): + """ + `Args:` + presentation_id: str + this is the ID of the presentation to put the duplicated slide + slide_number: int + this should reflect the slide # (e.g. 2 = 2nd slide) + image_obj: dict + the image object -- can use `get_slide_images()` + new_image_url: str + the url that contains the desired image + `Returns:` + None + """ + + slide = self.get_slide(presentation_id, slide_number) + + reqs = [ + { + "createImage": { + "url": new_image_url, + "elementProperties": { + "pageObjectId": slide["objectId"], + "size": image_obj["size"], + "transform": image_obj["transform"], + }, + } + }, + {"deleteObject": {"objectId": image_obj["objectId"]}}, + ] + + self.client.presentations().batchUpdate( + body={"requests": reqs}, presentationId=presentation_id + ).execute() + + return None diff --git a/test/test_google/test_google_drive.py b/test/test_google/test_google_drive.py new file mode 100644 index 0000000000..f42ff670b5 --- /dev/null +++ b/test/test_google/test_google_drive.py @@ -0,0 +1,33 @@ +import unittest +import os +from parsons import GoogleDrive +import random +import string + +# Test Slides: https://docs.google.com/presentation/d/19I-kicyaJV53KoPNwt77KJL10fHzWFdZ_c2mW4XJaxc + + +@unittest.skipIf( + not os.environ.get("LIVE_TEST"), "Skipping because not running live test" +) +class TestGoogleDrive(unittest.TestCase): + def setUp(self): + + self.gd = GoogleDrive() + + def test_get_permissions(self): + + file_id = "19I-kicyaJV53KoPNwt77KJL10fHzWFdZ_c2mW4XJaxc" + p = self.gd.get_permissions(file_id) + self.assertTrue(True, "anyoneWithLink" in [x["id"] for x in p["permissions"]]) + + def test_share_object(self): + + file_id = "19I-kicyaJV53KoPNwt77KJL10fHzWFdZ_c2mW4XJaxc" + email = ''.join(random.choices(string.ascii_letters, k=10))+"@gmail.com" + email_addresses = [email] + + before = self.gd.get_permissions(file_id)['permissions'] + self.gd.share_object(file_id, email_addresses) + after = self.gd.get_permissions(file_id)['permissions'] + self.assertTrue(True, len(after) > len(before)) diff --git a/test/test_google/test_google_slides.py b/test/test_google/test_google_slides.py new file mode 100644 index 0000000000..f5fbbf7655 --- /dev/null +++ b/test/test_google/test_google_slides.py @@ -0,0 +1,64 @@ +import unittest +import os +from parsons import GoogleSlides + +# Test Slides: https://docs.google.com/presentation/d/19I-kicyaJV53KoPNwt77KJL10fHzWFdZ_c2mW4XJaxc +slides_id = "19I-kicyaJV53KoPNwt77KJL10fHzWFdZ_c2mW4XJaxc" + +@unittest.skipIf( + not os.environ.get("LIVE_TEST"), "Skipping because not running live test" +) +class TestGoogleSlides(unittest.TestCase): + def setUp(self): + + self.gs = GoogleSlides() + + # we're going to grab our Test Slides and drop all slides beyond #1 & 2 + presentation = self.gs.get_presentation(slides_id) + slides = presentation["slides"] + if len(slides)>2: + for i in range(2, len(slides)): + self.gs.delete_slide(slides_id, i + 1) + + def test_get_presentation(self): + p = self.gs.get_presentation(slides_id) + self.assertEqual(9144000, p["pageSize"]["width"]["magnitude"]) + + def test_get_slide(self): + s = self.gs.get_slide(slides_id, 1) + self.assertEqual("p", s["objectId"]) + + def test_duplicate_slide(self): + # duplicating slide #2 to create 3 slides + self.gs.duplicate_slide(slides_id, 2) + p = self.gs.get_presentation(slides_id) + self.assertEqual(3, len(p["slides"])) + + def test_replace_slide_text(self): + self.gs.duplicate_slide(slides_id, 2) + original_text = "Replace Text" + replace_text = "Parsons is Fun" + self.gs.replace_slide_text( + slides_id, 3, original_text, replace_text + ) + + s = self.gs.get_slide(slides_id, 3) + content = s["pageElements"][0]["shape"]["text"]["textElements"][1]["textRun"][ + "content" + ] + self.assertTrue(True, "Parsons is Fun" in content) + + def test_get_slide_images(self): + img = self.gs.get_slide_images(slides_id, 2) + height = img[0]["size"]["height"]["magnitude"] + self.assertEqual(22525, height) + + def test_replace_slide_image(self): + presentation_id = slides_id + self.gs.duplicate_slide(slides_id, 2) + img = self.gs.get_slide_images(presentation_id, 3) + image_obj = img[0] + new_image_url = "https://media.tenor.com/yxJYCVXImqYAAAAM/westwing-josh.gif" + self.gs.replace_slide_image(presentation_id, 3, image_obj, new_image_url) + img = self.gs.get_slide_images(presentation_id, 3) + self.assertTrue(True, img[0]["image"]["sourceUrl"] == new_image_url)