adloop 0.35.0__tar.gz → 0.35.1__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.35.0 → adloop-0.35.1}/PKG-INFO +1 -1
- {adloop-0.35.0 → adloop-0.35.1}/pyproject.toml +1 -1
- {adloop-0.35.0 → adloop-0.35.1}/pyproject.toml.orig +1 -1
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/assets.py +35 -4
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/write.py +133 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/rules/adloop.md +4 -2
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/server.py +19 -3
- {adloop-0.35.0 → adloop-0.35.1}/README.md +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/__init__.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/__main__.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/_mcp_patches.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/ai_max.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/audiences.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/brands.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/client.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/conversion_actions.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/currency.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/custom_conversion_goals.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/enums.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/forecast.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/gaql.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/image_fetch.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/pmax.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/read.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/shopping.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/snippet_headers.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ads/validate_only.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/auth.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/cli.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/config.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/crossref.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/diagnostics.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ga4/client.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ga4/reports.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/ga4/write.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/gsc/__init__.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/gsc/client.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/gsc/reports.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/gtm/__init__.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/gtm/client.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/gtm/entities.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/gtm/read.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/gtm/write.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/merchant/__init__.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/merchant/client.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/merchant/read.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/pagespeed.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/reddit/__init__.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/reddit/auth.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/reddit/client.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/reddit/read.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/reddit/schedule.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/reddit/write.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/rules/__init__.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/rules/commands/analyze-performance.md +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/rules/commands/budget-plan.md +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/rules/commands/create-ad.md +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/rules/commands/create-campaign.md +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/rules/commands/optimize-campaign.md +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/rules_install.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/runtime.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/safety/__init__.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/safety/approval.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/safety/approval_card.html +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/safety/audit.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/safety/guards.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/safety/preview.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/tracking.py +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/upload_card.html +0 -0
- {adloop-0.35.0 → adloop-0.35.1}/src/adloop/uploads.py +0 -0
|
@@ -1415,13 +1415,14 @@ def draft_promotion(
|
|
|
1415
1415
|
scope: str = "",
|
|
1416
1416
|
language_code: str = "",
|
|
1417
1417
|
currency_code: str = "",
|
|
1418
|
+
ad_schedule: list[dict] | None = None,
|
|
1418
1419
|
**promotion,
|
|
1419
1420
|
) -> dict:
|
|
1420
1421
|
"""Draft a promotion asset linked to an ad group, a campaign or the account.
|
|
1421
1422
|
|
|
1422
1423
|
``promotion`` takes promotion_target, final_url, money_off or percent_off,
|
|
1423
1424
|
promotion_code or orders_over_amount, occasion, discount_modifier and the
|
|
1424
|
-
four dates.
|
|
1425
|
+
four dates. ``ad_schedule`` limits the hours it shows.
|
|
1425
1426
|
"""
|
|
1426
1427
|
from adloop.safety.preview import ChangePlan, store_plan
|
|
1427
1428
|
|
|
@@ -1430,6 +1431,8 @@ def draft_promotion(
|
|
|
1430
1431
|
scope, campaign_id, ad_group_id, errors = resolve_scope(scope, campaign_id, ad_group_id)
|
|
1431
1432
|
fields, parse_errors = _promotion_input(**promotion)
|
|
1432
1433
|
fields["language_code"] = _language(language_code, errors)
|
|
1434
|
+
fields["ad_schedule"], schedule_errors = _validate_schedule(ad_schedule or [])
|
|
1435
|
+
errors += schedule_errors
|
|
1433
1436
|
errors += parse_errors or _check_promotion(fields)
|
|
1434
1437
|
if errors:
|
|
1435
1438
|
return {"error": "Validation failed", "details": errors}
|
|
@@ -1456,6 +1459,8 @@ def draft_promotion(
|
|
|
1456
1459
|
preview = plan.to_preview()
|
|
1457
1460
|
preview["warnings"] = [*warnings, *_promotion_warnings(fields), *url_warnings] + (
|
|
1458
1461
|
[_account_scope_warning("promotions")] if scope == "account" else [])
|
|
1462
|
+
if fields["ad_schedule"]:
|
|
1463
|
+
preview["warnings"].append(_time_zone_note(_account_settings(config, customer_id)["time_zone"]))
|
|
1459
1464
|
return preview
|
|
1460
1465
|
|
|
1461
1466
|
|
|
@@ -1472,8 +1477,10 @@ _PROMOTION_FIELDS = {
|
|
|
1472
1477
|
"discount_modifier": "asset.promotion_asset.discount_modifier",
|
|
1473
1478
|
"language_code": "asset.promotion_asset.language_code",
|
|
1474
1479
|
**{field: f"asset.promotion_asset.{field}" for field in _DATE_FIELDS},
|
|
1480
|
+
"ad_schedule": "asset.promotion_asset.ad_schedule_targets",
|
|
1475
1481
|
}
|
|
1476
|
-
_CLEARABLE = ("promotion_code", "orders_over_amount", "occasion", "discount_modifier", *_DATE_FIELDS
|
|
1482
|
+
_CLEARABLE = ("promotion_code", "orders_over_amount", "occasion", "discount_modifier", *_DATE_FIELDS,
|
|
1483
|
+
"ad_schedule")
|
|
1477
1484
|
|
|
1478
1485
|
|
|
1479
1486
|
def _promotion_from_google(raw: dict) -> dict:
|
|
@@ -1490,6 +1497,7 @@ def _promotion_from_google(raw: dict) -> dict:
|
|
|
1490
1497
|
"discount_modifier": raw["discount_modifier"],
|
|
1491
1498
|
"language_code": raw["language_code"],
|
|
1492
1499
|
**{field: raw[field] for field in _DATE_FIELDS},
|
|
1500
|
+
"ad_schedule": _schedule_from_google(raw["ad_schedule"]),
|
|
1493
1501
|
}
|
|
1494
1502
|
|
|
1495
1503
|
|
|
@@ -1501,9 +1509,14 @@ def update_promotion(
|
|
|
1501
1509
|
language_code: str = "",
|
|
1502
1510
|
currency_code: str = "",
|
|
1503
1511
|
clear_fields: list[str] | None = None,
|
|
1512
|
+
ad_schedule: list[dict] | None = None,
|
|
1504
1513
|
**promotion,
|
|
1505
1514
|
) -> dict:
|
|
1506
|
-
"""Draft an in-place edit of a promotion asset; fields not passed stay as they are.
|
|
1515
|
+
"""Draft an in-place edit of a promotion asset; fields not passed stay as they are.
|
|
1516
|
+
|
|
1517
|
+
A non-empty ``ad_schedule`` replaces the promotion's schedule;
|
|
1518
|
+
``clear_fields=["ad_schedule"]`` removes it.
|
|
1519
|
+
"""
|
|
1507
1520
|
if blocked := _blocked("update_promotion", config):
|
|
1508
1521
|
return blocked
|
|
1509
1522
|
asset_id = _clean(asset_id)
|
|
@@ -1511,6 +1524,10 @@ def update_promotion(
|
|
|
1511
1524
|
passed, parse_errors = _promotion_input(**promotion)
|
|
1512
1525
|
errors += parse_errors
|
|
1513
1526
|
passed = {k: v for k, v in passed.items() if v}
|
|
1527
|
+
windows, schedule_errors = _validate_schedule(ad_schedule or [])
|
|
1528
|
+
errors += schedule_errors
|
|
1529
|
+
if windows:
|
|
1530
|
+
passed["ad_schedule"] = windows
|
|
1514
1531
|
if _clean(language_code):
|
|
1515
1532
|
passed["language_code"] = _language(language_code, errors)
|
|
1516
1533
|
clear = [_clean(f).lower() for f in clear_fields or []]
|
|
@@ -1539,7 +1556,7 @@ def update_promotion(
|
|
|
1539
1556
|
if "orders_over_amount" in passed:
|
|
1540
1557
|
merged["promotion_code"] = ""
|
|
1541
1558
|
for field in clear:
|
|
1542
|
-
merged[field] = 0.0
|
|
1559
|
+
merged[field] = {"orders_over_amount": 0.0, "ad_schedule": []}.get(field, "")
|
|
1543
1560
|
errors = _check_promotion(merged)
|
|
1544
1561
|
if errors:
|
|
1545
1562
|
return {"error": "Validation failed", "details": errors}
|
|
@@ -1565,6 +1582,10 @@ def update_promotion(
|
|
|
1565
1582
|
{"set": changed, "promotion": merged},
|
|
1566
1583
|
{k: current[k] for k in changed}, pinned={k: raw[k] for k in _PROMOTION_FIELDS})
|
|
1567
1584
|
preview["warnings"] += warnings + _promotion_warnings(merged)
|
|
1585
|
+
if "ad_schedule" in changed:
|
|
1586
|
+
preview["warnings"].append(
|
|
1587
|
+
_time_zone_note(_account_settings(config, customer_id)["time_zone"]) if merged["ad_schedule"]
|
|
1588
|
+
else "Without a schedule the promotion shows at all hours the campaign serves.")
|
|
1568
1589
|
return preview
|
|
1569
1590
|
|
|
1570
1591
|
|
|
@@ -1589,6 +1610,10 @@ def _populate_promotion(client, asset, promo: dict) -> None:
|
|
|
1589
1610
|
for field in _DATE_FIELDS:
|
|
1590
1611
|
if promo.get(field):
|
|
1591
1612
|
setattr(p, field, promo[field])
|
|
1613
|
+
for window in promo.get("ad_schedule") or []:
|
|
1614
|
+
info = client.get_type("AdScheduleInfo")
|
|
1615
|
+
_populate_schedule(client, info, window)
|
|
1616
|
+
p.ad_schedule_targets.append(info)
|
|
1592
1617
|
asset.final_urls.append(promo["final_url"])
|
|
1593
1618
|
|
|
1594
1619
|
|
|
@@ -1638,6 +1663,12 @@ def _apply_update_promotion(client, cid: str, changes: dict) -> dict:
|
|
|
1638
1663
|
if promo[field]:
|
|
1639
1664
|
setattr(p, field, promo[field])
|
|
1640
1665
|
paths.append(f"promotion_asset.{field}")
|
|
1666
|
+
if "ad_schedule" in changed: # replaces the whole list; empty clears it
|
|
1667
|
+
for window in promo["ad_schedule"]:
|
|
1668
|
+
info = client.get_type("AdScheduleInfo")
|
|
1669
|
+
_populate_schedule(client, info, window)
|
|
1670
|
+
p.ad_schedule_targets.append(info)
|
|
1671
|
+
paths.append("promotion_asset.ad_schedule_targets")
|
|
1641
1672
|
return list(dict.fromkeys(paths))
|
|
1642
1673
|
|
|
1643
1674
|
return _apply_asset_update(client, cid, changes["asset_id"], set_fields)
|
|
@@ -2267,8 +2267,11 @@ def update_campaign(
|
|
|
2267
2267
|
)
|
|
2268
2268
|
errors.extend(alias_errors)
|
|
2269
2269
|
|
|
2270
|
+
campaign_id = str(campaign_id or "").strip()
|
|
2270
2271
|
if not campaign_id:
|
|
2271
2272
|
errors.append("campaign_id is required")
|
|
2273
|
+
elif not campaign_id.isdigit():
|
|
2274
|
+
errors.append(f"campaign_id must be numeric, got '{campaign_id}'")
|
|
2272
2275
|
|
|
2273
2276
|
bs = bidding_strategy.upper() if bidding_strategy else ""
|
|
2274
2277
|
if bs and bs not in _VALID_BIDDING_STRATEGIES:
|
|
@@ -2361,7 +2364,46 @@ def update_campaign(
|
|
|
2361
2364
|
'entity_type="campaign_criterion".'
|
|
2362
2365
|
)
|
|
2363
2366
|
|
|
2367
|
+
# A budget can be shared by several campaigns, and a portfolio bid
|
|
2368
|
+
# strategy too: say who else a budget change reaches, and that a bidding
|
|
2369
|
+
# change takes the campaign out of its portfolio. Both need a double
|
|
2370
|
+
# confirmation, and apply re-checks the state the preview showed.
|
|
2371
|
+
shared: dict = {}
|
|
2372
|
+
requires_double_confirm = False
|
|
2373
|
+
if daily_budget or bs or max_cpc:
|
|
2374
|
+
shared = _budget_and_bidding_context(
|
|
2375
|
+
config, customer_id, campaign_id,
|
|
2376
|
+
budget=bool(daily_budget), portfolio=bool(bs or max_cpc),
|
|
2377
|
+
)
|
|
2378
|
+
if shared is None:
|
|
2379
|
+
return {"error": f"Campaign {campaign_id} was not found in this account"}
|
|
2380
|
+
if daily_budget and shared["budget_shared_with"]:
|
|
2381
|
+
others = ", ".join(f"'{c['name']}' ({c['id']})" for c in shared["budget_shared_with"])
|
|
2382
|
+
warnings.append(
|
|
2383
|
+
f"Shared budget: '{shared['budget_name']}' also funds {others}. The new "
|
|
2384
|
+
"amount is the daily budget of ALL of them together, not of this campaign "
|
|
2385
|
+
"alone. To change only this campaign, give it its own budget in Google Ads first."
|
|
2386
|
+
)
|
|
2387
|
+
requires_double_confirm = True
|
|
2388
|
+
if (bs or max_cpc) and shared.get("bidding_strategy_resource"):
|
|
2389
|
+
others = ", ".join(f"'{c['name']}'" for c in shared["portfolio_shared_with"]) or "no other campaigns"
|
|
2390
|
+
warnings.append(
|
|
2391
|
+
f"Portfolio bid strategy: this campaign uses '{shared['portfolio_name']}' "
|
|
2392
|
+
f"(also used by {others}). This change gives the campaign its own strategy "
|
|
2393
|
+
"and takes it out of the portfolio; the portfolio and its other campaigns stay "
|
|
2394
|
+
"as they are. To change the portfolio's targets for all of them, edit it in Google Ads."
|
|
2395
|
+
)
|
|
2396
|
+
requires_double_confirm = True
|
|
2397
|
+
|
|
2364
2398
|
changes: dict = {"campaign_id": campaign_id}
|
|
2399
|
+
if daily_budget:
|
|
2400
|
+
changes["current_daily_budget"] = shared["budget_amount"]
|
|
2401
|
+
changes["budget_name"] = shared["budget_name"]
|
|
2402
|
+
# Pinned: apply refuses if the campaign's budget or its sharing changed.
|
|
2403
|
+
changes["budget_resource"] = shared["budget_resource"]
|
|
2404
|
+
changes["budget_shared_with"] = sorted(c["id"] for c in shared["budget_shared_with"])
|
|
2405
|
+
if bs or max_cpc:
|
|
2406
|
+
changes["bidding_strategy_resource"] = shared["bidding_strategy_resource"]
|
|
2365
2407
|
if bs:
|
|
2366
2408
|
changes["bidding_strategy"] = bs
|
|
2367
2409
|
if target_cpa:
|
|
@@ -2387,6 +2429,7 @@ def update_campaign(
|
|
|
2387
2429
|
entity_id=campaign_id,
|
|
2388
2430
|
customer_id=customer_id,
|
|
2389
2431
|
changes=changes,
|
|
2432
|
+
requires_double_confirm=requires_double_confirm,
|
|
2390
2433
|
)
|
|
2391
2434
|
store_plan(plan)
|
|
2392
2435
|
preview = plan.to_preview()
|
|
@@ -3094,6 +3137,95 @@ def _campaign_bidding_strategy(
|
|
|
3094
3137
|
return rows[0].get("campaign.bidding_strategy_type")
|
|
3095
3138
|
|
|
3096
3139
|
|
|
3140
|
+
_SHARED_RESOURCE_RE = re.compile(r"customers/\d+/(campaignBudgets|biddingStrategies)/\d+")
|
|
3141
|
+
|
|
3142
|
+
|
|
3143
|
+
def _campaigns_using(config: AdLoopConfig, customer_id: str, field: str, resource: str,
|
|
3144
|
+
campaign_id: str) -> list[dict]:
|
|
3145
|
+
"""Other non-removed campaigns whose ``campaign.<field>`` is ``resource``."""
|
|
3146
|
+
from adloop.ads.gaql import execute_query
|
|
3147
|
+
|
|
3148
|
+
if not _SHARED_RESOURCE_RE.fullmatch(resource or ""):
|
|
3149
|
+
return []
|
|
3150
|
+
rows = execute_query(config, customer_id, (
|
|
3151
|
+
f"SELECT campaign.id, campaign.name FROM campaign WHERE campaign.{field} = '{resource}' "
|
|
3152
|
+
"AND campaign.status != 'REMOVED'"))
|
|
3153
|
+
return [{"id": str(r.get("campaign.id")), "name": r.get("campaign.name") or ""}
|
|
3154
|
+
for r in rows if str(r.get("campaign.id")) != str(campaign_id)]
|
|
3155
|
+
|
|
3156
|
+
|
|
3157
|
+
def _budget_and_bidding_context(
|
|
3158
|
+
config: AdLoopConfig, customer_id: str, campaign_id: str, *, budget: bool, portfolio: bool
|
|
3159
|
+
) -> dict | None:
|
|
3160
|
+
"""The campaign's budget and portfolio bid strategy, with who else uses them.
|
|
3161
|
+
|
|
3162
|
+
None when the campaign does not exist. Read failures propagate: whether a
|
|
3163
|
+
budget is shared is a safety question, not a decoration.
|
|
3164
|
+
"""
|
|
3165
|
+
from adloop.ads.gaql import execute_query
|
|
3166
|
+
|
|
3167
|
+
rows = execute_query(config, customer_id, (
|
|
3168
|
+
"SELECT campaign.id, campaign.campaign_budget, campaign.bidding_strategy, "
|
|
3169
|
+
"campaign_budget.name, campaign_budget.amount_micros "
|
|
3170
|
+
f"FROM campaign WHERE campaign.id = {campaign_id}"))
|
|
3171
|
+
if not rows:
|
|
3172
|
+
return None
|
|
3173
|
+
row = rows[0]
|
|
3174
|
+
budget_rn = row.get("campaign.campaign_budget") or ""
|
|
3175
|
+
portfolio_rn = row.get("campaign.bidding_strategy") or ""
|
|
3176
|
+
context = {
|
|
3177
|
+
"budget_resource": budget_rn,
|
|
3178
|
+
"budget_name": row.get("campaign_budget.name") or "",
|
|
3179
|
+
"budget_amount": (row.get("campaign_budget.amount_micros") or 0) / 1_000_000,
|
|
3180
|
+
"budget_shared_with": (
|
|
3181
|
+
_campaigns_using(config, customer_id, "campaign_budget", budget_rn, campaign_id) if budget else []),
|
|
3182
|
+
"bidding_strategy_resource": portfolio_rn,
|
|
3183
|
+
"portfolio_name": "",
|
|
3184
|
+
"portfolio_shared_with": [],
|
|
3185
|
+
}
|
|
3186
|
+
if portfolio and portfolio_rn and _SHARED_RESOURCE_RE.fullmatch(portfolio_rn):
|
|
3187
|
+
names = execute_query(config, customer_id, (
|
|
3188
|
+
f"SELECT bidding_strategy.name FROM bidding_strategy WHERE bidding_strategy.resource_name = '{portfolio_rn}'"))
|
|
3189
|
+
context["portfolio_name"] = (names[0].get("bidding_strategy.name") if names else "") or portfolio_rn
|
|
3190
|
+
context["portfolio_shared_with"] = _campaigns_using(
|
|
3191
|
+
config, customer_id, "bidding_strategy", portfolio_rn, campaign_id)
|
|
3192
|
+
return context
|
|
3193
|
+
|
|
3194
|
+
|
|
3195
|
+
def _check_budget_and_bidding_unchanged(service, cid: str, changes: dict) -> None:
|
|
3196
|
+
"""Refuse when the budget or portfolio state the preview showed has moved."""
|
|
3197
|
+
pins_budget = "budget_resource" in changes
|
|
3198
|
+
pins_bidding = "bidding_strategy_resource" in changes
|
|
3199
|
+
if not (pins_budget or pins_bidding):
|
|
3200
|
+
return # plans drafted before 0.35.1 carry no pins
|
|
3201
|
+
campaign_id = changes["campaign_id"]
|
|
3202
|
+
rows = list(service.search(customer_id=cid, query=(
|
|
3203
|
+
"SELECT campaign.campaign_budget, campaign.bidding_strategy "
|
|
3204
|
+
f"FROM campaign WHERE campaign.id = {campaign_id}")))
|
|
3205
|
+
if not rows:
|
|
3206
|
+
raise ValueError(f"Campaign {campaign_id} not found")
|
|
3207
|
+
campaign = rows[0].campaign
|
|
3208
|
+
moved = []
|
|
3209
|
+
if pins_budget:
|
|
3210
|
+
if campaign.campaign_budget != changes["budget_resource"]:
|
|
3211
|
+
moved.append("it uses a different budget")
|
|
3212
|
+
elif _SHARED_RESOURCE_RE.fullmatch(changes["budget_resource"] or ""):
|
|
3213
|
+
sharing = sorted(
|
|
3214
|
+
str(r.campaign.id) for r in service.search(customer_id=cid, query=(
|
|
3215
|
+
"SELECT campaign.id FROM campaign WHERE campaign.campaign_budget = "
|
|
3216
|
+
f"'{changes['budget_resource']}' AND campaign.status != 'REMOVED'"))
|
|
3217
|
+
if str(r.campaign.id) != str(campaign_id))
|
|
3218
|
+
if sharing != sorted(changes.get("budget_shared_with") or []):
|
|
3219
|
+
moved.append("other campaigns joined or left its budget")
|
|
3220
|
+
if pins_bidding and campaign.bidding_strategy != changes["bidding_strategy_resource"]:
|
|
3221
|
+
moved.append("its portfolio bid strategy changed")
|
|
3222
|
+
if moved:
|
|
3223
|
+
raise RuntimeError(
|
|
3224
|
+
f"Campaign {campaign_id} changed after this preview ({'; '.join(moved)}); "
|
|
3225
|
+
"nothing was sent. Draft the change again so the preview shows who it reaches."
|
|
3226
|
+
)
|
|
3227
|
+
|
|
3228
|
+
|
|
3097
3229
|
def _existing_negative_geo_exclusions(
|
|
3098
3230
|
config: AdLoopConfig, customer_id: str, campaign_id: str
|
|
3099
3231
|
) -> list[str]:
|
|
@@ -4264,6 +4396,7 @@ def _apply_update_campaign(client: object, cid: str, changes: dict) -> dict:
|
|
|
4264
4396
|
|
|
4265
4397
|
campaign_id = changes["campaign_id"]
|
|
4266
4398
|
resource_name = campaign_service.campaign_path(cid, campaign_id)
|
|
4399
|
+
_check_budget_and_bidding_unchanged(service, cid, changes)
|
|
4267
4400
|
|
|
4268
4401
|
# Bid strategy and campaign-level setting changes
|
|
4269
4402
|
bs = changes.get("bidding_strategy")
|
|
@@ -294,8 +294,8 @@ Conversions that happen offline (a sales call, a lead that turned into a deal) g
|
|
|
294
294
|
| `draft_call_asset` | Create a call asset (phone number shown with the ads) for an ad group, a campaign or the account | `phone_number`, `country_code` (REQUIRED, ISO alpha-2), `campaign_id` or `ad_group_id` or `scope="account"`; optional `call_conversion_action_id`, `ad_schedule` |
|
|
295
295
|
| `update_call_asset` | Edit a call asset IN PLACE: number, call conversion action / reporting state, schedule | `asset_id`; `phone_number` needs `country_code`; `ad_schedule` replaces the schedule, `clear_ad_schedule=true` removes it |
|
|
296
296
|
| `add_ad_schedule` | Add ad schedule windows to a campaign; existing windows stay | `campaign_id`, `schedule` list of {day_of_week, start_hour, end_hour, start_minute/end_minute 0/15/30/45}, whole numbers; no overlaps, at most 6 a day. A campaign's FIRST schedule needs a double confirmation |
|
|
297
|
-
| `draft_promotion` | Create a promotion asset ("20% off winter tyres") for an ad group, a campaign or the account | `promotion_target` (≤20), `final_url`, `language_code` (REQUIRED), `money_off` or `percent_off`; optional `promotion_code` (≤15) or `orders_over_amount`, `occasion`, `discount_modifier` UP_TO, dates; amounts in the account currency unless `currency_code` |
|
|
298
|
-
| `update_promotion` | Edit a promotion IN PLACE; fields not passed stay | `asset_id` plus the fields to change; `money_off`/`percent_off` and `promotion_code`/`orders_over_amount` replace each other; `clear_fields` removes optional ones |
|
|
297
|
+
| `draft_promotion` | Create a promotion asset ("20% off winter tyres") for an ad group, a campaign or the account | `promotion_target` (≤20), `final_url`, `language_code` (REQUIRED), `money_off` or `percent_off`; optional `promotion_code` (≤15) or `orders_over_amount`, `occasion`, `discount_modifier` UP_TO, dates, `ad_schedule` (hours it shows); amounts in the account currency unless `currency_code` |
|
|
298
|
+
| `update_promotion` | Edit a promotion IN PLACE; fields not passed stay | `asset_id` plus the fields to change; `money_off`/`percent_off` and `promotion_code`/`orders_over_amount` replace each other; `ad_schedule` replaces its schedule; `clear_fields` removes optional ones (incl. `ad_schedule`) |
|
|
299
299
|
| `draft_price_asset` | Create a price asset (3-8 offerings) for an ad group, a campaign or the account | `offerings` [{header ≤25, description ≤25, price, final_url, unit}], `price_type`, `language_code` (both REQUIRED); `price_qualifier` FROM/UP_TO/AVERAGE optional; account currency unless `currency_code` |
|
|
300
300
|
| `draft_image_assets` | Create image assets for a campaign from local files, public image URLs or uploads (does NOT publish) | `campaign_id`, `image_paths` (local PNG/JPEG/GIF files; not on a hosted server), `image_urls` (public http(s) image links, ≤5120 KB; re-checked at apply — if the image changed since the preview, draft again) and/or `upload_ids` (`up_...` from `upload_images` / `list_uploads`) |
|
|
301
301
|
| `draft_sitelinks` | Create sitelink extensions for a campaign (does NOT publish) | `campaign_id`, `sitelinks` list of {link_text ≤25 chars, final_url, description1 ≤35 chars, description2 ≤35 chars} |
|
|
@@ -363,6 +363,7 @@ Same draft → preview → `confirm_and_apply` gate; the plan carries `platform:
|
|
|
363
363
|
- `display_network_enabled` is the canonical Search display-expansion flag. `display_expansion_enabled` is only a compatibility alias and should be normalized away before presenting the plan to the user.
|
|
364
364
|
- `update_ad_group` is the right tool for later MANUAL_CPC bid changes. Use `update_campaign` for TARGET_SPEND (Maximize Clicks) `max_cpc` changes.
|
|
365
365
|
- Ad-group pause/enable is already handled by `pause_entity` / `enable_entity` with `entity_type="ad_group"`; do not invent a separate pause tool.
|
|
366
|
+
- `update_campaign` budget changes show the current amount (`current_daily_budget`). A shared budget (`budget_shared_with` lists the other campaign IDs) or a portfolio bid strategy makes the plan require double confirmation, and apply refuses if the budget, its sharing or the portfolio changed after the preview.
|
|
366
367
|
- `update_campaign` replaces POSITIVE geo/language targets entirely (not append). Pass the full desired list. NEGATIVE geo exclusions (criteria with `negative=TRUE`) are **preserved** across a positive-geo replacement — they survive the swap. The preview surfaces preserved negative geo IDs in `preserved_negative_geo_target_ids` so the change is auditable. To ADD negative geo exclusions (e.g. exclude a city inside a targeted state), use `add_negative_locations` — do not try to express exclusions through `geo_target_ids`. To remove a negative geo exclusion explicitly, use `remove_entity` with `entity_type="campaign_criterion"`.
|
|
367
368
|
- **Asset scope:** callouts and snippets link where the id points (`ad_group_id` → ad group, `campaign_id` → campaign). Account-wide (`scope="account"`) reaches every campaign without assets of its own; use it only when the user asks for account level. **Asset edits** (`update_callout`, `update_sitelink`, `update_structured_snippet`) change the asset everywhere it is linked; check with GAQL (`campaign_asset`, `ad_group_asset`, `customer_asset`) where else it is used before editing a shared asset. If the apply reports the asset changed in Google Ads since the preview, draft again.
|
|
368
369
|
- **Call assets, schedules, promotions and prices.** Never guess `country_code` or `language_code`: take them from the number and the landing page, and ask when unclear. Money amounts are in the account currency (the preview names it); pass `currency_code` only for a different one. Ad schedule hours are in the **account time zone** (the previews show it), not the searcher's: convert the user's local hours first. A campaign without a schedule serves at all hours, so its first `add_ad_schedule` windows switch off every other hour and day: say so, the preview needs a double confirmation. To change a window, remove the old one with `remove_entity` (`entity_type="campaign_criterion"`, the `remove_id` from the preview), then add the new one. Prices must match the landing page or Google disapproves them.
|
|
@@ -526,6 +527,7 @@ Most websites (especially in the EU) use a GDPR cookie consent banner. This has
|
|
|
526
527
|
- If changing to MANUAL_CPC: warn about the trade-offs (no automation, requires constant monitoring)
|
|
527
528
|
- If removing geo or language targets: warn that this broadens targeting and may waste budget
|
|
528
529
|
- If the campaign is in a learning phase: warn that changes will restart the learning phase
|
|
530
|
+
- **Shared budgets and portfolio strategies:** a budget change on a SHARED budget changes the daily budget of every campaign using it, and a bidding change on a campaign in a portfolio bid strategy takes it out of the portfolio. The preview names the other campaigns and asks for a double confirmation; tell the user plainly before they confirm. If they meant only this campaign, its own budget has to be set up in Google Ads first
|
|
529
531
|
4. Call `update_campaign` with only the parameters that need to change
|
|
530
532
|
5. Present the preview — clearly show what's changing (old → new)
|
|
531
533
|
6. Wait for explicit user approval before calling `confirm_and_apply`
|
|
@@ -3337,7 +3337,11 @@ def update_campaign(
|
|
|
3337
3337
|
|
|
3338
3338
|
Only the parameters passed are changed; omitted ones stay as they are, and at
|
|
3339
3339
|
least one change is required. Replacing geo targets keeps the campaign's
|
|
3340
|
-
negative geo exclusions (the preview lists them).
|
|
3340
|
+
negative geo exclusions (the preview lists them). A budget change shows the
|
|
3341
|
+
current amount; when the budget is shared with other campaigns, the
|
|
3342
|
+
preview names them and needs a double confirmation, because the new amount
|
|
3343
|
+
covers all of them. A bidding change on a campaign in a portfolio bid
|
|
3344
|
+
strategy takes it out of the portfolio, also with a double confirmation.
|
|
3341
3345
|
|
|
3342
3346
|
Returns a preview with a plan_id plus any warnings; nothing changes until
|
|
3343
3347
|
confirm_and_apply applies it.
|
|
@@ -3354,7 +3358,8 @@ def update_campaign(
|
|
|
3354
3358
|
target_roas: Target ROAS as a ratio (e.g. 4.0 = 400%); required if
|
|
3355
3359
|
bidding_strategy is TARGET_ROAS. 0 leaves it unchanged.
|
|
3356
3360
|
daily_budget: New daily budget in the account's currency (not micros),
|
|
3357
|
-
at most the config's max_daily_budget. 0 leaves it unchanged.
|
|
3361
|
+
at most the config's max_daily_budget. 0 leaves it unchanged. On a
|
|
3362
|
+
shared budget it is the amount for every campaign sharing it.
|
|
3358
3363
|
geo_target_ids: REPLACES all positive geo targets; must not be empty
|
|
3359
3364
|
when given. Common IDs: "2276" Germany, "2040" Austria, "2756"
|
|
3360
3365
|
Switzerland, "2840" USA, "2826" UK.
|
|
@@ -4425,6 +4430,7 @@ def draft_promotion(
|
|
|
4425
4430
|
end_date: str = "",
|
|
4426
4431
|
redemption_start_date: str = "",
|
|
4427
4432
|
redemption_end_date: str = "",
|
|
4433
|
+
ad_schedule: _DictList = [], # noqa: B006
|
|
4428
4434
|
customer_id: str = "",
|
|
4429
4435
|
) -> dict:
|
|
4430
4436
|
"""Draft a promotion asset ("20% off winter tyres") — returns a PREVIEW.
|
|
@@ -4461,6 +4467,10 @@ def draft_promotion(
|
|
|
4461
4467
|
end_date: Last day the promotion shows, YYYY-MM-DD.
|
|
4462
4468
|
redemption_start_date: First day the offer can be redeemed, YYYY-MM-DD.
|
|
4463
4469
|
redemption_end_date: Last day the offer can be redeemed, YYYY-MM-DD.
|
|
4470
|
+
ad_schedule: Optional windows when the promotion shows, each
|
|
4471
|
+
{day_of_week: MONDAY..SUNDAY, start_hour: 0-23, end_hour: 0-24,
|
|
4472
|
+
start_minute/end_minute: 0, 15, 30 or 45}. Hours are in the
|
|
4473
|
+
account time zone.
|
|
4464
4474
|
customer_id: Google Ads customer ID (digits, dashes optional). Defaults to
|
|
4465
4475
|
the configured account.
|
|
4466
4476
|
"""
|
|
@@ -4486,6 +4496,7 @@ def draft_promotion(
|
|
|
4486
4496
|
end_date=end_date,
|
|
4487
4497
|
redemption_start_date=redemption_start_date,
|
|
4488
4498
|
redemption_end_date=redemption_end_date,
|
|
4499
|
+
ad_schedule=ad_schedule,
|
|
4489
4500
|
)
|
|
4490
4501
|
|
|
4491
4502
|
|
|
@@ -4508,6 +4519,7 @@ def update_promotion(
|
|
|
4508
4519
|
redemption_start_date: str = "",
|
|
4509
4520
|
redemption_end_date: str = "",
|
|
4510
4521
|
clear_fields: _StrList = [], # noqa: B006
|
|
4522
|
+
ad_schedule: _DictList = [], # noqa: B006
|
|
4511
4523
|
customer_id: str = "",
|
|
4512
4524
|
) -> dict:
|
|
4513
4525
|
"""Draft changing a promotion asset in place — returns a PREVIEW.
|
|
@@ -4538,7 +4550,10 @@ def update_promotion(
|
|
|
4538
4550
|
redemption_end_date: Last day the offer can be redeemed, YYYY-MM-DD.
|
|
4539
4551
|
clear_fields: Optional fields to remove: promotion_code,
|
|
4540
4552
|
orders_over_amount, occasion, discount_modifier, start_date,
|
|
4541
|
-
end_date, redemption_start_date, redemption_end_date
|
|
4553
|
+
end_date, redemption_start_date, redemption_end_date,
|
|
4554
|
+
ad_schedule.
|
|
4555
|
+
ad_schedule: Windows that replace the promotion's whole schedule (same
|
|
4556
|
+
shape as draft_promotion). Empty leaves the schedule as it is.
|
|
4542
4557
|
customer_id: Google Ads customer ID (digits, dashes optional). Defaults to
|
|
4543
4558
|
the configured account.
|
|
4544
4559
|
"""
|
|
@@ -4551,6 +4566,7 @@ def update_promotion(
|
|
|
4551
4566
|
language_code=language_code,
|
|
4552
4567
|
currency_code=currency_code,
|
|
4553
4568
|
clear_fields=clear_fields,
|
|
4569
|
+
ad_schedule=ad_schedule,
|
|
4554
4570
|
promotion_target=promotion_target,
|
|
4555
4571
|
final_url=final_url,
|
|
4556
4572
|
money_off=money_off,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|