diff --git a/libs/community/google/ads/garf/community/google/ads/builtins/__init__.py b/libs/community/google/ads/garf/community/google/ads/builtins/__init__.py index 4e691f2e..96b0b44d 100644 --- a/libs/community/google/ads/garf/community/google/ads/builtins/__init__.py +++ b/libs/community/google/ads/garf/community/google/ads/builtins/__init__.py @@ -11,11 +11,16 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -from garf.community.google.ads.builtins import change_history, ocid_mapping +from garf.community.google.ads.builtins import ( + account_hierarchy, + change_history, + ocid_mapping, +) BUILTIN_QUERIES = { 'ocid_mapping': ocid_mapping.get_ocid_mapping, 'budget_history': change_history.budget_history, 'target_cpa_history': change_history.target_cpa_history, 'target_roas_history': change_history.target_roas_history, + 'account_hierarchy': account_hierarchy.get_account_hierarchy, } diff --git a/libs/community/google/ads/garf/community/google/ads/builtins/account_hierarchy.py b/libs/community/google/ads/garf/community/google/ads/builtins/account_hierarchy.py new file mode 100644 index 00000000..5246d023 --- /dev/null +++ b/libs/community/google/ads/garf/community/google/ads/builtins/account_hierarchy.py @@ -0,0 +1,118 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Any + +from garf.core.report import GarfReport + +# FIXME: hierarchy is available to level 1 only +# TODO: Add pairs mapping: account -> sub_account +# TODO: Add graph mapping- A:account{name, id, sub_accounts: list[A]} + + +def get_account_hierarchy( + report_fetcher: 'garf.community.google.ads.GoogleAdsApiReportFetcher', + account: str | list[str], + level: int = 0, + **kwargs: str, +) -> GarfReport: + level_mapping = _fetch_account_hierarchy(report_fetcher, account, level) + column_names = [ + 'level', + 'mcc_id', + 'mcc_name', + 'account_id', + 'account_name', + 'is_manager', + ] + results = [] + for level_in_hierarchy, result in level_mapping.items(): + for row in result.get('mcc'): + if row.get('manager_id') != row.get('account_id'): + results.append( + [ + row.get('level') + level_in_hierarchy, + row.get('manager_id'), + row.get('manager_name'), + row.get('account_id'), + row.get('account_name'), + True, + ] + ) + for row in result.get('direct'): + results.append( + [ + row.get('level') + level_in_hierarchy, + row.get('manager_id'), + row.get('manager_name'), + row.get('account_id'), + row.get('account_name'), + False, + ] + ) + return GarfReport(results=results, column_names=column_names) + + +def _fetch_account_hierarchy( + report_fetcher: 'garf.community.google.ads.GoogleAdsApiReportFetcher', + account: str | list[str], + level: int = 0, +) -> dict[str, Any]: + seed_accounts_query = """ + SELECT + customer_client.level AS level, + customer.descriptive_name AS manager_name, + customer.id AS manager_id, + customer_client.manager AS is_manager, + {level} AS level_in_hierarchy, + customer_client.id AS account_id, + customer_client.descriptive_name AS account_name + FROM customer_client + WHERE + customer_client.level <= 1 + AND customer_client.status = ENABLED + AND customer_client.hidden = FALSE + """ + + if isinstance(account, str): + account = account.split(',') + hierarchy = report_fetcher.fetch( + query_specification=seed_accounts_query.format(level=level), account=account + ) + level_mapping = {} + for level_in_hierarchy, accounts in hierarchy.to_dict( + 'level_in_hierarchy' + ).items(): + mccs = [] + direct_accounts = [] + for row in accounts: + if row.get('is_manager'): + mccs.append(row) + else: + direct_accounts.append(row) + level_mapping[level_in_hierarchy] = {'mcc': mccs, 'direct': direct_accounts} + + sub_mccs = level_mapping.get(level, {}).get('mcc') + if sub_mccs and ( + accounts := { + a.get('account_id') + for a in sub_mccs + if a.get('account_id') != a.get('manager_id') + } + ): + result = _fetch_account_hierarchy( + report_fetcher, account=accounts, level=level + 1 + ) + level_mapping.update(result) + return level_mapping diff --git a/libs/community/google/ads/garf/community/google/ads/report_fetcher.py b/libs/community/google/ads/garf/community/google/ads/report_fetcher.py index a19e220f..c9aef320 100644 --- a/libs/community/google/ads/garf/community/google/ads/report_fetcher.py +++ b/libs/community/google/ads/garf/community/google/ads/report_fetcher.py @@ -14,10 +14,13 @@ """Defines report fetcher for Google Ads API.""" +from __future__ import annotations + import asyncio import functools import operator import warnings +from typing import Literal import garf.core from garf.community.google.ads import ( @@ -60,7 +63,7 @@ def __init__( if kwargs.get('no_expand_mcc'): preprocessors = {} elif kwargs.get('expand_mcc') in (None, True): - preprocessors = {'account': self.expand_mcc} + preprocessors = {'account': self.expand_mcc, 'expand_mcc': lambda: False} else: preprocessors = {} super().__init__( @@ -78,6 +81,7 @@ def fetch( args: garf.core.query_editor.GarfQueryParameters | None = None, account: str | list[str] | None = None, expand_mcc: bool = False, + expand_mcc_type: Literal['roots', 'leaves', 'hierarchy'] = 'leaves', customer_ids_query: str | None = None, **kwargs: str, ) -> garf.core.GarfReport: @@ -88,6 +92,10 @@ def fetch( args: Optional parameters to fine-tune the query. account: Account(s) to get data from. expand_mcc: Whether to perform account expansion (MCC to Account). + expand_mcc_type: + Strategy to expanding MCCs. Can be roots (get only mccs), leaves + (get only child accounts) or hierarchy (get both mccs and child + accounts). customer_ids_query: Query to reduce number of accounts based a condition. Returns: @@ -111,7 +119,9 @@ def fetch( args = {} if expand_mcc or customer_ids_query: account = self.expand_mcc( - account=account, customer_ids_query=customer_ids_query + account=account, + customer_ids_query=customer_ids_query, + expand_mcc_type=expand_mcc_type, ) if not account: raise GoogleAdsApiReportFetcherError( @@ -157,6 +167,7 @@ def expand_mcc( account: str | list[str], customer_ids_query: str | None = None, customer_ids: str | list | None = None, + expand_mcc_type: Literal['roots', 'leaves', 'hierarchy'] = 'leaves', ) -> list[str]: """Performs Manager account(s) expansion to child accounts. @@ -164,9 +175,13 @@ def expand_mcc( account: Manager account(s) to be expanded. customer_ids_query: GAQL query used to reduce the number of customer_ids. customer_ids: Manager account(s) to be expanded. + expand_mcc_type: + Strategy to expanding MCCs. Can be roots (get only mccs), leaves + (get only child accounts) or hierarchy (get both mccs and child + accounts). Returns: - All child accounts under provided customer_ids. + All accounts under provided customer_ids. """ span = trace.get_current_span() if customer_ids and not account: @@ -176,18 +191,44 @@ def expand_mcc( stacklevel=2, ) account = customer_ids - query = """ - SELECT customer_client.id FROM customer_client - WHERE customer_client.manager = FALSE - AND customer_client.status = ENABLED - AND customer_client.hidden = FALSE - """ + span.set_attribute('accounts.seed_accounts', account) + if expand_mcc_type == 'roots': + query = """ + SELECT customer_client.id FROM customer_client + WHERE customer_client.manager = TRUE + AND customer_client.status = ENABLED + AND customer_client.hidden = FALSE + """ + child_customer_ids = self.fetch( + query_specification=query, account=account + ).to_list() + elif expand_mcc_type == 'leaves': + query = """ + SELECT customer_client.id FROM customer_client + WHERE customer_client.manager = FALSE + AND customer_client.status = ENABLED + AND customer_client.hidden = FALSE + """ + child_customer_ids = self.fetch( + query_specification=query, account=account + ).to_list() + elif expand_mcc_type == 'hierarchy': + query = 'SELECT * FROM builtin.account_hierarchy' + child_customer_ids = self.fetch( + query_specification=query, account=account + ) + child_customer_ids = list( + set( + child_customer_ids['mcc_id'].to_list() + + child_customer_ids['account_id'].to_list() + ) + ) + else: + raise GoogleAdsApiReportFetcherError( + f'Unsupported MCC expansion type: {expand_mcc_type}' + ) if isinstance(account, str): account = account.split(',') - span.set_attribute('accounts.seed_accounts', account) - child_customer_ids = self.fetch( - query_specification=query, account=account - ).to_list() if not child_customer_ids: raise GoogleAdsApiReportFetcherError( f'No ENABLED accounts found under provided seed accounts {account}' diff --git a/libs/community/google/ads/garf/community/google/ads/version.py b/libs/community/google/ads/garf/community/google/ads/version.py index 7a20ae60..27eb439e 100644 --- a/libs/community/google/ads/garf/community/google/ads/version.py +++ b/libs/community/google/ads/garf/community/google/ads/version.py @@ -11,4 +11,4 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -__version__ = '1.1.9' +__version__ = '1.2.0'