├── .gitignore
├── functions
├── requirements.txt
├── test
│ ├── __init__.py
│ ├── test_drive_api.py
│ ├── test_asset_value_by_type.py
│ ├── utils_test.py
│ ├── test_sheet_api.py
│ ├── test_pubsub.py
│ ├── test_asset_creation.py
│ ├── test_sitelink_creation.py
│ ├── test_asset_deletion.py
│ └── test_main.py
├── appScript
│ ├── config.ts
│ ├── pubsub.ts
│ ├── service.ts
│ ├── data_validation.ts
│ ├── enums.ts
│ ├── custom_menu.ts
│ ├── changes_tracking.ts
│ └── sheets_utils.ts
├── auth.py
├── main.py
├── drive_api.py
├── pubsub.py
├── sitelink_creation.py
├── utils.py
└── asset_deletion.py
├── terraform
├── main.tf
├── config-template.yaml
├── cloud_storage.tf
├── gcs_backend_state.tf
├── variables.tf
├── configuration-input.tfvars
├── api_activation.tf
├── cloud_function.tf
└── iam.tf
├── CONTRIBUTING.md
├── docs
├── user_guide.md
├── manual_deployment.md
└── tutorial.md
├── README.md
└── LICENSE
/.gitignore:
--------------------------------------------------------------------------------
1 | google-ads.yaml
2 | .venv/*
3 | venv/
4 | __pycache__/
5 | path/*
6 | functions/config*
7 | local_run.py
8 |
9 | # Terraform
10 | **/*.tfvars
11 | **/.terraform/*
12 | *.tfstate
13 | *.tfstate.*
14 | .terraform.lock.hcl
15 |
16 | # IDEs
17 | .idea/
18 |
--------------------------------------------------------------------------------
/functions/requirements.txt:
--------------------------------------------------------------------------------
1 | # Function dependencies
2 | absl-py==2.1.0
3 | google-ads==28.0.0
4 | google-api-python-client==2.182.0
5 | google-auth-httplib2==0.2.0
6 | google-auth-oauthlib==1.2.0
7 | oauth2client==4.1.3
8 | pillow==10.3.0
9 | functions-framework==3.3.0
10 | validators==0.28.2
11 | pytest == 8.1.1
12 |
--------------------------------------------------------------------------------
/functions/test/__init__.py:
--------------------------------------------------------------------------------
1 | # Copyright 2024 Google LLC
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 |
--------------------------------------------------------------------------------
/terraform/main.tf:
--------------------------------------------------------------------------------
1 | # Copyright 2023 Google LLC
2 | # Licensed under the Apache License, Version 2.0 (the "License");
3 | # you may not use this file except in compliance with the License.
4 | # You may obtain a copy of the License at
5 | # https://www.apache.org/licenses/LICENSE-2.0
6 | # Unless required by applicable law or agreed to in writing, software
7 | # distributed under the License is distributed on an "AS IS" BASIS,
8 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9 | # See the License for the specific language governing permissions and
10 | # limitations under the License.
11 |
12 | provider "google" {
13 | project = var.project_id
14 | region = var.cloud_function_region
15 | }
--------------------------------------------------------------------------------
/terraform/config-template.yaml:
--------------------------------------------------------------------------------
1 | # Copyright 2023 Google LLC
2 | # Licensed under the Apache License, Version 2.0 (the "License");
3 | # you may not use this file except in compliance with the License.
4 | # You may obtain a copy of the License at
5 | # https://www.apache.org/licenses/LICENSE-2.0
6 | # Unless required by applicable law or agreed to in writing, software
7 | # distributed under the License is distributed on an "AS IS" BASIS,
8 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9 | # See the License for the specific language governing permissions and
10 | # limitations under the License.
11 |
12 | # Google Ads API configuration
13 | use_proto_plus: "True"
14 | developer_token: "${developer_token}"
15 | login_customer_id: "${login_customer_id}"
16 |
17 | # Application configuration
18 | spreadsheet_id: "${spreadsheet_id}"
19 | customer_id_inclusion_list: "${customer_id_inclusion_list}"
20 |
21 | # OAuth2 configuration
22 | client_id: "${client_id}"
23 | client_secret: "${client_secret}"
24 | access_token: "${access_token}"
25 | refresh_token: "${refresh_token}"
26 |
--------------------------------------------------------------------------------
/functions/appScript/config.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2024 Google LLC
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | // Update with your Cloud Project Client ID and Client Secret.
18 | // Should be different credentials to the main pMAx Cloud credentials.
19 | // Use the following redirect URI is
20 | // https://script.google.com/macros/d/[REPLACE_WITH_SCRIPT_ID]/usercallback
21 | const CLIENT_ID = 'UPDATE_CLINET_ID_HERE'; // OAuth client id.
22 | const CLIENT_SECRET = 'UPDATE_CLIENT_SECRET_HERE'; // OAuth client secret.
23 | const PROJECT_ID = 'UPDATE_PROJECT_ID_HERE'; // The Project ID of your GCP project.
24 | const PUBSUB_TOPIC = 'UPDATE_PUBSUB_TOPIC_NAME_HERE'; // Name of PubSub topic.
25 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # How to contribute
2 |
3 | We'd love to accept your patches and contributions to this project.
4 |
5 | ## Before you begin
6 |
7 | ### Sign our Contributor License Agreement
8 |
9 | Contributions to this project must be accompanied by a
10 | [Contributor License Agreement](https://cla.developers.google.com/about) (CLA).
11 | You (or your employer) retain the copyright to your contribution; this simply
12 | gives us permission to use and redistribute your contributions as part of the
13 | project.
14 |
15 | If you or your current employer have already signed the Google CLA (even if it
16 | was for a different project), you probably don't need to do it again.
17 |
18 | Visit to see your current agreements or to
19 | sign a new one.
20 |
21 | ### Review our community guidelines
22 |
23 | This project follows
24 | [Google's Open Source Community Guidelines](https://opensource.google/conduct/).
25 |
26 | ## Contribution process
27 |
28 | ### Code reviews
29 |
30 | All submissions, including submissions by project members, require review. We
31 | use GitHub pull requests for this purpose. Consult
32 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more
33 | information on using pull requests.
34 |
--------------------------------------------------------------------------------
/terraform/cloud_storage.tf:
--------------------------------------------------------------------------------
1 | # Copyright 2023 Google LLC
2 | # Licensed under the Apache License, Version 2.0 (the "License");
3 | # you may not use this file except in compliance with the License.
4 | # You may obtain a copy of the License at
5 | # https://www.apache.org/licenses/LICENSE-2.0
6 | # Unless required by applicable law or agreed to in writing, software
7 | # distributed under the License is distributed on an "AS IS" BASIS,
8 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9 | # See the License for the specific language governing permissions and
10 | # limitations under the License.
11 |
12 | resource "google_storage_bucket" "config" {
13 | name = "pmax-config-${random_id.bucket_prefix.hex}"
14 | location = var.cloud_storage_region
15 | uniform_bucket_level_access = true
16 | force_destroy = true
17 | }
18 |
19 | resource "google_storage_bucket" "cf_upload_bucket" {
20 | name = "pmax-code-bucket-${random_id.bucket_prefix.hex}"
21 | location = var.cloud_function_region
22 | uniform_bucket_level_access = true
23 | force_destroy = true
24 | lifecycle_rule {
25 | condition {
26 | age = 1
27 | }
28 | action {
29 | type = "Delete"
30 | }
31 | }
32 | }
--------------------------------------------------------------------------------
/terraform/gcs_backend_state.tf:
--------------------------------------------------------------------------------
1 | # Copyright 2023 Google LLC
2 | # Licensed under the Apache License, Version 2.0 (the "License");
3 | # you may not use this file except in compliance with the License.
4 | # You may obtain a copy of the License at
5 | # https://www.apache.org/licenses/LICENSE-2.0
6 | # Unless required by applicable law or agreed to in writing, software
7 | # distributed under the License is distributed on an "AS IS" BASIS,
8 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9 | # See the License for the specific language governing permissions and
10 | # limitations under the License.
11 |
12 | # Links terraform to a cloud storage backend to manage Terraform state.
13 | # Conduct the following steps:
14 | # 1. Deploy Ads Api Tools through regular terraform workflow; init, plan, deploy
15 | # 2. Note down the bucket name of the resource created in
16 | # "google_storage_bucket.backend".
17 | # 3. Fill in the bucket name in terraform.backend.gcs below.
18 | # 4. Uncomment terraform.backend.gcs
19 | # 5. Reinitizalize terraform through terraform init and accept connection
20 | # to GCS backend.
21 |
22 | resource "google_storage_bucket" "backend" {
23 | name = "${random_id.bucket_prefix.hex}-bucket-tfstate"
24 | force_destroy = false
25 | location = var.cloud_storage_region
26 | storage_class = "STANDARD"
27 | uniform_bucket_level_access = true
28 | versioning {
29 | enabled = true
30 | }
31 | }
--------------------------------------------------------------------------------
/docs/user_guide.md:
--------------------------------------------------------------------------------
1 | # MadPMax User Guide: Scale Your PMAX Campaigns
2 |
3 | ## Understanding pMax Execute Functions
4 |
5 | Find the pMax execute button in the spreadsheet menu.
6 |
7 | * **Refresh Spreadsheet**:
8 | * **Refresh All Sheets**: Imports all of your current information from Google Ads.
9 | * **Refresh Individual Tabs**: Update specific tabs (CustomerList, CampaignList, AssetGroupList) separately.
10 | * **Upload to Google Ads**:
11 | * Uploads all new entries and re-tries any errors. Send any new campaigns, asset groups, or assets you've created in the sheet to Google Ads.
12 |
13 | ## Setting Up New Campaigns, Asset Groups, and Assets
14 | > Note: You can create multiple campaigns, asset groups, and assets in bulk, before using the “Upload to Google Ads” button.
15 |
16 | Follow these steps for each component:
17 |
18 | **New Campaigns (NewCampaigns tab)**:
19 | 1. Select the Google Ads account from the dropdown.
20 | 2. Fill in the campaign details (name, budget, etc.).
21 | 3. Click "Upload to Google Ads"
22 |
23 | **New Asset Groups (NewAssetGroup tab)**:
24 | 1. Select the account and campaign.
25 | Note: Pay attention to the minimum requirements for new asset groups (check column B).
26 | 1. Enter the asset group details.
27 | 2. Click "Upload to Google Ads"
28 |
29 | **Assets (Assets tab)**:
30 | 1. Select the account, campaign, and asset group.
31 | 2. Choose the asset type (Text, Image, YouTube video, etc.).
32 | 3. Enter the required information (Text, URLs, Call-to-action types).
33 | 4. Click "Upload to Google Ads"
34 | **Sitelinks**:
35 | 1. Set up sitelinks using the same process as assets.
36 |
37 | ### Important Notes
38 | * **Mandatory Fields**: The CustomerList tab must have at least one entry for campaigns to be created.
39 | * **Minimum Requirements**: Be sure to meet the minimum requirements for asset groups (indicated in column B of the NewAssetGroup tab).
40 | * **Errors**: Check the status column and the end of the row for error messages if an upload fails.
41 | * **Do Not Modify**: Avoid modifying columns A and B, as well as the first five rows of each tab.
42 |
43 | ### Additional Tips
44 | * Use the spreadsheet functions (drag down, copy/paste) for efficient data entry.
45 | * For Image and YouTube assets, provide publicly accessible URLs (ideally from a CDN).
46 |
--------------------------------------------------------------------------------
/functions/appScript/pubsub.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2024 Google LLC
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | /**
18 | * Publishes a message to a Pub/Sub topic.
19 | * @param {string} project The project ID.
20 | * @param {string} topic The topic ID.
21 | * @param {{id: string, value: string}} attr Object containing of the attributes of the message.
22 | * @param {string} data The data of the message.
23 | * @return {UrlFetchApp.HTTPResponse | undefined} The UrlFetchApp.HTTPResponse result object. If the
24 | * service does not have access, the function returns undefined.
25 | */
26 | function pubsub(project, topic, attr, data) {
27 | const service = getService();
28 |
29 | if (!service.hasAccess()) {
30 | const authorizationUrl = service.getAuthorizationUrl();
31 | const template = HtmlService.createTemplate(
32 | 'Authorize. ' +
33 | 'Reopen the sidebar when the authorization is complete.',
34 | );
35 | template.authorizationUrl = authorizationUrl;
36 | const page = template.evaluate();
37 | SpreadsheetApp.getUi().showSidebar(page);
38 |
39 | return;
40 | }
41 |
42 | const url =
43 | `https://pubsub.googleapis.com/v1/projects/${project}/topics/${topic}:publish`
44 |
45 | const body = {
46 | messages: [
47 | {
48 | attributes: attr,
49 | data: Utilities.base64Encode(data),
50 | },
51 | ],
52 | };
53 |
54 | const response = UrlFetchApp.fetch(url, {
55 | method: 'POST',
56 | contentType: 'application/json',
57 | muteHttpExceptions: true,
58 | payload: JSON.stringify(body),
59 | headers: {
60 | Authorization: 'Bearer ' + service.getAccessToken(),
61 | },
62 | });
63 |
64 | return response;
65 | }
66 |
--------------------------------------------------------------------------------
/functions/test/test_drive_api.py:
--------------------------------------------------------------------------------
1 | # Copyright 2024 Google LLC
2 |
3 | # Licensed under the Apache License, Version 2.0 (the "License")
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 |
7 | # https: // www.apache.org / licenses / LICENSE - 2.0
8 |
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 | """Tests for Sheet Service."""
15 |
16 | import unittest
17 | from unittest import mock
18 | import drive_api
19 |
20 |
21 | class TestSheetService(unittest.TestCase):
22 |
23 | def setUp(self):
24 | super().setUp()
25 | self.credentials = mock.Mock()
26 | self._google_ads_client = mock.Mock()
27 | self.google_ads_service = mock.MagicMock()
28 | self.drive_service = drive_api.DriveService(self.credentials)
29 |
30 | @mock.patch("drive_api.DriveService._download_drive_asset")
31 | def test_call_drive_download_for_drive_url(self, mock_download_drive_asset):
32 | url = "https://drive.google.com/file/d/1Mhj67eq5PKL613GMwx-cd4fd_gu3Lg0nYG"
33 | self.drive_service.download_asset_content(url)
34 | mock_download_drive_asset.assert_has_calls([mock.call(url)])
35 |
36 | @mock.patch("requests.get")
37 | def test_call_make_request_for_non_drive(self, mock_requests):
38 | mock_response = mock.Mock()
39 |
40 | # Set the status code and other attributes as needed
41 | mock_response.status_code = 200
42 | mock_response.text = '{"key": "value"}'
43 | mock_response.content = b"Some binary content"
44 | mock_response.headers = {"Content-Type": "application/json"}
45 | url = "https://tpc.googlesyndication.com/simgad/11111111111111111111111"
46 | mock_requests.return_value = mock_response
47 | self.drive_service.download_asset_content(url)
48 | mock_requests.assert_has_calls([mock.call(url)])
49 |
50 | def test_extract_file_id(self):
51 | url = "https://drive.google.com/file/d/1Mhj67eq5PKL613GMwx-cd4fd_gu3Lg0nYG"
52 | file_id = self.drive_service.extract_file_id(url)
53 | self.assertEqual("1Mhj67eq5PKL613GMwx-cd4fd_gu3Lg0nYG", file_id)
54 |
--------------------------------------------------------------------------------
/terraform/variables.tf:
--------------------------------------------------------------------------------
1 | # Copyright 2023 Google LLC
2 | # Licensed under the Apache License, Version 2.0 (the "License");
3 | # you may not use this file except in compliance with the License.
4 | # You may obtain a copy of the License at
5 | # https://www.apache.org/licenses/LICENSE-2.0
6 | # Unless required by applicable law or agreed to in writing, software
7 | # distributed under the License is distributed on an "AS IS" BASIS,
8 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9 | # See the License for the specific language governing permissions and
10 | # limitations under the License.
11 |
12 | variable "developer_token" {
13 | type = string
14 | description = "developer_token"
15 | }
16 |
17 | variable "project_id" {
18 | type = string
19 | description = "Project Id where the solutions will run"
20 | }
21 |
22 | variable "client_id" {
23 | type = string
24 | description = "client_id"
25 | }
26 |
27 | variable "client_secret" {
28 | type = string
29 | description = "client_secret"
30 | }
31 |
32 | variable "refresh_token" {
33 | type = string
34 | description = "refresh_token"
35 | }
36 |
37 | variable "access_token" {
38 | type = string
39 | description = "access_token"
40 | }
41 |
42 | variable "login_customer_id" {
43 | type = string
44 | description = "The Google Ads customer id of the manager account"
45 | }
46 |
47 | variable "customer_id_inclusion_list" {
48 | type = string
49 | description = "OPTIONAL inclusion list of Google Ads Customer Ids, the tool will only port data from the accounts in this list."
50 | }
51 |
52 | variable "cloud_function_region" {
53 | type = string
54 | description = "Cloud Function Region where the solutions will run"
55 | }
56 |
57 | variable "cloud_function_name" {
58 | type = string
59 | description = "The name of the cloud function"
60 | default = "pmax-campaign-manager-default"
61 | }
62 |
63 | variable "cloud_storage_region" {
64 | type = string
65 | description = "region where to deploy the cloud storage bucket containing the config"
66 | }
67 |
68 | variable "solution_user_list" {
69 | type = list
70 | description = "List of users for the solution"
71 | }
72 |
73 | variable "spreadsheet_id" {
74 | type = string
75 | description = "The Google Spreadsheet id for the sheet used to power the application."
76 | }
--------------------------------------------------------------------------------
/functions/appScript/service.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2024 Google LLC
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | /**
18 | * Creates a new service.
19 | * @return {GoogleAppsScript.OAuth2.Service} The authentication service
20 | * object.
21 | */
22 | function getService() {
23 | return OAuth2.createService('MyPubSub')
24 | .setAuthorizationBaseUrl('https://accounts.google.com/o/oauth2/auth')
25 | .setTokenUrl('https://accounts.google.com/o/oauth2/token')
26 | .setClientId(CLIENT_ID)
27 | .setClientSecret(CLIENT_SECRET)
28 | .setCallbackFunction('authCallback')
29 | .setPropertyStore(PropertiesService.getUserProperties())
30 | .setScope([
31 | 'https://www.googleapis.com/auth/cloud-platform',
32 | 'https://www.googleapis.com/auth/pubsub',
33 | 'https://www.googleapis.com/auth/script.external_request',
34 | ])
35 | .setParam('access_type', 'offline')
36 | .setParam('approval_prompt', 'force')
37 | .setParam('login_hint', Session.getActiveUser().getEmail());
38 | }
39 |
40 | /**
41 | * Handles the OAuth callback.
42 | * @param {GoogleAppsScript.Events.AppsScriptHttpRequestEvent} request
43 | * The HTTP request.
44 | * @return {GoogleAppsScript.HTML.HtmlOutput} The HTML output to be
45 | * rendered in browser.
46 | */
47 | function authCallback(request) {
48 | const service = getService();
49 | const isAuthorized = service.handleCallback(request);
50 | if (isAuthorized) {
51 | return HtmlService.createHtmlOutput('Success! You can close this tab.');
52 | } else {
53 | return HtmlService.createHtmlOutput('Denied. You can close this tab');
54 | }
55 | }
56 |
57 | /**
58 | * Resets the service.
59 | */
60 | function reset() {
61 | const service = getService();
62 | service.reset();
63 | }
64 |
65 | /**
66 | * Logs the redirect URI to register.
67 | */
68 | function logRedirectUri() {
69 | const service = getService();
70 | Logger.log(service.getRedirectUri());
71 | }
72 |
--------------------------------------------------------------------------------
/terraform/configuration-input.tfvars:
--------------------------------------------------------------------------------
1 | # Copyright 2023 Google LLC
2 | # Licensed under the Apache License, Version 2.0 (the "License");
3 | # you may not use this file except in compliance with the License.
4 | # You may obtain a copy of the License at
5 | # https://www.apache.org/licenses/LICENSE-2.0
6 | # Unless required by applicable law or agreed to in writing, software
7 | # distributed under the License is distributed on an "AS IS" BASIS,
8 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9 | # See the License for the specific language governing permissions and
10 | # limitations under the License.
11 |
12 | # Google Ads
13 | # To optain Google Ads Developer token refer to:
14 | # https://developers.google.com/google-ads/api/docs/first-call/dev-token
15 | # To obtain client_id and client_secret generate OAuth2 Credentials for Web Application, for guidance refer to:
16 | # https://developers.google.com/google-ads/api/docs/oauth/cloud-project
17 | # In initial setup, under "oauth consent screen", make sure to select "Internal"
18 |
19 | # Config variables for the Google Ads Yaml file.
20 | developer_token = "" # Google Ads API Developer Token
21 | login_customer_id = "" # Google Ads manager account id (without dashes)
22 |
23 | # Application Config variable.
24 | customer_id_inclusion_list = "" # "OPTIONAL inclusion list of Google Ads Customer Ids e.g. "1234,5678", the tool will only port data from the accounts in this list."
25 | spreadsheet_id = "" # Google Spreadsheet ID
26 |
27 | # Config variables for the OAuth Authentication flow.
28 | client_id = "" # "pmax-api" Client ID (from Cloud OAuth credentials)
29 | client_secret = "" # "pmax-api" Client Secret (from Cloud OAuth credentials)
30 | access_token = "" # https://developers.google.com/google-ads/api/docs/oauth/playground#generate_tokens
31 | refresh_token = "" # https://developers.google.com/google-ads/api/docs/oauth/playground#generate_tokens
32 |
33 | # General Cloud Configuration
34 | project_id = "" # Project id were the solution will run.
35 | cloud_function_name = "" # Name of the cloud function running the solution. e.g. pmax-automation
36 | cloud_function_region = "" # Cloud Region where the solutions will run. e.g. europe-west2
37 | cloud_storage_region = "" # Cloud Region for the storage bucket. e.g. europe-west2. Find the full list at https://cloud.google.com/storage/docs/locations
38 | solution_user_list = [""] # Comma separated list of solution users. e.g. ["user:example@example.com"] (gives permissions to publish the pubsub trigger)
39 |
--------------------------------------------------------------------------------
/functions/auth.py:
--------------------------------------------------------------------------------
1 | # Copyright 2023 Google LLC
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the 'License');
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # https://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an 'AS IS' BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 | """Gets the OAuth2 credential from file."""
15 |
16 | from collections.abc import Mapping
17 | from google.auth import exceptions
18 | from google.oauth2 import credentials
19 | import yaml
20 |
21 | _SCOPES = [
22 | "https://www.googleapis.com/auth/drive",
23 | "https://www.googleapis.com/auth/spreadsheets",
24 | "https://www.googleapis.com/auth/adwords",
25 | ]
26 | _TOKEN_URI = "https://oauth2.googleapis.com/token"
27 |
28 |
29 | def get_credentials_from_file(
30 | access_token: str, refresh_token: str, client_id: str, client_secret: str
31 | ) -> Mapping[str, str]:
32 | """Gets the Oauth2 credentials.
33 |
34 | Args:
35 | access_token: OAuth access token.
36 | refresh_token: OAuth refresh token.
37 | client_id: OAuth Client id.
38 | client_secret: OAuth client secret.
39 |
40 | Returns:
41 | An OAuth Credentials object for the authenticated user.
42 |
43 | Raises:
44 | Error when credentials cannot be generated.
45 | """
46 | creds = credentials.Credentials(
47 | token=access_token,
48 | refresh_token=refresh_token,
49 | token_uri=_TOKEN_URI,
50 | client_id=client_id,
51 | client_secret=client_secret,
52 | scopes=_SCOPES,
53 | )
54 | # Creds expired generate new creds using refresh token.
55 | if not creds or not creds.valid:
56 | if creds and creds.expired and creds.refresh_token:
57 | creds = credentials.Credentialsrefresh(
58 | refresh_token, client_id, client_secret
59 | )
60 | # Save the credentials for the next run
61 | with open("config.yaml", "w") as token:
62 | token.write(creds.to_json())
63 | else:
64 | raise exceptions.OAuthError(
65 | "Error while generating OAuth credentials, no credentials returned."
66 | )
67 | return creds
68 |
69 |
70 | if __name__ == "__main__":
71 | with open("config.yaml", "r") as ymlfile:
72 | cfg = yaml.safe_load(ymlfile)
73 | get_credentials_from_file(
74 | cfg["access_token"],
75 | cfg["refresh_token"],
76 | cfg["client_id"],
77 | cfg["client_secret"],
78 | )
79 |
--------------------------------------------------------------------------------
/terraform/api_activation.tf:
--------------------------------------------------------------------------------
1 | # Copyright 2023 Google LLC
2 | # Licensed under the Apache License, Version 2.0 (the "License");
3 | # you may not use this file except in compliance with the License.
4 | # You may obtain a copy of the License at
5 | # https://www.apache.org/licenses/LICENSE-2.0
6 | # Unless required by applicable law or agreed to in writing, software
7 | # distributed under the License is distributed on an "AS IS" BASIS,
8 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9 | # See the License for the specific language governing permissions and
10 | # limitations under the License.
11 |
12 | resource "google_project_service" "cloudfunctions" {
13 | project = var.project_id
14 | service = "cloudfunctions.googleapis.com"
15 | disable_on_destroy = false
16 | }
17 | resource "google_project_service" "logging" {
18 | project = var.project_id
19 | service = "logging.googleapis.com"
20 | disable_on_destroy = false
21 | }
22 | resource "google_project_service" "googleads" {
23 | project = var.project_id
24 | service = "googleads.googleapis.com"
25 | disable_on_destroy = false
26 | }
27 | resource "google_project_service" "cloudtasks" {
28 | project = var.project_id
29 | service = "cloudtasks.googleapis.com"
30 | disable_on_destroy = false
31 | }
32 | resource "google_project_service" "cloudbuild" {
33 | project = var.project_id
34 | service = "cloudbuild.googleapis.com"
35 | disable_on_destroy = false
36 | }
37 | resource "google_project_service" "sheets" {
38 | project = var.project_id
39 | service = "sheets.googleapis.com"
40 | disable_on_destroy = false
41 | }
42 | resource "google_project_service" "drive" {
43 | project = var.project_id
44 | service = "drive.googleapis.com"
45 | disable_on_destroy = false
46 | }
47 | resource "google_project_service" "youtube" {
48 | project = var.project_id
49 | service = "youtube.googleapis.com"
50 | disable_on_destroy = false
51 | }
52 | resource "google_project_service" "pubsub" {
53 | project = var.project_id
54 | service = "pubsub.googleapis.com"
55 | disable_on_destroy = false
56 | }
57 | resource "google_project_service" "eventarc" {
58 | project = var.project_id
59 | service = "eventarc.googleapis.com"
60 | disable_on_destroy = false
61 | }
62 | resource "google_project_service" "artifactregistry" {
63 | project = var.project_id
64 | service = "artifactregistry.googleapis.com"
65 | disable_on_destroy = false
66 | }
67 |
68 | resource "google_project_service" "iam" {
69 | project = var.project_id
70 | service = "iam.googleapis.com"
71 | disable_on_destroy = false
72 | }
73 |
74 | resource "google_project_service" "cloudresourcemanager" {
75 | project = var.project_id
76 | service = "cloudresourcemanager.googleapis.com"
77 | disable_on_destroy = false
78 | }
79 |
80 | resource "google_project_service" "cloudrun" {
81 | project = var.project_id
82 | service = "run.googleapis.com"
83 | disable_on_destroy = false
84 | }
85 |
--------------------------------------------------------------------------------
/terraform/cloud_function.tf:
--------------------------------------------------------------------------------
1 | # Copyright 2023 Google LLC
2 | # Licensed under the Apache License, Version 2.0 (the "License");
3 | # you may not use this file except in compliance with the License.
4 | # You may obtain a copy of the License at
5 | # https://www.apache.org/licenses/LICENSE-2.0
6 | # Unless required by applicable law or agreed to in writing, software
7 | # distributed under the License is distributed on an "AS IS" BASIS,
8 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9 | # See the License for the specific language governing permissions and
10 | # limitations under the License.
11 |
12 | resource "local_file" "ads_config" {
13 | content = templatefile("./config-template.yaml", {
14 | developer_token = "${var.developer_token}",
15 | client_id = "${var.client_id}",
16 | client_secret = "${var.client_secret}",
17 | access_token = "${var.access_token}",
18 | refresh_token = "${var.refresh_token}",
19 | login_customer_id = "${var.login_customer_id}"
20 | customer_id_inclusion_list = "${var.customer_id_inclusion_list}"
21 | spreadsheet_id = "${var.spreadsheet_id}"
22 | })
23 | filename = "../functions/config.yaml"
24 | }
25 |
26 | data "archive_file" "zip_code_repo" {
27 | type = "zip"
28 | source_dir = "../functions/"
29 | output_path = "../../dist/function-source.zip"
30 | depends_on = [
31 | local_file.ads_config
32 | ]
33 | }
34 |
35 | resource "random_id" "bucket_prefix" {
36 | byte_length = 8
37 | }
38 |
39 | resource "google_pubsub_topic" "default" {
40 | name = "performance-max-topic"
41 | }
42 |
43 | resource "google_storage_bucket_object" "cf_upload_object" {
44 | name = "src-${data.archive_file.zip_code_repo.output_md5}.zip"
45 | bucket = google_storage_bucket.cf_upload_bucket.name
46 | source = "../../dist/function-source.zip"
47 | depends_on = [
48 | data.archive_file.zip_code_repo
49 | ]
50 | }
51 |
52 | resource "google_cloudfunctions2_function" "function" {
53 | name = var.cloud_function_name
54 | location = var.cloud_function_region
55 | description = "mad Pmax function"
56 |
57 | build_config {
58 | runtime = "python312"
59 | entry_point = "pmax_trigger" # Set the entry entry_point
60 | source {
61 | storage_source {
62 | bucket = google_storage_bucket.cf_upload_bucket.name
63 | object = google_storage_bucket_object.cf_upload_object.name
64 | }
65 | }
66 | }
67 |
68 | service_config {
69 | available_memory = "1G"
70 | service_account_email = google_service_account.service_account.email
71 | }
72 |
73 | event_trigger {
74 | trigger_region = var.cloud_function_region
75 | event_type = "google.cloud.pubsub.topic.v1.messagePublished"
76 | pubsub_topic = google_pubsub_topic.default.id
77 | retry_policy = "RETRY_POLICY_DO_NOT_RETRY"
78 | service_account_email = google_service_account.service_account.email
79 | }
80 | }
--------------------------------------------------------------------------------
/functions/main.py:
--------------------------------------------------------------------------------
1 | # Copyright 2023 Google LLC
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # https://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 | """Main trigger, Used to run the mad pMax Creative Management tools."""
15 |
16 | import base64
17 | from typing import Final
18 | from absl import logging
19 | from cloudevents.http import CloudEvent
20 | from data_references import ConfigFile
21 | import functions_framework
22 | from google.ads.googleads import client
23 | import pubsub
24 | import yaml
25 |
26 | _CONFIG_FILE_NAME: Final[str] = "config.yaml"
27 | _API_VERSIONAPI_VERSION: Final[str] = "v21"
28 |
29 |
30 | def retrieve_config(config_name: str) -> ConfigFile:
31 | """Retreive configuration for using Google API.
32 |
33 | Args:
34 | config_name: Name of a config file containing API access data.
35 |
36 | Returns:
37 | ConfigFile object representing JSON structure of config file.
38 | """
39 | try:
40 | with open(config_name, "r") as config_file:
41 | return ConfigFile(**yaml.safe_load(config_file))
42 | except (ValueError, TypeError) as ex:
43 | raise TypeError("Wrong structure or type of config file.") from ex
44 |
45 |
46 | @functions_framework.cloud_event
47 | def pmax_trigger(cloud_event: CloudEvent) -> None:
48 | """Listener function for pubsub trigger.
49 |
50 | Based on trigger message activate corresponding mad Max
51 | function.
52 |
53 | Args:
54 | cloud_event: Cloud event class for pubsub event.
55 | """
56 | google_ads_client = client.GoogleAdsClient.load_from_storage(
57 | _CONFIG_FILE_NAME, version=_API_VERSIONAPI_VERSION
58 | )
59 | config = retrieve_config(_CONFIG_FILE_NAME)
60 | pubsub_utils = pubsub.PubSub(config, google_ads_client)
61 | if cloud_event:
62 | logging.info(
63 | "------- START %s EXECUTION -------",
64 | base64.b64decode(cloud_event.data["message"]["data"]).decode()
65 | )
66 | message_data = base64.b64decode(
67 | cloud_event.data["message"]["data"]
68 | ).decode()
69 | match message_data:
70 | case "REFRESH":
71 | pubsub_utils.refresh_spreadsheet()
72 | case "UPLOAD":
73 | pubsub_utils.create_api_operations()
74 | case "DELETE":
75 | pubsub_utils.delete_api_operations()
76 | case "REFRESH_CUSTOMER_LIST":
77 | pubsub_utils.refresh_customer_id_list()
78 | case "REFRESH_CAMPAIGN_LIST":
79 | pubsub_utils.refresh_campaign_list()
80 | case "REFRESH_ASSET_GROUP_LIST":
81 | pubsub_utils.refresh_asset_group_list()
82 | case "REFRESH_ASSETS":
83 | pubsub_utils.refresh_assets_list()
84 | case "REFRESH_SITELINKS":
85 | pubsub_utils.refresh_sitelinks_list()
86 |
87 | logging.info(
88 | "------- END %s EXECUTION -------",
89 | base64.b64decode(cloud_event.data["message"]["data"]).decode()
90 | )
91 |
--------------------------------------------------------------------------------
/functions/appScript/data_validation.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2024 Google LLC
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | /**
18 | * Generate data validation dropdowns for Asset Group Sheet.
19 | * @param {Spreadsheet} spreadSheet Spreadsheet class from Google Sheet API.
20 | * @param {number} column Column index
21 | * @param {number} row Row number of the input
22 | * @param {number} numRows Rowcount of the input
23 | */
24 | function assetGroupDataValidation(
25 | spreadSheet,
26 | column,
27 | row,
28 | numRows,
29 | ) {
30 | if (column <= NEW_ASSET_GROUPS.ASSET_GROUP_NAME + 1) {
31 | const sheet = spreadSheet.getSheetByName(SHEET_NAMES.NEW_ASSET_GROUPS);
32 | const dropDownRange = sheet.getRange(row, NEW_ASSET_GROUPS.CAMPAIGN_NAME + 1);
33 | const accountCell = sheet
34 | .getRange(row, NEW_ASSET_GROUPS.ACCOUNT_NAME + 1)
35 | .getValue();
36 | const userEditedCampaignsValues = getProperty();
37 |
38 | const dropdownArray = Object.keys(userEditedCampaignsValues[accountCell]);
39 | const rule = SpreadsheetApp.newDataValidation()
40 | .requireValueInList(dropdownArray, true)
41 | .build();
42 |
43 | dropDownRange.setDataValidation(rule);
44 |
45 | if (numRows > 1) {
46 | assetGroupDataValidation(spreadSheet, sheetName, row + 1, numRows - 1);
47 | }
48 | }
49 | }
50 |
51 | /**
52 | * Generate data validation dropdowns for Assets Sheet.
53 | * @param {Spreadsheet} spreadSheet Spreadsheet class from Google Sheet API.
54 | * @param {number} column Column index
55 | * @param {number} row Row index of the input
56 | * @param {number} numRows Rowcount of the input
57 | */
58 | function assetDataValidation(
59 | spreadSheet,
60 | column,
61 | row,
62 | numRows,
63 | ) {
64 | if (column <= ASSETS.ASSET_GROUP_NAME + 1) {
65 | const sheet = spreadSheet.getSheetByName(SHEET_NAMES.ASSETS);
66 |
67 | const userEditedProperty = getProperty();
68 | const accountCell = sheet.getRange(row + ':' + row).getValues();
69 | const customer = accountCell[0][ASSETS.ACCOUNT_NAME];
70 |
71 | if (column === ASSETS.ACCOUNT_NAME + 1) {
72 | const dropDown = sheet.getRange(row, ASSETS.CAMPAIGN_NAME + 1);
73 | const campaigns = Object.keys(userEditedProperty[customer]);
74 | const ruleForCampaigns = SpreadsheetApp.newDataValidation()
75 | .requireValueInList(campaigns, true)
76 | .build();
77 | dropDown.setDataValidation(ruleForCampaigns);
78 | }
79 |
80 | if (column === ASSETS.CAMPAIGN_NAME + 1) {
81 | const campaign = accountCell[0][ASSETS.CAMPAIGN_NAME];
82 | const assetGroups = userEditedProperty[customer][campaign];
83 | const dropDown = sheet.getRange(row, ASSETS.ASSET_GROUP_NAME + 1);
84 | const ruleForAssetGroups = SpreadsheetApp.newDataValidation()
85 | .requireValueInList(assetGroups, true)
86 | .build();
87 | dropDown.setDataValidation(ruleForAssetGroups);
88 | }
89 |
90 | if (numRows > 1) {
91 | assetDataValidation(spreadSheet, column, row + 1, numRows - 1);
92 | }
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/functions/drive_api.py:
--------------------------------------------------------------------------------
1 | # Copyright 2023 Google LLC
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # https://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 | """Provides functionality to interact with Google Drive platform."""
15 |
16 | from collections.abc import Mapping
17 | import io
18 | from absl import logging
19 | from googleapiclient import discovery
20 | from googleapiclient import errors
21 | from googleapiclient import http
22 | import requests
23 |
24 |
25 | # The format of Drive URL
26 | _DRIVE_URL = "drive.google.com"
27 |
28 |
29 | class DriveService:
30 | """Provides Drive APIs to download images from Drive."""
31 |
32 | def __init__(self, credential: Mapping[str, str]) -> None:
33 | """Creates a instance of drive service to handle requests.
34 |
35 | Args:
36 | credential: Drive APIs credentials.
37 | """
38 | self._drive_service = discovery.build("drive", "v3", credentials=credential)
39 |
40 | def download_asset_content(self, url: str) -> bytes:
41 | """Downloads an asset based on url, from drive or the web.
42 |
43 | Args:
44 | url: Url to fetch the asset from.
45 |
46 | Returns:
47 | Asset data array.
48 | """
49 | if _DRIVE_URL in url:
50 | file_id = self.extract_file_id(url)
51 | return self._download_drive_asset(file_id)
52 | else:
53 | response = requests.get(url)
54 | return io.BytesIO(response.content).read()
55 |
56 | def extract_file_id(self, image_url: str) -> str:
57 | url_parameters = image_url.split("/")
58 | return url_parameters[url_parameters.index("d") + 1]
59 |
60 | def _download_drive_asset(self, file_id: str) -> bytes:
61 | try:
62 | request = self._drive_service.files().get_media(fileId=file_id)
63 | file = io.BytesIO()
64 | downloader = http.MediaIoBaseDownload(file, request)
65 | done = False
66 | while done is False:
67 | status, done = downloader.next_chunk()
68 | logging.info(f"Download {int(status.progress() * 100)}.")
69 |
70 | except errors.HttpError as error:
71 | logging.error(f"An error occurred: {error}")
72 | file = None
73 |
74 | return file.getvalue()
75 |
76 | def get_file_by_name(self, file_name: str) -> str:
77 | """Retrieves the file by name and returns id.
78 |
79 | Args:
80 | file_name: Name of the spreadsheet file to retrieve.
81 |
82 | Returns:
83 | File id of the spreadsheet.
84 | """
85 | file_id = None
86 | try:
87 | page_token = None
88 | while True:
89 | response = (
90 | self._drive_service.files()
91 | .list(
92 | q=f"name = '{file_name}'",
93 | spaces="drive",
94 | fields="nextPageToken, files(id)",
95 | pageToken=page_token,
96 | )
97 | .execute()
98 | )
99 | for file in response.get("files", []):
100 | file_id = file.get("id")
101 | break
102 | page_token = response.get("nextPageToken", None)
103 | if page_token is None:
104 | break
105 | except http.HttpError as error:
106 | logging.error("An error occurred: %s ", error)
107 | return file_id
108 |
--------------------------------------------------------------------------------
/functions/appScript/enums.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2024 Google LLC
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | /**
18 | * Sheetnames as in the master spreadsheet.
19 | * @enum {string}
20 | */
21 | const SHEET_NAMES = {
22 | CUSTOMERS: 'CustomerList',
23 | CAMPAIGNS: 'CampaignList',
24 | ASSET_GROUPS: 'AssetGroupList',
25 | ASSETS: 'Assets',
26 | SITELINKS: 'Sitelinks',
27 | NEW_CAMPAIGNS: 'NewCampaigns',
28 | NEW_ASSET_GROUPS: 'NewAssetGroups',
29 | };
30 |
31 | /**
32 | * Minimum asset requirements per pmax asset group.
33 | * @enum {number}
34 | */
35 | const REQUIRED_ASSETS = {
36 | HEADLINES: 3,
37 | LONG_HEADLINES: 1,
38 | DESCRIPTIONS: 2,
39 | BUSINESS_NAME: 1,
40 | MARKETING_IMAGE: 1,
41 | SQUARE_IMAGE: 1,
42 | LOGO: 1,
43 | };
44 |
45 | /**
46 | * Column mapping for the NewAssetGroups sheet.
47 | * @enum {number}
48 | */
49 | const NEW_ASSET_GROUPS = {
50 | STATUS: 0,
51 | ASSET_CHECK: 1,
52 | ACCOUNT_NAME: 2,
53 | CAMPAIGN_NAME: 3,
54 | ASSET_GROUP_NAME: 4,
55 | ASSET_GROUP_STATUS: 5,
56 | FINAL_URL: 6,
57 | MOBILE_URL: 7,
58 | PATH1: 8,
59 | PATH2: 9,
60 | HEADLINE1: 10,
61 | HEADLINE2: 11,
62 | HEADLINE3: 12,
63 | DESCRIPTION1: 13,
64 | DESCRIPTION2: 14,
65 | LONG_HEADLINE: 15,
66 | BUSINESS_NAME: 16,
67 | MARKETING_IMAGE: 17,
68 | SQUARE_IMAGE: 18,
69 | LOGO: 19
70 | };
71 |
72 | /**
73 | * Column mapping for the NewCampaigns sheet.
74 | * @enum {number}
75 | */
76 | const NEW_CAMPAIGNS = {
77 | STATUS: 0,
78 | ACCOUNT_NAME: 1,
79 | CAMPAIGN_NAME: 2
80 | };
81 |
82 | /**
83 | * Column mapping for the Assets sheet.
84 | * @enum {number}
85 | */
86 | const ASSETS = {
87 | STATUS: 0,
88 | DELETE_OPTION: 1,
89 | ACCOUNT_NAME: 2,
90 | CAMPAIGN_NAME: 3,
91 | ASSET_GROUP_NAME: 4,
92 | TYPE: 5
93 | };
94 |
95 | /**
96 | * Column mapping for the Sitelinks sheet.
97 | * @enum {number}
98 | */
99 | const SITELINKS = {
100 | STATUS: 0,
101 | DELETE_OPTION: 1,
102 | ACCOUNT_NAME: 2,
103 | CAMPAIGN_NAME: 3,
104 | LINK_TEXT: 4
105 | };
106 |
107 | /**
108 | * Column mapping for the AssetGroupList sheet.
109 | * @enum {number}
110 | */
111 | const ASSET_GROUP_LIST = {
112 | CUSTOMER_NAME: 0,
113 | CUSTOMER_ID: 1,
114 | CAMPAIGN_NAME: 2,
115 | CAMPAIGN_ID: 3,
116 | ASSET_GROUP_NAME: 4,
117 | ASSET_GROUP_ID: 5
118 | };
119 |
120 | /**
121 | * Column mapping for the CampaignList sheet.
122 | * @enum {number}
123 | */
124 | const CAMPAIGN_LIST = {
125 | CUSTOMER_NAME: 0,
126 | CUSTOMER_ID: 1,
127 | CAMPAIGN_NAME: 2,
128 | CAMPAIGN_ID: 3
129 | };
130 |
131 | /**
132 | * Column mapping for the CustomerList sheet.
133 | * @enum {number}
134 | */
135 | const CUSTOMER_LIST = {
136 | CUSTOMER_NAME: 0,
137 | CUSTOMER_ID: 1
138 | };
139 |
140 | /**
141 | * Row Status codes as in the master spreadsheet.
142 | * @enum {string}
143 | */
144 | const ROW_STATUS = {
145 | UPLOADED: 'UPLOADED',
146 | ERROR: 'ERROR'
147 | }
148 |
149 | /**
150 | * Asset Types for pMAx as in the master spreadsheet.
151 | * @enum {string}
152 | */
153 | const ASSET_TYPES = {
154 | HEADLINE: 'HEADLINE',
155 | LONG_HEADLINE: 'LONG_HEADLINE',
156 | DESCRIPTION: 'DESCRIPTION',
157 | BUSINESS: 'BUSINESS',
158 | IMAGE: 'IMAGE',
159 | SQUARE_IMAGE: 'SQUARE_IMAGE',
160 | LOGO: 'LOGO'
161 | }
162 |
--------------------------------------------------------------------------------
/terraform/iam.tf:
--------------------------------------------------------------------------------
1 | # Copyright 2023 Google LLC
2 | # Licensed under the Apache License, Version 2.0 (the "License");
3 | # you may not use this file except in compliance with the License.
4 | # You may obtain a copy of the License at
5 | # https://www.apache.org/licenses/LICENSE-2.0
6 | # Unless required by applicable law or agreed to in writing, software
7 | # distributed under the License is distributed on an "AS IS" BASIS,
8 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9 | # See the License for the specific language governing permissions and
10 | # limitations under the License.
11 |
12 | # COMPUTE SERVICE ACCOUNT --------------------------------------------------------------
13 | resource "google_service_account" "service_account" {
14 | account_id = "mad-pmax-runner"
15 | display_name = "Service Account for running Mad PMax"
16 | }
17 |
18 | resource "google_project_iam_member" "storage_object_viewer_role" {
19 | project = var.project_id
20 | role = "roles/storage.objectViewer"
21 | member = "serviceAccount:${google_service_account.service_account.email}"
22 | }
23 | resource "google_project_iam_member" "logs_writer_role" {
24 | project = var.project_id
25 | role = "roles/logging.logWriter"
26 | member = "serviceAccount:${google_service_account.service_account.email}"
27 | }
28 | resource "google_project_iam_member" "artifact_registry_administrator_role" {
29 | project = var.project_id
30 | role = "roles/artifactregistry.admin"
31 | member = "serviceAccount:${google_service_account.service_account.email}"
32 | }
33 |
34 | resource "google_storage_bucket_iam_member" "member" {
35 | bucket = google_storage_bucket.config.name
36 | role = "roles/storage.objectViewer"
37 | member = "serviceAccount:${google_service_account.service_account.email}"
38 | }
39 |
40 | resource "google_cloudfunctions2_function_iam_member" "eventarc_invoker" {
41 | project = google_cloudfunctions2_function.function.project
42 | location = google_cloudfunctions2_function.function.location
43 | cloud_function = google_cloudfunctions2_function.function.name
44 | role = "roles/cloudfunctions.invoker"
45 | member = "serviceAccount:${google_service_account.service_account.email}"
46 | }
47 |
48 | resource "google_project_iam_binding" "cloud_functions_invoker" {
49 | project = var.project_id
50 | role = "roles/cloudfunctions.invoker"
51 | members = [
52 | "serviceAccount:${google_service_account.service_account.email}"
53 | ]
54 | }
55 |
56 | resource "google_project_iam_binding" "service_account_token" {
57 | project = var.project_id
58 | role = "roles/iam.serviceAccountTokenCreator"
59 | members = [
60 | "serviceAccount:${google_service_account.service_account.email}"
61 | ]
62 | }
63 |
64 | resource "google_project_iam_binding" "pubsub_editor" {
65 | project = var.project_id
66 | role = "roles/pubsub.editor"
67 | members = [
68 | "serviceAccount:${google_service_account.service_account.email}"
69 | ]
70 | }
71 |
72 | resource "google_project_iam_binding" "run_invoker" {
73 | project = var.project_id
74 | role = "roles/run.invoker"
75 | members = [
76 | "serviceAccount:${google_service_account.service_account.email}"
77 | ]
78 | }
79 |
80 | resource "google_project_iam_binding" "eventarc_publisher" {
81 | project = var.project_id
82 | role = "roles/eventarc.publisher"
83 | members = [
84 | "serviceAccount:${google_service_account.service_account.email}"
85 | ]
86 | }
87 |
88 | resource "google_project_iam_binding" "pubsub_publisher" {
89 | project = var.project_id
90 | role = "roles/pubsub.publisher"
91 | members = var.solution_user_list
92 | }
93 |
94 | resource "google_project_iam_binding" "log_writer" {
95 | project = var.project_id
96 | role = "roles/logging.logWriter"
97 | members = [
98 | "serviceAccount:${google_service_account.service_account.email}"
99 | ]
100 | }
101 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Mad PMax: PMax Asset Automation
2 |
3 | [Performance Max (PMax)](https://support.google.com/google-ads/answer/10724817?hl=en-GB) uses the full power of Google AI to help advertisers drive conversions across Google Ads inventory. By optimizing workflows for the creation of PMax campaigns, asset groups, and asset uploads, PMax can scale even better.
4 |
5 | Is there a solution to manage and upload PMax campaigns, asset groups and assets at scale?
6 |
7 | Yes! Mad PMax combines the simplicity of Google Sheets with the power of Google Cloud. Just add
8 | the details (like account name, campaign name, etc.) for your PMax campaigns, asset groups and assets into the ‘Mad PMax’ sheet, and with a single click, your changes are seamlessly uploaded to Google Ads.
9 |
10 | 
11 |
12 | ## Use Cases
13 | * Replicate PMax campaigns at scale
14 | * Upload PMax assets at scale
15 | * Create asset groups for PMax at scale
16 | * Prevent Pmax Setup errors
17 |
18 | ## Requirements
19 | * Google Cloud Project
20 | * Google Ads Developer Token
21 | * Terraform deployment
22 |
23 | ## Instructions
24 |
25 | The Mad Pmax: Performance Max Asset Automation solution can be deployed on Google Cloud through Terraform. See the steps below. This will require roughly 2-4 hours to deploy.
26 |
27 | Use the interactive cloud tutorial to deploy the solution:
28 |
29 | [](https://console.cloud.google.com/?cloudshell=true&cloudshell_git_repo=https://github.com/google-marketing-solutions/madpmax&cloudshell_tutorial=docs/tutorial.md)
30 |
31 | Alternatively, you can find the [full deployment guide here](https://github.com/google-marketing-solutions/madpmax/wiki/Manual-Deployment-Guide).
32 |
33 | ## Using the tool
34 |
35 | Check out the full [User Guide](https://github.com/google-marketing-solutions/madpmax/wiki/User-Guide).
36 |
37 | In brief, you can find the **pMax Execute** menu option in the template spreadsheet with two functions:
38 | * **Refresh Sheet**: loads all existing in your account Campaigns, Asset Groups and Assets into related pages in the spreadsheet
39 | * **Upload to Google Ads**: uploads all new Campaigns, Asset Groups and Assets into your account. All errors will be shown on related pages in the last column
40 |
41 | ### Template Sheet guide
42 |
43 | * **NewCampaigns**: creation of new Campaigns page
44 | * **CampaignList**: page showing existing Campaigns in your account after running *pMax Execute* -> *Refresh Sheet*
45 | * **NewAssetGroup**: creation of new Asset Group
46 | * **AssetGroupList**: page showing existing Asset Groups in your account after running *pMax Execute* -> *Refresh Sheet*
47 | * **Assets**: contains Assets to create and existing Assets in the account after running *pMax Execute* -> *Refresh Sheet*
48 | * **Customer List**: list of cutomers for the application
49 | * **Sitelinks**: page to see existing Sitelinks or create new ones
50 |
51 | Choose customers you would like to use for the application in **Customer List**.
52 | Use drop down menu on creation pages (NewCampaign, NewAssetGroup, Assets) to assign new Asset, Asset Group and Campaigns to the correct accounts and campaigns.
53 |
54 | ## Disclaimer
55 | __This is not an officially supported Google product.__
56 |
57 | Copyright 2024 Google LLC. This solution, including any related sample code or
58 | data, is made available on an "as is", "as available", and "with all faults"
59 | basis, solely for illustrative purposes, and without warranty or representation
60 | of any kind. This solution is experimental, unsupported and provided solely for
61 | your convenience. Your use of it is subject to your agreements with Google, as
62 | applicable, and may constitute a beta feature as defined under those agreements.
63 | To the extent that you make any data available to Google in connection with your
64 | use of the solution, you represent and warrant that you have all necessary and
65 | appropriate rights, consents and permissions to permit Google to use and process
66 | that data. By using any portion of this solution, you acknowledge, assume and
67 | accept all risks, known and unknown, associated with its usage, including with
68 | respect to your deployment of any portion of this solution in your systems, or
69 | usage in connection with your business, if at all.
70 |
--------------------------------------------------------------------------------
/functions/appScript/custom_menu.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2024 Google LLC
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | /**
18 | * The event handler triggered when opening the spreadsheet.
19 | * Triggers the generation of a custom UI menu, which can be used to
20 | * trigger further functionalities in the solution, through PubSub Triggers.
21 | * @param {Event} e The onOpen event.
22 | */
23 | function onOpen(e) {
24 | updateUploadedValuesIntoProperty();
25 | const ui = SpreadsheetApp.getUi();
26 |
27 | ui.createMenu('pMax Execute')
28 | .addSubMenu(
29 | ui
30 | .createMenu('Refresh Spreadsheet')
31 | .addItem('Refresh all sheets', 'pubsubRefreshAllRequest')
32 | .addSeparator()
33 | .addItem('CustomerList', 'pubsubRefreshCustomersRequest')
34 | .addItem('CampaignList', 'pubsubRefreshCampaignsRequest')
35 | .addItem('AssetGroupList', 'pubsubRefreshAssetGroupsRequest')
36 | .addItem('Assets', 'pubsubRefreshAssetsRequest')
37 | .addItem('Sitelinks', 'pubsubRefreshSitelinksRequest'),
38 | )
39 | .addSeparator()
40 | .addItem('Upload to Google Ads', 'pubsubUploadRequest')
41 | .addSeparator()
42 | .addItem('Delete Assets', 'pubsubDeleteRequest')
43 | .addToUi();
44 | }
45 |
46 | /**
47 | * Generate a PubSub trigger to the Cloud Project.
48 | * PubSub Trigger will be processed and based on the attached attribute
49 | * It will run the related action in the Cloud Function.
50 | */
51 | function pubsubRefreshAllRequest() {
52 | const attr = {
53 | id: 'madmax',
54 | value: 'run_all',
55 | };
56 | pubsub(PROJECT_ID, PUBSUB_TOPIC, attr, 'REFRESH');
57 | updateUploadedValuesIntoProperty();
58 | }
59 |
60 | /**
61 | * Generate a PubSub trigger to the Cloud Project.
62 | * PubSub Trigger will be processed and based on the attached attribute
63 | * It will run the related action in the Cloud Function.
64 | */
65 | function pubsubRefreshCustomersRequest() {
66 | const attr = {
67 | id: 'madmax',
68 | value: 'run_all',
69 | };
70 | pubsub(PROJECT_ID, PUBSUB_TOPIC, attr, 'REFRESH_CUSTOMER_LIST');
71 | updateUploadedValuesIntoProperty();
72 | }
73 |
74 | /**
75 | * Generate a PubSub trigger to the Cloud Project.
76 | * PubSub Trigger will be processed and based on the attached attribute
77 | * It will run the related action in the Cloud Function.
78 | */
79 | function pubsubRefreshCampaignsRequest() {
80 | const attr = {
81 | id: 'madmax',
82 | value: 'run_all',
83 | };
84 | pubsub(PROJECT_ID, PUBSUB_TOPIC, attr, 'REFRESH_CAMPAIGN_LIST');
85 | updateUploadedValuesIntoProperty();
86 | }
87 |
88 | /**
89 | * Generate a PubSub trigger to the Cloud Project.
90 | * PubSub Trigger will be processed and based on the attached attribute
91 | * It will run the related action in the Cloud Function.
92 | */
93 | function pubsubRefreshAssetGroupsRequest() {
94 | const attr = {
95 | id: 'madmax',
96 | value: 'run_all',
97 | };
98 | pubsub(PROJECT_ID, PUBSUB_TOPIC, attr, 'REFRESH_ASSET_GROUP_LIST');
99 | updateUploadedValuesIntoProperty();
100 | }
101 |
102 | /**
103 | * Generate a PubSub trigger to the Cloud Project.
104 | * PubSub Trigger will be processed and based on the attached attribute
105 | * It will run the related action in the Cloud Function.
106 | */
107 | function pubsubRefreshAssetsRequest() {
108 | const attr = {
109 | id: 'madmax',
110 | value: 'run_all',
111 | };
112 | pubsub(PROJECT_ID, PUBSUB_TOPIC, attr, 'REFRESH_ASSETS');
113 | updateUploadedValuesIntoProperty();
114 | }
115 |
116 | /**
117 | * Generate a PubSub trigger to the Cloud Project.
118 | * PubSub Trigger will be processed and based on the attached attribute
119 | * It will run the related action in the Cloud Function.
120 | */
121 | function pubsubRefreshSitelinksRequest() {
122 | const attr = {
123 | id: 'madmax',
124 | value: 'run_all',
125 | };
126 | pubsub(PROJECT_ID, PUBSUB_TOPIC, attr, 'REFRESH_SITELINKS');
127 | updateUploadedValuesIntoProperty();
128 | }
129 |
130 | /**
131 | * Generate a PubSub trigger to the Cloud Project.
132 | * PubSub Trigger will be processed and based on the attached attribute
133 | * It will run the related action in the Cloud Function.
134 | */
135 | function pubsubUploadRequest() {
136 | const attr = {
137 | id: 'madmax',
138 | value: 'run_all',
139 | };
140 | pubsub(PROJECT_ID, PUBSUB_TOPIC, attr, 'UPLOAD');
141 | }
142 |
143 | /**
144 | * Generate a PubSub trigger to the Cloud Project.
145 | * PubSub Trigger will be processed and based on the attached attribute
146 | * It will run the related action in the Cloud Function.
147 | */
148 | function pubsubDeleteRequest() {
149 | const attr = {
150 | id: 'madmax',
151 | value: 'run_all',
152 | };
153 | pubsub(PROJECT_ID, PUBSUB_TOPIC, attr, 'DELETE');
154 | }
155 |
--------------------------------------------------------------------------------
/functions/test/test_asset_value_by_type.py:
--------------------------------------------------------------------------------
1 | # Copyright 2024 Google LLC
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | from unittest import mock
16 | from asset_creation import AssetService
17 | import pytest
18 |
19 |
20 | # Run this test from funtions folder with: python -m pytest tests/test_asset_value_by_type.py
21 | @pytest.mark.parametrize(
22 | "type_input,expected",
23 | [
24 | ("HEADLINE", "Let's test it"),
25 | ("BUSINESS_NAME", "Let's test it"),
26 | ("DESCRIPTION", "Let's test it"),
27 | ("LONG_HEADLINE", "Let's test it"),
28 | ("CALL_TO_ACTION_SELECTION", "BOOK NOW"),
29 | ("YOUTUBE_VIDEO", "https://www.exmaple_url.me"),
30 | ("MARKETING_IMAGE", "https://www.exmaple_url.me"),
31 | ("SQUARE_MARKETING_IMAGE", "https://www.exmaple_url.me"),
32 | ("PORTRAIT_MARKETING_IMAGE", "https://www.exmaple_url.me"),
33 | ("LOGO", "https://www.exmaple_url.me"),
34 | ("LANDSCAPE_LOGO", "https://www.exmaple_url.me"),
35 | ],
36 | )
37 | def test_get_asset_value_for_all_assets(type_input, expected):
38 | google_ads_service = mock.MagicMock()
39 | google_ads_client = mock.Mock()
40 | sheet_service = mock.MagicMock()
41 | asset_service = AssetService(
42 | google_ads_client, google_ads_service, sheet_service
43 | )
44 |
45 | input_sheet_row = [
46 | "",
47 | "flase",
48 | "Test account",
49 | "Test Camapign",
50 | "Test Asset Gtoup Name",
51 | "HEADLINE",
52 | "Let's test it",
53 | "BOOK NOW",
54 | "https://www.exmaple_url.me",
55 | "abcd",
56 | "",
57 | ]
58 | result = asset_service.get_asset_value_by_type(input_sheet_row, type_input)
59 |
60 | assert result == expected
61 |
62 |
63 | @pytest.mark.parametrize(
64 | "type_input",
65 | [
66 | "MARKETING_IMAGE",
67 | "SQUARE_MARKETING_IMAGE",
68 | "PORTRAIT_MARKETING_IMAGE",
69 | "LOGO",
70 | "LANDSCAPE_LOGO",
71 | ],
72 | )
73 | @mock.patch("asset_creation.AssetService.create_image_asset")
74 | @mock.patch("validators.url")
75 | def test_create_image_asset_for_images_types(
76 | mock_validators_url, mock_create_image_asset, type_input
77 | ):
78 | google_ads_service = mock.MagicMock()
79 | google_ads_client = mock.Mock()
80 | sheet_service = mock.MagicMock()
81 | asset_service = AssetService(
82 | google_ads_client, google_ads_service, sheet_service
83 | )
84 | mock_create_image_asset.return_value = "Image object let's say"
85 | mock_validators_url.return_value = True
86 | result = asset_service.create_asset(
87 | type_input, "Test Image Asset", "customer10"
88 | )
89 |
90 | assert result == "Image object let's say"
91 |
92 |
93 | @pytest.mark.parametrize(
94 | "type_input",
95 | [
96 | "HEADLINE",
97 | "BUSINESS_NAME",
98 | "DESCRIPTION",
99 | "LONG_HEADLINE",
100 | ],
101 | )
102 | @mock.patch("asset_creation.AssetService.create_text_asset")
103 | @mock.patch("validators.url")
104 | def test_create_text_asset_for_text_types(
105 | mock_validators_url, mock_create_text_asset, type_input
106 | ):
107 | google_ads_service = mock.MagicMock()
108 | google_ads_client = mock.Mock()
109 | sheet_service = mock.MagicMock()
110 | asset_service = AssetService(
111 | google_ads_client, google_ads_service, sheet_service
112 | )
113 | mock_create_text_asset.return_value = "Now it is a text object"
114 | mock_validators_url.return_value = True
115 | result = asset_service.create_asset(type_input, "Test Asset", "customer10")
116 |
117 | assert result == "Now it is a text object"
118 |
119 |
120 | @pytest.mark.parametrize(
121 | "type_input,error",
122 | [
123 | ("HEADLINE", "Asset text is required to create a HEADLINE Asset"),
124 | (
125 | "BUSINESS_NAME",
126 | "Asset text is required to create a BUSINESS_NAME Asset",
127 | ),
128 | ("DESCRIPTION", "Asset text is required to create a DESCRIPTION Asset"),
129 | (
130 | "LONG_HEADLINE",
131 | "Asset text is required to create a LONG_HEADLINE Asset",
132 | ),
133 | (
134 | "CALL_TO_ACTION_SELECTION",
135 | (
136 | "Call to action is required to create a"
137 | " CALL_TO_ACTION_SELECTION Asset"
138 | ),
139 | ),
140 | ("YOUTUBE_VIDEO", "Asset URL YOUTUBE_VIDEO is not a valid URL"),
141 | ("MARKETING_IMAGE", "Asset URL MARKETING_IMAGE is not a valid URL"),
142 | (
143 | "SQUARE_MARKETING_IMAGE",
144 | "Asset URL SQUARE_MARKETING_IMAGE is not a valid URL",
145 | ),
146 | (
147 | "PORTRAIT_MARKETING_IMAGE",
148 | "Asset URL PORTRAIT_MARKETING_IMAGE is not a valid URL",
149 | ),
150 | ("LOGO", "Asset URL LOGO' is not a valid URL"),
151 | ("LANDSCAPE_LOGO", "Asset URL LANDSCAPE_LOGO is not a valid URL"),
152 | ],
153 | )
154 | def test_rise_error_when_no_asset_value_for_create_asset(type_input, error):
155 | google_ads_service = mock.MagicMock()
156 | google_ads_client = mock.Mock()
157 | sheet_service = mock.MagicMock()
158 | asset_service = AssetService(
159 | google_ads_client, google_ads_service, sheet_service
160 | )
161 | with pytest.raises(ValueError, match=error):
162 | asset_service.create_asset(type_input, None, "customer10")
163 |
--------------------------------------------------------------------------------
/functions/test/utils_test.py:
--------------------------------------------------------------------------------
1 | # Copyright 2024 Google LLC
2 |
3 | # Licensed under the Apache License, Version 2.0 (the "License")
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 |
7 | # https: // www.apache.org / licenses / LICENSE - 2.0
8 |
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 | """Tests for Utils Module."""
15 |
16 | import unittest
17 | from unittest import mock
18 | import data_references
19 | import utils
20 |
21 |
22 | class TestUtils(unittest.TestCase):
23 | """Test utils."""
24 |
25 | def setUp(self):
26 | super().setUp()
27 | self.google_ads_service = mock.MagicMock()
28 | self.sheet_service = mock.MagicMock()
29 |
30 | @mock.patch("sheet_api.SheetsService")
31 | def test_retrieve_customer_id(self, mock_sheets_service):
32 | """Test Retrieve Customer ID when match in sheet."""
33 | customer_name = "customer_name_1"
34 | customer_id = "customer_id_1"
35 | mock_sheets_service.get_sheet_values.return_value = [
36 | [customer_name, customer_id]]
37 |
38 | self.assertEqual(utils.retrieve_customer_id(
39 | customer_name, mock_sheets_service), customer_id)
40 |
41 | @mock.patch("sheet_api.SheetsService")
42 | def test_retrieve_customer_id_not_in_sheet(self, mock_sheets_service):
43 | """Test Retrieve Customer ID when no match in sheet."""
44 | mock_sheets_service.get_sheet_values.return_value = [
45 | ["Other_Customer", "Other_Customer_Id"]]
46 |
47 | self.assertIsNone(utils.retrieve_customer_id(
48 | "customer_name_1", mock_sheets_service))
49 |
50 | @mock.patch("utils.process_api_response_and_errors")
51 | def test_process_operations(self, mock_process_api_response_and_errors):
52 | """Test processing of API operations.
53 |
54 | Validates if the relevant function calls are made, based on input.
55 | """
56 | mock_process_api_response_and_errors.return_value = True
57 | self.sheet_service.get_sheet_id.return_value = "1234abd"
58 | self.google_ads_service.bulk_mutate.return_value = ("dummy_response", "")
59 |
60 | utils.process_operations_and_errors(
61 | "customer_id_1",
62 | ("dummy_budget_operation", "dummy_campaign_operation"),
63 | "",
64 | 1,
65 | self.sheet_service,
66 | self.google_ads_service,
67 | "Sitelinks"
68 | )
69 |
70 | self.google_ads_service.bulk_mutate.assert_called_once_with(
71 | ("dummy_budget_operation", "dummy_campaign_operation"),
72 | "customer_id_1"
73 | )
74 |
75 | def test_process_operations_errors(self):
76 | """Test processing of API operation errors.
77 |
78 | Validates if the relevant function calls are made, based on input.
79 | """
80 | self.sheet_service.get_sheet_id.return_value = "1234abd"
81 | self.google_ads_service.bulk_mutate.return_value = (None, "dummy_response")
82 |
83 | utils.process_operations_and_errors(
84 | "customer_id_1",
85 | ("dummy_budget_operation", "dummy_campaign_operation"),
86 | "",
87 | 1,
88 | self.sheet_service,
89 | self.google_ads_service,
90 | "NewCampaigns"
91 | )
92 |
93 | self.sheet_service.variable_update_sheet_status.assert_called_once_with(
94 | 1,
95 | "1234abd",
96 | data_references.NewCampaigns.campaign_upload_status,
97 | data_references.RowStatus.error,
98 | error_message="dummy_response",
99 | error_col_id=data_references.NewCampaigns.error_message,
100 | )
101 |
102 | def test_process_api_response_and_errors_no_errors(self):
103 | """Test processing of API response.
104 |
105 | Validates if the relevant function calls are made, based on input.
106 | """
107 | mock_response = mock.MagicMock()
108 | mock_response.mutate_operation_responses[
109 | 1].campaign_asset_result.resource_name = "Test Resource Name"
110 |
111 | self.sheet_service.get_sheet_id.return_value = "1234abd"
112 |
113 | utils.process_api_response_and_errors(
114 | mock_response,
115 | "",
116 | 1,
117 | "sheetid_1234",
118 | "Sitelinks",
119 | self.sheet_service,
120 | )
121 |
122 | self.sheet_service.variable_update_sheet_status.assert_called_once_with(
123 | 1,
124 | "sheetid_1234",
125 | data_references.Sitelinks.upload_status,
126 | data_references.RowStatus.uploaded,
127 | error_message="",
128 | error_col_id=data_references.Sitelinks.error_message,
129 | resource_name="Test Resource Name",
130 | resource_col_id=data_references.Sitelinks.sitelink_resource,
131 | )
132 | self.sheet_service.refresh_campaign_list.assert_called_once()
133 |
134 | def test_process_api_response_and_errors_with_errors(self):
135 | """Test processing of API Errors.
136 |
137 | Validates if the relevant function calls are made, based on input.
138 | """
139 | self.sheet_service.get_sheet_id.return_value = "1234abd"
140 |
141 | utils.process_api_response_and_errors(
142 | None,
143 | "dummy_response",
144 | 1,
145 | "sheetid_1234",
146 | "Sitelinks",
147 | self.sheet_service,
148 | )
149 |
150 | self.sheet_service.variable_update_sheet_status.assert_called_once_with(
151 | 1,
152 | "sheetid_1234",
153 | data_references.Sitelinks.upload_status,
154 | data_references.RowStatus.error,
155 | error_message="dummy_response",
156 | error_col_id=data_references.Sitelinks.error_message,
157 | )
158 |
159 | @mock.patch("sheet_api.SheetsService")
160 | def test_retrieve_campaign_id(self, mock_sheets_service):
161 | """Test Retrieve campaign ID when match in sheet."""
162 | customer_name = "customer_name_1"
163 | customer_id = "customer_id_1"
164 | campaign_name = "campaign_name_1"
165 | campaign_id = "campaign_id_1"
166 | mock_sheets_service.get_sheet_values.return_value = [
167 | [customer_name, customer_id, campaign_name, campaign_id]
168 | ]
169 |
170 | self.assertEqual(
171 | utils.retrieve_campaign_id(
172 | customer_name, campaign_name, mock_sheets_service),
173 | (customer_id, campaign_id))
174 |
175 | @mock.patch("sheet_api.SheetsService")
176 | def test_retrieve_campaign_id_not_in_sheet(self, mock_sheets_service):
177 | """Test Retrieve campaign ID when no match in sheet."""
178 | mock_sheets_service.get_sheet_values.return_value = [
179 | ["Other_campaign", "Other_campaign_Id"]]
180 |
181 | self.assertIsNone(utils.retrieve_campaign_id(
182 | "customer_name_1", "campaign_name_1", mock_sheets_service))
183 |
--------------------------------------------------------------------------------
/functions/test/test_sheet_api.py:
--------------------------------------------------------------------------------
1 | # Copyright 2024 Google LLC
2 |
3 | # Licensed under the Apache License, Version 2.0 (the "License")
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 |
7 | # https: // www.apache.org / licenses / LICENSE - 2.0
8 |
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 | """Tests for Sheet Service."""
15 |
16 | from collections import namedtuple
17 | import unittest
18 | from unittest import mock
19 | import data_references
20 | from sheet_api import SheetsService
21 |
22 |
23 | class TestSheetService(unittest.TestCase):
24 |
25 | def setUp(self):
26 | super().setUp()
27 | self.credentials = mock.Mock()
28 | self._google_ads_client = mock.Mock()
29 | self.google_ads_service = mock.MagicMock()
30 | self.sheet_service = SheetsService(
31 | self.credentials, self._google_ads_client, self.google_ads_service
32 | )
33 |
34 | @mock.patch("sheet_api.SheetsService.update_asset_sheet_output")
35 | @mock.patch("sheet_api.SheetsService.update_sheet_lists")
36 | def test_refresh_assets_list(
37 | self, mock_update_sheet_lists, mock_update_asset_sheet_output
38 | ):
39 | Customer = namedtuple("Customer", ["id"])
40 | ResultRow = namedtuple("ResultRow", ["customer_client"])
41 | retrieve_all_customers = [
42 | ResultRow(customer_client=Customer(id="customer1")),
43 | ResultRow(customer_client=Customer(id="customer2")),
44 | ]
45 | mock_update_asset_sheet_output.return_value = None
46 |
47 | self.google_ads_service.retrieve_all_customers.return_value = (
48 | retrieve_all_customers
49 | )
50 | mock_update_sheet_lists.return_value = {
51 | "customer1": {"Campaign1", "Campaign2"},
52 | "customer2": {"Test Campaign1"},
53 | }
54 |
55 | self.sheet_service.refresh_assets_list()
56 | self.google_ads_service.retrieve_all_assets.assert_has_calls(
57 | [mock.call("customer1"), mock.call("customer2")]
58 | )
59 |
60 | @mock.patch("sheet_api.SheetsService.update_asset_sheet_output")
61 | @mock.patch("sheet_api.SheetsService.update_sheet_lists")
62 | def test_update_asset_sheet_output(
63 | self, mock_update_sheet_lists, mock_update_asset_sheet_output
64 | ):
65 | Customer = namedtuple("Customer", ["id"])
66 | ResultRow = namedtuple("ResultRow", ["customer_client"])
67 | retrieve_all_customers = [
68 | ResultRow(customer_client=Customer(id="customer1")),
69 | ResultRow(customer_client=Customer(id="customer2")),
70 | ]
71 |
72 | AssetGroupAsset = namedtuple("AssetGroupAsset", ["resource_name"])
73 | AssetGroupRow = namedtuple("AssetGroupRow", ["asset_group_asset"])
74 |
75 | refresh_results = [
76 | AssetGroupRow(
77 | asset_group_asset=AssetGroupAsset(resource_name="resource_name1")
78 | ),
79 | AssetGroupRow(
80 | asset_group_asset=AssetGroupAsset(resource_name="resource_name2")
81 | ),
82 | ]
83 | self.google_ads_service.retrieve_all_customers.return_value = (
84 | retrieve_all_customers
85 | )
86 | account_map = {
87 | "customer1": {"Campaign1", "Campaign2"},
88 | "customer2": {"Test Campaign1"},
89 | }
90 | mock_update_sheet_lists.return_value = account_map
91 | self.google_ads_service.retrieve_all_assets.return_value = refresh_results
92 |
93 | self.sheet_service.refresh_assets_list()
94 |
95 | mock_update_asset_sheet_output.assert_has_calls([
96 | mock.call(refresh_results, account_map),
97 | mock.call(refresh_results, account_map),
98 | ])
99 |
100 | @mock.patch("sheet_api.SheetsService.update_sitelink_sheet_output")
101 | @mock.patch("sheet_api.SheetsService.update_asset_sheet_output")
102 | @mock.patch("sheet_api.SheetsService._set_cell_value")
103 | @mock.patch("sheet_api.SheetsService.update_sheet_lists")
104 | def test_update_asset_sheet_output_with_empty_campaign_for_first_customer(
105 | self,
106 | mock_update_sheet_lists,
107 | mock_set_cell_value,
108 | mock_update_asset_sheet_output,
109 | mock_update_sitelink_sheet_output,
110 | ):
111 | Customer = namedtuple("Customer", ["id"])
112 | ResultRow = namedtuple("ResultRow", ["customer_client"])
113 | retrieve_all_customers = [
114 | ResultRow(customer_client=Customer(id="customer1")),
115 | ResultRow(customer_client=Customer(id="customer2")),
116 | ]
117 |
118 | self.google_ads_service.retrieve_all_customers.return_value = (
119 | retrieve_all_customers
120 | )
121 | account_map = {
122 | "customer1": {},
123 | "customer2": {"Test Campaign1"},
124 | }
125 | mock_update_sheet_lists.return_value = account_map
126 | self.google_ads_service.retrieve_all_campaigns.side_effect = [
127 | {},
128 | {"Test Campaign1"},
129 | ]
130 | self.google_ads_service.retrieve_all_asset_groups.return_value = {}
131 | self.sheet_service.refresh_spreadsheet()
132 |
133 | mock_update_sheet_lists.assert_has_calls([
134 | mock.call(
135 | retrieve_all_customers,
136 | data_references.SheetNames.customers,
137 | "!B:B",
138 | {},
139 | ),
140 | mock.call(
141 | {"Test Campaign1"},
142 | data_references.SheetNames.campaigns,
143 | "!D:D",
144 | account_map,
145 | ),
146 | ])
147 |
148 | @mock.patch("sheet_api.SheetsService.update_sitelink_sheet_output")
149 | @mock.patch("sheet_api.SheetsService.update_asset_sheet_output")
150 | @mock.patch("sheet_api.SheetsService._set_cell_value")
151 | @mock.patch("sheet_api.SheetsService.update_sheet_lists")
152 | def test_update_asset_sheet_output_with_empty_asset_group_for_first_customer(
153 | self,
154 | mock_update_sheet_lists,
155 | mock_set_cell_value,
156 | mock_update_asset_sheet_output,
157 | mock_update_sitelink_sheet_output,
158 | ):
159 | Customer = namedtuple("Customer", ["id"])
160 | ResultRow = namedtuple("ResultRow", ["customer_client"])
161 | retrieve_all_customers = [
162 | ResultRow(customer_client=Customer(id="customer1")),
163 | ResultRow(customer_client=Customer(id="customer2")),
164 | ]
165 |
166 | self.google_ads_service.retrieve_all_customers.return_value = (
167 | retrieve_all_customers
168 | )
169 | account_map = {
170 | "customer1": {"Campaign1"},
171 | "customer2": {"Test Campaign1"},
172 | }
173 | mock_update_sheet_lists.return_value = account_map
174 | self.google_ads_service.retrieve_all_campaigns.side_effect = [
175 | {"Campaign1"},
176 | {"Test Campaign1"},
177 | ]
178 | self.google_ads_service.retrieve_all_asset_groups.side_effect = [
179 | {},
180 | {"AssertGroup1"},
181 | ]
182 |
183 | self.sheet_service.refresh_spreadsheet()
184 |
185 | mock_update_sheet_lists.assert_has_calls([
186 | mock.call(
187 | retrieve_all_customers,
188 | data_references.SheetNames.customers,
189 | "!B:B",
190 | {},
191 | ),
192 | mock.call(
193 | {"Campaign1"},
194 | data_references.SheetNames.campaigns,
195 | "!D:D",
196 | account_map,
197 | ),
198 | mock.call(
199 | {"Test Campaign1"},
200 | data_references.SheetNames.campaigns,
201 | "!D:D",
202 | account_map,
203 | ),
204 | mock.call(
205 | {"AssertGroup1"},
206 | data_references.SheetNames.asset_groups,
207 | "!F:F",
208 | account_map,
209 | ),
210 | ])
211 |
--------------------------------------------------------------------------------
/functions/pubsub.py:
--------------------------------------------------------------------------------
1 | # Copyright 2023 Google LLC
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # https://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 | """Main function, Used to run the mad pMax Creative Management tools."""
15 |
16 | from functools import cached_property
17 | from absl import logging
18 | import ads_api
19 | import asset_creation
20 | import asset_group_creation
21 | import auth
22 | import campaign_creation
23 | import data_references
24 | import drive_api
25 | from google.ads.googleads import client
26 | import sheet_api
27 | import sitelink_creation
28 |
29 |
30 | class PubSub:
31 | """Main function to call update and refresh for spreadsheet functionality."""
32 |
33 | def __init__(
34 | self,
35 | config: data_references.ConfigFile,
36 | google_ads_client: client.GoogleAdsClient,
37 | ) -> None:
38 | """Constructs the PubSub instance.
39 |
40 | Args:
41 | config: JSON formatted configuration data for accessing API.
42 | google_ads_client: Instance of Google Ads API client.
43 |
44 | Returns:
45 | None. Initiates instances during the call.
46 | """
47 | self.config = config
48 | self.google_ads_client = google_ads_client
49 |
50 | @cached_property
51 | def credentials(self):
52 | return auth.get_credentials_from_file(
53 | self.config.access_token,
54 | self.config.refresh_token,
55 | self.config.client_id,
56 | self.config.client_secret,
57 | )
58 |
59 | @cached_property
60 | def login_customer_id(self):
61 | return self.config.login_customer_id
62 |
63 | @cached_property
64 | def google_ads_service(self):
65 | return ads_api.AdService(self.google_ads_client)
66 |
67 | @cached_property
68 | def drive_service(self):
69 | return drive_api.DriveService(self.credentials)
70 |
71 | @cached_property
72 | def sheet_service(self):
73 | return sheet_api.SheetsService(
74 | self.credentials, self.google_ads_client, self.google_ads_service
75 | )
76 |
77 | @cached_property
78 | def campaign_service(self):
79 | return campaign_creation.CampaignService(
80 | self.google_ads_service, self.sheet_service, self.google_ads_client
81 | )
82 |
83 | @cached_property
84 | def asset_service(self):
85 | return asset_creation.AssetService(
86 | self.google_ads_client,
87 | self.google_ads_service,
88 | self.sheet_service,
89 | self.drive_service,
90 | )
91 |
92 | @cached_property
93 | def asset_deletion_service(self):
94 | return asset_deletion.AssetDeletionService(
95 | self.google_ads_client, self.google_ads_service, self.sheet_service
96 | )
97 |
98 | @cached_property
99 | def asset_group_service(self):
100 | return asset_group_creation.AssetGroupService(
101 | self.google_ads_service,
102 | self.sheet_service,
103 | self.asset_service,
104 | self.google_ads_client,
105 | )
106 |
107 | @cached_property
108 | def sitelink_service(self):
109 | return sitelink_creation.SitelinkService(
110 | self.sheet_service, self.google_ads_service, self.google_ads_client
111 | )
112 |
113 | def refresh_spreadsheet(self) -> None:
114 | """Requests the overall data from the Ads API and updates the spreadsheet."""
115 | self.sheet_service.refresh_spreadsheet()
116 |
117 | def refresh_customer_id_list(self) -> None:
118 | """Requests customer list data from the Ads API and updates the spreadsheet."""
119 | self.sheet_service.refresh_customer_id_list()
120 |
121 | def refresh_campaign_list(self) -> None:
122 | """Requests campaign data from the Ads API and updates the spreadsheet."""
123 | self.sheet_service.refresh_campaign_list()
124 |
125 | def refresh_asset_group_list(self) -> None:
126 | """Requests asset group data from the Ads API and updates the spreadsheet."""
127 | self.sheet_service.refresh_asset_group_list()
128 |
129 | def refresh_assets_list(self) -> None:
130 | """Requests assets data from the Ads API and updates the spreadsheet."""
131 | self.sheet_service.refresh_assets_list()
132 |
133 | def refresh_sitelinks_list(self) -> None:
134 | """Requests sitelinks data from the Ads API and updates the spreadsheet."""
135 | self.sheet_service.refresh_sitelinks_list()
136 |
137 | def delete_api_operations(self) -> None:
138 | """Reads the asset data from the input sheet and deletes assets.
139 |
140 | For the assets provided. Removes the provided assets, and
141 | writes the results back to the spreadsheet.
142 | """
143 | logging.info("Processing Assets Deletion data")
144 | asset_data = self.sheet_service.get_sheet_values(
145 | data_references.SheetNames.assets
146 | + "!"
147 | + data_references.SheetRanges.assets
148 | )
149 |
150 | if asset_data:
151 | logging.info("Delete Assets")
152 | self.asset_deletion_service.asset_deletion(
153 | asset_data
154 | )
155 |
156 | def create_api_operations(self) -> None:
157 | """Reads the campaigns and asset groups from the input sheet, creates assets.
158 |
159 | For the assets provided. Removes the provided placeholder assets, and
160 | writes the results back to the spreadsheet.
161 | """
162 | logging.info("Processing NewCampaigns data")
163 | new_campaign_data = self.sheet_service.get_sheet_values(
164 | data_references.SheetNames.new_campaigns
165 | + "!"
166 | + data_references.SheetRanges.new_campaigns
167 | )
168 | logging.info("Processing Sitelink data")
169 | sitelink_data = self.sheet_service.get_sheet_values(
170 | data_references.SheetNames.sitelinks
171 | + "!"
172 | + data_references.SheetRanges.sitelinks
173 | )
174 | logging.info("Processing Assets data")
175 | asset_data = self.sheet_service.get_sheet_values(
176 | data_references.SheetNames.assets
177 | + "!"
178 | + data_references.SheetRanges.assets
179 | )
180 | logging.info("Processing New Asset Groups data")
181 | new_asset_group_data = self.sheet_service.get_sheet_values(
182 | data_references.SheetNames.new_asset_groups
183 | + "!"
184 | + data_references.SheetRanges.new_asset_groups
185 | )
186 | logging.info("Processing Asset Groups data")
187 | asset_group_data = self.sheet_service.get_sheet_values(
188 | data_references.SheetNames.asset_groups
189 | + "!"
190 | + data_references.SheetRanges.asset_groups
191 | )
192 | logging.info("Processing Campaign data")
193 | campaign_data = self.sheet_service.get_sheet_values(
194 | data_references.SheetNames.campaigns
195 | + "!"
196 | + data_references.SheetRanges.campaigns
197 | )
198 |
199 | if new_campaign_data:
200 | logging.info("Creating new Campaigns")
201 | self.campaign_service.process_campaign_input_sheet(new_campaign_data)
202 | self.refresh_campaign_list()
203 |
204 | if new_asset_group_data and campaign_data:
205 | logging.info("Creating new Asset Groups")
206 | self.asset_group_service.process_asset_group_data_and_create(
207 | new_asset_group_data, campaign_data
208 | )
209 | self.refresh_asset_group_list()
210 |
211 | if asset_data and asset_group_data:
212 | logging.info("Creating Assets")
213 | self.asset_service.process_asset_data_and_create(
214 | asset_data, asset_group_data
215 | )
216 |
217 | if sitelink_data and campaign_data:
218 | logging.info("Creating new Sitelinks")
219 | self.sitelink_service.process_sitelink_input_sheet(sitelink_data)
220 |
--------------------------------------------------------------------------------
/functions/test/test_pubsub.py:
--------------------------------------------------------------------------------
1 | # Copyright 2024 Google LLC
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | from typing import Final
16 | import unittest
17 | from unittest.mock import call
18 | from unittest.mock import Mock
19 | from unittest.mock import patch
20 | from pubsub import PubSub
21 |
22 | _SHEET_RANGE_ARGS: Final[dict] = dict(
23 | new_campaigns_arg="NewCampaigns!A6:L",
24 | campaigns_arg="CampaignList!A6:D",
25 | new_asset_groups_arg="NewAssetGroups!A6:T",
26 | asset_group_arg="AssetGroupList!A6:F",
27 | sitelinks_arg="Sitelinks!A6:J",
28 | assets_arg="Assets!A6:L",
29 | )
30 |
31 |
32 | def _mock_get_sheet_values_callback(input_value):
33 | """Mock SheetsService.get_sheet_values function based on the input.
34 |
35 | Args:
36 | input_value: Expected input value for SheetsService.get_sheet_values function.
37 | """
38 | if input_value == _SHEET_RANGE_ARGS["new_campaigns_arg"]:
39 | return [["New Campaigns Test Data"]]
40 | if input_value == _SHEET_RANGE_ARGS["campaigns_arg"]:
41 | return [["Campaigns Test Data"]]
42 | if input_value == _SHEET_RANGE_ARGS["new_asset_groups_arg"]:
43 | return [["New Asset Groups Test Data"]]
44 | if input_value == _SHEET_RANGE_ARGS["asset_group_arg"]:
45 | return [["New Asset Groups Test Data"]]
46 | if input_value == _SHEET_RANGE_ARGS["sitelinks_arg"]:
47 | return [["Sitelinks Test Data"]]
48 | if input_value == _SHEET_RANGE_ARGS["assets_arg"]:
49 | return [["Assets Test Data"]]
50 |
51 |
52 | class TestPubSubCall(unittest.TestCase):
53 |
54 | @patch("data_references.ConfigFile")
55 | def setUp(self, config_file):
56 | config_file.login_customer_id = "1234"
57 | # Set up mock dependencies
58 | self._google_ads_client = Mock()
59 | # Initialize CampaignService with mocked dependencies
60 | self.pubsub = PubSub(config_file, self._google_ads_client)
61 |
62 | @patch("sheet_api.SheetsService.refresh_spreadsheet")
63 | def test_refresh_spreadsheet_calls_sheet_service_refresh_spreadsheet(
64 | self, mock_refresh_spreadsheet
65 | ):
66 | """Test refresh_spreadsheet method in PubSub.
67 |
68 | Confirms if service calls correct function.
69 | """
70 | self.pubsub.refresh_spreadsheet()
71 | mock_refresh_spreadsheet.assert_called()
72 |
73 | @patch("sheet_api.SheetsService.refresh_customer_id_list")
74 | def test_refresh_customer_id_list_calls_sheet_service_refresh_customer_id_list(
75 | self, mock_refresh_customer_id_list
76 | ):
77 | """Test refresh_customer_id_list method in PubSub.
78 |
79 | Confirms if service calls correct function.
80 | """
81 | self.pubsub.refresh_customer_id_list()
82 | mock_refresh_customer_id_list.assert_called()
83 |
84 | @patch("sheet_api.SheetsService.refresh_campaign_list")
85 | def test_refresh_campaign_list_calls_sheet_service_refresh_campaign_list(
86 | self, mock_refresh_campaign_list
87 | ):
88 | """Test refresh_campaign_list method in PubSub.
89 |
90 | Confirms if service calls correct function.
91 | """
92 | self.pubsub.refresh_campaign_list()
93 | mock_refresh_campaign_list.assert_called()
94 |
95 | @patch("sheet_api.SheetsService.refresh_asset_group_list")
96 | def test_refresh_asset_group_list_calls_sheet_service_refresh_asset_group_list(
97 | self, mock_refresh_asset_group_list
98 | ):
99 | """Test refresh_asset_group_list method in PubSub.
100 |
101 | Confirms if service calls correct function.
102 | """
103 | self.pubsub.refresh_asset_group_list()
104 | mock_refresh_asset_group_list.assert_called()
105 |
106 | @patch("sheet_api.SheetsService.refresh_assets_list")
107 | def test_refresh_assets_list_calls_sheet_service_refresh_assets_list(
108 | self, mock_refresh_assets_list
109 | ):
110 | """Test refresh_assets_list method in PubSub.
111 |
112 | Confirm if service calls correct function.
113 | """
114 | self.pubsub.refresh_assets_list()
115 | mock_refresh_assets_list.assert_called()
116 |
117 | @patch("sheet_api.SheetsService.refresh_sitelinks_list")
118 | def test_refresh_sitelinks_list_calls_sheet_service_refresh_sitelinks_list(
119 | self, mock_refresh_sitelinks_list
120 | ):
121 | """Test refresh_sitelinks_list method in PubSub.
122 |
123 | Confirms if service calls correctfunction.
124 | """
125 | self.pubsub.refresh_sitelinks_list()
126 | mock_refresh_sitelinks_list.assert_called()
127 |
128 | @patch("asset_creation.AssetService.process_asset_data_and_create")
129 | @patch("sheet_api.SheetsService.get_sheet_id")
130 | @patch("sheet_api.SheetsService.get_sheet_values")
131 | def test_create_api_operations_calls_value_retrieval_services_correctly(
132 | self,
133 | mock_get_sheet_values,
134 | mock_get_sheet_id,
135 | mock_process_asset_data_and_create,
136 | ):
137 | """Test create_api_operations method in PubSub.
138 |
139 | Confirms if service retrives correct amount of data.
140 | """
141 | mock_process_asset_data_and_create.return_value = True
142 | mock_get_sheet_id.return_value = "1234abcd"
143 | mock_get_sheet_values.side_effect = _mock_get_sheet_values_callback
144 | self.pubsub.create_api_operations()
145 |
146 | expected_calls = [
147 | call(_SHEET_RANGE_ARGS["new_campaigns_arg"]),
148 | call(_SHEET_RANGE_ARGS["sitelinks_arg"]),
149 | call(_SHEET_RANGE_ARGS["assets_arg"]),
150 | call(_SHEET_RANGE_ARGS["new_asset_groups_arg"]),
151 | call(_SHEET_RANGE_ARGS["asset_group_arg"]),
152 | call(_SHEET_RANGE_ARGS["campaigns_arg"]),
153 | ]
154 |
155 | mock_get_sheet_values.assert_has_calls(expected_calls)
156 |
157 | expected_call_count = 6
158 | self.assertTrue(mock_get_sheet_values.call_count >= expected_call_count)
159 |
160 | @patch("sheet_api.SheetsService.get_sheet_id")
161 | @patch("sitelink_creation.SitelinkService.process_sitelink_input_sheet")
162 | @patch("asset_creation.AssetService.process_asset_data_and_create")
163 | @patch(
164 | "asset_group_creation.AssetGroupService.process_asset_group_data_and_create"
165 | )
166 | @patch(
167 | "campaign_creation.CampaignService.process_campaign_input_sheet"
168 | )
169 | @patch("sheet_api.SheetsService.get_sheet_values")
170 | def test_create_api_operations_calls_services_correctly(
171 | self,
172 | mock_get_sheet_values,
173 | mock_process_campaign_input_sheet,
174 | mock_process_asset_group_data_and_create,
175 | mock_process_asset_data_and_create,
176 | mock_process_sitelink_input_sheet,
177 | mock_get_sheet_id,
178 | ):
179 | """Test if create_api_operations method in PubSub.
180 |
181 | Confirms if service calls correct functions with all data available.
182 | """
183 | mock_get_sheet_id.return_value = "1234abcd"
184 | mock_get_sheet_values.side_effect = _mock_get_sheet_values_callback
185 | self.pubsub.create_api_operations()
186 |
187 | mock_process_campaign_input_sheet.assert_called_with(
188 | [["New Campaigns Test Data"]]
189 | )
190 | mock_process_asset_group_data_and_create.assert_called_with(
191 | [["New Asset Groups Test Data"]], [["Campaigns Test Data"]]
192 | )
193 | mock_process_sitelink_input_sheet.assert_called_once_with(
194 | [["Sitelinks Test Data"]]
195 | )
196 | mock_process_asset_data_and_create.assert_called_with(
197 | [["Assets Test Data"]], [["New Asset Groups Test Data"]]
198 | )
199 |
200 | @patch("sitelink_creation.SitelinkService.process_sitelink_input_sheet")
201 | @patch("asset_creation.AssetService.process_asset_data_and_create")
202 | @patch(
203 | "asset_group_creation.AssetGroupService.process_asset_group_data_and_create"
204 | )
205 | @patch(
206 | "campaign_creation.CampaignService.process_campaign_input_sheet"
207 | )
208 | @patch("sheet_api.SheetsService.get_sheet_values")
209 | def test_create_api_operations_dont_call_asset_group_service(
210 | self,
211 | mock_get_sheet_values,
212 | mock_process_campaign_input_sheet,
213 | mock_process_asset_group_data_and_create,
214 | mock_process_asset_data_and_create,
215 | mock_process_sitelink_input_sheet,
216 | ):
217 | """Test create_api_operations method in PubSub.
218 |
219 | Confirms if service ignores assets creation when no data available.
220 | """
221 | mock_get_sheet_values.return_value = None
222 | self.pubsub.create_api_operations()
223 |
224 | mock_process_campaign_input_sheet.assert_not_called()
225 | mock_process_asset_group_data_and_create.assert_not_called()
226 | mock_process_sitelink_input_sheet.assert_not_called()
227 | mock_process_asset_data_and_create.assert_not_called()
228 |
--------------------------------------------------------------------------------
/functions/sitelink_creation.py:
--------------------------------------------------------------------------------
1 | # Copyright 2023 Google LLC
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # https://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 | """Provides functionality to create sitelinks."""
15 |
16 | from typing import TypeAlias, Mapping
17 | from absl import logging
18 | import ads_api
19 | import data_references
20 | from google.ads.googleads.client import GoogleAdsClient
21 | import sheet_api
22 | import utils
23 | import validators
24 |
25 |
26 | _SitelinkOperation: TypeAlias = Mapping[
27 | str,
28 | str | list | Mapping[str, str]
29 | ]
30 | _LinkSitelinkOperation: TypeAlias = Mapping[
31 | str, str
32 | ]
33 |
34 | _CUSTOMER_ID: str = "customer_id"
35 | _OPERATIONS: str = "operations"
36 | _ERROR_LOG: str = "error_log"
37 |
38 |
39 | class SitelinkService:
40 | """Class for Sitelink Creation.
41 |
42 | Contains all methods to create Sitelinks in Google Ads.
43 | """
44 |
45 | def __init__(
46 | self,
47 | sheet_service: sheet_api.SheetsService,
48 | google_ads_service: ads_api.AdService,
49 | google_ads_client: GoogleAdsClient
50 | ) -> None:
51 | """Constructs the SitelinksService instance.
52 |
53 | Args:
54 | sheet_service: Instance of sheet_service for dependency injection.
55 | google_ads_service: Instance of the google_ads_service for dependency
56 | injection.
57 | google_ads_client: Instance of Google Ads API client.
58 | """
59 |
60 | self._google_ads_client = google_ads_client
61 | self._google_ads_service = google_ads_service
62 | self._sheet_service = sheet_service
63 | self._sitelinks_temporary_id = -1
64 |
65 | def process_sitelink_input_sheet(
66 | self,
67 | sitelink_data: list[list[str]]
68 | ) -> None:
69 | """Loops through input Lists, and decides on next action.
70 |
71 | Verifies if input list meets the minimum length requirement and if the row
72 | has not been uploaded to Google Ads. If conditions are met, the function
73 | triggers the Sitelink Creation flow. Results of sitelink creation are
74 | processed and output is logged and written to the spreadsheet.
75 |
76 | Args:
77 | sitelink_data: Input data for creating new sitelinks in array form.
78 | """
79 |
80 | for row_num, row in enumerate(sitelink_data):
81 | result = None
82 | if (len(row) > data_references.Sitelinks.description2
83 | and row[data_references.Sitelinks.upload_status] !=
84 | data_references.RowStatus.uploaded):
85 | if result := self.process_sitelink_data_and_create_sitelink(
86 | row):
87 | utils.process_operations_and_errors(
88 | result[_CUSTOMER_ID],
89 | result[_OPERATIONS],
90 | result[_ERROR_LOG],
91 | row_num,
92 | self._sheet_service,
93 | self._google_ads_service,
94 | data_references.SheetNames.sitelinks
95 | )
96 |
97 | def process_sitelink_data_and_create_sitelink(
98 | self,
99 | sitelink_data: list[str]
100 | ) -> Mapping[str, tuple[_SitelinkOperation, _LinkSitelinkOperation] | str]:
101 | """Creates campaigns via google API based.
102 |
103 | Args:
104 | sitelink_data: Array for creating new sitelinks.
105 |
106 | Returns:
107 | Values for Customer Id, Google Ads API Mutate operations or an Error
108 | Message to write to the sheet. For example:
109 |
110 | {'customer_id': '123456',
111 | 'operations': (_SitelinkOperation, _LinkSitelinkOperation)
112 | 'error_log': 'Sitelink Data not Complete.'}
113 |
114 | """
115 | customer_id, campaign_id = utils.retrieve_campaign_id(
116 | sitelink_data[data_references.Sitelinks.customer_name],
117 | sitelink_data[data_references.Sitelinks.campaign_name],
118 | self._sheet_service
119 | )
120 |
121 | logging.info("Creating Sitelink API Operation")
122 | sitelink_error = None
123 | sitelink_operation = None
124 | try:
125 | sitelink_operation = self.create_sitelink(
126 | customer_id, sitelink_data
127 | )
128 | except ValueError as e:
129 | sitelink_error = str(e)
130 |
131 | logging.info("Creating Campaign Asset API Operation")
132 | campaign_asset_error = None
133 | campaign_asset_operation = None
134 | try:
135 | campaign_asset_operation = self.link_sitelink_to_campaign(
136 | customer_id,
137 | campaign_id
138 | )
139 | except ValueError as e:
140 | campaign_asset_error = str(e)
141 |
142 | error_message = "\n".join(
143 | x for x in [sitelink_error, campaign_asset_error] if x)
144 |
145 | result = {
146 | _CUSTOMER_ID: customer_id,
147 | _OPERATIONS: (sitelink_operation, campaign_asset_operation),
148 | _ERROR_LOG: error_message
149 | }
150 |
151 | if error_message:
152 | result[_OPERATIONS] = None
153 |
154 | return result
155 |
156 | def create_sitelink(
157 | self,
158 | customer_id: str,
159 | sitelink_data: list[str]
160 | ) -> tuple[_SitelinkOperation, str]:
161 | """Sets mutate object for creating campaign and budget for the campaign.
162 |
163 | Args:
164 | customer_id: Google ads customer id.
165 | sitelink_data: Array of string values to create sitelink.
166 |
167 | Returns:
168 | The Google Ads sitelink asset mutate api operation.
169 |
170 | Raises:
171 | ValueError: In case required input fields are missing from sitelink_data.
172 | """
173 | asset_service = self._google_ads_client.get_service("AssetService")
174 | self._sitelinks_temporary_id -= 1
175 | resource_name = asset_service.asset_path(
176 | customer_id, self._sitelinks_temporary_id)
177 |
178 | sitelink_operation = self._google_ads_client.get_type("MutateOperation")
179 |
180 | sitelink_asset = sitelink_operation.asset_operation.create
181 | if sitelink_data[data_references.Sitelinks.final_urls]:
182 | if validators.url(sitelink_data[data_references.Sitelinks.final_urls]):
183 | sitelink_asset.final_urls.append(
184 | sitelink_data[data_references.Sitelinks.final_urls])
185 | else:
186 | raise ValueError("Final URL is not a valid URL.")
187 | else:
188 | raise ValueError("Final URL can not be empty.")
189 |
190 | sitelink_asset.resource_name = resource_name
191 | if sitelink_data[data_references.Sitelinks.description1]:
192 | sitelink_asset.sitelink_asset.description1 = sitelink_data[
193 | data_references.Sitelinks.description1]
194 | else:
195 | raise ValueError("Description1 can not be empty.")
196 | if sitelink_data[data_references.Sitelinks.description2]:
197 | sitelink_asset.sitelink_asset.description2 = sitelink_data[
198 | data_references.Sitelinks.description2]
199 | else:
200 | raise ValueError("Description2 can not be empty.")
201 | if sitelink_data[data_references.Sitelinks.link_text]:
202 | sitelink_asset.sitelink_asset.link_text = sitelink_data[
203 | data_references.Sitelinks.link_text]
204 | else:
205 | raise ValueError("Link Text can not be empty.")
206 |
207 | return sitelink_operation
208 |
209 | def link_sitelink_to_campaign(
210 | self,
211 | customer_id: str,
212 | campaign_id: str
213 | ) -> _LinkSitelinkOperation:
214 | """Creates sitelink assets, which can be added to campaigns.
215 |
216 | Args:
217 | customer_id: The customer ID for which to add the keyword.
218 | campaign_id: The campaign to which sitelinks will be added.
219 |
220 | Returns:
221 | The Google Ads mutate api operation.
222 |
223 | Raises:
224 | ValueError: In case campaign or customer id are missing.
225 | """
226 | if not customer_id:
227 | raise ValueError(
228 | "Customer ID is required to link a sitelink to a campaign.")
229 | if not campaign_id:
230 | raise ValueError(
231 | "Campaign ID is required to link a sitelink to a campaign.")
232 |
233 | asset_service = self._google_ads_client.get_service("AssetService")
234 | resource_name = asset_service.asset_path(
235 | customer_id, self._sitelinks_temporary_id
236 | )
237 |
238 | campaign_service = self._google_ads_client.get_service("CampaignService")
239 | campaign_operation = self._google_ads_client.get_type(
240 | "MutateOperation")
241 | campaign_asset = campaign_operation.campaign_asset_operation.create
242 | campaign_asset.asset = resource_name
243 | campaign_asset.campaign = campaign_service.campaign_path(
244 | customer_id, campaign_id
245 | )
246 | campaign_asset.field_type = (
247 | self._google_ads_client.enums.AssetFieldTypeEnum.SITELINK
248 | )
249 |
250 | return campaign_operation
251 |
--------------------------------------------------------------------------------
/functions/test/test_asset_creation.py:
--------------------------------------------------------------------------------
1 | # Copyright 2024 Google LLC
2 |
3 | # Licensed under the Apache License, Version 2.0 (the "License")
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 |
7 | # https: // www.apache.org / licenses / LICENSE - 2.0
8 |
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 | """Tests for Asset Creation."""
15 |
16 | import unittest
17 | from unittest import mock
18 | from asset_creation import AssetService
19 | import data_references
20 |
21 |
22 | class TestAssetOperationResourceName:
23 |
24 | def __init__(self, resource_name):
25 | self.resource_name = resource_name
26 |
27 |
28 | class TestAssetOperationCreate:
29 |
30 | def __init__(self, create):
31 | self.create = create
32 |
33 |
34 | class TestAssetOperation:
35 |
36 | def __init__(self, asset_operation):
37 | self.asset_operation = asset_operation
38 |
39 |
40 | class TestAssetService(unittest.TestCase):
41 |
42 | def setUp(self):
43 | self.google_ads_service = mock.Mock()
44 | self._google_ads_client = mock.Mock()
45 | self.sheet_service = mock.MagicMock()
46 | self.asset_service = AssetService(
47 | self._google_ads_client, self.google_ads_service, self.sheet_service
48 | )
49 | self._google_ads_client.enums.AssetFieldTypeEnum = {"LOGO": "LOGO"}
50 | self._google_ads_client.enums.CallToActionTypeEnum = {"BOOK": "BOOK"}
51 |
52 | self._google_ads_client.enums.AssetTypeEnum.IMAGE = "IMAGE"
53 |
54 | def test_compile_asset_group_alias_return_alias_when_get_data(self):
55 | input_sheet_row = ["", "true", "customer1", "test_camapign", "AssetGroup1"]
56 | result = self.asset_service.compile_asset_group_alias(input_sheet_row)
57 |
58 | self.assertEqual(result, "customer1;test_camapign;AssetGroup1")
59 |
60 | def test_compile_asset_group_alias_return_none_when_no_data(self):
61 | input_sheet_row = ["", "true", "customer1", "test_camapign"]
62 | result = self.asset_service.compile_asset_group_alias(input_sheet_row)
63 |
64 | self.assertIsNone(result)
65 |
66 | def test_add_asset_to_asset_group_returns_object(self):
67 | result = self.asset_service.add_asset_to_asset_group(
68 | "Test Name", "Asset_group_id_123", "LOGO", "customer10"
69 | )
70 | self.assertIsNotNone(result)
71 |
72 | def test_create_imge_asset_returns_correct_object(self):
73 | result = self.asset_service.create_image_asset(
74 | "https://example.co.uk", "Test Image Asset", "customer10"
75 | )
76 |
77 | # Checking elements of the same result object
78 | self.assertEqual(result.asset_operation.create.type, "IMAGE")
79 | self.assertEqual(
80 | result.asset_operation.create.image_asset.full_size.url,
81 | "https://example.co.uk",
82 | )
83 | self.assertEqual(result.asset_operation.create.name, "Test Image Asset")
84 |
85 | @mock.patch("requests.get")
86 | def test_create_image_asset_returns_correct_object(self, mock_requests_get):
87 | mock_response = mock.MagicMock()
88 | mock_response.content = "Test Content"
89 | mock_requests_get.return_value = mock_response
90 | result = self.asset_service.create_image_asset(
91 | "https://example.co.uk", "Test Image Asset", "customer10"
92 | )
93 |
94 | self.assertEqual(
95 | result.asset_operation.create.image_asset.data, "Test Content"
96 | )
97 |
98 | def test_create_text_asset_returns_correct_object(self):
99 | result = self.asset_service.create_text_asset(
100 | "Random text to test", "customer10"
101 | )
102 |
103 | self.assertEqual(
104 | result.asset_operation.create.text_asset.text, "Random text to test"
105 | )
106 |
107 | def test_create_video_asset_returns_correct_youtube_video_id(self):
108 | self.google_ads_service._retrieve_yt_id.return_value = "Test Content"
109 | result = self.asset_service.create_video_asset(
110 | "https://example.co.uk", "customer10"
111 | )
112 |
113 | self.assertEqual(
114 | result.asset_operation.create.youtube_video_asset.youtube_video_id,
115 | "Test Content",
116 | )
117 |
118 | def test_create_video_asset_returns_correct_youtube_video_title(self):
119 | result = self.asset_service.create_video_asset(
120 | "https://example.co.uk", "customer10"
121 | )
122 |
123 | self.assertIn(
124 | "Marketing Video #",
125 | result.asset_operation.create.youtube_video_asset.youtube_video_title,
126 | )
127 |
128 | @mock.patch("asset_creation.AssetService.create_video_asset")
129 | @mock.patch("validators.url")
130 | def test_create_video_asset_for_video_type(
131 | self, mock_validators_url, mock_create_video_asset
132 | ):
133 | mock_create_video_asset.return_value = "Video object let's say"
134 | mock_validators_url.return_value = True
135 | result = self.asset_service.create_asset(
136 | data_references.AssetTypes.youtube_video, "Test Asset", "customer10"
137 | )
138 |
139 | self.assertEqual(result, "Video object let's say")
140 |
141 | @mock.patch("asset_creation.AssetService.create_call_to_action_asset")
142 | @mock.patch("validators.url")
143 | def test_create_call_to_action_asset_for_video_type(
144 | self, mock_validators_url, mock_create_call_to_action_asset
145 | ):
146 | mock_create_call_to_action_asset.return_value = "Calling to act now!"
147 | mock_validators_url.return_value = True
148 | result = self.asset_service.create_asset(
149 | data_references.AssetTypes.call_to_action, "Test Asset", "customer10"
150 | )
151 |
152 | self.assertEqual(result, "Calling to act now!")
153 |
154 | @mock.patch("validators.url")
155 | def test_rise_error_when_url_is_not_valid_for_video(
156 | self, mock_validators_url
157 | ):
158 | mock_validators_url.return_value = False
159 | with self.assertRaisesRegex(
160 | ValueError, "Asset URL Test Asset is not a valid URL"
161 | ):
162 | self.asset_service.create_asset(
163 | data_references.AssetTypes.youtube_video, "Test Asset", "customer10"
164 | )
165 |
166 | def test_rise_error_when_no_asset_value_for_create_asset(self):
167 | with self.assertRaisesRegex(
168 | ValueError,
169 | "Asset URL None is not a valid URL",
170 | ):
171 | self.asset_service.create_asset(
172 | data_references.AssetTypes.youtube_video, None, "customer10"
173 | )
174 |
175 | @mock.patch("asset_creation.AssetService.create_asset")
176 | @mock.patch("asset_creation.AssetService.upload_assets_to_sheet")
177 | @mock.patch("asset_creation.AssetService.add_asset_to_asset_group")
178 | @mock.patch("asset_creation.AssetService.compile_asset_group_alias")
179 | def test_create_asset(
180 | self,
181 | mock_compile_asset_group_alias,
182 | mock_add_asset_to_asset_group,
183 | mock_upload_assets_to_sheet,
184 | mock_create_asset,
185 | ):
186 | mock_compile_asset_group_alias.return_value = (
187 | "TestAccount;ThisisaCampaign;TestAGN"
188 | )
189 | test_asset_group_data = [
190 | "Test Account",
191 | "Test ID",
192 | "This is a Campaign",
193 | "Campaign ID",
194 | "AGN",
195 | "AGI",
196 | ]
197 | self.sheet_service.get_sheet_row.return_value = test_asset_group_data
198 | test_asset_group_asset_operation = {"service": "AssetGroupService"}
199 | mock_add_asset_to_asset_group.return_value = (
200 | test_asset_group_asset_operation
201 | )
202 | test_asset_operation = TestAssetOperation(
203 | TestAssetOperationCreate(
204 | TestAssetOperationResourceName("Test Resource Name")
205 | )
206 | )
207 | self.asset_service.create_asset.return_value = test_asset_operation
208 |
209 | asset_data = [[
210 | "",
211 | "",
212 | "Test Account",
213 | "This is a Campaign",
214 | "Test AGN",
215 | "LOGO",
216 | "Test Logo",
217 | "http://ex.com",
218 | ]]
219 | test_operations = {}
220 | test_operations["Test ID"] = []
221 | test_operations["Test ID"].append(test_asset_operation)
222 | test_operations["Test ID"].append(test_asset_group_asset_operation)
223 |
224 | self.asset_service.process_asset_data_and_create(
225 | asset_data, test_asset_group_data
226 | )
227 |
228 | mock_upload_assets_to_sheet.assert_called_once_with(
229 | test_operations, {}, {"Test Resource Name": 0}
230 | )
231 |
232 | def test_upload_asset_to_sheet_return_exception_on_error_message(self):
233 | self.google_ads_service.bulk_mutate.return_value = (
234 | None,
235 | "Attention! Error!",
236 | )
237 | operations = {}
238 | operations["Customer ID 1"] = ["Test", ""]
239 |
240 | with self.assertRaisesRegex(
241 | ValueError, f"Couldn't update Assets \n Attention! Error!"
242 | ):
243 | self.asset_service.upload_assets_to_sheet(operations, {}, ["Test"])
244 |
245 |
246 | if __name__ == "__main__":
247 | unittest.main()
248 |
--------------------------------------------------------------------------------
/functions/utils.py:
--------------------------------------------------------------------------------
1 | # Copyright 2023 Google LLC
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # https://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 | """Util functions that help process sheet content for API operations.
15 |
16 | Data is stored and modified in a Google Spreadsheet, this sheet data
17 | serves as input for the API functions. In this file there are some generic
18 | functions that help process the sheet data, and request the relevant API
19 | operations.
20 | """
21 |
22 | from collections.abc import Sequence
23 | import ads_api
24 | import data_references
25 | from sheet_api import SheetsService
26 |
27 |
28 | def process_operations_and_errors(
29 | customer_id: str,
30 | operations: (
31 | tuple[ads_api.BudgetOperation, ads_api.CampaignOperation]
32 | | tuple[ads_api.SitelinkOperation, ads_api.LinkSitelinkOperation]
33 | | None
34 | ),
35 | error_log: str,
36 | row_number: int,
37 | sheet_service: SheetsService,
38 | google_ads_service: ads_api.AdService,
39 | sheet_name: str,
40 | ) -> None:
41 | """Processing Mutate Operations and Error Log from campaign service.
42 |
43 | Creative campaigns in Google Ads based on mutate operations input, and / or
44 | writing the status / error message to the spreadsheet for further action.
45 |
46 | Args:
47 | customer_id: Google ads customer id.
48 | operations: Tuple containing two dicts (BudgetOperation,
49 | CampaignOperation)
50 | error_log: String representation of the error message.
51 | row_number: Corresponding sheetrow number to the sheet entry.
52 | sheet_service: instance of sheet_service for dependancy injection.
53 | google_ads_service: instance of google_ads_service.
54 | sheet_name: Name of the sheet in google spreadsheet.
55 |
56 | Returns:
57 | None. Output written to Google Ads API and status written to logs and
58 | sheet.
59 | """
60 | sheet_id = sheet_service.get_sheet_id(sheet_name)
61 |
62 | if error_log:
63 | sheet_service.variable_update_sheet_status(
64 | row_number,
65 | sheet_id,
66 | data_references.NewCampaigns.campaign_upload_status,
67 | data_references.RowStatus.error,
68 | error_log,
69 | data_references.NewCampaigns.error_message,
70 | )
71 | elif operations:
72 | response, error_message = (
73 | google_ads_service.bulk_mutate(
74 | operations,
75 | customer_id,
76 | )
77 | )
78 | process_api_response_and_errors(
79 | response, error_message, row_number, sheet_id, sheet_name, sheet_service
80 | )
81 |
82 |
83 | def process_api_response_and_errors(
84 | response: ads_api.ApiResponse,
85 | error_message: str,
86 | row_number: int,
87 | sheet_id: str,
88 | sheet_name: str,
89 | sheet_service: SheetsService,
90 | campaign_details: Sequence[str | int] = None,
91 | asset_group_name: str = None,
92 | ) -> None:
93 | """Processing the API responce and write to spreadsheet.
94 |
95 | Args:
96 | response: Google Ads API reponse dict.
97 | error_message: String representation of the error message.
98 | row_number: Corresponding sheetrow number to the sheet entry.
99 | sheet_id: Google Sheets id for the spreadsheet.
100 | sheet_name: Google Sheets name for the spreadsheet.
101 | sheet_service: Instance of sheet_service for dependancy injection.
102 | campaign_details: Campaign data from spreadsheet in array form if updating
103 | Asset Group.
104 | asset_group_name: Name of the asset groupif updating Asset Group.
105 |
106 | Returns:
107 | None. Status written to logs and sheet.
108 | """
109 | sitelink_resource = None
110 | resource_name_col = None
111 | upload_status_col = None
112 | error_message_col = None
113 | if sheet_name == data_references.SheetNames.new_asset_groups:
114 | upload_status_col = data_references.newAssetGroupsColumnMap.STATUS.value
115 | error_message_col = data_references.newAssetGroupsColumnMap.MESSAGE
116 | resource_name_col = None
117 | if sheet_name == data_references.SheetNames.new_campaigns:
118 | upload_status_col = data_references.NewCampaigns.campaign_upload_status
119 | error_message_col = data_references.NewCampaigns.error_message
120 | resource_name_col = None
121 | if sheet_name == data_references.SheetNames.sitelinks:
122 | upload_status_col = data_references.Sitelinks.upload_status
123 | error_message_col = data_references.Sitelinks.error_message
124 | resource_name_col = data_references.Sitelinks.sitelink_resource
125 | if response:
126 | sitelink_resource = response.mutate_operation_responses[
127 | 1
128 | ].campaign_asset_result.resource_name
129 |
130 | if response:
131 | sheet_service.variable_update_sheet_status(
132 | row_number,
133 | sheet_id,
134 | upload_status_col,
135 | data_references.RowStatus.uploaded,
136 | error_message="",
137 | message_col_id=error_message_col,
138 | resource_name=sitelink_resource,
139 | resource_col_id=resource_name_col,
140 | )
141 |
142 | if sheet_name == data_references.SheetNames.new_asset_groups:
143 | add_asset_group_sheetlist_to_spreadsheet(
144 | response, campaign_details, asset_group_name, sheet_service
145 | )
146 |
147 | elif error_message:
148 | sheet_service.variable_update_sheet_status(
149 | row_number,
150 | sheet_id,
151 | upload_status_col,
152 | data_references.RowStatus.error,
153 | error_message=error_message,
154 | message_col_id=error_message_col,
155 | )
156 |
157 |
158 | def add_asset_group_sheetlist_to_spreadsheet(
159 | response: ads_api.ApiResponse,
160 | campaign_details: Sequence[str | int],
161 | asset_group_name: str,
162 | sheet_service: SheetsService,
163 | ) -> None:
164 | """Adds Asset Group to spreadsheet.
165 |
166 | Args:
167 | response: Response object from creating asset group through the API.
168 | campaign_details: Details of the campaign that contains this Asset Group.
169 | asset_group_name: Name of the asset group that is being added to the
170 | spreadsheet.
171 | sheet_service: Instance of sheet_service for dependancy injection.
172 |
173 | Returns:
174 | Str or None. String value containing the Google Ads customer id.
175 | """
176 | asset_group_id = response.mutate_operation_responses[
177 | 0
178 | ].asset_group_result.resource_name.split("/")[-1]
179 |
180 | # Add asset_group_sheetlist to the spreadsheet.
181 | sheet_service.add_new_asset_group_to_list_sheet([
182 | campaign_details[data_references.CampaignList.customer_name],
183 | campaign_details[data_references.CampaignList.customer_id],
184 | campaign_details[data_references.CampaignList.campaign_name],
185 | campaign_details[data_references.CampaignList.campaign_id],
186 | asset_group_name,
187 | asset_group_id,
188 | ])
189 |
190 |
191 | def retrieve_customer_id(
192 | customer_name: str, sheet_service: SheetsService
193 | ) -> str | None:
194 | """Retrieves Customer ID for input customer name.
195 |
196 | Args:
197 | customer_name: String value containing Google Ads Customer name.
198 | sheet_service: instance of sheet_service for dependancy injection.
199 |
200 | Returns:
201 | Str or None. String value containing the Google Ads customer id.
202 | """
203 | customer_data: Sequence[Sequence[str]] = sheet_service.get_sheet_values(
204 | data_references.SheetNames.customers
205 | + "!"
206 | + data_references.SheetRanges.customers
207 | )
208 |
209 | for row in customer_data:
210 | if customer_name in row:
211 | return row[data_references.CustomerList.customer_id]
212 |
213 | return None
214 |
215 |
216 | def retrieve_campaign_id(
217 | customer_name: str, campaign_name: str, sheet_service: SheetsService
218 | ) -> tuple[str, str] | None:
219 | """Retrieves Campaign ID for input campaign name.
220 |
221 | Args:
222 | customer_name: String value containing Google Ads Customer name.
223 | campaign_name: String value containing Google Ads Campaign name.
224 | sheet_service: Instance of sheet_service for dependancy injection.
225 |
226 | Returns:
227 | Tuple or None. Tuple containing the Google Ads customer id and campaign id.
228 | (customer_id, campaign_id)
229 | """
230 | campaign_data: Sequence[Sequence[str]] = sheet_service.get_sheet_values(
231 | f"{data_references.SheetNames.campaigns}!"
232 | f"{data_references.SheetRanges.campaigns}"
233 | )
234 | for row in campaign_data:
235 | if campaign_name in row and customer_name in row:
236 | return (
237 | row[data_references.CampaignList.customer_id],
238 | row[data_references.CampaignList.campaign_id],
239 | )
240 |
241 | return None
242 |
--------------------------------------------------------------------------------
/functions/test/test_sitelink_creation.py:
--------------------------------------------------------------------------------
1 | # Copyright 2024 Google LLC
2 |
3 | # Licensed under the Apache License, Version 2.0 (the "License")
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 |
7 | # https: // www.apache.org / licenses / LICENSE - 2.0
8 |
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 | """Tests for Sitelink Creation."""
15 |
16 | from typing import Final
17 | import unittest
18 | from unittest import mock
19 | import data_references
20 | import sitelink_creation
21 |
22 |
23 | _CUSTOMER_ID: Final[str] = "customer_id_1"
24 | _CAMPAIGN_ID: Final[str] = "campaign_id_1"
25 | _VALID_SHEET_DATA: Final[list[list[str]]] = [
26 | [
27 | "",
28 | "",
29 | "Test Customer 1",
30 | "Test Campaign 1",
31 | "Test Site Link Text",
32 | "https://www.example.com",
33 | "Sitelink Description 1",
34 | "Sitelink Description 2",
35 | ],
36 | [
37 | "",
38 | "",
39 | "Test Customer 2",
40 | "Test Campaign 2",
41 | "Test Site Link Text",
42 | "https://www.example.com",
43 | "Sitelink Description 1",
44 | "Sitelink Description 2",
45 | ]
46 | ]
47 |
48 | _CUSTOMER_ID_KEY: str = "customer_id"
49 | _OPERATIONS_KEY: str = "operations"
50 | _ERROR_LOG_KEY: str = "error_log"
51 |
52 |
53 | class TestSitelinkCreation(unittest.TestCase):
54 | """Test Sitelink Creation."""
55 |
56 | def setUp(self):
57 | super().setUp()
58 | self.sheet_service = mock.MagicMock()
59 | self.google_ads_client = mock.Mock()
60 | self.google_ads_service = mock.Mock()
61 | self.sitelink_service = sitelink_creation.SitelinkService(
62 | self.sheet_service, self.google_ads_service, self.google_ads_client
63 | )
64 | self.google_ads_client.enums.AssetFieldTypeEnum.SITELINK = "SITELINK"
65 |
66 | @mock.patch.object(sitelink_creation.SitelinkService,
67 | "process_sitelink_data_and_create_sitelink")
68 | @mock.patch("utils.process_operations_and_errors")
69 | def test_sheet_input_with_valid_data(
70 | self,
71 | mock_process_operations_and_errors,
72 | mock_process_sitelink_data_and_create_sitelink
73 | ):
74 | """Test process_sitelink_input_sheet method in SitelinkService.
75 |
76 | Verifying wheter the service calls the correct function.
77 | """
78 | expected_result = {
79 | _CUSTOMER_ID_KEY: "123456",
80 | _OPERATIONS_KEY: ("dummy_operation", "dummy_operation"),
81 | _ERROR_LOG_KEY: ""
82 | }
83 | mock_process_sitelink_data_and_create_sitelink.return_value = expected_result
84 | self.sitelink_service.process_sitelink_input_sheet(
85 | _VALID_SHEET_DATA
86 | )
87 | mock_process_operations_and_errors.assert_called()
88 |
89 | @mock.patch.object(sitelink_creation.SitelinkService,
90 | "create_sitelink")
91 | @mock.patch.object(sitelink_creation.SitelinkService,
92 | "link_sitelink_to_campaign")
93 | @mock.patch("utils.retrieve_campaign_id")
94 | def test_sitelink_data_row_with_valid_data(
95 | self,
96 | mock_retrieve_campaign_id,
97 | mock_create_sitelink,
98 | mock_link_sitelink_to_campaign
99 | ):
100 | """Test process_sitelink_data_and_create_sitelink in SitelinkService.
101 |
102 | Verify whether return object contains expected structure and values.
103 | """
104 | mock_retrieve_campaign_id.return_value = ("123456", "56789")
105 | mock_create_sitelink.return_value = "dummy_operation"
106 | mock_link_sitelink_to_campaign.return_value = "dummy_operation"
107 | expected_result = {
108 | _CUSTOMER_ID_KEY: "123456",
109 | _OPERATIONS_KEY: ("dummy_operation", "dummy_operation"),
110 | _ERROR_LOG_KEY: ""
111 | }
112 | result = self.sitelink_service.process_sitelink_data_and_create_sitelink(
113 | _VALID_SHEET_DATA[0]
114 | )
115 | self.assertEqual(result, expected_result)
116 |
117 | @mock.patch("utils.retrieve_campaign_id")
118 | def test_sitelink_data_with_faulty_data(self, mock_retrieve_campaign_id):
119 | """Test process_sitelink_data_and_create_sitelink in SitelinkService.
120 |
121 | Verify whether return object contains expected structure and values when
122 | input data is invalid.
123 | """
124 | mock_retrieve_campaign_id.return_value = ("123456", "56789")
125 | result = self.sitelink_service.process_sitelink_data_and_create_sitelink(
126 | [
127 | "",
128 | "",
129 | "Test Customer 2",
130 | "Test Campaign 2",
131 | "Test Site Link Text",
132 | "",
133 | "Sitelink Description 1",
134 | "Sitelink Description 2",
135 | ]
136 | )
137 |
138 | self.assertEqual(result[_ERROR_LOG_KEY], "Final URL can not be empty.")
139 |
140 | def test_create_sitelink_desciption1(self):
141 | """Test _create_sitelink method in SitelinkService.
142 |
143 | Verify if return tuple contains expected structure and values.
144 | """
145 | description1 = _VALID_SHEET_DATA[0][data_references.Sitelinks.description1]
146 |
147 | sitelink_operation = self.sitelink_service.create_sitelink(
148 | _CUSTOMER_ID, _VALID_SHEET_DATA[0]
149 | )
150 | self.assertEqual(
151 | sitelink_operation.asset_operation.create.sitelink_asset.description1,
152 | description1,
153 | )
154 |
155 | def test_create_sitelink_link_text(self):
156 | """Test _create_sitelink method in SitelinkService.
157 |
158 | Verify if return tuple contains expected structure and values.
159 | """
160 | link_text = _VALID_SHEET_DATA[0][data_references.Sitelinks.link_text]
161 |
162 | sitelink_operation = self.sitelink_service.create_sitelink(
163 | _CUSTOMER_ID, _VALID_SHEET_DATA[0]
164 | )
165 | self.assertEqual(
166 | sitelink_operation.asset_operation.create.sitelink_asset.link_text,
167 | link_text,
168 | )
169 |
170 | def test_throw_error_when_no_link_text(self):
171 | """Test create_pmax_campaign_operation method in CampaignService.
172 |
173 | Validate if missing CPA triggers the expected error.
174 | """
175 | invalid_data = _VALID_SHEET_DATA[0].copy()
176 | invalid_data[data_references.Sitelinks.link_text] = ""
177 |
178 | with self.assertRaisesRegex(
179 | ValueError,
180 | "Link Text can not be empty."
181 | ):
182 | self.sitelink_service.create_sitelink(
183 | _CUSTOMER_ID,
184 | invalid_data
185 | )
186 |
187 | @mock.patch("validators.url")
188 | def test_throw_error_when_invalid_url(self, mock_validators_url):
189 | """Test create_pmax_campaign_operation method in CampaignService.
190 |
191 | Validate if missing CPA triggers the expected error.
192 | """
193 | mock_validators_url.return_value = False
194 | invalid_data = _VALID_SHEET_DATA[0].copy()
195 | invalid_data[data_references.Sitelinks.final_urls] = "error"
196 |
197 | with self.assertRaisesRegex(
198 | ValueError,
199 | "Final URL is not a valid URL."
200 | ):
201 | self.sitelink_service.create_sitelink(
202 | _CUSTOMER_ID,
203 | invalid_data
204 | )
205 |
206 | def test_link_sitelink_to_campaign_field_type(self):
207 | """Test link_sitelink_to_campaign method in SitelinkService.
208 |
209 | Verify if method returns expected API operation structure and sitelink
210 | value.
211 | """
212 | link_sitelink_operation = self.sitelink_service.link_sitelink_to_campaign(
213 | _CUSTOMER_ID, _CAMPAIGN_ID
214 | )
215 | self.assertEqual(
216 | link_sitelink_operation.campaign_asset_operation.create.field_type,
217 | data_references.AssetTypes.sitelink,
218 | )
219 |
220 | def test_link_sitelink_to_campaign_campaign_resource(self):
221 | """Test link_sitelink_to_campaign method in SitelinkService.
222 |
223 | Verify if method returns expected API operation structure and campaign
224 | resource value.
225 | """
226 | campaign_resource = f"customers/{_CUSTOMER_ID}/campaigns/{_CAMPAIGN_ID}"
227 | self.google_ads_client.get_service(
228 | "CampaignService").campaign_path.return_value = campaign_resource
229 |
230 | link_sitelink_operation = self.sitelink_service.link_sitelink_to_campaign(
231 | _CUSTOMER_ID, _CAMPAIGN_ID
232 | )
233 |
234 | self.assertEqual(
235 | link_sitelink_operation.campaign_asset_operation.create.campaign,
236 | campaign_resource,
237 | )
238 |
239 | def test_link_sitelink_to_campaign_no_customer_id(self):
240 | """Test link_sitelink_to_campaign method in SitelinkService.
241 |
242 | Verify if method returns expected Value Error for missing customer id.
243 | """
244 | with self.assertRaisesRegex(
245 | ValueError,
246 | "Customer ID is required to link a sitelink to a campaign."
247 | ):
248 | self.sitelink_service.link_sitelink_to_campaign(
249 | None, _CAMPAIGN_ID
250 | )
251 |
252 | def test_link_sitelink_to_campaign_no_campaign_id(self):
253 | """Test link_sitelink_to_campaign method in SitelinkService.
254 |
255 | Verify if method returns expected Value Error for missing campaign id.
256 | """
257 | with self.assertRaisesRegex(
258 | ValueError,
259 | "Campaign ID is required to link a sitelink to a campaign."
260 | ):
261 | self.sitelink_service.link_sitelink_to_campaign(
262 | _CUSTOMER_ID, None
263 | )
264 |
--------------------------------------------------------------------------------
/functions/asset_deletion.py:
--------------------------------------------------------------------------------
1 | # Copyright 2024 Google LLC
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # https://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 | """Provides functionality to delete Asset Group Assets in Google Ads."""
15 |
16 | from collections.abc import Mapping, Sequence
17 | from typing import TypeAlias
18 | from absl import logging
19 | import ads_api
20 | import data_references
21 | from google.ads.googleads import client
22 | from sheet_api import SheetsService
23 | import utils
24 |
25 | ApiResponse: TypeAlias = Mapping[str, bool | Mapping[str, str]]
26 | AssetGroupAssetOperation: TypeAlias = Mapping[str, str]
27 |
28 |
29 | class AssetDeletionService:
30 | """Class for Asset deletion.
31 |
32 | Contains all methods to delete assets in Google Ads pMax Asset Groups.
33 | """
34 |
35 | def __init__(
36 | self,
37 | google_ads_client: client.GoogleAdsClient,
38 | google_ads_service: ads_api.AdService,
39 | sheet_service: SheetsService,
40 | ) -> None:
41 | """Constructs the AssetDeletionService instance.
42 |
43 | Args:
44 | google_ads_client: Google Ads API client, dependency injection.
45 | google_ads_service: Ads Service Class dependency injection.
46 | sheet_service: Google Sheets API method class.
47 | """
48 | self._google_ads_client = google_ads_client
49 | self._google_ads_service = google_ads_service
50 | self.sheet_service = sheet_service
51 |
52 | def asset_deletion(self, asset_data: Sequence[str | int]) -> None:
53 | """Asset Deletion method.
54 |
55 | Args:
56 | asset_data: Array of Asset data from the sheeet.
57 |
58 | Returns:
59 | Void. Writes output to sheets, and makes changes in Google Ads through the
60 | API.
61 | """
62 | operations, row_to_operations_mapping = self.process_asset_deletion_input(
63 | asset_data)
64 |
65 | rows_for_removal, error_sheet_output = self.process_api_deletion_operations(
66 | operations,
67 | row_to_operations_mapping)
68 |
69 | if error_sheet_output:
70 | self.sheet_service.bulk_update_sheet_status(
71 | data_references.SheetNames.assets,
72 | data_references.Assets.status,
73 | data_references.Assets.error_message,
74 | data_references.Assets.asset_group_asset,
75 | error_sheet_output)
76 |
77 | if rows_for_removal:
78 | self.sheet_service.remove_sheet_rows(
79 | rows_for_removal,
80 | data_references.SheetNames.assets)
81 |
82 | def delete_asset(
83 | self, asset_resource: str
84 | ) -> AssetGroupAssetOperation:
85 | """Set up mutate object for deleting an asset.
86 |
87 | Args:
88 | asset_resource: Resource name of the Asset Groups Asset to be deleted.
89 |
90 | Returns:
91 | asset group asset delete operation
92 | """
93 | mutate_operation = None
94 |
95 | if asset_resource:
96 | mutate_operation = self._google_ads_client.get_type("MutateOperation")
97 | mutate_operation.asset_group_asset_operation.remove = asset_resource
98 |
99 | return mutate_operation
100 |
101 | def process_asset_deletion_input(
102 | self,
103 | asset_data: Sequence[str | int]
104 | ) -> tuple[Mapping[str, list[AssetGroupAssetOperation]], Mapping[
105 | str, list[int]]]:
106 | """Process data from the sheet to delete assets.
107 |
108 | Args:
109 | asset_data: Array of Asset data from the sheeet.
110 |
111 | Returns:
112 | The API mutate operations, organized by Customer Id, and the Sheet Rows
113 | these API mutrations are referring to.
114 |
115 | (
116 | {"customerid": [AssetGroupAssetOperation]},
117 | {"customerid": [0]}
118 | )
119 | """
120 | operations = {}
121 | row_to_operations_mapping = {}
122 | customer_mapping = {}
123 |
124 | for sheet_row_index, asset in enumerate(asset_data):
125 | if (
126 | asset[data_references.Assets.status]
127 | == data_references.RowStatus.uploaded and
128 | asset[data_references.Assets.delete_asset] == "TRUE"
129 | ):
130 | customer_name = asset[data_references.Assets.customer_name]
131 | if customer_name not in customer_mapping:
132 | customer_mapping[customer_name] = utils.retrieve_customer_id(
133 | customer_name, self.sheet_service)
134 | customer_id = customer_mapping[customer_name]
135 |
136 | asset_operation = None
137 | if data_references.Assets.asset_group_asset < len(asset) and asset[
138 | data_references.Assets.asset_group_asset]:
139 | asset_operation = self.delete_asset(
140 | asset[data_references.Assets.asset_group_asset]
141 | )
142 |
143 | if asset_operation:
144 | if (
145 | customer_id not in operations.keys()
146 | or not operations[customer_id]
147 | ):
148 | operations[customer_id] = []
149 | operations[customer_id].append(asset_operation)
150 |
151 | # map the index of the row to the resource that is process for
152 | # allocating errors from the API call later
153 | if (
154 | customer_id not in row_to_operations_mapping.keys()
155 | or not row_to_operations_mapping[customer_id]
156 | ):
157 | row_to_operations_mapping[customer_id] = []
158 | row_to_operations_mapping[
159 | customer_id].append(sheet_row_index)
160 |
161 | return operations, row_to_operations_mapping
162 |
163 | def process_api_deletion_operations(
164 | self,
165 | operations: Mapping[str, Mapping[str, str]],
166 | row_to_operations_mapping: Mapping[str, str],
167 | ) -> None:
168 | """Uploading asset results to the sheet.
169 |
170 | Args:
171 | operations: List of operations from asset deletion.
172 | row_to_operations_mapping: Mapping of the resourse name and related row
173 | for uploading to the sheet.
174 |
175 | Returns:
176 | A tuple containing a list with all row numbers of successfully processed
177 | API requests, and a Mapping between the row in the sheet and error
178 | message from the response for the rows that failed.
179 | """
180 | error_rows = []
181 | all_rows = []
182 | error_sheet_output = {}
183 |
184 | for customer_id in operations:
185 | all_rows.extend(row_to_operations_mapping[customer_id])
186 | response, error_message = self._google_ads_service.bulk_mutate(
187 | operations[customer_id], customer_id, True
188 | )
189 | if error_message:
190 | raise ValueError(f"Couldn't update Assets \n {error_message}")
191 | if response:
192 | customer_sheet_output = self.process_asset_errors(
193 | response, row_to_operations_mapping[customer_id],
194 | operations[customer_id])
195 | error_sheet_output.update(customer_sheet_output)
196 | error_rows.extend(list(customer_sheet_output.keys()))
197 |
198 | rows_for_removal = list(set(all_rows) - set(error_rows))
199 | rows_for_removal.sort(reverse=True)
200 |
201 | return rows_for_removal, error_sheet_output
202 |
203 | def process_asset_errors(
204 | self,
205 | response: ApiResponse,
206 | row_to_operations_mapping: list[int],
207 | operations: list[Mapping[str, Mapping[str, str]]],
208 | ) -> Mapping[str, Mapping[str, str]]:
209 | """Captures partial failure errors and success messages from a response.
210 |
211 | Args:
212 | response: A ApiResponse message instance.
213 | row_to_operations_mapping: Mapping of the resourse name and related row
214 | for uploading to the sheet.
215 | operations: List of operations from asset deletion.
216 |
217 | Returns:
218 | Mapping between the row in the sheet and status and error message from the
219 | response.
220 | """
221 | error_obj = {}
222 | # Check for existence of any partial failures in the response.
223 | if self._google_ads_service.is_partial_failure_error_present(response):
224 | partial_failure = getattr(response, "partial_failure_error", None)
225 | # partial_failure_error.details is a repeated field and iterable
226 | error_details = getattr(partial_failure, "details", [])
227 |
228 | for error_detail in error_details:
229 | # Retrieve an instance of the GoogleAdsFailure class from the client
230 | failure_message = self._google_ads_client.get_type("GoogleAdsFailure")
231 | # Parse the string into a google_ads_failure message instance.
232 | # To access class-only methods on the message we retrieve its type.
233 | google_ads_failure = type(failure_message)
234 | failure_object = google_ads_failure.deserialize(error_detail.value)
235 | for error in failure_object.errors:
236 | # Construct a list that details which element in
237 | # the above ad_group_operations list failed (by index number)
238 | # as well as the error message and error code.
239 | row_number = row_to_operations_mapping[
240 | error.location.field_path_elements[0].index]
241 |
242 | # In case it is the first error for the row create the error object.
243 | if row_number not in error_obj:
244 | resource_name = operations[
245 | error.location.field_path_elements[
246 | 0].index].asset_group_asset_operation.remove
247 | error_obj[row_number] = {
248 | "status": data_references.RowStatus.uploaded,
249 | "message": (f"Error message: {error.message}\n"
250 | f"\tError code: {str(error.error_code).strip()}"),
251 | "asset_group_asset": resource_name
252 | }
253 | # In case of multiple errors for one row, append the error message to
254 | # the object.
255 | else:
256 | error_obj[row_number]["message"] = (
257 | error_obj[row_number]["message"] + "\n"
258 | f"Error message: {error.message}\n"
259 | f"\tError code: {str(error.error_code).strip()}")
260 | else:
261 | logging.info(
262 | "All operations completed successfully. No partial failure to show."
263 | )
264 | return error_obj
265 |
--------------------------------------------------------------------------------
/functions/appScript/changes_tracking.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2024 Google LLC
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | /**
18 | * @fileoverview This file contains the functions that are triggered by
19 | * edits made to the spreadsheet. It triggers validation of input to prevent
20 | * downstream API errors.
21 | */
22 |
23 | /**
24 | * This function is called when a cell is edited.
25 | * @param {Event} e The event object.
26 | */
27 | function onEdit(e) {
28 | const ss = SpreadsheetApp.getActiveSpreadsheet();
29 | const column = e.range.getColumn();
30 | const row = e.range.getRow();
31 | const numRows = e.range.getNumRows();
32 | const sheetName = e.range.getSheet().getSheetName();
33 | const editedValue = e.value;
34 | const oldValue = e.oldValue;
35 | const numberOfColumnsChanged = e.range.getNumColumns();
36 | const numberOfRowsChanged = e.range.getNumColumns();
37 |
38 | // More scenarios will be added with following CLs.
39 | switch (sheetName) {
40 | case SHEET_NAMES.NEW_ASSET_GROUPS:
41 | addNewAssetGroupsUserEditsIntoProperty(
42 | editedValue,
43 | oldValue,
44 | row,
45 | column,
46 | numberOfColumnsChanged,
47 | numberOfRowsChanged,
48 | ss.getSheetByName(sheetName),
49 | );
50 | assetGroupDataValidation(ss, column, row, numRows);
51 | checkMinimumAssets(ss);
52 | break;
53 | case SHEET_NAMES.NEW_CAMPAIGNS:
54 | addNewCampaignUserEditsIntoProperty(
55 | editedValue,
56 | oldValue,
57 | row,
58 | column,
59 | numberOfColumnsChanged,
60 | numberOfRowsChanged,
61 | ss.getSheetByName(sheetName)
62 | );
63 | break;
64 | case SHEET_NAMES.SITELINKS:
65 | assetGroupDataValidation(ss, column, row, numRows);
66 | break;
67 | case SHEET_NAMES.ASSETS:
68 | assetDataValidation(ss, column, row, numRows);
69 | break;
70 | default:
71 | break;
72 | }
73 | }
74 |
75 | /**
76 | * Validates the minimal asset requirements for each Asset Groups.
77 | *
78 | * Checks newAssetGroup sheet when edited. Loops through the rows that have
79 | * been edited. Validates the Assets and writes the status back to the sheet.
80 | * @param {Spreadsheet} spreadSheet The spreadsheet object.
81 | */
82 | function checkMinimumAssets(spreadSheet) {
83 | newAssetGroupsSheet = spreadSheet.getSheetByName(
84 | SHEET_NAMES.NEW_ASSET_GROUPS,
85 | );
86 | newValues = newAssetGroupsSheet.getDataRange().getValues();
87 |
88 | let result = {};
89 |
90 | for (let id in newValues) {
91 | if (newValues.hasOwnProperty(id)) {
92 | let headlineCount = 0;
93 | let longHeadlineCount = 0;
94 | let descriptionCount = 0;
95 | let businessCount = 0;
96 | let imageCount = 0;
97 | let squareImageCount = 0;
98 | let logoCount = 0;
99 |
100 | result[id] = { message: '', status: true };
101 |
102 | if (newValues[id][NEW_ASSET_GROUPS.STATUS] === ROW_STATUS.UPLOADED) {
103 | continue;
104 | }
105 | if (
106 | newValues[id][NEW_ASSET_GROUPS.ACCOUNT_NAME] === '' ||
107 | newValues[id][NEW_ASSET_GROUPS.CAMPAIGN_NAME] === '' ||
108 | newValues[id][NEW_ASSET_GROUPS.ASSET_GROUP_NAME] === ''
109 | ) {
110 | continue;
111 | }
112 | if (id < 5) {
113 | continue;
114 | }
115 |
116 | headlineCount = assetCounter(
117 | headlineCount,
118 | newValues[id][NEW_ASSET_GROUPS.HEADLINE1],
119 | );
120 | headlineCount = assetCounter(
121 | headlineCount,
122 | newValues[id][NEW_ASSET_GROUPS.HEADLINE2],
123 | );
124 | headlineCount = assetCounter(
125 | headlineCount,
126 | newValues[id][NEW_ASSET_GROUPS.HEADLINE3],
127 | );
128 | descriptionCount = assetCounter(
129 | descriptionCount,
130 | newValues[id][NEW_ASSET_GROUPS.DESCRIPTION1],
131 | );
132 | descriptionCount = assetCounter(
133 | descriptionCount,
134 | newValues[id][NEW_ASSET_GROUPS.DESCRIPTION2],
135 | );
136 | longHeadlineCount = assetCounter(
137 | longHeadlineCount,
138 | newValues[id][NEW_ASSET_GROUPS.LONG_HEADLINE],
139 | );
140 | businessCount = assetCounter(
141 | businessCount,
142 | newValues[id][NEW_ASSET_GROUPS.BUSINESS_NAME],
143 | );
144 | imageCount = assetCounter(
145 | imageCount,
146 | newValues[id][NEW_ASSET_GROUPS.MARKETING_IMAGE],
147 | );
148 | squareImageCount = assetCounter(
149 | squareImageCount,
150 | newValues[id][NEW_ASSET_GROUPS.SQUARE_IMAGE],
151 | );
152 | logoCount = assetCounter(logoCount, newValues[id][NEW_ASSET_GROUPS.LOGO]);
153 |
154 | result[id] = assetStatus(
155 | newValues[id][NEW_ASSET_GROUPS.HEADLINE1],
156 | headlineCount,
157 | REQUIRED_ASSETS.HEADLINES,
158 | result[id],
159 | ASSET_TYPES.HEADLINE,
160 | );
161 | result[id] = assetStatus(
162 | newValues[id][NEW_ASSET_GROUPS.LONG_HEADLINE],
163 | longHeadlineCount,
164 | REQUIRED_ASSETS.LONG_HEADLINES,
165 | result[id],
166 | ASSET_TYPES.LONG_HEADLINE,
167 | );
168 | result[id] = assetStatus(
169 | newValues[id][NEW_ASSET_GROUPS.DESCRIPTION1],
170 | descriptionCount,
171 | REQUIRED_ASSETS.DESCRIPTIONS,
172 | result[id],
173 | ASSET_TYPES.DESCRIPTION,
174 | );
175 | result[id] = assetStatus(
176 | newValues[id][NEW_ASSET_GROUPS.BUSINESS_NAME],
177 | businessCount,
178 | REQUIRED_ASSETS.BUSINESS_NAME,
179 | result[id],
180 | ASSET_TYPES.BUSINESS,
181 | );
182 | result[id] = assetStatus(
183 | newValues[id][NEW_ASSET_GROUPS.MARKETING_IMAGE],
184 | imageCount,
185 | REQUIRED_ASSETS.MARKETING_IMAGE,
186 | result[id],
187 | ASSET_TYPES.IMAGE,
188 | );
189 | result[id] = assetStatus(
190 | newValues[id][NEW_ASSET_GROUPS.SQUARE_IMAGE],
191 | squareImageCount,
192 | REQUIRED_ASSETS.SQUARE_IMAGE,
193 | result[id],
194 | ASSET_TYPES.SQUARE_IMAGE,
195 | );
196 | result[id] = assetStatus(
197 | newValues[id][NEW_ASSET_GROUPS.LOGO],
198 | logoCount,
199 | REQUIRED_ASSETS.LOGO,
200 | result[id],
201 | ASSET_TYPES.LOGO,
202 | );
203 |
204 | if (!result[id].status) {
205 | result[id].message =
206 | 'ERROR: Asset requirements are not met:' + result[id].message;
207 | } else {
208 | result[id].message = 'SUCCESS';
209 | }
210 |
211 | const outputRow = Number(id) + 1;
212 | newAssetGroupsSheet.getRange(outputRow, 2).setValue(result[id].message);
213 | }
214 | }
215 | }
216 |
217 | /**
218 | * Incremental counter, to count the occurences of each Asset Type.
219 | * @param {number} counter The counter object.
220 | * @param {string} cellValue The cell value object.
221 | * @returns {number} The incremental count value for the number of assets.
222 | */
223 | function assetCounter(counter, cellValue) {
224 | if (cellValue !== '') {
225 | counter = counter + 1;
226 | }
227 | return counter;
228 | }
229 |
230 | /**
231 | * Validate if the the Asset Groups meets requirements for each asset type.
232 | *
233 | * Assigns the respective status and error message if relevant.
234 | * @param {string} assetValue The asset value object.
235 | * @param {number} assetCount The asset count object.
236 | * @param {number} assetRequirement The asset requirement object.
237 | * @param {{status: boolean, message: string}} assetStatus The asset status
238 | * object.
239 | * @param {string} assetType The asset type object.
240 | * @returns {{status: boolean, message: string}} Object containg the status
241 | * details for the given Asset Type.
242 | */
243 | function assetStatus(
244 | assetValue,
245 | assetCount,
246 | assetRequirement,
247 | assetStatus,
248 | assetType,
249 | ) {
250 | let errorMssg = '';
251 | let urlError = '';
252 | let noValueError = '';
253 | let validURL = true;
254 |
255 | switch (assetType) {
256 | case ASSET_TYPES.HEADLINE:
257 | noValueError =
258 | '\n\tNot enough headlines assigned to the Asset Group (3 required)';
259 | break;
260 | case ASSET_TYPES.LONG_HEADLINE:
261 | noValueError =
262 | '\n\tNo long headlines assigned to the Asset Group (1 required)';
263 | break;
264 | case ASSET_TYPES.DESCRIPTION:
265 | noValueError =
266 | '\n\tNot enough descriptions assigned to the Asset Group (2 required)';
267 | break;
268 | case ASSET_TYPES.BUSINESS:
269 | noValueError =
270 | '\n\tNo business name assigned to the Asset Group. (1 required)';
271 | break;
272 | case ASSET_TYPES.IMAGE:
273 | noValueError =
274 | '\n\tNo marketing image assigned to the Asset Group. (1 required)';
275 | validURL = isValidURL(assetValue);
276 | urlError = '\n\tMarketing Image contains an invalid URL';
277 | break;
278 | case ASSET_TYPES.SQUARE_IMAGE:
279 | noValueError =
280 | '\n\tNo square image assigned to the Asset Group. (1 required)';
281 | validURL = isValidURL(assetValue);
282 | urlError = '\n\tSquare Image contains an invalid URL';
283 | break;
284 | case ASSET_TYPES.LOGO:
285 | noValueError = '\n\tNo logo assigned to the Asset Group. (1 required)';
286 | validURL = isValidURL(assetValue);
287 | urlError = '\n\tLogo contains an invalid URL';
288 | break;
289 | default:
290 | break;
291 | }
292 | if (noValueError && !urlError) {
293 | errorMssg = errorMssg + noValueError;
294 | }
295 | if (!validURL && urlError && assetValue) {
296 | errorMssg = errorMssg + urlError;
297 | }
298 |
299 | if (assetCount < assetRequirement || !validURL) {
300 | assetStatus.status = false;
301 | assetStatus.message = assetStatus.message + errorMssg;
302 | }
303 | return assetStatus;
304 | }
305 |
306 | /**
307 | * Validate sting is in URL format for image assets.
308 | * @param {string} str The string object.
309 | * @returns {boolean} True is the string contains a url otherwise false.
310 | */
311 | function isValidURL(str) {
312 | return /^(http(s):\/\/.)[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)$/g.test(
313 | str,
314 | );
315 | }
316 |
--------------------------------------------------------------------------------
/docs/manual_deployment.md:
--------------------------------------------------------------------------------
1 | # Mad PMax: PMax Asset Automation
2 |
3 | ## Instructions
4 |
5 | The Mad Pmax: Performance Max Asset Automation solution can be deployed on Google Cloud through Terraform. See the steps below.
6 |
7 | ### Join the [Mad pMax users](https://groups.google.com/g/mad-pmax-users) group
8 | Joining this group will grant you access to the template Google Sheet.
9 |
10 | 
11 |
12 | ### Make a copy of the [Mad pMax Template sheet](https://docs.google.com/spreadsheets/d/1uj1IA7Bf8W5av2h1Mw_WEAyhiWa6Rxu9KbFxKXW3v2k/copy)
13 |
14 | **Note**: You can check usage instructions in the [User Guide page](https://github.com/google-marketing-solutions/madpmax/wiki/User-Guide).
15 |
16 | ### Create new or Select an existing Google Cloud Project
17 |
18 | Select an **existing Cloud Project** to deploy the solution, or follow the next steps to **create a new cloud project**.
19 |
20 | 1. Navigate to [Create a Project](https://console.cloud.google.com/projectcreate) in Google Cloud Console.
21 | 2. In the **Project Name** field, enter a descriptive name for your project.
22 | 3. When deploying within a Cloud organisation, you will need to select the **Billing account** and the **Organisation** to deploy your new project. If billing is not correctly set up you won't be able to enable the Compute Engine API below.
23 | 4. In the **Location** field, click Browse to display potential locations for your project.
24 | 5. Click **Create**.
25 |
26 | ### Enable the following APIs
27 | Navigate to the [API Library](https://console.cloud.google.com/apis/library) and enable the following APIs either by searching them by name or by directly clicking on the links below and click on the **Enable** button:
28 |
29 | * [Google Drive API](https://console.cloud.google.com/apis/library/drive.googleapis.com)
30 | * [Google Sheets API](https://console.cloud.google.com/apis/library/sheets.googleapis.com)
31 | * [Google Ads API](https://console.cloud.google.com/apis/library/googleads.googleapis.com)
32 | * [Compute Engine API](https://console.cloud.google.com/apis/library/compute.googleapis.com)
33 | * [Identity and Access Management (IAM) API](https://console.cloud.google.com/apis/library/iam.googleapis.com)
34 | * [Cloud Resource Manager API](https://console.cloud.google.com/apis/library/cloudresourcemanager.googleapis.com)
35 | * [Service Usage API](https://console.cloud.google.com/apis/library/serviceusage.googleapis.com)
36 | * [Cloud Pub/Sub API](https://console.cloud.google.com/apis/library/pubsub.googleapis.com)
37 |
38 | ### Generate OAuth Credentials
39 |
40 | The Credentials are required 1) for pMax API access permissions and 2) for permissions to trigger the application to run.
41 |
42 | 1. Navigate to the [Credentials](https://console.developers.google.com/apis/credentials) page
43 | 2. If you haven’t configured a Consent Screen, configure a new [OAuth Consent Screen](https://console.cloud.google.com/apis/credentials/consent)
44 | * User Type: “Internal”
45 | * Add following "scopes":
46 | * Google Ads API (.../auth/adwords)
47 | * Google Drive API (.../auth/drive.readonly)
48 | * Google Sheets API (.../auth/spreadsheets)
49 | * Cloud Pub/Sub API (.../auth/pubsub)
50 | 3. [Create 'OAuth client ID'](https://console.cloud.google.com/apis/credentials/oauthclient) with Application Type: 'Web application'
51 | 4. Add the following 'Authorized redirect URIs':
52 | * `'https://developers.google.com/oauthplayground'`
53 | * `'https://script.google.com/macros/d//usercallback'`
54 | Obtain your **Script ID** by navigating to Your Copy of the Template Spreadsheet. In the top menu, select *"Extensions > Apps Script"*. The Apps Script editor will open in a new tab. Navigate on the left hand side to *Project Settings* and copy the **Script ID**.
55 |
56 | 
57 |
58 | 5. Copy the **Client ID** and **Client Secret** and store safely for use later in the configuration.
59 | 6. Navigate to the Apps Script Code Editor, and copy values for the **Client ID** and **Client Secret** to the respective variables in the `Config.gs` file. Then, find your **Google Cloud Project Name** from the Project Info section of your [Google Cloud Project Dashboard](https://console.cloud.google.com/home/dashboard) and copy its value to the respective variable in the same file.
60 |
61 | #### Generate Access and Refresh tokens
62 | 1. Go to the [OAuth2 Playground](https://developers.google.com/oauthplayground/#step1&scopes=https%3A//www.googleapis.com/auth/adwords,https%3A//www.googleapis.com/auth/drive,https%3A//www.googleapis.com/auth/spreadsheets&content_type=application/json&http_method=GET&useDefaultOauthCred=checked&oauthEndpointSelect=Google&oauthAuthEndpointValue=https%3A//accounts.google.com/o/oauth2/v2/auth&oauthTokenEndpointValue=https%3A//oauth2.googleapis.com/token&includeCredentials=unchecked&accessTokenType=bearer&autoRefreshToken=unchecked&accessType=offline&forceAprovalPrompt=checked&response_type=code) (opens in a new window)
63 | 2. On the right-hand pane, paste the `client_id` and `client_secret` in the appropriate fields 
64 | 3. Then on the left hand side of the screen, click the blue **Authorize APIs** button 
65 |
66 | Make sure the following scopes are included:
67 | *
68 | *
69 | *
70 |
71 | If you are prompted to authorize access, please choose your Google account that has access to Google Ads and approve.
72 | 1. Now, click the new blue button **Exchange authorization code for tokens** 
73 |
74 | 2. Finally, in the middle of the screen you'll see your refresh token on the last line. Copy it and save it for future reference.  *Do not copy the quotation marks*
75 |
76 | **Important**: Make sure to use the **Client ID** and **Client Secret** generated in step 4 for [pmax-api] to generate the tokens.
77 |
78 | ### Terraform Deployment
79 |
80 | 1. Open the cloud project where you want to deploy the solution and open the [Cloud Editor](https://shell.cloud.google.com/?show=ide%2Cterminal). Make sure to select the project where you want to deploy the solution using `gcloud config set project [PROJECT_ID]`
81 |
82 | 2. In the terminal, run
83 |
84 | ```bash
85 | git clone https://github.com/google-marketing-solutions/madpmax.git
86 | ```
87 |
88 | 3. Run
89 |
90 | ```bash
91 | cd madpmax/terraform
92 | ```
93 |
94 | 4. Open `/terraform/configuration-input.tfvars` file and complete all required input variables in the `configuration-input.tfvars` file.
95 |
96 | 5. Run
97 |
98 | ```bash
99 | terraform init
100 | ```
101 | 6. Run
102 |
103 | ```bash
104 | terraform apply -var-file="configuration-input.tfvars"
105 | ```
106 |
107 | 7. Wait for terraform to deploy the solution.
108 |
109 | 8. In case you want to **delete** the service, run
110 |
111 | ```bash
112 | terraform destroy -var-file="configuration-input.tfvars"
113 | ```
114 |
115 | **Note**: To obtain Google Ads Developer token refer to [Apply for access to the Google Ads API](https://developers.google.com/google-ads/api/docs/get-started/dev-token#apply-token)..
116 |
117 | ### Link your copy of the Template Sheet to the Google Cloud Project
118 | 1. Open the **Extensions** menu in your Template Sheet and click on **Apps Script**.
119 | 2. Find the cog wheel icon, titled as **Project Settings**, on the left side of the screen and click on it.
120 | 3. Scroll down to the **Google Cloud Platform (GCP) Project** section and click on the button titled as **Change Project**.
121 | 
122 | 4. In another tab of your browser, navigate to the Project Info section of your [Google Cloud Project Dashboard](https://console.cloud.google.com/home/dashboard) and copy the value for the **Project Number**.
123 | 5. Copy this value into your Template Sheet and click on the button titled as **Set Project** to complete the process.
124 |
125 | ## Using the tool
126 |
127 | Check out the full [User Guide](https://github.com/google-marketing-solutions/madpmax/wiki/User-Guide).
128 |
129 | In brief, you can find the **pMax Execute** menu option in the template spreadsheet with two functions:
130 | * **Refresh Sheet**: loads all existing in your account Campaigns, Asset Groups and Assets into related pages in the spreadsheet
131 | * **Upload to Google Ads**: uploads all new Campaigns, Asset Groups and Assets into your account. All errors will be shown on related pages in the last column
132 |
133 | ### Template Sheet guide
134 |
135 | * **NewCampaigns**: creation of new Campaigns page
136 | * **CampaignList**: page showing existing Campaigns in your account after running *pMax Execute* -> *Refresh Sheet*
137 | * **NewAssetGroup**: creation of new Asset Group
138 | * **AssetGroupList**: page showing existing Asset Groups in your account after running *pMax Execute* -> *Refresh Sheet*
139 | * **Assets**: contains Assets to create and existing Assets in the account after running *pMax Execute* -> *Refresh Sheet*
140 | * **Customer List**: list of cutomers for the application
141 | * **Sitelinks**: page to see existing Sitelinks or create new ones
142 |
143 | Choose customers you would like to use for the application in **Customer List**.
144 | Use drop down menu on creation pages (NewCampaign, NewAssetGroup, Assets) to assign new Asset, Asset Group and Campaigns to the correct accounts and campaigns.
145 |
146 | ## Disclaimer
147 | __This is not an officially supported Google product.__
148 |
149 | Copyright 2024 Google LLC. This solution, including any related sample code or
150 | data, is made available on an "as is", "as available", and "with all faults"
151 | basis, solely for illustrative purposes, and without warranty or representation
152 | of any kind. This solution is experimental, unsupported and provided solely for
153 | your convenience. Your use of it is subject to your agreements with Google, as
154 | applicable, and may constitute a beta feature as defined under those agreements.
155 | To the extent that you make any data available to Google in connection with your
156 | use of the solution, you represent and warrant that you have all necessary and
157 | appropriate rights, consents and permissions to permit Google to use and process
158 | that data. By using any portion of this solution, you acknowledge, assume and
159 | accept all risks, known and unknown, associated with its usage, including with
160 | respect to your deployment of any portion of this solution in your systems, or
161 | usage in connection with your business, if at all.
162 |
--------------------------------------------------------------------------------
/functions/appScript/sheets_utils.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Copyright 2024 Google LLC
3 | *
4 | * Licensed under the Apache License, Version 2.0 (the "License");
5 | * you may not use this file except in compliance with the License.
6 | * You may obtain a copy of the License at
7 | *
8 | * http://www.apache.org/licenses/LICENSE-2.0
9 | *
10 | * Unless required by applicable law or agreed to in writing, software
11 | * distributed under the License is distributed on an "AS IS" BASIS,
12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | * See the License for the specific language governing permissions and
14 | * limitations under the License.
15 | */
16 |
17 | const USER_EDITED_PROPERTY_NAME = 'UserEditedCampaignValues';
18 |
19 | /**
20 | * Returns the property object from the document properties
21 | * @return {{customer: {campaign: string[]}}} Object containing account mapping
22 | */
23 | function getProperty() {
24 | return (
25 | JSON.parse(
26 | PropertiesService.getDocumentProperties().getProperty(
27 | USER_EDITED_PROPERTY_NAME,
28 | ),
29 | ) || {}
30 | );
31 | }
32 |
33 | /**
34 | * Sets the property object in the document properties
35 | * @param {{customer: {campaign: string[]}}} propertyObject Object containing
36 | * account mapping
37 | */
38 | function setProperty(propertyObject) {
39 | PropertiesService.getDocumentProperties().setProperty(
40 | USER_EDITED_PROPERTY_NAME,
41 | JSON.stringify(propertyObject),
42 | );
43 | }
44 |
45 | /**
46 | * Deleted the property object from the document properties.
47 | */
48 | function clearProperty() {
49 | PropertiesService.getDocumentProperties().deleteProperty(
50 | USER_EDITED_PROPERTY_NAME
51 | );
52 | }
53 |
54 | /**
55 | * Adds the new campaign user edits into the property object
56 | * @param {string} value New cell value
57 | * @param {string} oldValue Previous cell value
58 | * @param {number} row Index of the row of the edited cell
59 | * @param {number} column Index of the column of the edited cell
60 | * @param {number} numberOfColumnsChanged Count of number of columns edited.
61 | * @param {number} numberOfRowsChanged Count of number of rows edited.
62 | * @param {Spreadsheet.Sheet} sheet Sheet object contain sheet data.
63 | */
64 | function addNewCampaignUserEditsIntoProperty(
65 | value,
66 | oldValue,
67 | row,
68 | column,
69 | numberOfColumnsChanged,
70 | numberOfRowsChanged,
71 | sheet,
72 | ) {
73 | if (column <= NEW_CAMPAIGNS.CAMPAIGN_NAME + 1) {
74 | let userEditedPropertyValuesObject = getProperty();
75 |
76 | const customer = getCellValueFromRowData(
77 | sheet,
78 | row,
79 | NEW_CAMPAIGNS.CUSTOMER_NAME,
80 | );
81 | const campaign = getCellValueFromRowData(
82 | sheet,
83 | row,
84 | NEW_CAMPAIGNS.CAMPAIGN_NAME,
85 | );
86 |
87 | if (numberOfColumnsChanged > 1 || numberOfRowsChanged > 1) {
88 | updateUploadedValuesIntoProperty();
89 | } else if (column === NEW_CAMPAIGNS.CUSTOMER_NAME + 1) {
90 | if (campaign !== '') {
91 | delete userEditedPropertyValuesObject[oldValue][campaign];
92 | userEditedPropertyValuesObject[value] = {
93 | ...userEditedPropertyValuesObject[value],
94 | [campaign]: [],
95 | };
96 | }
97 | } else if (column === NEW_CAMPAIGNS.CAMPAIGN_NAME + 1) {
98 | if (
99 | oldValue &&
100 | userEditedPropertyValuesObject[customer][oldValue] !== undefined
101 | ) {
102 | delete userEditedPropertyValuesObject[customer][oldValue];
103 | }
104 | userEditedPropertyValuesObject[customer] = {
105 | ...userEditedPropertyValuesObject[customer],
106 | [value]: [],
107 | };
108 | }
109 |
110 | setProperty(userEditedPropertyValuesObject);
111 | }
112 | }
113 |
114 | /**
115 | * Adds the new asset group user edits into the property object
116 | * @param {string} value New cell value
117 | * @param {string} oldValue Previous cell value
118 | * @param {number} row Index of the row of the edited cell
119 | * @param {number} column Index of the column of the edited cell
120 | * @param {number} numberOfColumnsChanged Count of number of columns edited.
121 | * @param {number} numberOfRowsChanged Count of number of rows edited.
122 | * @param {Spreadsheet.Sheet} sheet Sheet object contain sheet data.
123 | */
124 | function addNewAssetGroupsUserEditsIntoProperty(
125 | value,
126 | oldValue,
127 | row,
128 | column,
129 | numberOfColumnsChanged,
130 | numberOfRowsChanged,
131 | sheet,
132 | ) {
133 | if (column <= NEW_ASSET_GROUPS.ASSET_GROUP_NAME + 1) {
134 | const customer = getCellValueFromRowData(
135 | sheet,
136 | row,
137 | NEW_ASSET_GROUPS.CUSTOMER_NAME,
138 | );
139 | const campaign = getCellValueFromRowData(
140 | sheet,
141 | row,
142 | NEW_ASSET_GROUPS.CAMPAIGN_NAME,
143 | );
144 |
145 | let userEditedPropertyValuesObject = getProperty();
146 |
147 | if (numberOfColumnsChanged > 1 || numberOfRowsChanged > 1) {
148 | updateUploadedValuesIntoProperty();
149 | } else if (column === NEW_ASSET_GROUPS.ASSET_GROUP_NAME + 1) {
150 | if (
151 | oldValue &&
152 | userEditedPropertyValuesObject[customer]?.[campaign] !== undefined
153 | ) {
154 | const indexOfOldValue =
155 | userEditedPropertyValuesObject[customer][campaign].indexOf(oldValue);
156 | if (indexOfOldValue !== -1) {
157 | userEditedPropertyValuesObject[customer][campaign].splice(
158 | indexOfOldValue,
159 | 1,
160 | );
161 | }
162 | }
163 | userEditedPropertyValuesObject[customer][campaign] = [
164 | ...(userEditedPropertyValuesObject[customer][campaign] || []),
165 | value,
166 | ];
167 | } else if (
168 | column === NEW_ASSET_GROUPS.CAMPAIGN_NAME + 1 &&
169 | oldValue &&
170 | getCellValueFromRowData(
171 | sheet,
172 | row,
173 | NEW_ASSET_GROUPS.ASSET_GROUP_NAME,
174 | ) !== ''
175 | ) {
176 | if (!userEditedPropertyValuesObject[customer]?.[value]) {
177 | userEditedPropertyValuesObject[customer][value] = [];
178 | }
179 | const indexOfOldValue = userEditedPropertyValuesObject[customer][
180 | oldValue
181 | ]?.indexOf(
182 | getCellValueFromRowData(
183 | sheet,
184 | row,
185 | NEW_ASSET_GROUPS.ASSET_GROUP_NAME,
186 | ),
187 | );
188 | if (indexOfOldValue !== -1) {
189 | userEditedPropertyValuesObject[customer][value].push(
190 | userEditedPropertyValuesObject[customer][oldValue][indexOfOldValue],
191 | );
192 | userEditedPropertyValuesObject[customer][oldValue].splice(
193 | indexOfOldValue,
194 | 1,
195 | );
196 | }
197 | } else if (
198 | column === NEW_ASSET_GROUPS.CUSTOMER_NAME + 1 &&
199 | oldValue &&
200 | getCellValueFromRowData(
201 | sheet,
202 | row,
203 | NEW_ASSET_GROUPS.ASSET_GROUP_NAME,
204 | ) !== ''
205 | ) {
206 | if (!userEditedPropertyValuesObject[value]?.[campaign]) {
207 | userEditedPropertyValuesObject[value][campaign] = [];
208 | }
209 | const indexOfOldValue = userEditedPropertyValuesObject[oldValue]?.[
210 | campaign
211 | ]?.indexOf(
212 | getCellValueFromRowData(
213 | sheet,
214 | row,
215 | NEW_ASSET_GROUPS.ASSET_GROUP_NAME,
216 | ),
217 | );
218 | if (indexOfOldValue !== -1) {
219 | userEditedPropertyValuesObject[value][campaign].push(
220 | userEditedPropertyValuesObject[oldValue][campaign][indexOfOldValue],
221 | );
222 | userEditedPropertyValuesObject[oldValue][campaign].splice(
223 | indexOfOldValue,
224 | 1,
225 | );
226 | }
227 | }
228 |
229 | setProperty(userEditedPropertyValuesObject);
230 | }
231 | }
232 |
233 | /**
234 | * Gets the cell value from the row data
235 | * @param {Spreadsheet.Sheet} sheet Sheet object contain sheet data.
236 | * @param {number} row Row index of change.
237 | * @param {number} position Index of cell.
238 | * @return {string} Cell content.
239 | */
240 | function getCellValueFromRowData(sheet, row, position) {
241 | const range = sheet.getRange(row + ':' + row).getValues();
242 |
243 | return range[0][position];
244 | }
245 |
246 | /**
247 | * Updates the uploaded values into the property
248 | */
249 | function updateUploadedValuesIntoProperty() {
250 | const assetGroupSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(
251 | SHEET_NAMES.ASSET_GROUPS,
252 | );
253 | const campaignSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(
254 | SHEET_NAMES.CAMPAIGNS,
255 | );
256 | const newAssetGroupSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(
257 | SHEET_NAMES.NEW_ASSET_GROUPS,
258 | );
259 | const newCampaignSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(
260 | SHEET_NAMES.NEW_CAMPAIGNS,
261 | );
262 | const assetGroupValues = assetGroupSheet
263 | .getRange('6:' + assetGroupSheet.getMaxRows())
264 | .getValues(); // row 6 is where actual data starts
265 | const campaignValues = campaignSheet
266 | .getRange('6:' + campaignSheet.getMaxRows())
267 | .getValues();
268 | const newAssetGroupValues = newAssetGroupSheet
269 | .getRange('6:' + newAssetGroupSheet.getMaxRows())
270 | .getValues(); // row 6 is where actual data starts
271 | const newCampaignValues = newCampaignSheet
272 | .getRange('6:' + newCampaignSheet.getMaxRows())
273 | .getValues();
274 |
275 | clearProperty();
276 | let campaignAssetGroupStructure = getProperty();
277 | if (!campaignAssetGroupStructure) {
278 | campaignAssetGroupStructure = {};
279 | }
280 | campaignValues.forEach((row) => {
281 | const customer = row[CAMPAIGN_LIST.CUSTOMER_NAME];
282 | const campaign = row[CAMPAIGN_LIST.CAMPAIGN_NAME];
283 | campaignAssetGroupStructure[customer] =
284 | campaignAssetGroupStructure[customer] || {};
285 | campaignAssetGroupStructure[customer][campaign] =
286 | campaignAssetGroupStructure[customer][campaign] || [];
287 | });
288 |
289 | newCampaignValues.forEach((row) => {
290 | const customer = row[NEW_CAMPAIGNS.CUSTOMER_NAME];
291 | const campaign = row[NEW_CAMPAIGNS.CAMPAIGN_NAME];
292 | campaignAssetGroupStructure[customer] =
293 | campaignAssetGroupStructure[customer] || {};
294 | campaignAssetGroupStructure[customer][campaign] =
295 | campaignAssetGroupStructure[customer][campaign] || [];
296 | });
297 |
298 | assetGroupValues.forEach((row) => {
299 | const customer = row[ASSET_GROUP_LIST.CUSTOMER_NAME];
300 | const campaign = row[ASSET_GROUP_LIST.CAMPAIGN_NAME];
301 | const assetGroup = row[ASSET_GROUP_LIST.ASSET_GROUP_NAME];
302 | try {
303 | campaignAssetGroupStructure[customer][campaign].push(assetGroup);
304 | } catch (error) {
305 | Logger.log(error);
306 | }
307 | });
308 |
309 | newAssetGroupValues.forEach((row) => {
310 | const customer = row[NEW_ASSET_GROUPS.CUSTOMER_NAME];
311 | const campaign = row[NEW_ASSET_GROUPS.CAMPAIGN_NAME];
312 | const assetGroup = row[NEW_ASSET_GROUPS.ASSET_GROUP_NAME];
313 | try {
314 | campaignAssetGroupStructure[customer][campaign].push(assetGroup);
315 | } catch (error) {
316 | Logger.log(error);
317 | }
318 | });
319 |
320 | setProperty(campaignAssetGroupStructure);
321 | }
322 |
--------------------------------------------------------------------------------
/docs/tutorial.md:
--------------------------------------------------------------------------------
1 | # Deploying Mad PMax
2 |
3 |
4 |
5 |
6 |
7 |
8 | ## Introduction
9 |
10 | In this walkthrough, you'll generate OAuth credentials in preparation for the deployment of Mad PMax.
11 |
12 |
13 |
14 |
15 | ## Join Google Group to access Spreadsheet template
16 |
17 | 1. Use [this link](https://groups.google.com/g/mad-pmax-users) to access the Mad Pmax users group URL and click on "Join Group"
18 |
19 | 
20 |
21 | 1. Make a copy of the [Mad PMax Spreadsheet template](https://docs.google.com/spreadsheets/d/1uj1IA7Bf8W5av2h1Mw_WEAyhiWa6Rxu9KbFxKXW3v2k/copy)
22 |
23 | Note: You can check usage instructions in the [User Guide page](https://github.com/google-marketing-solutions/madpmax/wiki/User-Guide).
24 |
25 | ## Google Cloud Project Setup
26 |
27 | GCP organizes resources into projects. This allows you to
28 | collect all of the related resources for a single application in one place.
29 |
30 | Begin by creating a new project or selecting an existing project for this
31 | solution.
32 |
33 |
34 |
35 | For details, see
36 | [Creating a project](https://cloud.google.com/resource-manager/docs/creating-managing-projects#creating_a_project).
37 |
38 | ### Enable Google Cloud APIs
39 |
40 | Enable the APIs necessary to run the Mad PMax solution so that they're incorporated in the credentials you will generate in the next step.
41 |
42 |
43 |
44 |
45 |
46 | ## Switch Off Ephemeral Mode
47 |
48 | First, let's switch off your shell's ephemeral mode.
49 |
50 | Click **More** and look for the `Ephemeral Mode` option. If it is turned on turn it off. This allows the Mad PMax code to persist across sessions.
51 |
52 | ## Authorize shell scripts commands
53 |
54 | Copy the following command into the shell, press enter and follow the instructions:
55 | ```bash
56 | cd
57 | git clone https://github.com/google-marketing-solutions/madpmax.git
58 | cd madpmax
59 | gcloud auth login
60 | ```
61 |
62 |
63 | ## Configure OAuth Consent Screen
64 |
65 | An authorization token is needed for the dashboard to communicate with Google Ads.
66 |
67 | 1. Go to the **APIs & Services > OAuth consent screen** page in the Cloud
68 | Console. You can use the button below to find the section.
69 |
70 |
71 |
72 | 1. Choose the correct user type for your application.
73 |
74 | * If you have an organization for your application, select **Internal**.
75 | * If you don't have an organization configured for your application,
76 | select **External**.
77 |
78 | 1. Click
79 | **Create**
80 | to continue.
81 |
82 | 1. Under *App information*, enter the **Application name** you want to display.
83 | You can copy the name below and enter it as the application name.
84 |
85 | ```
86 | MadPmax
87 | ```
88 |
89 | 1. For the **Support email** dropdown menu, select the email address you want
90 | to display as a public contact. This email address must be your email
91 | address, or a Google Group you own.
92 | 2. Under **Developer contact information**, enter a valid email address.
93 |
94 | Click
95 | **Save
96 | and continue**.
97 |
98 | ## Add Sensitive Scopes to Consent Screen
99 |
100 | Scope the consent screen for Google Sheets API, Drive API, PubSub API and Google Ads API.
101 |
102 | 1. Click Add or remove scopes
103 | 1. Now in Enter property name or value search for **Google Ads API**, check the box for the first option to choose it.
104 | 1. Do the same for
105 | * **Google Drive API** (select the `drive.readonly` option)
106 | * **Google Sheets API**
107 | * **Cloud Pub/Sub API**.
108 | 1. Click Update
109 |
110 | ## Creating OAuth Credentials
111 |
112 | Create the credentials that are needed to generate a refresh token.
113 |
114 | Make sure to **copy each of the credentials you create**, you will need them later.
115 |
116 | 1. On the APIs & Services page, click the
117 | **Credentials**
118 | tab.
119 |
120 | 1. On the
121 | **Create
122 | credentials** drop-down list, select **OAuth
123 | client ID**.
124 | 1. Under
125 | **Application
126 | type**, select **Web application**.
127 |
128 | 1. Add a
129 | **Name**
130 | for your OAuth client ID.
131 |
132 | 1. Click Authorized redirect URI
133 | and copy the following:
134 | ```
135 | https://developers.google.com/oauthplayground
136 | ```
137 | 1. Add a redirect URI fro your spreadsheet as well. Obtain your `Script ID` by navigating to your copy of the Template Spreadsheet. In the top menu, select "Extensions > Apps Script". The Apps Script editor will open in a new tab. Navigate on the left hand side to Project Settings and copy the Script ID.
138 | ```
139 | https://script.google.com/macros/d//usercallback
140 | ```
141 | 
142 |
143 | 1. Click **Create**. Your OAuth client ID and client secret are generated and
144 | displayed on the OAuth client window.
145 |
146 | After generating the client_id and client_secret keep the confirmation screen open and go to the next step.
147 |
148 |
149 | ## Generate Refresh Token
150 |
151 | 1. Go to the [OAuth2 Playground](https://developers.google.com/oauthplayground/#step1&scopes=https%3A//www.googleapis.com/auth/adwords,https%3A//www.googleapis.com/auth/drive,https%3A//www.googleapis.com/auth/spreadsheets&content_type=application/json&http_method=GET&useDefaultOauthCred=checked&oauthEndpointSelect=Google&oauthAuthEndpointValue=https%3A//accounts.google.com/o/oauth2/v2/auth&oauthTokenEndpointValue=https%3A//oauth2.googleapis.com/token&includeCredentials=unchecked&accessTokenType=bearer&autoRefreshToken=unchecked&accessType=offline&forceAprovalPrompt=checked&response_type=code) (opens in a new window)
152 | 2. On the right-hand pane, paste the `client_id` and `client_secret` in the appropriate fields 
153 | 3. Then on the left hand side of the screen, click the blue **Authorize APIs** button 
154 |
155 | If you are prompted to authorize access, please choose your Google account that has access to Google Ads and approve.
156 |
157 | 5. Now, click the new blue button **Exchange authorization code for tokens** 
158 |
159 | 6. Finally, in the middle of the screen you'll see your refresh token on the last line. Copy it and save it for future reference.  *Do not copy the quotation marks*
160 |
161 |
162 | ## Deploy Solution
163 |
164 | Run the following command:
165 |
166 | ```bash
167 | cd terraform
168 | ```
169 | Open the `/terraform/configuration-input.tfvars` file and complete the required input variables
170 | Open
171 | configuration-input.tfvars
172 |
173 | Run the following command:
174 |
175 | ```bash
176 | terraform init
177 | ```
178 | With the providers downloaded and a project set, you're ready to use Terraform.
179 | Go ahead!
180 |
181 | ```bash
182 | terraform apply -var-file="configuration-input.tfvars"
183 | ```
184 | Terraform will show you what it plans to do, and prompt you to accept. Type "yes" to accept the plan.
185 |
186 | Terraform will now take some time to deploy the solution for you with the configuration specified in the configuration-input.tfvars file. Terraform will keep a state in this cloud shell environment, so if you update/change the configuration settings and run terrafom apply command again it will update all resources accordingly.
187 |
188 | If you want to remove the deployed resources from Google Cloud Platform again you can run the following command.
189 |
190 | ```bash
191 | terraform destroy -var-file="configuration-input.tfvars"
192 | ```
193 | **Note**: To obtain Google Ads Developer token refer to [Apply for access to the Google Ads API](https://developers.google.com/google-ads/api/docs/get-started/dev-token#apply-token).
194 |
195 | ## Link your copy of the Template Sheet to the Google Cloud Project
196 | 1. Open the **Extensions** menu in your Template Sheet and click on **Apps Script**.
197 | 2. Navigate to the Apps Script Code Editor, and copy values for the **Client ID** and **Client Secret** to the respective variables in the `Config.gs` file. Then, find your **Google Cloud Project Name** from the Project Info section of your [Google Cloud Project Dashboard](https://console.cloud.google.com/home/dashboard) and copy its value to the respective variable in the same file.
198 | 3. Find the cog wheel icon, titled as **Project Settings**, on the left side of the screen and click on it.
199 | 4. Scroll down to the **Google Cloud Platform (GCP) Project** section and click on the button titled as **Change Project**.
200 | 
201 | 1. In another tab of your browser, navigate to the Project Info section of your [Google Cloud Project Dashboard](https://console.cloud.google.com/home/dashboard) and copy the value for the **Project Number**.
202 | 2. Copy this value into your Template Sheet and click on the button titled as **Set Project** to complete the process.
203 |
204 | ## Conclusion
205 |
206 | Congratulations. You've set up Mad PMax! How to use it? Check out the [User Guide](https://github.com/google-marketing-solutions/madpmax/wiki/User-Guide)
207 |
208 |
209 |
210 |
211 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 |
2 | Apache License
3 | Version 2.0, January 2004
4 | http://www.apache.org/licenses/
5 |
6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7 |
8 | 1. Definitions.
9 |
10 | "License" shall mean the terms and conditions for use, reproduction,
11 | and distribution as defined by Sections 1 through 9 of this document.
12 |
13 | "Licensor" shall mean the copyright owner or entity authorized by
14 | the copyright owner that is granting the License.
15 |
16 | "Legal Entity" shall mean the union of the acting entity and all
17 | other entities that control, are controlled by, or are under common
18 | control with that entity. For the purposes of this definition,
19 | "control" means (i) the power, direct or indirect, to cause the
20 | direction or management of such entity, whether by contract or
21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
22 | outstanding shares, or (iii) beneficial ownership of such entity.
23 |
24 | "You" (or "Your") shall mean an individual or Legal Entity
25 | exercising permissions granted by this License.
26 |
27 | "Source" form shall mean the preferred form for making modifications,
28 | including but not limited to software source code, documentation
29 | source, and configuration files.
30 |
31 | "Object" form shall mean any form resulting from mechanical
32 | transformation or translation of a Source form, including but
33 | not limited to compiled object code, generated documentation,
34 | and conversions to other media types.
35 |
36 | "Work" shall mean the work of authorship, whether in Source or
37 | Object form, made available under the License, as indicated by a
38 | copyright notice that is included in or attached to the work
39 | (an example is provided in the Appendix below).
40 |
41 | "Derivative Works" shall mean any work, whether in Source or Object
42 | form, that is based on (or derived from) the Work and for which the
43 | editorial revisions, annotations, elaborations, or other modifications
44 | represent, as a whole, an original work of authorship. For the purposes
45 | of this License, Derivative Works shall not include works that remain
46 | separable from, or merely link (or bind by name) to the interfaces of,
47 | the Work and Derivative Works thereof.
48 |
49 | "Contribution" shall mean any work of authorship, including
50 | the original version of the Work and any modifications or additions
51 | to that Work or Derivative Works thereof, that is intentionally
52 | submitted to Licensor for inclusion in the Work by the copyright owner
53 | or by an individual or Legal Entity authorized to submit on behalf of
54 | the copyright owner. For the purposes of this definition, "submitted"
55 | means any form of electronic, verbal, or written communication sent
56 | to the Licensor or its representatives, including but not limited to
57 | communication on electronic mailing lists, source code control systems,
58 | and issue tracking systems that are managed by, or on behalf of, the
59 | Licensor for the purpose of discussing and improving the Work, but
60 | excluding communication that is conspicuously marked or otherwise
61 | designated in writing by the copyright owner as "Not a Contribution."
62 |
63 | "Contributor" shall mean Licensor and any individual or Legal Entity
64 | on behalf of whom a Contribution has been received by Licensor and
65 | subsequently incorporated within the Work.
66 |
67 | 2. Grant of Copyright License. Subject to the terms and conditions of
68 | this License, each Contributor hereby grants to You a perpetual,
69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70 | copyright license to reproduce, prepare Derivative Works of,
71 | publicly display, publicly perform, sublicense, and distribute the
72 | Work and such Derivative Works in Source or Object form.
73 |
74 | 3. Grant of Patent License. Subject to the terms and conditions of
75 | this License, each Contributor hereby grants to You a perpetual,
76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77 | (except as stated in this section) patent license to make, have made,
78 | use, offer to sell, sell, import, and otherwise transfer the Work,
79 | where such license applies only to those patent claims licensable
80 | by such Contributor that are necessarily infringed by their
81 | Contribution(s) alone or by combination of their Contribution(s)
82 | with the Work to which such Contribution(s) was submitted. If You
83 | institute patent litigation against any entity (including a
84 | cross-claim or counterclaim in a lawsuit) alleging that the Work
85 | or a Contribution incorporated within the Work constitutes direct
86 | or contributory patent infringement, then any patent licenses
87 | granted to You under this License for that Work shall terminate
88 | as of the date such litigation is filed.
89 |
90 | 4. Redistribution. You may reproduce and distribute copies of the
91 | Work or Derivative Works thereof in any medium, with or without
92 | modifications, and in Source or Object form, provided that You
93 | meet the following conditions:
94 |
95 | (a) You must give any other recipients of the Work or
96 | Derivative Works a copy of this License; and
97 |
98 | (b) You must cause any modified files to carry prominent notices
99 | stating that You changed the files; and
100 |
101 | (c) You must retain, in the Source form of any Derivative Works
102 | that You distribute, all copyright, patent, trademark, and
103 | attribution notices from the Source form of the Work,
104 | excluding those notices that do not pertain to any part of
105 | the Derivative Works; and
106 |
107 | (d) If the Work includes a "NOTICE" text file as part of its
108 | distribution, then any Derivative Works that You distribute must
109 | include a readable copy of the attribution notices contained
110 | within such NOTICE file, excluding those notices that do not
111 | pertain to any part of the Derivative Works, in at least one
112 | of the following places: within a NOTICE text file distributed
113 | as part of the Derivative Works; within the Source form or
114 | documentation, if provided along with the Derivative Works; or,
115 | within a display generated by the Derivative Works, if and
116 | wherever such third-party notices normally appear. The contents
117 | of the NOTICE file are for informational purposes only and
118 | do not modify the License. You may add Your own attribution
119 | notices within Derivative Works that You distribute, alongside
120 | or as an addendum to the NOTICE text from the Work, provided
121 | that such additional attribution notices cannot be construed
122 | as modifying the License.
123 |
124 | You may add Your own copyright statement to Your modifications and
125 | may provide additional or different license terms and conditions
126 | for use, reproduction, or distribution of Your modifications, or
127 | for any such Derivative Works as a whole, provided Your use,
128 | reproduction, and distribution of the Work otherwise complies with
129 | the conditions stated in this License.
130 |
131 | 5. Submission of Contributions. Unless You explicitly state otherwise,
132 | any Contribution intentionally submitted for inclusion in the Work
133 | by You to the Licensor shall be under the terms and conditions of
134 | this License, without any additional terms or conditions.
135 | Notwithstanding the above, nothing herein shall supersede or modify
136 | the terms of any separate license agreement you may have executed
137 | with Licensor regarding such Contributions.
138 |
139 | 6. Trademarks. This License does not grant permission to use the trade
140 | names, trademarks, service marks, or product names of the Licensor,
141 | except as required for reasonable and customary use in describing the
142 | origin of the Work and reproducing the content of the NOTICE file.
143 |
144 | 7. Disclaimer of Warranty. Unless required by applicable law or
145 | agreed to in writing, Licensor provides the Work (and each
146 | Contributor provides its Contributions) on an "AS IS" BASIS,
147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148 | implied, including, without limitation, any warranties or conditions
149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150 | PARTICULAR PURPOSE. You are solely responsible for determining the
151 | appropriateness of using or redistributing the Work and assume any
152 | risks associated with Your exercise of permissions under this License.
153 |
154 | 8. Limitation of Liability. In no event and under no legal theory,
155 | whether in tort (including negligence), contract, or otherwise,
156 | unless required by applicable law (such as deliberate and grossly
157 | negligent acts) or agreed to in writing, shall any Contributor be
158 | liable to You for damages, including any direct, indirect, special,
159 | incidental, or consequential damages of any character arising as a
160 | result of this License or out of the use or inability to use the
161 | Work (including but not limited to damages for loss of goodwill,
162 | work stoppage, computer failure or malfunction, or any and all
163 | other commercial damages or losses), even if such Contributor
164 | has been advised of the possibility of such damages.
165 |
166 | 9. Accepting Warranty or Additional Liability. While redistributing
167 | the Work or Derivative Works thereof, You may choose to offer,
168 | and charge a fee for, acceptance of support, warranty, indemnity,
169 | or other liability obligations and/or rights consistent with this
170 | License. However, in accepting such obligations, You may act only
171 | on Your own behalf and on Your sole responsibility, not on behalf
172 | of any other Contributor, and only if You agree to indemnify,
173 | defend, and hold each Contributor harmless for any liability
174 | incurred by, or claims asserted against, such Contributor by reason
175 | of your accepting any such warranty or additional liability.
176 |
177 | END OF TERMS AND CONDITIONS
178 |
179 | APPENDIX: How to apply the Apache License to your work.
180 |
181 | To apply the Apache License to your work, attach the following
182 | boilerplate notice, with the fields enclosed by brackets "[]"
183 | replaced with your own identifying information. (Don't include
184 | the brackets!) The text should be enclosed in the appropriate
185 | comment syntax for the file format. We also recommend that a
186 | file or class name and description of purpose be included on the
187 | same "printed page" as the copyright notice for easier
188 | identification within third-party archives.
189 |
190 | Copyright [yyyy] [name of copyright owner]
191 |
192 | Licensed under the Apache License, Version 2.0 (the "License");
193 | you may not use this file except in compliance with the License.
194 | You may obtain a copy of the License at
195 |
196 | http://www.apache.org/licenses/LICENSE-2.0
197 |
198 | Unless required by applicable law or agreed to in writing, software
199 | distributed under the License is distributed on an "AS IS" BASIS,
200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201 | See the License for the specific language governing permissions and
202 | limitations under the License.
203 |
--------------------------------------------------------------------------------
/functions/test/test_asset_deletion.py:
--------------------------------------------------------------------------------
1 | # Copyright 2024 Google LLC
2 |
3 | # Licensed under the Apache License, Version 2.0 (the "License")
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 |
7 | # https: // www.apache.org / licenses / LICENSE - 2.0
8 |
9 |
10 | # Unless required by applicable law or agreed to in writing, software
11 | # distributed under the License is distributed on an "AS IS" BASIS,
12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | # See the License for the specific language governing permissions and
14 | # limitations under the License.
15 | """Tests for Asset Deletion."""
16 |
17 | from typing import Final
18 | from unittest import mock
19 | from asset_deletion import AssetDeletionService
20 | import data_references
21 | import pytest
22 |
23 |
24 | _CUSTOMER_ID: Final[str] = "customer_id_1"
25 | _CAMPAIGN_ID: Final[str] = "campaign_id_1"
26 | _VALID_SHEET_DATA: Final[list[list[str]]] = [
27 | [
28 | "UPLOADED",
29 | "TRUE",
30 | "Test Customer 1",
31 | "Test Campaign 1",
32 | "Test Asset Group 1",
33 | "HEADLINE",
34 | "Test Headline 1",
35 | "",
36 | "",
37 | "",
38 | "",
39 | "customers/1234567890/assetGroupAssets/1234567890~1234567890~HEADLINE",
40 | ],
41 | [
42 | "",
43 | "TRUE",
44 | "Test Customer 1",
45 | "Test Campaign 1",
46 | "Test Asset Group 1",
47 | "HEADLINE",
48 | "Test Headline 1",
49 | "",
50 | "",
51 | "",
52 | "",
53 | "",
54 | ],
55 | [
56 | "UPLOADED",
57 | "FALSE",
58 | "Test Customer 2",
59 | "Test Campaign 2",
60 | "Test Asset Group 1",
61 | "HEADLINE",
62 | "Test Headline 1",
63 | "",
64 | "",
65 | "",
66 | "",
67 | "customers/1234567890/assetGroupAssets/1234567890~1234567890~HEADLINE",
68 | ],
69 | [
70 | "UPLOADED",
71 | "TRUE",
72 | "Test Customer 2",
73 | "Test Campaign 2",
74 | "Test Asset Group 1",
75 | "HEADLINE",
76 | "Test Headline 1",
77 | "",
78 | "",
79 | "",
80 | "",
81 | "",
82 | ]
83 | ]
84 |
85 |
86 | class DotDict(dict):
87 | """Class to convert dictionary to dot notation."""
88 | __setattr__ = dict.__setitem__
89 | __delattr__ = dict.__delitem__
90 |
91 | def __init__(self, data):
92 | if isinstance(data, str):
93 | data = json.loads(data)
94 |
95 | for name, value in data.items():
96 | setattr(self, name, self._wrap(value))
97 |
98 | def __getattr__(self, attr):
99 | def _traverse(obj, attr):
100 | if self._is_indexable(obj):
101 | try:
102 | return obj[int(attr)]
103 | except:
104 | return None
105 | elif isinstance(obj, dict):
106 | return obj.get(attr, None)
107 | else:
108 | return attr
109 |
110 | if "." in attr:
111 | return reduce(_traverse, attr.split("."), self)
112 | return self.get(attr, None)
113 |
114 | def _wrap(self, value):
115 | if self._is_indexable(value):
116 | # (!) recursive (!)
117 | return type(value)([self._wrap(v) for v in value])
118 | elif isinstance(value, dict):
119 | return DotDict(value)
120 | else:
121 | return value
122 |
123 | @staticmethod
124 | def _is_indexable(obj):
125 | return isinstance(obj, (tuple, list, set, frozenset))
126 |
127 | # Dictionary Template mocking the Google Ads API response class.
128 | MockMutateGoogleAdsResponse = {
129 | "partial_failure_error": {
130 | "code": None,
131 | "message": None,
132 | "details": [
133 | {
134 | "value": {
135 | "errors": [{
136 | "error_code": None,
137 | "location": {
138 | "field_path_elements": [{"index": None}]
139 | },
140 | "message": None
141 | }]
142 | }
143 | }
144 | ]
145 | },
146 | "mutate_operation_responses": []
147 | }
148 |
149 |
150 | @pytest.fixture
151 | def service_mocks():
152 | """Fixture to set up your mocks."""
153 | google_ads_client = mock.MagicMock()
154 | google_ads_service = mock.Mock()
155 | sheet_service = mock.MagicMock()
156 |
157 | return google_ads_client, google_ads_service, sheet_service
158 |
159 |
160 | class MockGoogleAdsFailure:
161 | """Mock Google Ads API Failure Object, required for testing."""
162 | errors: []
163 | request_id: str
164 |
165 | def deserialize(self):
166 | return self
167 |
168 |
169 | @pytest.mark.parametrize(
170 | "row_num, asset_group_asset",
171 | [(
172 | 0,
173 | "customers/1234567890/assetGroupAssets/1234567890~1234567890~HEADLINE")
174 | ])
175 | @mock.patch("asset_deletion.AssetDeletionService.delete_asset")
176 | @mock.patch("utils.retrieve_customer_id")
177 | def test_process_asset_deletion_input(mock_retrieve_customer_id,
178 | mock_delete_asset, service_mocks, row_num, asset_group_asset):
179 | """Test process_asset_deletion_input method in AssetDeletionService.
180 |
181 | Verifying wheter the returns the expected data.
182 | """
183 | google_ads_client, google_ads_service, sheet_service = service_mocks
184 | asset_service = AssetDeletionService(
185 | google_ads_client, google_ads_service, sheet_service)
186 |
187 | mock_delete_asset.return_value = {
188 | "remove": asset_group_asset
189 | }
190 | mock_retrieve_customer_id.return_value = _CUSTOMER_ID
191 | operations, row_mapping = asset_service.process_asset_deletion_input(
192 | _VALID_SHEET_DATA
193 | )
194 | expected_operations = {
195 | _CUSTOMER_ID: [
196 | {
197 | "remove": asset_group_asset
198 | }
199 | ]
200 | }
201 | expected_row_mapping = {_CUSTOMER_ID: [row_num]}
202 |
203 | assert operations == expected_operations, "%s != %s" % (
204 | operations, expected_operations)
205 | assert row_mapping == expected_row_mapping, "%s != %s" % (
206 | row_mapping, expected_row_mapping)
207 |
208 |
209 | @pytest.mark.parametrize(
210 | "asset_group_asset",
211 | [(
212 | "customers/1234567890/assetGroupAssets/1234567890~1234567890~HEADLINE"),
213 | (None)
214 | ])
215 | def test_create_delete_asset_object(service_mocks, asset_group_asset):
216 | """Test delete_asset method in AssetDeletionService.
217 |
218 | Verify if return object contains expected structure and values.
219 | """
220 | google_ads_client, google_ads_service, sheet_service = service_mocks
221 | asset_service = AssetDeletionService(
222 | google_ads_client, google_ads_service, sheet_service)
223 |
224 | asset_resource = asset_group_asset
225 |
226 | delete_asset_operation = asset_service.delete_asset(
227 | asset_resource)
228 |
229 | if delete_asset_operation:
230 | output = delete_asset_operation.asset_group_asset_operation.remove
231 | else:
232 | output = delete_asset_operation
233 |
234 | assert output == asset_resource, "%s != %s" % (
235 | output, asset_resource)
236 |
237 |
238 | @pytest.mark.parametrize(
239 | "row_num,asset_group_asset,error_message,error_code",
240 | [(0,
241 | "customers/1234567890/assetGroupAssets/1234567890~1234567890~HEADLINE",
242 | None,
243 | None),
244 | (0,
245 | "invalid_resource_name",
246 | "Error Message",
247 | "Error Code")
248 | ])
249 | def test_process_asset_errors(
250 | service_mocks, row_num, asset_group_asset, error_message, error_code):
251 | """Test process_asset_errors method in AssetDeletionService.
252 |
253 | Verifying wheter the service output is in the expected format when there
254 | are not partial failures returned in the Google Ads API response.
255 | """
256 | google_ads_client, google_ads_service, sheet_service = service_mocks
257 | asset_service = AssetDeletionService(
258 | google_ads_client, google_ads_service, sheet_service)
259 | opererations_dict = {
260 | "asset_group_asset_operation": {
261 | "remove": asset_group_asset
262 | }
263 | }
264 | operations = [
265 | DotDict(opererations_dict)
266 | ]
267 | mock_response = DotDict(MockMutateGoogleAdsResponse.copy())
268 |
269 | expected_result = {
270 | row_num: {
271 | "status": data_references.RowStatus.uploaded,
272 | "message": (f"Error message: {error_message}"
273 | f"\n\tError code: {error_code}"),
274 | "asset_group_asset": asset_group_asset
275 | }
276 | }
277 |
278 | if error_message:
279 | mock_response.partial_failure_error.details[0].value.errors[
280 | 0].error_code = error_code
281 | mock_response.partial_failure_error.details[0].value.errors[
282 | 0].message = error_message
283 | mock_response.partial_failure_error.details[0].value.errors[
284 | 0].location.field_path_elements[0].index = row_num
285 | else:
286 | mock_response.partial_failure_error = None
287 | expected_result = {}
288 |
289 | google_ads_client.get_type.return_value = MockGoogleAdsFailure()
290 |
291 | result = asset_service.process_asset_errors(
292 | mock_response, [row_num], operations)
293 |
294 | # Assertions
295 | assert result == expected_result, "%s != %s" % (
296 | result, expected_result)
297 |
298 |
299 | @pytest.mark.parametrize(
300 | "row_nums,asset_group_asset,expected_rows,expected_errors",
301 | [([0],
302 | "customers/1234567890/assetGroupAssets/1234567890~1234567890~HEADLINE",
303 | [0],
304 | {}),
305 | ([0],
306 | "invalid_resource_name",
307 | [],
308 | {0: {"message": "Error code: request_error: RESOURCE_NAME_MALFORMED"}}),
309 | ([],
310 | "",
311 | [],
312 | {})])
313 | @mock.patch("asset_deletion.AssetDeletionService.process_asset_errors")
314 | def test_process_api_deletion_operations(
315 | mock_process_asset_errors, service_mocks, row_nums, asset_group_asset,
316 | expected_rows, expected_errors):
317 | """Test process_api_deletion_operations method in AssetDeletionService.
318 |
319 | Verifying wheter the service calls the correct function.
320 | """
321 | google_ads_client, google_ads_service, sheet_service = service_mocks
322 | asset_service = AssetDeletionService(
323 | google_ads_client, google_ads_service, sheet_service)
324 |
325 | row_to_operations_mapping = {_CUSTOMER_ID: row_nums}
326 | operations = {
327 | _CUSTOMER_ID: [
328 | {
329 | "asset_group_asset_operation": {
330 | "remove": asset_group_asset
331 | }
332 | }
333 | ]
334 | }
335 |
336 | google_ads_service.bulk_mutate.return_value = ("Partial Response", None)
337 | mock_process_asset_errors.return_value = expected_errors
338 |
339 | (
340 | rows_for_removal, error_sheet_output
341 | ) = asset_service.process_api_deletion_operations(
342 | operations, row_to_operations_mapping)
343 |
344 | assert rows_for_removal == expected_rows, "%s != %s" % (
345 | rows_for_removal, expected_rows)
346 | assert error_sheet_output == expected_errors, "%s != %s" % (
347 | error_sheet_output, expected_errors)
348 |
349 |
350 | @pytest.mark.parametrize(
351 | "row_num, asset_group_asset",
352 | [(
353 | 0,
354 | "customers/1234567890/assetGroupAssets/1234567890~1234567890~HEADLINE"
355 | )])
356 | @mock.patch(
357 | "asset_deletion.AssetDeletionService.process_api_deletion_operations")
358 | @mock.patch("asset_deletion.AssetDeletionService.process_asset_deletion_input")
359 | def test_asset_deletion(
360 | mock_process_asset_deletion_input, mock_process_api_deletion_operations,
361 | service_mocks, row_num, asset_group_asset):
362 | """Test process_asset_deletion_input method in AssetDeletionService.
363 |
364 | Verifying wheter the returns the expected data.
365 | """
366 | google_ads_client, google_ads_service, sheet_service = service_mocks
367 | asset_service = AssetDeletionService(
368 | google_ads_client, google_ads_service, sheet_service)
369 |
370 | mock_process_asset_deletion_input.return_value = ({
371 | _CUSTOMER_ID: [
372 | {
373 | "remove": asset_group_asset
374 | }
375 | ]
376 | }, {_CUSTOMER_ID: [row_num]})
377 |
378 | mock_process_api_deletion_operations. return_value = (
379 | [row_num], {})
380 |
381 | asset_service.asset_deletion(_VALID_SHEET_DATA)
382 | sheet_service.remove_sheet_rows.assert_called_once_with(
383 | [row_num],
384 | data_references.SheetNames.assets
385 | )
386 |
--------------------------------------------------------------------------------
/functions/test/test_main.py:
--------------------------------------------------------------------------------
1 | # Copyright 2024 Google LLC
2 | #
3 | # Licensed under the Apache License, Version 2.0 (the "License");
4 | # you may not use this file except in compliance with the License.
5 | # You may obtain a copy of the License at
6 | #
7 | # http://www.apache.org/licenses/LICENSE-2.0
8 | #
9 | # Unless required by applicable law or agreed to in writing, software
10 | # distributed under the License is distributed on an "AS IS" BASIS,
11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | # See the License for the specific language governing permissions and
13 | # limitations under the License.
14 |
15 | import base64
16 | import contextlib
17 | import io
18 | import json
19 | import os
20 | import shutil
21 | import tempfile
22 | from typing import Any, AnyStr, BinaryIO, ContextManager, Iterator, Optional, Tuple
23 | import unittest
24 | from unittest.mock import patch
25 | import main
26 | import data_references
27 |
28 |
29 | class _TempFile(object):
30 | """Represents a tempfile for tests.
31 |
32 | Creation of this class is internal. Using its public methods is OK.
33 |
34 | This class implements the `os.PathLike` interface (specifically,
35 | `os.PathLike[str]`). This means, in Python 3, it can be directly passed
36 | to e.g. `os.path.join()`.
37 | """
38 |
39 | def __init__(self, path: str) -> None:
40 | """Private: use _create instead."""
41 | self._path = path
42 |
43 | @classmethod
44 | def _create(
45 | cls,
46 | base_path: str,
47 | file_path: Optional[str],
48 | content: AnyStr,
49 | mode: str,
50 | encoding: str,
51 | errors: str,
52 | ) -> Tuple['_TempFile', str]:
53 | """Module-private: create a tempfile instance."""
54 | if file_path:
55 | cleanup_path = os.path.join(base_path, _get_first_part(file_path))
56 | path = os.path.join(base_path, file_path)
57 | os.makedirs(os.path.dirname(path), exist_ok=True)
58 | # The file may already exist, in which case, ensure it's writable so that
59 | # it can be truncated.
60 | if os.path.exists(path) and not os.access(path, os.W_OK):
61 | stat_info = os.stat(path)
62 | os.chmod(path, stat_info.st_mode | stat.S_IWUSR)
63 | else:
64 | os.makedirs(base_path, exist_ok=True)
65 | fd, path = tempfile.mkstemp(dir=str(base_path))
66 | os.close(fd)
67 | cleanup_path = path
68 |
69 | tf = cls(path)
70 |
71 | if content:
72 | if isinstance(content, str):
73 | tf.write_text(content, mode=mode, encoding=encoding, errors=errors)
74 | else:
75 | tf.write_bytes(content, mode)
76 |
77 | else:
78 | tf.write_bytes(b'')
79 |
80 | return tf, cleanup_path
81 |
82 | @property
83 | def full_path(self) -> str:
84 | """Returns the path, as a string, for the file.
85 |
86 | TIP: Instead of e.g. `os.path.join(temp_file.full_path)`, you can simply
87 | do `os.path.join(temp_file)` because `__fspath__()` is implemented.
88 | """
89 | return self._path
90 |
91 | def __fspath__(self) -> str:
92 | """See os.PathLike."""
93 | return self.full_path
94 |
95 | def write_bytes(self, data: bytes, mode: str = 'wb') -> None:
96 | """Write bytes to the file.
97 |
98 | Args:
99 | data: bytes to write.
100 | mode: Mode to open the file for writing. The "b" flag is implicit if not
101 | already present. It must not have the "t" flag.
102 | """
103 | with self.open_bytes(mode) as fp:
104 | fp.write(data)
105 |
106 | def open_bytes(self, mode: str = 'rb') -> ContextManager[BinaryIO]:
107 | """Return a context manager for opening the file in binary mode.
108 |
109 | Args:
110 | mode: The mode to open the file in. The "b" mode is implicit if not
111 | already present. It must not have the "t" flag.
112 |
113 | Returns:
114 | Context manager that yields an open file.
115 |
116 | Raises:
117 | ValueError: if invalid inputs are provided.
118 | """
119 | if 't' in mode:
120 | raise ValueError(
121 | 'Invalid mode {!r}: "t" flag not allowed when opening '
122 | 'file in binary mode'.format(mode)
123 | )
124 | if 'b' not in mode:
125 | mode += 'b'
126 | cm = self._open(mode, encoding=None, errors=None)
127 | return cm
128 |
129 | @contextlib.contextmanager
130 | def _open(
131 | self,
132 | mode: str,
133 | encoding: Optional[str] = 'utf8',
134 | errors: Optional[str] = 'strict',
135 | ) -> Iterator[Any]:
136 | with io.open(
137 | self.full_path, mode=mode, encoding=encoding, errors=errors
138 | ) as fp:
139 | yield fp
140 |
141 |
142 | class _TempDir(object):
143 | """Represents a temporary directory for tests.
144 |
145 | Creation of this class is internal. Using its public methods is OK.
146 |
147 | This class implements the `os.PathLike` interface (specifically,
148 | `os.PathLike[str]`). This means, in Python 3, it can be directly passed
149 | to e.g. `os.path.join()`.
150 | """
151 |
152 | def __init__(self, path: str) -> None:
153 | """Module-private: do not instantiate outside module."""
154 | self._path = path
155 |
156 | @property
157 | def full_path(self) -> str:
158 | """Returns the path, as a string, for the directory.
159 |
160 | TIP: Instead of e.g. `os.path.join(temp_dir.full_path)`, you can simply
161 | do `os.path.join(temp_dir)` because `__fspath__()` is implemented.
162 | """
163 | return self._path
164 |
165 | def create_file(
166 | self,
167 | file_path: Optional[str] = None,
168 | content: Optional[AnyStr] = None,
169 | mode: str = 'w',
170 | encoding: str = 'utf8',
171 | errors: str = 'strict',
172 | ) -> '_TempFile':
173 | """Create a file in the directory.
174 |
175 | NOTE: If the file already exists, it will be made writable and overwritten.
176 |
177 | Args:
178 | file_path: Optional file path for the temp file. If not given, a unique
179 | file name will be generated and used. Slashes are allowed in the name;
180 | any missing intermediate directories will be created. NOTE: This path is
181 | the path that will be cleaned up, including any directories in the path,
182 | e.g., 'foo/bar/baz.txt' will `rm -r foo`
183 | content: Optional string or bytes to initially write to the file. If not
184 | specified, then an empty file is created.
185 | mode: Mode string to use when writing content. Only used if `content` is
186 | non-empty.
187 | encoding: Encoding to use when writing string content. Only used if
188 | `content` is text.
189 | errors: How to handle text to bytes encoding errors. Only used if
190 | `content` is text.
191 |
192 | Returns:
193 | A _TempFile representing the created file.
194 | """
195 | tf, _ = _TempFile._create(
196 | self._path, file_path, content, mode, encoding, errors
197 | )
198 | return tf
199 |
200 |
201 | class TestMain(unittest.TestCase):
202 |
203 | def testConfig_retrieval_successful_with_correct_file(self):
204 | file_dict = {
205 | 'use_proto_plus': True,
206 | 'developer_token': 'zZzZzZz',
207 | 'client_id': '123456789',
208 | 'client_secret': 'ClIeNt_sEcReT',
209 | 'access_token': 'z-z-Z-Z',
210 | 'refresh_token': 'Y-y-Y-y',
211 | 'login_customer_id': '12345',
212 | 'customer_id_inclusion_list': '654321',
213 | 'spreadsheet_id': 'SheetId123',
214 | }
215 | out_dir = self.create_tempdir('tmp_test')
216 | json_string = json.dumps(file_dict).encode('utf-8')
217 | out_file = out_dir.create_file('output_correct_file.yaml', json_string)
218 |
219 | result = main.retrieve_config(out_file)
220 | expected = data_references.ConfigFile(
221 | True,
222 | 'zZzZzZz',
223 | '123456789',
224 | 'ClIeNt_sEcReT',
225 | 'z-z-Z-Z',
226 | 'Y-y-Y-y',
227 | '12345',
228 | '654321',
229 | 'SheetId123',
230 | )
231 |
232 | self.assertEqual(result, expected)
233 |
234 | def testConfig_retrieval_fails_with_incorrect_file(self):
235 | file_dict = {
236 | 'use_proto_plus': True,
237 | 'developer_token_wrong': 'zZzZzZz',
238 | 'client_id_wrong': '123456789',
239 | 'refresh_token': 'Y-y-Y-y',
240 | 'login_customer_id': '12345',
241 | 'customer_id_inclusion_list': '654321',
242 | 'spreadsheet_id': 'SheetId123',
243 | }
244 |
245 | out_dir = self.create_tempdir('tmp_test')
246 | json_string = json.dumps(file_dict).encode('utf-8')
247 | out_file = out_dir.create_file('output_wrong_file.yaml', json_string)
248 |
249 | self.assertRaises(TypeError, main.retrieve_config, out_file)
250 |
251 | @patch('pubsub.PubSub.refresh_spreadsheet')
252 | def test_pmax_trigger_to_call_refresh_spreadsheet_for_refresh_cloud_event(
253 | self, mock_refresh_spreadsheet
254 | ):
255 | encoded_refresh_data = base64.b64encode('REFRESH'.encode('utf-8'))
256 | mock_cloud_event = type(
257 | 'mock_CloudEvent',
258 | (object,),
259 | {'data': {'message': {'data': encoded_refresh_data}}},
260 | )
261 |
262 | main.pmax_trigger(mock_cloud_event)
263 |
264 | mock_refresh_spreadsheet.assert_called()
265 |
266 | @patch('pubsub.PubSub.create_api_operations')
267 | def test_pmax_trigger_to_call_create_api_operations_for_upload_cloud_event(
268 | self, mock_create_api_operations
269 | ):
270 | encoded_refresh_data = base64.b64encode('UPLOAD'.encode('utf-8'))
271 | mock_cloud_event = type(
272 | 'mock_CloudEvent',
273 | (object,),
274 | {'data': {'message': {'data': encoded_refresh_data}}},
275 | )
276 |
277 | main.pmax_trigger(mock_cloud_event)
278 |
279 | mock_create_api_operations.assert_called()
280 |
281 | @patch('pubsub.PubSub.refresh_spreadsheet')
282 | @patch('pubsub.PubSub.create_api_operations')
283 | @patch('pubsub.PubSub.refresh_customer_id_list')
284 | @patch('pubsub.PubSub.refresh_campaign_list')
285 | @patch('pubsub.PubSub.refresh_asset_group_list')
286 | @patch('pubsub.PubSub.refresh_assets_list')
287 | @patch('pubsub.PubSub.refresh_sitelinks_list')
288 | def test_pmax_trigger_to_call_nothing_for_random_cloud_event(
289 | self,
290 | mock_refresh_spreadsheet,
291 | mock_create_api_operations,
292 | mock_refresh_customer_id_list,
293 | mock_refresh_campaign_list,
294 | mock_refresh_asset_group_list,
295 | mock_refresh_assets_list,
296 | mock_refresh_sitelinks_list,
297 | ):
298 | encoded_refresh_data = base64.b64encode('UPLOADDDD'.encode('utf-8'))
299 | mock_cloud_event = type(
300 | 'mock_CloudEvent',
301 | (object,),
302 | {'data': {'message': {'data': encoded_refresh_data}}},
303 | )
304 |
305 | main.pmax_trigger(mock_cloud_event)
306 |
307 | mock_create_api_operations.assert_not_called()
308 | mock_refresh_spreadsheet.assert_not_called()
309 | mock_refresh_customer_id_list.assert_not_called()
310 | mock_refresh_campaign_list.assert_not_called()
311 | mock_refresh_asset_group_list.assert_not_called()
312 | mock_refresh_assets_list.assert_not_called()
313 | mock_refresh_sitelinks_list.assert_not_called()
314 |
315 | def create_tempdir(self, name: str) -> _TempDir:
316 | """Create a temporary directory specific to the test.
317 |
318 | NOTE: The directory and its contents will be recursively cleared before
319 | creation. This ensures that there is no pre-existing state.
320 |
321 | This creates a named directory on disk that is isolated to this test, and
322 | will be properly cleaned up by the test. This avoids several pitfalls of
323 | # absl:google3-begin(Comment for google3-users)
324 | (see go/python-tips/032#use-create-tempfile)
325 | # absl:google3-end
326 | creating temporary directories for test purposes, as well as makes it easier
327 | to setup directories and verify their contents. For example::
328 |
329 | def test_foo(self):
330 | out_dir = self.create_tempdir()
331 | out_log = out_dir.create_file('output.log')
332 | expected_outputs = [
333 | os.path.join(out_dir, 'data-0.txt'),
334 | os.path.join(out_dir, 'data-1.txt'),
335 | ]
336 | code_under_test(out_dir)
337 | self.assertTrue(os.path.exists(expected_paths[0]))
338 | self.assertTrue(os.path.exists(expected_paths[1]))
339 | self.assertEqual('foo', out_log.read_text())
340 |
341 | See also: :meth:`create_tempfile` for creating temporary files.
342 |
343 | Args:
344 | name: Optional name of the directory. If not given, a unique name will be
345 | generated and used.
346 | cleanup: Optional cleanup policy on when/if to remove the directory (and
347 | all its contents) at the end of the test. If None, then uses
348 | :attr:`tempfile_cleanup`.
349 |
350 | Returns:
351 | A _TempDir representing the created directory; see _TempDir class docs
352 | for usage.
353 | """
354 | test_path = os.getcwd()
355 | path = os.path.join(test_path, name)
356 | cleanup_path = os.path.join(test_path, _get_first_part(name))
357 |
358 | _rmtree_ignore_errors(cleanup_path)
359 | os.makedirs(path, exist_ok=True)
360 |
361 | self.addCleanup(_rmtree_ignore_errors, path)
362 |
363 | return _TempDir(path)
364 |
365 |
366 | def _get_first_part(path: str) -> str:
367 | parts = path.split(os.sep, 1)
368 | return parts[0]
369 |
370 |
371 | def _rmtree_ignore_errors(path: str) -> None:
372 | if os.path.isfile(path):
373 | try:
374 | os.unlink(path)
375 | except OSError:
376 | pass
377 | else:
378 | shutil.rmtree(path, ignore_errors=True)
379 |
--------------------------------------------------------------------------------