diff --git a/.claude/rules/adloop.md b/.claude/rules/adloop.md index 7a463b9..273364c 100644 --- a/.claude/rules/adloop.md +++ b/.claude/rules/adloop.md @@ -279,13 +279,13 @@ Conversions that happen offline (a sales call, a lead that turned into a deal) g | Tool | What It Does | Validation | |------|-------------|------------| -| `draft_campaign` | Create full campaign structure (budget + campaign + ad group + keywords + geo/language targeting) | `campaign_name`, `daily_budget`, `bidding_strategy`, `geo_target_ids` (REQUIRED), `language_ids` (REQUIRED), optional `search_partners_enabled`, `display_network_enabled`, `display_expansion_enabled`, optional `max_cpc` for MANUAL_CPC ad-group bids or TARGET_SPEND CPC caps, optional `location_option` / `negative_location_option` | +| `draft_campaign` | Create full campaign structure (budget + campaign + ad group + keywords + geo/language targeting) | `campaign_name`, `daily_budget`, `bidding_strategy`, `geo_target_ids` (REQUIRED), `language_ids` (REQUIRED), optional `search_partners_enabled`, `display_network_enabled`, `display_expansion_enabled`, optional `max_cpc` for MANUAL_CPC ad-group bids or TARGET_SPEND CPC caps, optional `location_option` / `negative_location_option`, optional `tracking_url_template` / `final_url_suffix` / `url_custom_parameters` | | `draft_shopping_campaign` | Create a Standard Shopping campaign for a Merchant Center feed: budget + campaign (PAUSED) + product ad group + Shopping ad + one "All products" product group, atomically | `campaign_name`, `daily_budget`, `merchant_id` (must be linked to the Ads account; checked), `product_bid` (CPC of the product group, required by Google), `geo_target_ids` (REQUIRED), `bidding_strategy` MANUAL_CPC (default) / TARGET_SPEND (Maximize clicks, optional `max_cpc`) / TARGET_ROAS (+ `target_roas`), `campaign_priority` LOW/MEDIUM/HIGH, optional `feed_label`. No keywords, no languages | | `draft_asset_group_assets` | Add and/or remove assets of an existing PMax asset group in one atomic change: text, images, YouTube videos | `asset_group_id`, `headlines` (≤30), `long_headlines` (≤90), `descriptions` (≤90), `business_name` (≤25), `images` (`{"url"}` or `{"upload_id"}`, optional `role` marketing/logo/landscape_logo; marketing images sorted into landscape 1.91:1, square 1:1, portrait 4:5 by ratio), `youtube_video_ids`, `remove_asset_ids` | | `draft_asset_group_signals` | Add search themes or set the audience signal of a PMax asset group, or remove signals | `asset_group_id`, `add_search_themes` (≤80 chars, up to 50 per group), `audience_id` (one per group), `remove_signal_ids` | | `draft_pmax_product_exclusions` | Set the full list of products a retail PMax asset group excludes, on one dimension; everything else stays included | `asset_group_id`, `exclude_values` (the COMPLETE list; `[]` includes everything again), `dimension` item_id (default) / brand / custom_label_0–4 | | `draft_ad_group` | Create a new ad group within an existing campaign (does NOT publish) | `campaign_id` (REQUIRED), `ad_group_name` (REQUIRED), `keywords` (optional list of {text, match_type}), `cpc_bid_micros` (optional) | -| `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets, location options, Search partners, display expansion | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids`, `location_option`, `negative_location_option`, `search_partners_enabled`, `display_network_enabled`, TARGET_SPEND `max_cpc` | +| `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets, location options, Search partners, display expansion, URL options | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids`, `location_option`, `negative_location_option`, `search_partners_enabled`, `display_network_enabled`, TARGET_SPEND `max_cpc`, `tracking_url_template`, `final_url_suffix`, `url_custom_parameters` | | `update_ad_group` | Update ad group name and/or MANUAL_CPC `max_cpc` | `ad_group_id`, optional `ad_group_name`, optional `max_cpc` | | `draft_responsive_search_ad` | Create RSA preview (does NOT publish) | 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), final_url required, path1/path2 (≤15 chars each). Each headline/description may be a plain string (unpinned) or `{"text": "...", "pinned_field": "HEADLINE_1"}` (pinned). Valid pin slots: `HEADLINE_1/2/3`, `DESCRIPTION_1/2`. Several assets may pin to one slot (they rotate there), but every slot must stay fillable: by an asset pinned to it or by a distinct unpinned asset. | | `draft_callouts` | Create callout assets for an ad group, a campaign or the account (does NOT publish) | `campaign_id` or `ad_group_id` (the scope follows the id), or `scope="account"`; `callouts` list with 1-25 chars each | @@ -369,6 +369,7 @@ Same draft → preview → `confirm_and_apply` gate; the plan carries `platform: - Ad-group pause/enable is already handled by `pause_entity` / `enable_entity` with `entity_type="ad_group"`; do not invent a separate pause tool. - `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. - Location options: `location_option` is who the geo targets reach — `PRESENCE` (people in or regularly in the locations), `PRESENCE_OR_INTEREST` (Google's default; also people elsewhere who show interest in them) or `SEARCH_INTEREST`. `negative_location_option` (`PRESENCE` or `PRESENCE_OR_INTEREST`) is who the exclusions exclude. Empty leaves the value as it is. The preview warns when a change widens reach: for `location_option` that is anything but `PRESENCE`, for `negative_location_option` it is `PRESENCE`, because then the exclusions only keep out people physically there, not people interested in the excluded places; `update_campaign` shows the current value as `current_location_option` / `current_negative_location_option`. Read back with `get_campaign_settings`. +- Campaign URL options (`tracking_url_template`, `final_url_suffix`, `url_custom_parameters`): omitted leaves a value unchanged, `""` or `[]` removes it, and `url_custom_parameters` replaces the whole list. The template must contain `{lpurl}` or a variant (`{lpurl+2}`, `{unescapedlpurl}`, `{escapedlpurl+3}`, ...). Custom parameter keys are alphanumeric, at most 8 characters. The `update_campaign` preview shows each current value (`current_tracking_url_template` etc.), warns on a removal, and gives `example_click`: one of the campaign's final URLs with the campaign's options applied. It is approximate: a template on the ad group, ad or keyword overrides the campaign's, and ValueTrack parameters are filled at click time. Check it for typos before approving. Read back with `get_campaign_settings`. - `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"`. - **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. - **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. diff --git a/.cursor/rules/adloop.mdc b/.cursor/rules/adloop.mdc index 219ee49..2263af9 100644 --- a/.cursor/rules/adloop.mdc +++ b/.cursor/rules/adloop.mdc @@ -281,13 +281,13 @@ Conversions that happen offline (a sales call, a lead that turned into a deal) g | Tool | What It Does | Validation | |------|-------------|------------| -| `draft_campaign` | Create full campaign structure (budget + campaign + ad group + keywords + geo/language targeting) | `campaign_name`, `daily_budget`, `bidding_strategy`, `geo_target_ids` (REQUIRED), `language_ids` (REQUIRED), optional `search_partners_enabled`, `display_network_enabled`, `display_expansion_enabled`, optional `max_cpc` for MANUAL_CPC ad-group bids or TARGET_SPEND CPC caps, optional `location_option` / `negative_location_option` | +| `draft_campaign` | Create full campaign structure (budget + campaign + ad group + keywords + geo/language targeting) | `campaign_name`, `daily_budget`, `bidding_strategy`, `geo_target_ids` (REQUIRED), `language_ids` (REQUIRED), optional `search_partners_enabled`, `display_network_enabled`, `display_expansion_enabled`, optional `max_cpc` for MANUAL_CPC ad-group bids or TARGET_SPEND CPC caps, optional `location_option` / `negative_location_option`, optional `tracking_url_template` / `final_url_suffix` / `url_custom_parameters` | | `draft_shopping_campaign` | Create a Standard Shopping campaign for a Merchant Center feed: budget + campaign (PAUSED) + product ad group + Shopping ad + one "All products" product group, atomically | `campaign_name`, `daily_budget`, `merchant_id` (must be linked to the Ads account; checked), `product_bid` (CPC of the product group, required by Google), `geo_target_ids` (REQUIRED), `bidding_strategy` MANUAL_CPC (default) / TARGET_SPEND (Maximize clicks, optional `max_cpc`) / TARGET_ROAS (+ `target_roas`), `campaign_priority` LOW/MEDIUM/HIGH, optional `feed_label`. No keywords, no languages | | `draft_asset_group_assets` | Add and/or remove assets of an existing PMax asset group in one atomic change: text, images, YouTube videos | `asset_group_id`, `headlines` (≤30), `long_headlines` (≤90), `descriptions` (≤90), `business_name` (≤25), `images` (`{"url"}` or `{"upload_id"}`, optional `role` marketing/logo/landscape_logo; marketing images sorted into landscape 1.91:1, square 1:1, portrait 4:5 by ratio), `youtube_video_ids`, `remove_asset_ids` | | `draft_asset_group_signals` | Add search themes or set the audience signal of a PMax asset group, or remove signals | `asset_group_id`, `add_search_themes` (≤80 chars, up to 50 per group), `audience_id` (one per group), `remove_signal_ids` | | `draft_pmax_product_exclusions` | Set the full list of products a retail PMax asset group excludes, on one dimension; everything else stays included | `asset_group_id`, `exclude_values` (the COMPLETE list; `[]` includes everything again), `dimension` item_id (default) / brand / custom_label_0–4 | | `draft_ad_group` | Create a new ad group within an existing campaign (does NOT publish) | `campaign_id` (REQUIRED), `ad_group_name` (REQUIRED), `keywords` (optional list of {text, match_type}), `cpc_bid_micros` (optional) | -| `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets, location options, Search partners, display expansion | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids`, `location_option`, `negative_location_option`, `search_partners_enabled`, `display_network_enabled`, TARGET_SPEND `max_cpc` | +| `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets, location options, Search partners, display expansion, URL options | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids`, `location_option`, `negative_location_option`, `search_partners_enabled`, `display_network_enabled`, TARGET_SPEND `max_cpc`, `tracking_url_template`, `final_url_suffix`, `url_custom_parameters` | | `update_ad_group` | Update ad group name and/or MANUAL_CPC `max_cpc` | `ad_group_id`, optional `ad_group_name`, optional `max_cpc` | | `draft_responsive_search_ad` | Create RSA preview (does NOT publish) | 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), final_url required, path1/path2 (≤15 chars each). Each headline/description may be a plain string (unpinned) or `{"text": "...", "pinned_field": "HEADLINE_1"}` (pinned). Valid pin slots: `HEADLINE_1/2/3`, `DESCRIPTION_1/2`. Several assets may pin to one slot (they rotate there), but every slot must stay fillable: by an asset pinned to it or by a distinct unpinned asset. | | `draft_callouts` | Create callout assets for an ad group, a campaign or the account (does NOT publish) | `campaign_id` or `ad_group_id` (the scope follows the id), or `scope="account"`; `callouts` list with 1-25 chars each | @@ -371,6 +371,7 @@ Same draft → preview → `confirm_and_apply` gate; the plan carries `platform: - Ad-group pause/enable is already handled by `pause_entity` / `enable_entity` with `entity_type="ad_group"`; do not invent a separate pause tool. - `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. - Location options: `location_option` is who the geo targets reach — `PRESENCE` (people in or regularly in the locations), `PRESENCE_OR_INTEREST` (Google's default; also people elsewhere who show interest in them) or `SEARCH_INTEREST`. `negative_location_option` (`PRESENCE` or `PRESENCE_OR_INTEREST`) is who the exclusions exclude. Empty leaves the value as it is. The preview warns when a change widens reach: for `location_option` that is anything but `PRESENCE`, for `negative_location_option` it is `PRESENCE`, because then the exclusions only keep out people physically there, not people interested in the excluded places; `update_campaign` shows the current value as `current_location_option` / `current_negative_location_option`. Read back with `get_campaign_settings`. +- Campaign URL options (`tracking_url_template`, `final_url_suffix`, `url_custom_parameters`): omitted leaves a value unchanged, `""` or `[]` removes it, and `url_custom_parameters` replaces the whole list. The template must contain `{lpurl}` or a variant (`{lpurl+2}`, `{unescapedlpurl}`, `{escapedlpurl+3}`, ...). Custom parameter keys are alphanumeric, at most 8 characters. The `update_campaign` preview shows each current value (`current_tracking_url_template` etc.), warns on a removal, and gives `example_click`: one of the campaign's final URLs with the campaign's options applied. It is approximate: a template on the ad group, ad or keyword overrides the campaign's, and ValueTrack parameters are filled at click time. Check it for typos before approving. Read back with `get_campaign_settings`. - `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"`. - **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. - **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. diff --git a/src/adloop/ads/write.py b/src/adloop/ads/write.py index 40ce092..54c61ba 100644 --- a/src/adloop/ads/write.py +++ b/src/adloop/ads/write.py @@ -2082,6 +2082,9 @@ def draft_campaign( max_cpc: float = 0, location_option: str = "", negative_location_option: str = "", + tracking_url_template: str | None = None, + final_url_suffix: str | None = None, + url_custom_parameters: list[dict] | None = None, ) -> dict: """Draft a full campaign structure — returns preview, does NOT execute. @@ -2095,6 +2098,8 @@ def draft_campaign( ["1000"] for English). REQUIRED — campaigns must target specific languages. location_option / negative_location_option: campaign.geo_target_type_setting. Empty leaves Google's default (PRESENCE_OR_INTEREST for both). + tracking_url_template / final_url_suffix / url_custom_parameters: campaign + URL options. Omitted or empty leaves them unset. """ from adloop.safety.guards import ( SafetyViolation, @@ -2122,6 +2127,13 @@ def draft_campaign( ) if location_errors: return {"error": "Validation failed", "details": location_errors} + url_options, url_errors = _normalize_url_options( + tracking_url_template, final_url_suffix, url_custom_parameters + ) + if url_errors: + return {"error": "Validation failed", "details": url_errors} + # A new campaign has nothing to clear: only values that are set go in. + url_options = {key: value for key, value in url_options.items() if value} errors, warnings = _validate_campaign( config, @@ -2173,6 +2185,7 @@ def draft_campaign( "display_network_enabled": normalized_display_network_enabled, "max_cpc": max_cpc if max_cpc else None, **location_changes, + **url_options, }, ) store_plan(plan) @@ -2259,12 +2272,17 @@ def update_campaign( max_cpc: float = 0, location_option: str = "", negative_location_option: str = "", + tracking_url_template: str | None = None, + final_url_suffix: str | None = None, + url_custom_parameters: list[dict] | None = None, ) -> dict: """Draft an update to an existing campaign — returns preview, does NOT execute. All parameters except campaign_id are optional — only include what you want to change. Geo/language targets are REPLACED entirely (not appended). A location option change shows the current value next to the new one. + URL options: None leaves one unchanged, an empty string or list clears it; + the preview shows the current values and an example click URL. """ from adloop.safety.guards import ( SafetyViolation, @@ -2292,6 +2310,10 @@ def update_campaign( _normalize_location_options(location_option, negative_location_option) ) errors.extend(location_errors) + url_options, url_errors = _normalize_url_options( + tracking_url_template, final_url_suffix, url_custom_parameters + ) + errors.extend(url_errors) campaign_id = str(campaign_id or "").strip() if not campaign_id: @@ -2350,6 +2372,7 @@ def update_campaign( max_cpc, location_option, negative_location_option, + url_options, ]) if not has_any_change: errors.append("No changes specified — provide at least one parameter to update") @@ -2405,6 +2428,23 @@ def update_campaign( if negative_location_option != current_locations["negative_location_option"] else "", )) + current_urls: dict = {} + url_example: dict = {} + if url_options: + current_urls = _current_url_options(config, customer_id, campaign_id) + if current_urls is None: + return {"error": f"Campaign {campaign_id} was not found in this account"} + for field in _URL_OPTION_FIELDS: + if field in url_options and not url_options[field] and current_urls[field]: + warnings.append( + f"Removes the campaign's {field} (currently {current_urls[field]!r})." + ) + if current_urls["final_url"]: + effective = { + field: url_options.get(field, current_urls[field]) + for field in _URL_OPTION_FIELDS + } + url_example = _example_click_urls(current_urls["final_url"], effective) # A budget can be shared by several campaigns, and a portfolio bid # strategy too: say who else a budget change reaches, and that a bidding @@ -2472,6 +2512,10 @@ def update_campaign( current_locations["negative_location_option"] ) changes["negative_location_option"] = negative_location_option + for field in _URL_OPTION_FIELDS: + if field in url_options: + changes[f"current_{field}"] = current_urls[field] + changes[field] = url_options[field] plan = ChangePlan( operation="update_campaign", @@ -2489,6 +2533,8 @@ def update_campaign( preview["preserved_negative_geo_target_ids"] = sorted( preserved_negative_geos ) + if url_example: + preview["example_click"] = url_example return preview @@ -3658,6 +3704,111 @@ def _current_location_options( "negative_location_option": rows[0].get( "campaign.geo_target_type_setting.negative_geo_target_type") or "", } +# Campaign URL options. Validation is deliberately light: the template must +# insert the landing page somewhere, and custom parameters must fit Google's +# lengths. The validate_only dry run catches anything else Google rejects. +# {lpurl}, {lpurl+2}, {lpurl+3}, {unescapedlpurl}, {escapedlpurl}, {escapedlpurl+2}, ... +_LPURL_RE = re.compile(r"\{(un)?(escaped)?lpurl(\+[23])?\}", re.IGNORECASE) +_CUSTOM_PARAMETER_KEY_RE = re.compile(r"^[A-Za-z0-9]{1,8}$") +_CUSTOM_PARAMETER_VALUE_MAX = 250 +_URL_OPTION_FIELDS = ("tracking_url_template", "final_url_suffix", "url_custom_parameters") + + +def _normalize_url_options( + tracking_url_template: str | None, + final_url_suffix: str | None, + url_custom_parameters: list[dict] | None, +) -> tuple[dict, list[str]]: + """The URL options that were given, normalised. None means "leave as is"; + an empty string or list means "clear". Returns (options, errors).""" + options: dict = {} + errors: list[str] = [] + if tracking_url_template is not None: + template = str(tracking_url_template).strip() + if template and not _LPURL_RE.search(template): + errors.append( + "tracking_url_template must contain {lpurl} or a variant ({lpurl+2}, " + "{unescapedlpurl}, {escapedlpurl+3}, ...) so the click still reaches the " + "landing page" + ) + options["tracking_url_template"] = template + if final_url_suffix is not None: + options["final_url_suffix"] = str(final_url_suffix).strip().lstrip("?&") + if url_custom_parameters is not None: + params = [] + for i, item in enumerate(url_custom_parameters): + key = str((item or {}).get("key", "")).strip().lstrip("_") + value = str((item or {}).get("value", "")).strip() + if not key or not value: + errors.append(f"url_custom_parameters[{i}] needs a key and a value") + if key and not _CUSTOM_PARAMETER_KEY_RE.match(key): + errors.append( + f"url_custom_parameters[{i}] key '{key}' must be alphanumeric, " + "at most 8 characters" + ) + if len(value) > _CUSTOM_PARAMETER_VALUE_MAX: + errors.append( + f"url_custom_parameters[{i}] value is longer than " + f"{_CUSTOM_PARAMETER_VALUE_MAX} characters" + ) + params.append({"key": key, "value": value}) + options["url_custom_parameters"] = params + return options, errors + + +def _current_url_options( + config: AdLoopConfig, customer_id: str, campaign_id: str +) -> dict | None: + """The campaign's URL options and one of its ads' final URLs; None when it + does not exist.""" + from adloop.ads.gaql import execute_query + + rows = execute_query(config, customer_id, ( + "SELECT campaign.tracking_url_template, campaign.final_url_suffix, " + "campaign.url_custom_parameters " + f"FROM campaign WHERE campaign.id = {campaign_id}")) + if not rows: + return None + params = rows[0].get("campaign.url_custom_parameters") + current = { + "tracking_url_template": rows[0].get("campaign.tracking_url_template") or "", + "final_url_suffix": rows[0].get("campaign.final_url_suffix") or "", + "url_custom_parameters": [ + {"key": p.get("key", ""), "value": p.get("value", "")} + for p in (params if isinstance(params, list) else []) if isinstance(p, dict) + ], + "final_url": "", + } + ads = execute_query(config, customer_id, ( + "SELECT ad_group_ad.ad.final_urls FROM ad_group_ad " + f"WHERE campaign.id = {campaign_id} AND ad_group_ad.status != 'REMOVED' LIMIT 1")) + final_urls = ads[0].get("ad_group_ad.ad.final_urls") if ads else None + if isinstance(final_urls, list) and final_urls: + current["final_url"] = str(final_urls[0]) + return current + + +def _example_click_urls(final_url: str, options: dict) -> dict: + """What a click looks like with these options, for the preview. + + Approximate on purpose: a template on the ad group, ad or keyword overrides + the campaign's; Google fills ValueTrack parameters such as {keyword} at + click time and escapes {lpurl} by its position, so those are left as they + are, and {lpurl} is shown as the bare final URL. The point is to make a + typo visible before it applies. + """ + suffix = options.get("final_url_suffix") or "" + landing = final_url + if suffix: + landing += ("&" if "?" in final_url else "?") + suffix + example = {"final_url": final_url, "landing_page_url": landing} + template = options.get("tracking_url_template") or "" + if template: + tracking = _LPURL_RE.sub(lambda _m: final_url, template) + for param in options.get("url_custom_parameters") or []: + tracking = tracking.replace("{_" + param["key"] + "}", param["value"]) + example["tracking_url"] = tracking + return example def _validate_campaign( @@ -4397,6 +4548,12 @@ def _apply_create_campaign(client: object, cid: str, changes: dict) -> dict: campaign.geo_target_type_setting.negative_geo_target_type = getattr( client.enums.NegativeGeoTargetTypeEnum, changes["negative_location_option"] ) + if changes.get("tracking_url_template"): + campaign.tracking_url_template = changes["tracking_url_template"] + if changes.get("final_url_suffix"): + campaign.final_url_suffix = changes["final_url_suffix"] + for param in changes.get("url_custom_parameters") or []: + campaign.url_custom_parameters.append(_custom_parameter(client, param)) # EU political advertising declaration — required for campaigns that may # serve in EU countries. This is an ENUM, not a bool. Value 3 means @@ -4522,6 +4679,13 @@ def _apply_create_ad_group(client: object, cid: str, changes: dict) -> dict: return results +def _custom_parameter(client: object, param: dict) -> object: + custom = client.get_type("CustomParameter") + custom.key = param["key"] + custom.value = param["value"] + return custom + + def _apply_update_campaign(client: object, cid: str, changes: dict) -> dict: """Update an existing campaign's settings.""" from google.protobuf import field_mask_pb2 @@ -4541,6 +4705,7 @@ def _apply_update_campaign(client: object, cid: str, changes: dict) -> dict: display_network_enabled = changes.get("display_network_enabled") location_option = changes.get("location_option") negative_location_option = changes.get("negative_location_option") + url_fields = [field for field in _URL_OPTION_FIELDS if field in changes] if ( bs or search_partners_enabled is not None @@ -4548,6 +4713,7 @@ def _apply_update_campaign(client: object, cid: str, changes: dict) -> dict: or changes.get("max_cpc") or location_option or negative_location_option + or url_fields ): campaign_op = client.get_type("MutateOperation") campaign = campaign_op.campaign_operation.update @@ -4604,6 +4770,14 @@ def _apply_update_campaign(client: object, cid: str, changes: dict) -> dict: client.enums.NegativeGeoTargetTypeEnum, negative_location_option ) field_paths.append("geo_target_type_setting.negative_geo_target_type") + # An empty value under the mask clears the field. + if "tracking_url_template" in changes: + campaign.tracking_url_template = changes["tracking_url_template"] + if "final_url_suffix" in changes: + campaign.final_url_suffix = changes["final_url_suffix"] + for param in changes.get("url_custom_parameters") or []: + campaign.url_custom_parameters.append(_custom_parameter(client, param)) + field_paths.extend(url_fields) if field_paths: campaign_op.campaign_operation.update_mask.CopyFrom( diff --git a/src/adloop/rules/adloop.md b/src/adloop/rules/adloop.md index 7a463b9..273364c 100644 --- a/src/adloop/rules/adloop.md +++ b/src/adloop/rules/adloop.md @@ -279,13 +279,13 @@ Conversions that happen offline (a sales call, a lead that turned into a deal) g | Tool | What It Does | Validation | |------|-------------|------------| -| `draft_campaign` | Create full campaign structure (budget + campaign + ad group + keywords + geo/language targeting) | `campaign_name`, `daily_budget`, `bidding_strategy`, `geo_target_ids` (REQUIRED), `language_ids` (REQUIRED), optional `search_partners_enabled`, `display_network_enabled`, `display_expansion_enabled`, optional `max_cpc` for MANUAL_CPC ad-group bids or TARGET_SPEND CPC caps, optional `location_option` / `negative_location_option` | +| `draft_campaign` | Create full campaign structure (budget + campaign + ad group + keywords + geo/language targeting) | `campaign_name`, `daily_budget`, `bidding_strategy`, `geo_target_ids` (REQUIRED), `language_ids` (REQUIRED), optional `search_partners_enabled`, `display_network_enabled`, `display_expansion_enabled`, optional `max_cpc` for MANUAL_CPC ad-group bids or TARGET_SPEND CPC caps, optional `location_option` / `negative_location_option`, optional `tracking_url_template` / `final_url_suffix` / `url_custom_parameters` | | `draft_shopping_campaign` | Create a Standard Shopping campaign for a Merchant Center feed: budget + campaign (PAUSED) + product ad group + Shopping ad + one "All products" product group, atomically | `campaign_name`, `daily_budget`, `merchant_id` (must be linked to the Ads account; checked), `product_bid` (CPC of the product group, required by Google), `geo_target_ids` (REQUIRED), `bidding_strategy` MANUAL_CPC (default) / TARGET_SPEND (Maximize clicks, optional `max_cpc`) / TARGET_ROAS (+ `target_roas`), `campaign_priority` LOW/MEDIUM/HIGH, optional `feed_label`. No keywords, no languages | | `draft_asset_group_assets` | Add and/or remove assets of an existing PMax asset group in one atomic change: text, images, YouTube videos | `asset_group_id`, `headlines` (≤30), `long_headlines` (≤90), `descriptions` (≤90), `business_name` (≤25), `images` (`{"url"}` or `{"upload_id"}`, optional `role` marketing/logo/landscape_logo; marketing images sorted into landscape 1.91:1, square 1:1, portrait 4:5 by ratio), `youtube_video_ids`, `remove_asset_ids` | | `draft_asset_group_signals` | Add search themes or set the audience signal of a PMax asset group, or remove signals | `asset_group_id`, `add_search_themes` (≤80 chars, up to 50 per group), `audience_id` (one per group), `remove_signal_ids` | | `draft_pmax_product_exclusions` | Set the full list of products a retail PMax asset group excludes, on one dimension; everything else stays included | `asset_group_id`, `exclude_values` (the COMPLETE list; `[]` includes everything again), `dimension` item_id (default) / brand / custom_label_0–4 | | `draft_ad_group` | Create a new ad group within an existing campaign (does NOT publish) | `campaign_id` (REQUIRED), `ad_group_name` (REQUIRED), `keywords` (optional list of {text, match_type}), `cpc_bid_micros` (optional) | -| `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets, location options, Search partners, display expansion | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids`, `location_option`, `negative_location_option`, `search_partners_enabled`, `display_network_enabled`, TARGET_SPEND `max_cpc` | +| `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets, location options, Search partners, display expansion, URL options | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids`, `location_option`, `negative_location_option`, `search_partners_enabled`, `display_network_enabled`, TARGET_SPEND `max_cpc`, `tracking_url_template`, `final_url_suffix`, `url_custom_parameters` | | `update_ad_group` | Update ad group name and/or MANUAL_CPC `max_cpc` | `ad_group_id`, optional `ad_group_name`, optional `max_cpc` | | `draft_responsive_search_ad` | Create RSA preview (does NOT publish) | 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), final_url required, path1/path2 (≤15 chars each). Each headline/description may be a plain string (unpinned) or `{"text": "...", "pinned_field": "HEADLINE_1"}` (pinned). Valid pin slots: `HEADLINE_1/2/3`, `DESCRIPTION_1/2`. Several assets may pin to one slot (they rotate there), but every slot must stay fillable: by an asset pinned to it or by a distinct unpinned asset. | | `draft_callouts` | Create callout assets for an ad group, a campaign or the account (does NOT publish) | `campaign_id` or `ad_group_id` (the scope follows the id), or `scope="account"`; `callouts` list with 1-25 chars each | @@ -369,6 +369,7 @@ Same draft → preview → `confirm_and_apply` gate; the plan carries `platform: - Ad-group pause/enable is already handled by `pause_entity` / `enable_entity` with `entity_type="ad_group"`; do not invent a separate pause tool. - `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. - Location options: `location_option` is who the geo targets reach — `PRESENCE` (people in or regularly in the locations), `PRESENCE_OR_INTEREST` (Google's default; also people elsewhere who show interest in them) or `SEARCH_INTEREST`. `negative_location_option` (`PRESENCE` or `PRESENCE_OR_INTEREST`) is who the exclusions exclude. Empty leaves the value as it is. The preview warns when a change widens reach: for `location_option` that is anything but `PRESENCE`, for `negative_location_option` it is `PRESENCE`, because then the exclusions only keep out people physically there, not people interested in the excluded places; `update_campaign` shows the current value as `current_location_option` / `current_negative_location_option`. Read back with `get_campaign_settings`. +- Campaign URL options (`tracking_url_template`, `final_url_suffix`, `url_custom_parameters`): omitted leaves a value unchanged, `""` or `[]` removes it, and `url_custom_parameters` replaces the whole list. The template must contain `{lpurl}` or a variant (`{lpurl+2}`, `{unescapedlpurl}`, `{escapedlpurl+3}`, ...). Custom parameter keys are alphanumeric, at most 8 characters. The `update_campaign` preview shows each current value (`current_tracking_url_template` etc.), warns on a removal, and gives `example_click`: one of the campaign's final URLs with the campaign's options applied. It is approximate: a template on the ad group, ad or keyword overrides the campaign's, and ValueTrack parameters are filled at click time. Check it for typos before approving. Read back with `get_campaign_settings`. - `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"`. - **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. - **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. diff --git a/src/adloop/server.py b/src/adloop/server.py index 1fbb145..9d759aa 100644 --- a/src/adloop/server.py +++ b/src/adloop/server.py @@ -3302,6 +3302,9 @@ def draft_campaign( max_cpc: float = 0, location_option: str = "", negative_location_option: str = "", + tracking_url_template: str | None = None, + final_url_suffix: str | None = None, + url_custom_parameters: _DictListOpt = None, ) -> dict: """Draft a full campaign structure — returns a PREVIEW, does NOT create anything. @@ -3364,6 +3367,15 @@ def draft_campaign( PRESENCE or PRESENCE_OR_INTEREST; empty leaves Google's default, PRESENCE_OR_INTEREST. PRESENCE excludes fewer people (only those physically there), so it widens reach and the preview warns. + tracking_url_template: Campaign tracking template, e.g. + "{lpurl}?utm_source=google&utm_campaign={campaignid}". Must contain + {lpurl} or a variant ({lpurl+2}, {unescapedlpurl}, + {escapedlpurl+3}, ...). Omitted leaves it unset. + final_url_suffix: Parameters appended to the landing page URL, e.g. + "utm_medium=cpc". Omitted leaves it unset. + url_custom_parameters: List of {"key": "grp", "value": "b2c"}, used as + {_grp} in the template. Keys alphanumeric, max 8 characters; values + up to 250. """ from adloop.ads.write import draft_campaign as _impl @@ -3386,6 +3398,9 @@ def draft_campaign( max_cpc=max_cpc, location_option=location_option, negative_location_option=negative_location_option, + tracking_url_template=tracking_url_template, + final_url_suffix=final_url_suffix, + url_custom_parameters=url_custom_parameters, ) @@ -3450,6 +3465,9 @@ def update_campaign( max_cpc: float = 0, location_option: str = "", negative_location_option: str = "", + tracking_url_template: str | None = None, + final_url_suffix: str | None = None, + url_custom_parameters: _DictListOpt = None, ) -> dict: """Draft an update to an existing campaign — returns a PREVIEW, does NOT apply. @@ -3502,6 +3520,17 @@ def update_campaign( location exclusions exclude; the preview shows the current value. PRESENCE excludes fewer people (only those physically there), so it widens reach and the preview warns. Empty leaves it unchanged. + tracking_url_template: Campaign tracking template; must contain {lpurl} + or a variant. Omitted leaves it unchanged; "" removes it. + final_url_suffix: Parameters appended to the landing page URL. Omitted + leaves it unchanged; "" removes it. + url_custom_parameters: List of {"key", "value"}; REPLACES the + campaign's custom parameters. Keys alphanumeric, max 8 characters. + Omitted leaves them unchanged; [] removes them. The preview shows + each current value (current_tracking_url_template etc.), names any + removal, and gives example_click: one of the campaign's final URLs + with the campaign's options applied. It is approximate: a template + on the ad group, ad or keyword overrides the campaign's. """ from adloop.ads.write import update_campaign as _impl @@ -3521,6 +3550,9 @@ def update_campaign( max_cpc=max_cpc, location_option=location_option, negative_location_option=negative_location_option, + tracking_url_template=tracking_url_template, + final_url_suffix=final_url_suffix, + url_custom_parameters=url_custom_parameters, ) diff --git a/tests/test_campaign_url_options.py b/tests/test_campaign_url_options.py new file mode 100644 index 0000000..d9147f7 --- /dev/null +++ b/tests/test_campaign_url_options.py @@ -0,0 +1,293 @@ +"""Campaign URL options on draft_campaign and update_campaign (#105). + +tracking_url_template, final_url_suffix and url_custom_parameters. None leaves +a value unchanged, an empty string or list clears it, and the update preview +shows the current values plus an example click URL. +""" + +from __future__ import annotations + +import pytest + +from adloop.ads import write +from adloop.config import AdLoopConfig, AdsConfig, SafetyConfig +from adloop.safety import preview as preview_store +from tests.test_ads_write import ( + _FakeClient, + _FakeGoogleAdsService, + _FakeMutateOperationResponse, + _FakePathService, +) + +CAMPAIGN = "1001" +TEMPLATE = "{lpurl}?utm_source=google&utm_medium=cpc&utm_content={_grp}" + + +@pytest.fixture(autouse=True) +def _plans(): + preview_store.set_plan_store(preview_store.InMemoryPlanStore()) + yield + preview_store.set_plan_store(preview_store.InMemoryPlanStore()) + + +@pytest.fixture +def config(tmp_path) -> AdLoopConfig: + return AdLoopConfig( + ads=AdsConfig(customer_id="1234567890"), + safety=SafetyConfig(log_file=str(tmp_path / "audit.log"), max_daily_budget=500), + ) + + +def _current(monkeypatch, *, template="", suffix="", params=(), final_url="https://example.com/landing", + found=True): + queries: list[str] = [] + + def execute_query(_config, _cid, query): + queries.append(query) + if "FROM ad_group_ad" in query: + return [{"ad_group_ad.ad.final_urls": [final_url]}] if final_url else [] + if not found: + return [] + return [{ + "campaign.tracking_url_template": template, + "campaign.final_url_suffix": suffix, + "campaign.url_custom_parameters": list(params), + }] + + monkeypatch.setattr("adloop.ads.gaql.execute_query", execute_query) + return queries + + +def _update(config, **kwargs): + return write.update_campaign(config, customer_id="1234567890", campaign_id=CAMPAIGN, **kwargs) + + +def _draft(config, **kwargs): + return write.draft_campaign( + config, + customer_id="1234567890", + campaign_name="Search", + daily_budget=20, + bidding_strategy="TARGET_SPEND", + geo_target_ids=["2840"], + language_ids=["1000"], + **kwargs, + ) + + +class TestValidation: + @pytest.mark.parametrize("tag", [ + "{lpurl}", "{LPURL+2}", "{lpurl+3}", "{unescapedlpurl}", "{escapedlpurl}", + "{escapedlpurl+2}", "{escapedlpurl+3}", "{unescapedlpurl+2}", + ]) + def test_any_lpurl_variant_is_accepted(self, config, tag): + result = _draft(config, tracking_url_template=f"https://t.example/?u={tag}") + + assert result["changes"]["tracking_url_template"] == f"https://t.example/?u={tag}" + + @pytest.mark.parametrize("tag", ["{lpurl+4}", "{lpurl2}", "{escaped_lpurl}"]) + def test_lookalike_tags_are_refused(self, config, tag): + result = _draft(config, tracking_url_template=f"https://t.example/?u={tag}") + + assert result["error"] == "Validation failed" + + def test_the_example_fills_every_variant(self): + example = write._example_click_urls( + "https://example.com/p", {"tracking_url_template": "https://t.example/?u={escapedlpurl+2}"} + ) + + assert example["tracking_url"] == "https://t.example/?u=https://example.com/p" + + def test_a_template_without_lpurl_is_refused(self, config): + result = _draft(config, tracking_url_template="https://example.com/?utm_source=google") + + assert result["error"] == "Validation failed" + assert any("{lpurl}" in d for d in result["details"]) + + def test_custom_parameter_lengths(self, config): + result = _draft(config, url_custom_parameters=[ + {"key": "k" * 9, "value": "v"}, + {"key": "my-key", "value": "v"}, + {"key": "ok", "value": "v" * 251}, + {"key": "", "value": "x"}, + ]) + + details = result["details"] + assert any("'kkkkkkkkk' must be alphanumeric, at most 8" in d for d in details) + assert any("'my-key' must be alphanumeric" in d for d in details) + assert any("longer than 250" in d for d in details) + assert any("needs a key and a value" in d for d in details) + + def test_a_leading_underscore_on_a_key_is_dropped(self, config): + """{_grp} in the template is key "grp"; people often type the underscore.""" + result = _draft(config, url_custom_parameters=[{"key": "_grp", "value": "b2c"}]) + + assert result["changes"]["url_custom_parameters"] == [{"key": "grp", "value": "b2c"}] + + def test_invalid_input_never_reads_the_account(self, config): + # The suite-wide no_live_gaql fixture raises if anything is read. + result = _update(config, tracking_url_template="https://example.com/") + + assert result["error"] == "Validation failed" + + +class TestDraftCampaign: + def test_omitted_leaves_the_preview_unchanged(self, config): + changes = _draft(config)["changes"] + + for field in ("tracking_url_template", "final_url_suffix", "url_custom_parameters"): + assert field not in changes + + def test_empty_values_have_nothing_to_clear_on_a_new_campaign(self, config): + changes = _draft(config, tracking_url_template="", final_url_suffix="", + url_custom_parameters=[])["changes"] + + assert "tracking_url_template" not in changes + assert "url_custom_parameters" not in changes + + def test_all_three_are_planned(self, config): + changes = _draft( + config, tracking_url_template=TEMPLATE, final_url_suffix="?gclsrc=aw", + url_custom_parameters=[{"key": "grp", "value": "b2c"}], + )["changes"] + + assert changes["tracking_url_template"] == TEMPLATE + assert changes["final_url_suffix"] == "gclsrc=aw" + assert changes["url_custom_parameters"] == [{"key": "grp", "value": "b2c"}] + + +class TestUpdateCampaign: + def test_preview_shows_current_values_and_an_example_click(self, config, monkeypatch): + _current(monkeypatch, suffix="a=1") + + result = _update(config, tracking_url_template=TEMPLATE, + url_custom_parameters=[{"key": "grp", "value": "b2c"}]) + + changes = result["changes"] + assert changes["current_tracking_url_template"] == "" + assert changes["tracking_url_template"] == TEMPLATE + assert changes["current_url_custom_parameters"] == [] + assert "final_url_suffix" not in changes # not given: unchanged + assert result["example_click"] == { + "final_url": "https://example.com/landing", + "landing_page_url": "https://example.com/landing?a=1", + "tracking_url": ( + "https://example.com/landing?utm_source=google&utm_medium=cpc&utm_content=b2c" + ), + } + assert result["requires_double_confirm"] is False + assert "warnings" not in result + + def test_an_empty_value_is_shown_as_a_removal(self, config, monkeypatch): + _current(monkeypatch, template=TEMPLATE, params=[{"key": "grp", "value": "b2c"}]) + + result = _update(config, tracking_url_template="", url_custom_parameters=[]) + + assert result["changes"]["tracking_url_template"] == "" + assert result["changes"]["current_tracking_url_template"] == TEMPLATE + assert result["changes"]["url_custom_parameters"] == [] + assert any("Removes the campaign's tracking_url_template" in w for w in result["warnings"]) + assert any("Removes the campaign's url_custom_parameters" in w for w in result["warnings"]) + assert "tracking_url" not in result["example_click"] + + def test_clearing_something_already_empty_is_not_a_removal(self, config, monkeypatch): + _current(monkeypatch) + + result = _update(config, final_url_suffix="") + + assert "warnings" not in result + + def test_no_ads_yet_means_no_example(self, config, monkeypatch): + _current(monkeypatch, final_url="") + + result = _update(config, final_url_suffix="utm_medium=cpc") + + assert "example_click" not in result + assert result["changes"]["final_url_suffix"] == "utm_medium=cpc" + + def test_unknown_campaign(self, config, monkeypatch): + _current(monkeypatch, found=False) + + assert _update(config, final_url_suffix="a=1") == { + "error": f"Campaign {CAMPAIGN} was not found in this account" + } + + +def _client(service): + return _FakeClient({ + "GoogleAdsService": service, + "CampaignService": _FakePathService("campaigns"), + "CampaignBudgetService": _FakePathService("campaignBudgets"), + "AdGroupService": _FakePathService("adGroups"), + }) + + +class TestApply: + def test_create_sets_all_three(self): + service = _FakeGoogleAdsService([ + _FakeMutateOperationResponse("campaign_budget_result", "customers/1/campaignBudgets/1"), + _FakeMutateOperationResponse("campaign_result", "customers/1/campaigns/2"), + _FakeMutateOperationResponse("ad_group_result", "customers/1/adGroups/3"), + ]) + + write._apply_create_campaign(_client(service), "1234567890", { + "campaign_name": "Search", + "daily_budget": 20, + "bidding_strategy": "TARGET_SPEND", + "channel_type": "SEARCH", + "geo_target_ids": [], + "language_ids": [], + "tracking_url_template": TEMPLATE, + "final_url_suffix": "gclsrc=aw", + "url_custom_parameters": [{"key": "grp", "value": "b2c"}], + }) + + campaign = service.operations[1].campaign_operation.create + assert campaign.tracking_url_template == TEMPLATE + assert campaign.final_url_suffix == "gclsrc=aw" + assert [(p.key, p.value) for p in campaign.url_custom_parameters] == [("grp", "b2c")] + + def test_update_masks_only_the_fields_given(self): + service = _FakeGoogleAdsService( + [_FakeMutateOperationResponse("campaign_result", "customers/1/campaigns/1")] + ) + + write._apply_update_campaign(_client(service), "1234567890", { + "campaign_id": CAMPAIGN, + "current_tracking_url_template": "", + "tracking_url_template": TEMPLATE, + }) + + operation = service.operations[0].campaign_operation + assert list(operation.update_mask.paths) == ["tracking_url_template"] + assert operation.update.tracking_url_template == TEMPLATE + + def test_update_clears_with_empty_values_under_the_mask(self): + service = _FakeGoogleAdsService( + [_FakeMutateOperationResponse("campaign_result", "customers/1/campaigns/1")] + ) + + write._apply_update_campaign(_client(service), "1234567890", { + "campaign_id": CAMPAIGN, + "tracking_url_template": "", + "final_url_suffix": "", + "url_custom_parameters": [], + }) + + operation = service.operations[0].campaign_operation + assert set(operation.update_mask.paths) == { + "tracking_url_template", "final_url_suffix", "url_custom_parameters", + } + assert operation.update.tracking_url_template == "" + assert list(operation.update.url_custom_parameters) == [] + + +@pytest.mark.asyncio +async def test_both_tools_document_the_parameters(): + from adloop.server import mcp + + tools = {t.name: t for t in await mcp.list_tools()} + for name in ("draft_campaign", "update_campaign"): + properties = tools[name].parameters["properties"] + for param in ("tracking_url_template", "final_url_suffix", "url_custom_parameters"): + assert properties[param].get("description"), (name, param)