adloop 0.36.0__tar.gz → 0.37.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {adloop-0.36.0 → adloop-0.37.0}/PKG-INFO +5 -3
- {adloop-0.36.0 → adloop-0.37.0}/README.md +4 -2
- {adloop-0.36.0 → adloop-0.37.0}/pyproject.toml +1 -1
- {adloop-0.36.0 → adloop-0.37.0}/pyproject.toml.orig +1 -1
- adloop-0.37.0/src/adloop/ads/recommendation_subscriptions.py +202 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/ads/write.py +312 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/reddit/read.py +14 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/reddit/write.py +62 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/rules/adloop.md +9 -4
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/server.py +141 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/__init__.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/__main__.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/_mcp_patches.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/ads/ai_max.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/ads/assets.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/ads/audiences.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/ads/brands.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/ads/campaign_settings.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/ads/client.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/ads/conversion_actions.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/ads/currency.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/ads/custom_conversion_goals.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/ads/enums.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/ads/forecast.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/ads/gaql.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/ads/image_fetch.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/ads/pmax.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/ads/read.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/ads/recommendations.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/ads/shopping.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/ads/snippet_headers.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/ads/validate_only.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/auth.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/cli.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/config.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/crossref.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/diagnostics.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/ga4/client.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/ga4/reports.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/ga4/write.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/gsc/__init__.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/gsc/client.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/gsc/reports.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/gtm/__init__.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/gtm/client.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/gtm/entities.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/gtm/read.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/gtm/write.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/merchant/__init__.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/merchant/client.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/merchant/read.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/pagespeed.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/reddit/__init__.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/reddit/auth.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/reddit/client.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/reddit/schedule.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/rules/__init__.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/rules/commands/budget-plan.md +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/rules/commands/create-ad.md +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/rules/commands/create-campaign.md +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/rules_install.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/runtime.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/safety/__init__.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/safety/approval.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/safety/approval_card.html +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/safety/audit.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/safety/guards.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/safety/preview.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/tracking.py +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/upload_card.html +0 -0
- {adloop-0.36.0 → adloop-0.37.0}/src/adloop/uploads.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: adloop
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.37.0
|
|
4
4
|
Summary: The AI command center for Google Ads, Reddit Ads, GA4, and tracking code.
|
|
5
5
|
Keywords: mcp,google-ads,reddit-ads,google-analytics,ga4,cursor,marketing
|
|
6
6
|
Author: Daniel Klose
|
|
@@ -131,6 +131,7 @@ The best features come from real workflows. If you're using AdLoop and find your
|
|
|
131
131
|
| `get_negative_keyword_list_keywords` | List the keywords inside a specific shared negative keyword list |
|
|
132
132
|
| `get_negative_keyword_list_campaigns` | List which campaigns a shared negative keyword list is attached to |
|
|
133
133
|
| `get_recommendations` | Google's auto-generated recommendations with type, estimated impact, and campaign context |
|
|
134
|
+
| `get_recommendation_subscriptions` | Which recommendation types Google applies automatically (auto-apply), with status and last change; flags any that are on |
|
|
134
135
|
| `get_pmax_performance` | Performance Max campaign metrics with network breakdown + asset group ad strength |
|
|
135
136
|
| `get_pmax_assets` | Per-asset details for PMax — field type, serving status, content |
|
|
136
137
|
| `get_asset_group_signals` | A PMax asset group's search themes and audience signal, plus the audiences available for it |
|
|
@@ -297,12 +298,12 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
|
|
|
297
298
|
|
|
298
299
|
| Tool | What It Does |
|
|
299
300
|
|------|-------------|
|
|
300
|
-
| `draft_campaign` | Create a full campaign structure — budget + campaign (PAUSED) + ad group + optional keywords. Supports Search partners, display expansion, and `max_cpc` for either MANUAL_CPC initial ad-group bids or TARGET_SPEND (Maximize Clicks) CPC caps. |
|
|
301
|
+
| `draft_campaign` | Create a full campaign structure — budget + campaign (PAUSED) + ad group + optional keywords. Supports Search partners, display expansion, the location option (presence vs presence or interest), campaign URL options (tracking template, final URL suffix, custom parameters), and `max_cpc` for either MANUAL_CPC initial ad-group bids or TARGET_SPEND (Maximize Clicks) CPC caps. |
|
|
301
302
|
| `draft_shopping_campaign` | Create a Standard Shopping campaign for a linked Merchant Center feed — budget, campaign (PAUSED), product ad group, Shopping ad and an "All products" group in one atomic request. Manual CPC, Maximize clicks or target ROAS. |
|
|
302
303
|
| `draft_asset_group_assets` | Add and remove Performance Max assets (text, images incl. uploads, YouTube videos) in one change, so replacing an asset never drops a group below Google's minimums |
|
|
303
304
|
| `draft_asset_group_signals` | Add search themes or set the audience signal of a PMax asset group |
|
|
304
305
|
| `draft_pmax_product_exclusions` | Exclude products from a retail PMax asset group by item id, brand or custom label |
|
|
305
|
-
| `update_campaign` | Modify existing campaign settings — bidding, budget, geo/language targeting, Search partners, display expansion, and TARGET_SPEND (Maximize Clicks) `max_cpc` caps. |
|
|
306
|
+
| `update_campaign` | Modify existing campaign settings — bidding, budget, geo/language targeting, the location option, campaign URL options, Search partners, display expansion, and TARGET_SPEND (Maximize Clicks) `max_cpc` caps. |
|
|
306
307
|
| `draft_ad_group` | Create a paused SEARCH_STANDARD ad group inside an existing campaign, with optional MANUAL_CPC `max_cpc`. |
|
|
307
308
|
| `update_ad_group` | Update an ad group name and/or MANUAL_CPC `max_cpc`. Use `pause_entity` / `enable_entity` for ad-group status changes. |
|
|
308
309
|
| `draft_responsive_search_ad` | Create RSA preview (3-15 headlines ≤30 chars, 2-4 descriptions ≤90 chars). Warns if headline/description count is below best practice. |
|
|
@@ -336,6 +337,7 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
|
|
|
336
337
|
| `draft_assign_custom_conversion_goal` | Point a campaign at a custom conversion goal (`goal_config_level = CAMPAIGN`) |
|
|
337
338
|
| `draft_clear_custom_conversion_goal` | Put a campaign back on the account-level goals (rollback) |
|
|
338
339
|
| `draft_dismiss_recommendations` | Dismiss Google recommendations you won't take, so they stop reappearing (Google can show them again later) |
|
|
340
|
+
| `draft_recommendation_subscriptions` | Pause auto-apply for recommendation types, so Google stops changing the account without a preview. Switching auto-apply back on stays in the Google Ads UI |
|
|
339
341
|
| `pause_entity` | Pause a campaign, ad group, ad, keyword or PMax asset group |
|
|
340
342
|
| `enable_entity` | Re-enable a paused entity |
|
|
341
343
|
| `remove_entity` | Permanently remove an entity (irreversible — prefers pause). Supports keywords, negative keywords, ads, ad groups, campaigns. |
|
|
@@ -103,6 +103,7 @@ The best features come from real workflows. If you're using AdLoop and find your
|
|
|
103
103
|
| `get_negative_keyword_list_keywords` | List the keywords inside a specific shared negative keyword list |
|
|
104
104
|
| `get_negative_keyword_list_campaigns` | List which campaigns a shared negative keyword list is attached to |
|
|
105
105
|
| `get_recommendations` | Google's auto-generated recommendations with type, estimated impact, and campaign context |
|
|
106
|
+
| `get_recommendation_subscriptions` | Which recommendation types Google applies automatically (auto-apply), with status and last change; flags any that are on |
|
|
106
107
|
| `get_pmax_performance` | Performance Max campaign metrics with network breakdown + asset group ad strength |
|
|
107
108
|
| `get_pmax_assets` | Per-asset details for PMax — field type, serving status, content |
|
|
108
109
|
| `get_asset_group_signals` | A PMax asset group's search themes and audience signal, plus the audiences available for it |
|
|
@@ -269,12 +270,12 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
|
|
|
269
270
|
|
|
270
271
|
| Tool | What It Does |
|
|
271
272
|
|------|-------------|
|
|
272
|
-
| `draft_campaign` | Create a full campaign structure — budget + campaign (PAUSED) + ad group + optional keywords. Supports Search partners, display expansion, and `max_cpc` for either MANUAL_CPC initial ad-group bids or TARGET_SPEND (Maximize Clicks) CPC caps. |
|
|
273
|
+
| `draft_campaign` | Create a full campaign structure — budget + campaign (PAUSED) + ad group + optional keywords. Supports Search partners, display expansion, the location option (presence vs presence or interest), campaign URL options (tracking template, final URL suffix, custom parameters), and `max_cpc` for either MANUAL_CPC initial ad-group bids or TARGET_SPEND (Maximize Clicks) CPC caps. |
|
|
273
274
|
| `draft_shopping_campaign` | Create a Standard Shopping campaign for a linked Merchant Center feed — budget, campaign (PAUSED), product ad group, Shopping ad and an "All products" group in one atomic request. Manual CPC, Maximize clicks or target ROAS. |
|
|
274
275
|
| `draft_asset_group_assets` | Add and remove Performance Max assets (text, images incl. uploads, YouTube videos) in one change, so replacing an asset never drops a group below Google's minimums |
|
|
275
276
|
| `draft_asset_group_signals` | Add search themes or set the audience signal of a PMax asset group |
|
|
276
277
|
| `draft_pmax_product_exclusions` | Exclude products from a retail PMax asset group by item id, brand or custom label |
|
|
277
|
-
| `update_campaign` | Modify existing campaign settings — bidding, budget, geo/language targeting, Search partners, display expansion, and TARGET_SPEND (Maximize Clicks) `max_cpc` caps. |
|
|
278
|
+
| `update_campaign` | Modify existing campaign settings — bidding, budget, geo/language targeting, the location option, campaign URL options, Search partners, display expansion, and TARGET_SPEND (Maximize Clicks) `max_cpc` caps. |
|
|
278
279
|
| `draft_ad_group` | Create a paused SEARCH_STANDARD ad group inside an existing campaign, with optional MANUAL_CPC `max_cpc`. |
|
|
279
280
|
| `update_ad_group` | Update an ad group name and/or MANUAL_CPC `max_cpc`. Use `pause_entity` / `enable_entity` for ad-group status changes. |
|
|
280
281
|
| `draft_responsive_search_ad` | Create RSA preview (3-15 headlines ≤30 chars, 2-4 descriptions ≤90 chars). Warns if headline/description count is below best practice. |
|
|
@@ -308,6 +309,7 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
|
|
|
308
309
|
| `draft_assign_custom_conversion_goal` | Point a campaign at a custom conversion goal (`goal_config_level = CAMPAIGN`) |
|
|
309
310
|
| `draft_clear_custom_conversion_goal` | Put a campaign back on the account-level goals (rollback) |
|
|
310
311
|
| `draft_dismiss_recommendations` | Dismiss Google recommendations you won't take, so they stop reappearing (Google can show them again later) |
|
|
312
|
+
| `draft_recommendation_subscriptions` | Pause auto-apply for recommendation types, so Google stops changing the account without a preview. Switching auto-apply back on stays in the Google Ads UI |
|
|
311
313
|
| `pause_entity` | Pause a campaign, ad group, ad, keyword or PMax asset group |
|
|
312
314
|
| `enable_entity` | Re-enable a paused entity |
|
|
313
315
|
| `remove_entity` | Permanently remove an entity (irreversible — prefers pause). Supports keywords, negative keywords, ads, ad groups, campaigns. |
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Read and pause Google Ads auto-apply recommendation subscriptions.
|
|
2
|
+
|
|
3
|
+
A subscription makes Google apply every new recommendation of one type
|
|
4
|
+
without asking, so a change reaches the account with no preview. The v25 API
|
|
5
|
+
(verified against the SDK):
|
|
6
|
+
|
|
7
|
+
recommendation_subscription resource_name, type, status (ENABLED | PAUSED),
|
|
8
|
+
create_date_time, modify_date_time
|
|
9
|
+
RecommendationSubscriptionService.MutateRecommendationSubscription
|
|
10
|
+
MutateRecommendationSubscriptionRequest{customer_id, operations[],
|
|
11
|
+
partial_failure, validate_only, response_content_type}
|
|
12
|
+
|
|
13
|
+
A type with no subscription row is not auto-applied. The write side can only
|
|
14
|
+
set a subscription to PAUSED: re-enabling auto-apply is a decision for the
|
|
15
|
+
Google Ads UI, because it lets Google change the account without a preview,
|
|
16
|
+
which is what AdLoop's flow exists to prevent. Pausing reduces automation, so
|
|
17
|
+
a single confirmation is enough. The request has ``validate_only``, so the dry
|
|
18
|
+
run goes through the validate-only client like other Google Ads writes.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
from datetime import date, datetime, timezone
|
|
25
|
+
from typing import TYPE_CHECKING
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from adloop.config import AdLoopConfig
|
|
29
|
+
|
|
30
|
+
OPERATION = "pause_recommendation_subscriptions"
|
|
31
|
+
|
|
32
|
+
# A subscription modified this recently is reported as a change, not only as a
|
|
33
|
+
# standing setting: someone (or something) turned it on or touched it lately.
|
|
34
|
+
RECENT_DAYS = 30
|
|
35
|
+
|
|
36
|
+
_TYPE_RE = re.compile(r"^[A-Z][A-Z0-9_]*$")
|
|
37
|
+
|
|
38
|
+
_QUERY = """
|
|
39
|
+
SELECT recommendation_subscription.resource_name,
|
|
40
|
+
recommendation_subscription.type,
|
|
41
|
+
recommendation_subscription.status,
|
|
42
|
+
recommendation_subscription.create_date_time,
|
|
43
|
+
recommendation_subscription.modify_date_time
|
|
44
|
+
FROM recommendation_subscription
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _read(config: AdLoopConfig, customer_id: str) -> list[dict]:
|
|
49
|
+
from adloop.ads.gaql import execute_query
|
|
50
|
+
|
|
51
|
+
return [
|
|
52
|
+
{
|
|
53
|
+
"type": row.get("recommendation_subscription.type", ""),
|
|
54
|
+
"status": row.get("recommendation_subscription.status", ""),
|
|
55
|
+
"resource_name": row.get("recommendation_subscription.resource_name", ""),
|
|
56
|
+
"create_date_time": row.get("recommendation_subscription.create_date_time", ""),
|
|
57
|
+
"modify_date_time": row.get("recommendation_subscription.modify_date_time", ""),
|
|
58
|
+
}
|
|
59
|
+
for row in execute_query(config, customer_id, _QUERY)
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _day(value: str) -> date | None:
|
|
64
|
+
"""``2026-10-27 09:14:03`` (account time zone) -> the date, or None."""
|
|
65
|
+
try:
|
|
66
|
+
return date.fromisoformat(str(value or "")[:10])
|
|
67
|
+
except ValueError:
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_recommendation_subscriptions(
|
|
72
|
+
config: AdLoopConfig, *, customer_id: str = "", today: date | None = None
|
|
73
|
+
) -> dict:
|
|
74
|
+
"""Read-only: every auto-apply subscription with its status and dates."""
|
|
75
|
+
subscriptions = sorted(_read(config, customer_id), key=lambda s: s["type"])
|
|
76
|
+
enabled = [s for s in subscriptions if s["status"] == "ENABLED"]
|
|
77
|
+
today = today or datetime.now(timezone.utc).date()
|
|
78
|
+
|
|
79
|
+
insights: list[str] = []
|
|
80
|
+
if enabled:
|
|
81
|
+
insights.append(
|
|
82
|
+
f"{len(enabled)} recommendation type(s) auto-apply without a preview: "
|
|
83
|
+
+ ", ".join(s["type"] for s in enabled)
|
|
84
|
+
+ ". Google changes the account for these on its own. "
|
|
85
|
+
"draft_recommendation_subscriptions pauses them."
|
|
86
|
+
)
|
|
87
|
+
for sub in enabled:
|
|
88
|
+
modified = _day(sub["modify_date_time"])
|
|
89
|
+
if modified and (today - modified).days <= RECENT_DAYS:
|
|
90
|
+
insights.append(
|
|
91
|
+
f"{sub['type']} auto-apply was turned on or changed on "
|
|
92
|
+
f"{sub['modify_date_time']}, within the last {RECENT_DAYS} days. "
|
|
93
|
+
"Confirm that was intended."
|
|
94
|
+
)
|
|
95
|
+
if not enabled:
|
|
96
|
+
insights.append("No recommendation type is set to auto-apply.")
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
"subscriptions": subscriptions,
|
|
100
|
+
"total_subscriptions": len(subscriptions),
|
|
101
|
+
"enabled_count": len(enabled),
|
|
102
|
+
"insights": insights,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def draft_recommendation_subscriptions(
|
|
107
|
+
config: AdLoopConfig,
|
|
108
|
+
*,
|
|
109
|
+
customer_id: str = "",
|
|
110
|
+
recommendation_types: list[str] | None = None,
|
|
111
|
+
) -> dict:
|
|
112
|
+
"""Draft pausing auto-apply for recommendation types — returns PREVIEW.
|
|
113
|
+
|
|
114
|
+
Pausing is the only change this can make. Each type must have an ENABLED
|
|
115
|
+
subscription; a type with none, or one already paused, is refused by name
|
|
116
|
+
so the preview only lists what the apply will change.
|
|
117
|
+
"""
|
|
118
|
+
from adloop.safety.guards import SafetyViolation, check_blocked_operation
|
|
119
|
+
from adloop.safety.preview import ChangePlan, store_plan
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
check_blocked_operation(OPERATION, config.safety)
|
|
123
|
+
except SafetyViolation as e:
|
|
124
|
+
return {"error": str(e)}
|
|
125
|
+
|
|
126
|
+
types: list[str] = []
|
|
127
|
+
errors: list[str] = []
|
|
128
|
+
for raw in recommendation_types or []:
|
|
129
|
+
value = str(raw or "").strip().upper()
|
|
130
|
+
if not value:
|
|
131
|
+
continue
|
|
132
|
+
if not _TYPE_RE.match(value):
|
|
133
|
+
errors.append(f"'{raw}' is not a RecommendationType name")
|
|
134
|
+
elif value not in types:
|
|
135
|
+
types.append(value)
|
|
136
|
+
if not types and not errors:
|
|
137
|
+
errors.append("At least one recommendation type is required")
|
|
138
|
+
if errors:
|
|
139
|
+
return {"error": "Validation failed", "details": errors}
|
|
140
|
+
|
|
141
|
+
current = {s["type"]: s for s in _read(config, customer_id)}
|
|
142
|
+
not_enabled = [t for t in types if current.get(t, {}).get("status") != "ENABLED"]
|
|
143
|
+
if not_enabled:
|
|
144
|
+
return {
|
|
145
|
+
"error": "Validation failed",
|
|
146
|
+
"details": [
|
|
147
|
+
"Not auto-applied in this account (no subscription, or already "
|
|
148
|
+
"paused): " + ", ".join(not_enabled)
|
|
149
|
+
+ ". get_recommendation_subscriptions lists the subscriptions."
|
|
150
|
+
],
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
subscriptions = [
|
|
154
|
+
{
|
|
155
|
+
"type": t,
|
|
156
|
+
"resource_name": current[t]["resource_name"],
|
|
157
|
+
"current_status": "ENABLED",
|
|
158
|
+
"status": "PAUSED",
|
|
159
|
+
"modify_date_time": current[t]["modify_date_time"],
|
|
160
|
+
}
|
|
161
|
+
for t in types
|
|
162
|
+
]
|
|
163
|
+
plan = ChangePlan(
|
|
164
|
+
operation=OPERATION,
|
|
165
|
+
entity_type="recommendation_subscription",
|
|
166
|
+
entity_id=",".join(types),
|
|
167
|
+
customer_id=customer_id,
|
|
168
|
+
changes={
|
|
169
|
+
"subscriptions": subscriptions,
|
|
170
|
+
"count": len(subscriptions),
|
|
171
|
+
"note": (
|
|
172
|
+
"Pausing stops Google applying these recommendation types on its "
|
|
173
|
+
"own. Recommendations still appear in get_recommendations. "
|
|
174
|
+
"Re-enabling auto-apply is done in the Google Ads UI."
|
|
175
|
+
),
|
|
176
|
+
},
|
|
177
|
+
)
|
|
178
|
+
store_plan(plan)
|
|
179
|
+
return plan.to_preview()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _apply_pause_recommendation_subscriptions(
|
|
183
|
+
client: object, cid: str, changes: dict
|
|
184
|
+
) -> dict:
|
|
185
|
+
"""Set each planned subscription to PAUSED in one request."""
|
|
186
|
+
from google.protobuf import field_mask_pb2
|
|
187
|
+
|
|
188
|
+
service = client.get_service("RecommendationSubscriptionService")
|
|
189
|
+
request = client.get_type("MutateRecommendationSubscriptionRequest")
|
|
190
|
+
request.customer_id = cid
|
|
191
|
+
paused_status = client.enums.RecommendationSubscriptionStatusEnum.PAUSED
|
|
192
|
+
for sub in changes["subscriptions"]:
|
|
193
|
+
# Hard-coded: whatever a stored plan says, this path only pauses.
|
|
194
|
+
operation = client.get_type("RecommendationSubscriptionOperation")
|
|
195
|
+
operation.update.resource_name = sub["resource_name"]
|
|
196
|
+
operation.update.status = paused_status
|
|
197
|
+
operation.update_mask.CopyFrom(field_mask_pb2.FieldMask(paths=["status"]))
|
|
198
|
+
request.operations.append(operation)
|
|
199
|
+
|
|
200
|
+
response = service.mutate_recommendation_subscription(request=request)
|
|
201
|
+
paused = [result.resource_name for result in response.results]
|
|
202
|
+
return {"paused": paused, "paused_count": len(paused)}
|