Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/fopost/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@
GoogleKeyword,
GoogleKeywordIdea,
GoogleLocalServicesLead,
GoogleOptimizationScore,
GoogleOptimizationScoreCampaign,
GoogleRecommendation,
GoogleRecommendationImpact,
GoogleSearchTerm,
GoogleSharedSet,
InboxAccount,
Expand Down Expand Up @@ -361,6 +365,10 @@
"GoogleKeyword",
"GoogleKeywordIdea",
"GoogleLocalServicesLead",
"GoogleOptimizationScore",
"GoogleOptimizationScoreCampaign",
"GoogleRecommendation",
"GoogleRecommendationImpact",
"GoogleSearchTerm",
"GoogleSharedSet",
"InsightsMetrics",
Expand Down
32 changes: 32 additions & 0 deletions src/fopost/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1997,6 +1997,38 @@ class GoogleAssetGroup(FopostModel):
final_urls: list[str] = []


class GoogleRecommendationImpact(FopostModel):
base_clicks: float | None = None
potential_clicks: float | None = None
base_cost_minor: int | None = None
potential_cost_minor: int | None = None
base_conversions: float | None = None
potential_conversions: float | None = None


class GoogleRecommendation(FopostModel):
"""``id`` is the Google resource name, which is what apply and dismiss take."""

id: str
type: str
campaign_id: str | None = None
ad_group_id: str | None = None
dismissed: bool = False
impact: GoogleRecommendationImpact | None = None


class GoogleOptimizationScoreCampaign(FopostModel):
id: str
name: str
score: float | None = None


class GoogleOptimizationScore(FopostModel):
score: float | None = None
weight: float | None = None
campaigns: list[GoogleOptimizationScoreCampaign] = []


class GoogleLocalServicesLead(FopostModel):
id: str
category: str | None = None
Expand Down
66 changes: 66 additions & 0 deletions src/fopost/resources/google_ads.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
GoogleKeyword,
GoogleKeywordIdea,
GoogleLocalServicesLead,
GoogleOptimizationScore,
GoogleRecommendation,
GoogleSearchTerm,
GoogleSharedSet,
)
Expand Down Expand Up @@ -491,6 +493,70 @@ def upload_conversion_adjustments(
result = unwrap(self._http.post("/ads/google/conversions/adjustments", body))
return int(result.get("uploaded", 0)) if isinstance(result, dict) else 0

# ── Recommendations ──

def recommendations(
self,
*,
connection_id: str,
customer_id: str,
types: Sequence[str] | None = None,
workspace_id: str | None = None,
) -> builtins.list[GoogleRecommendation]:
"""Google's own read on what the account should change next."""
params = _query(workspace_id, connection_id, customer_id)
if types:
params["types"] = ",".join(types)
return parse_list(
GoogleRecommendation,
unwrap(self._http.get("/ads/google/recommendations", params)),
)

def optimization_score(
self,
*,
connection_id: str,
customer_id: str,
workspace_id: str | None = None,
) -> GoogleOptimizationScore:
"""The account's score and weight, and the score of each live campaign."""
return GoogleOptimizationScore.model_validate(
unwrap(
self._http.get(
"/ads/google/optimization-score",
_query(workspace_id, connection_id, customer_id),
)
)
)

def apply_recommendations(
self,
*,
workspace_id: str,
connection_id: str,
customer_id: str,
ids: Sequence[str],
) -> int:
"""Changes what the live account serves or bids. Needs ``publish`` as well as ``ads``."""
body = _scope(workspace_id, connection_id, customer_id)
body["ids"] = list(ids)
result = unwrap(self._http.post("/ads/google/recommendations/apply", body))
return int(result.get("applied", 0)) if isinstance(result, dict) else 0

def dismiss_recommendations(
self,
*,
workspace_id: str,
connection_id: str,
customer_id: str,
ids: Sequence[str],
) -> int:
"""Hides each recommendation. Needs ``publish`` as well as ``ads``."""
body = _scope(workspace_id, connection_id, customer_id)
body["ids"] = list(ids)
result = unwrap(self._http.post("/ads/google/recommendations/dismiss", body))
return int(result.get("dismissed", 0)) if isinstance(result, dict) else 0

# ── GAQL ──

def query(
Expand Down
45 changes: 45 additions & 0 deletions tests/test_google_ads.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,48 @@ def test_authorize_google_has_its_own_route(client: Fopost) -> None:
url = client.ads.authorize_google(workspace_id="ws_1")
assert url == "https://accounts.google.com/o/x"
assert route.called


@respx.mock
def test_recommendations_joins_the_types_filter(client: Fopost) -> None:
route = respx.get(f"{BASE_URL}/ads/google/recommendations").mock(
return_value=httpx.Response(
200,
json={
"data": [
{
"id": "customers/1234567890/recommendations/ABC~1",
"type": "KEYWORD",
"campaignId": "1234567890~campaign~55",
"dismissed": False,
"impact": {"baseClicks": 10, "potentialClicks": 25},
}
]
},
)
)
rows = client.ads.google.recommendations(
connection_id="conn_1",
customer_id="1234567890",
types=["KEYWORD", "TARGET_CPA_OPT_IN"],
)
assert rows[0].type == "KEYWORD"
assert rows[0].impact is not None and rows[0].impact.potential_clicks == 25
assert route.calls.last.request.url.params["types"] == "KEYWORD,TARGET_CPA_OPT_IN"


@respx.mock
def test_apply_recommendations_sends_the_ids(client: Fopost) -> None:
route = respx.post(f"{BASE_URL}/ads/google/recommendations/apply").mock(
return_value=httpx.Response(200, json={"data": {"applied": 1}})
)
applied = client.ads.google.apply_recommendations(
workspace_id="ws_1",
connection_id="conn_1",
customer_id="1234567890",
ids=["customers/1234567890/recommendations/ABC~1"],
)
assert applied == 1
assert json.loads(route.calls.last.request.content)["ids"] == [
"customers/1234567890/recommendations/ABC~1"
]
Loading