diff --git a/src/adloop/ads/conversion_actions.py b/src/adloop/ads/conversion_actions.py new file mode 100644 index 0000000..3f3edd6 --- /dev/null +++ b/src/adloop/ads/conversion_actions.py @@ -0,0 +1,1067 @@ +"""Conversion-action write tools — Google Ads ConversionActionService. + +All operations follow the AdLoop safety pattern: + 1. draft_* → creates a ChangePlan, stores it, returns plan_id + 2. confirm_and_apply(plan_id) → executes via the Google Ads API + +Supported types (conversion_action.type): + AD_CALL — calls from Call assets in ads + WEBSITE_CALL — Google Forwarding Number calls (uses + phone_call_duration_seconds threshold) + WEBPAGE — page-load conversions with code-based tracking + WEBPAGE_CODELESS — page-load conversions detected by Ads (no snippet) + GOOGLE_ANALYTICS_4_CUSTOM — imported from GA4 (custom event) + GOOGLE_ANALYTICS_4_PURCHASE — imported from GA4 (purchase event) + UPLOAD_CALLS, UPLOAD_CLICKS — offline imports + +NOT supported here (Google manages them — mutations are rejected with +MUTATE_NOT_ALLOWED): + SMART_CAMPAIGN_* — auto-created by Smart Campaigns + GOOGLE_HOSTED — auto-created by Google Business Profile / LSA links +""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from adloop.ads.enums import enum_names + +if TYPE_CHECKING: + from adloop.config import AdLoopConfig + + +# Pulled dynamically from the google-ads SDK at the API version we're +# pinned to (see adloop.ads.client.GOOGLE_ADS_API_VERSION). Keeps the +# validators in sync with whatever the SDK supports — no hand-maintained +# parallel lists to drift. +_VALID_TYPES = enum_names("ConversionActionTypeEnum") +_VALID_CATEGORIES = enum_names("ConversionActionCategoryEnum") +_VALID_COUNTING_TYPES = enum_names("ConversionActionCountingTypeEnum") +_VALID_ATTRIBUTION_MODELS = enum_names("AttributionModelEnum") + +# These types ARE in ConversionActionTypeEnum but Google rejects mutations +# on them with MUTATE_NOT_ALLOWED (they're auto-created by Smart Campaigns, +# Local Services, and Business Profile links). We don't filter them from +# `_VALID_TYPES` — the SDK accepts them syntactically — but warn callers. +_AUTO_MANAGED_TYPES = frozenset({ + "SMART_CAMPAIGN_TRACKED_CALLS", + "SMART_CAMPAIGN_MAP_DIRECTIONS", + "SMART_CAMPAIGN_MAP_CLICKS_TO_CALL", + "SMART_CAMPAIGN_AD_CLICKS_TO_CALL", + "GOOGLE_HOSTED", +}) + + +# --------------------------------------------------------------------------- +# Validators +# --------------------------------------------------------------------------- + + +def _validate_create_inputs( + *, + name: str, + type_: str, + category: str, + counting_type: str, + default_value: float, + currency_code: str, + phone_call_duration_seconds: int, + click_through_window_days: int, + view_through_window_days: int, + attribution_model: str, +) -> list[str]: + errors: list[str] = [] + if not name or not name.strip(): + errors.append("name is required") + if type_ not in _VALID_TYPES: + errors.append( + f"type '{type_}' invalid; valid: {sorted(_VALID_TYPES)}" + ) + if category and category not in _VALID_CATEGORIES: + errors.append( + f"category '{category}' invalid; valid: {sorted(_VALID_CATEGORIES)}" + ) + if counting_type and counting_type not in _VALID_COUNTING_TYPES: + errors.append( + f"counting_type '{counting_type}' invalid; valid: " + f"{sorted(_VALID_COUNTING_TYPES)}" + ) + if default_value < 0: + errors.append("default_value must be >= 0") + if currency_code and len(currency_code) != 3: + errors.append( + f"currency_code '{currency_code}' must be a 3-letter ISO code" + ) + if phone_call_duration_seconds and phone_call_duration_seconds < 0: + errors.append("phone_call_duration_seconds must be >= 0") + if (click_through_window_days + and not (1 <= click_through_window_days <= 90)): + errors.append( + "click_through_window_days must be between 1 and 90" + ) + if (view_through_window_days + and not (1 <= view_through_window_days <= 30)): + errors.append( + "view_through_window_days must be between 1 and 30" + ) + if attribution_model and attribution_model not in _VALID_ATTRIBUTION_MODELS: + errors.append( + f"attribution_model '{attribution_model}' invalid; valid: " + f"{sorted(_VALID_ATTRIBUTION_MODELS)}" + ) + return errors + + +def _validate_update_inputs( + *, + counting_type: str, + default_value: float, + currency_code: str, + phone_call_duration_seconds: int, + click_through_window_days: int, + view_through_window_days: int, + attribution_model: str, +) -> list[str]: + errors: list[str] = [] + if counting_type and counting_type not in _VALID_COUNTING_TYPES: + errors.append( + f"counting_type '{counting_type}' invalid; valid: " + f"{sorted(_VALID_COUNTING_TYPES)}" + ) + if default_value < 0: + errors.append("default_value must be >= 0") + if currency_code and len(currency_code) != 3: + errors.append( + f"currency_code '{currency_code}' must be a 3-letter ISO code" + ) + if phone_call_duration_seconds and phone_call_duration_seconds < 0: + errors.append("phone_call_duration_seconds must be >= 0") + if (click_through_window_days + and not (1 <= click_through_window_days <= 90)): + errors.append( + "click_through_window_days must be between 1 and 90" + ) + if (view_through_window_days + and not (1 <= view_through_window_days <= 30)): + errors.append( + "view_through_window_days must be between 1 and 30" + ) + if attribution_model and attribution_model not in _VALID_ATTRIBUTION_MODELS: + errors.append( + f"attribution_model '{attribution_model}' invalid; valid: " + f"{sorted(_VALID_ATTRIBUTION_MODELS)}" + ) + return errors + + +# --------------------------------------------------------------------------- +# Draft tools (return PREVIEW + plan_id) +# --------------------------------------------------------------------------- + + +def draft_create_conversion_action( + config: AdLoopConfig, + *, + customer_id: str = "", + name: str, + type_: str, + category: str = "DEFAULT", + default_value: float = 0, + currency_code: str = "USD", + always_use_default_value: bool = False, + counting_type: str = "ONE_PER_CLICK", + phone_call_duration_seconds: int = 0, + primary_for_goal: bool = True, + include_in_conversions_metric: bool = True, + click_through_window_days: int = 0, + view_through_window_days: int = 0, + attribution_model: str = "", +) -> dict: + """Draft a new ConversionAction — returns a PREVIEW. + + type_: the ConversionAction.type enum value (AD_CALL, WEBSITE_CALL, + WEBPAGE, WEBPAGE_CODELESS, GOOGLE_ANALYTICS_4_CUSTOM, etc.). + category: the conversion category (PHONE_CALL_LEAD, SUBMIT_LEAD_FORM, + PURCHASE, etc.). Defaults to DEFAULT. + default_value: monetary value attributed to each conversion. Set 250 + for high-intent lead actions (per BGI Lead Conversion playbook). + always_use_default_value: when True, transaction values from the + snippet/import are ignored and default_value is used instead. + counting_type: ONE_PER_CLICK (recommended for lead gen — one click, + one conversion no matter how many events fire) or MANY_PER_CLICK + (better for ecommerce where multiple purchases per click are real). + phone_call_duration_seconds: ONLY meaningful for PHONE_CALL_LEAD + category. The call must last at least this many seconds to count. + primary_for_goal: True = drives Smart Bidding optimization; + False = Secondary (records but doesn't affect bidding). + include_in_conversions_metric: True (default) = appears in the + "Conversions" column; False = "All conversions" only. + click_through_window_days / view_through_window_days: attribution + windows. 30/1 is the typical lead-gen pair. + attribution_model: leave empty for the default. For data-driven, + pass GOOGLE_SEARCH_ATTRIBUTION_DATA_DRIVEN. + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("create_conversion_action", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + errors = _validate_create_inputs( + name=name, + type_=type_, + category=category, + counting_type=counting_type, + default_value=default_value, + currency_code=currency_code, + phone_call_duration_seconds=phone_call_duration_seconds, + click_through_window_days=click_through_window_days, + view_through_window_days=view_through_window_days, + attribution_model=attribution_model, + ) + if errors: + return {"error": "Validation failed", "details": errors} + + plan = ChangePlan( + operation="create_conversion_action", + entity_type="conversion_action", + entity_id="", + customer_id=customer_id, + changes={ + "name": name.strip(), + "type": type_, + "category": category, + "default_value": float(default_value), + "currency_code": currency_code.upper(), + "always_use_default_value": bool(always_use_default_value), + "counting_type": counting_type, + "phone_call_duration_seconds": int(phone_call_duration_seconds or 0), + "primary_for_goal": bool(primary_for_goal), + "include_in_conversions_metric": bool(include_in_conversions_metric), + "click_through_window_days": int(click_through_window_days or 0), + "view_through_window_days": int(view_through_window_days or 0), + "attribution_model": attribution_model, + }, + ) + store_plan(plan) + return plan.to_preview() + + +def draft_update_conversion_action( + config: AdLoopConfig, + *, + customer_id: str = "", + conversion_action_id: str, + name: str = "", + primary_for_goal: bool | None = None, + default_value: float = 0, + currency_code: str = "", + always_use_default_value: bool | None = None, + counting_type: str = "", + phone_call_duration_seconds: int = 0, + include_in_conversions_metric: bool | None = None, + click_through_window_days: int = 0, + view_through_window_days: int = 0, + attribution_model: str = "", +) -> dict: + """Draft a partial UPDATE of an existing ConversionAction — returns PREVIEW. + + Only the parameters you pass non-empty/non-default will be sent to the + API. Use this to rename, demote a Primary to Secondary, change value, + adjust the call-duration threshold, or change attribution settings. + + conversion_action_id: numeric ID. Find via: + SELECT conversion_action.id, conversion_action.name FROM conversion_action + + Note: Google rejects mutations on SMART_CAMPAIGN_* and GOOGLE_HOSTED + types with MUTATE_NOT_ALLOWED. Catch and report this at apply time. + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("update_conversion_action", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + if not conversion_action_id: + return {"error": "conversion_action_id is required"} + + errors = _validate_update_inputs( + counting_type=counting_type, + default_value=default_value, + currency_code=currency_code, + phone_call_duration_seconds=phone_call_duration_seconds, + click_through_window_days=click_through_window_days, + view_through_window_days=view_through_window_days, + attribution_model=attribution_model, + ) + if errors: + return {"error": "Validation failed", "details": errors} + + # Track which fields the caller actually wants to update so we build + # the right field_mask at apply time. + changes: dict = {"conversion_action_id": str(conversion_action_id)} + if name: + changes["name"] = name.strip() + if primary_for_goal is not None: + changes["primary_for_goal"] = bool(primary_for_goal) + if default_value: + changes["default_value"] = float(default_value) + if currency_code: + changes["currency_code"] = currency_code.upper() + if always_use_default_value is not None: + changes["always_use_default_value"] = bool(always_use_default_value) + if counting_type: + changes["counting_type"] = counting_type + if phone_call_duration_seconds: + changes["phone_call_duration_seconds"] = int(phone_call_duration_seconds) + if include_in_conversions_metric is not None: + changes["include_in_conversions_metric"] = bool( + include_in_conversions_metric + ) + if click_through_window_days: + changes["click_through_window_days"] = int(click_through_window_days) + if view_through_window_days: + changes["view_through_window_days"] = int(view_through_window_days) + if attribution_model: + changes["attribution_model"] = attribution_model + + if len(changes) == 1: # only conversion_action_id + return {"error": "No fields to update"} + + plan = ChangePlan( + operation="update_conversion_action", + entity_type="conversion_action", + entity_id=str(conversion_action_id), + customer_id=customer_id, + changes=changes, + ) + store_plan(plan) + return plan.to_preview() + + +def draft_remove_conversion_action( + config: AdLoopConfig, + *, + customer_id: str = "", + conversion_action_id: str, +) -> dict: + """Draft a REMOVAL of a ConversionAction — returns PREVIEW. + + Removed conversion actions stop counting and disappear from goal lists. + Historical data is preserved. SMART_CAMPAIGN_* and GOOGLE_HOSTED types + cannot be removed via API (Google manages them); the apply will fail + with MUTATE_NOT_ALLOWED for those. + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("remove_conversion_action", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + if not conversion_action_id: + return {"error": "conversion_action_id is required"} + + plan = ChangePlan( + operation="remove_conversion_action", + entity_type="conversion_action", + entity_id=str(conversion_action_id), + customer_id=customer_id, + changes={"conversion_action_id": str(conversion_action_id)}, + ) + store_plan(plan) + preview = plan.to_preview() + preview["warnings"] = [ + "Removing a ConversionAction is irreversible. Smart Campaign / GBP-" + "managed types reject mutation with MUTATE_NOT_ALLOWED." + ] + return preview + + +# --------------------------------------------------------------------------- +# Apply handlers +# --------------------------------------------------------------------------- + + +def _apply_create_conversion_action(client: object, cid: str, changes: dict) -> dict: + """Create a new ConversionAction.""" + svc = client.get_service("ConversionActionService") + op = client.get_type("ConversionActionOperation") + ca = op.create + ca.name = changes["name"] + ca.type_ = getattr(client.enums.ConversionActionTypeEnum, changes["type"]) + ca.category = getattr( + client.enums.ConversionActionCategoryEnum, changes["category"] + ) + ca.status = client.enums.ConversionActionStatusEnum.ENABLED + ca.counting_type = getattr( + client.enums.ConversionActionCountingTypeEnum, changes["counting_type"] + ) + ca.value_settings.default_value = changes["default_value"] + ca.value_settings.default_currency_code = changes["currency_code"] + ca.value_settings.always_use_default_value = changes["always_use_default_value"] + ca.primary_for_goal = changes["primary_for_goal"] + ca.include_in_conversions_metric = changes["include_in_conversions_metric"] + if changes.get("phone_call_duration_seconds"): + ca.phone_call_duration_seconds = changes["phone_call_duration_seconds"] + if changes.get("click_through_window_days"): + ca.click_through_lookback_window_days = changes["click_through_window_days"] + if changes.get("view_through_window_days"): + ca.view_through_lookback_window_days = changes["view_through_window_days"] + if changes.get("attribution_model"): + ca.attribution_model_settings.attribution_model = getattr( + client.enums.AttributionModelEnum, changes["attribution_model"] + ) + + response = svc.mutate_conversion_actions( + customer_id=cid, operations=[op] + ) + return {"resource_name": response.results[0].resource_name} + + +def _apply_update_conversion_action(client: object, cid: str, changes: dict) -> dict: + """Partial update of an existing ConversionAction. + + Builds a FieldMask listing only the fields the caller wanted to update. + """ + from google.protobuf import field_mask_pb2 + + svc = client.get_service("ConversionActionService") + op = client.get_type("ConversionActionOperation") + ca = op.update + ca.resource_name = svc.conversion_action_path( + cid, changes["conversion_action_id"] + ) + + paths: list[str] = [] + + if "name" in changes: + ca.name = changes["name"] + paths.append("name") + if "primary_for_goal" in changes: + ca.primary_for_goal = changes["primary_for_goal"] + paths.append("primary_for_goal") + if "default_value" in changes: + ca.value_settings.default_value = changes["default_value"] + paths.append("value_settings.default_value") + if "currency_code" in changes: + ca.value_settings.default_currency_code = changes["currency_code"] + paths.append("value_settings.default_currency_code") + if "always_use_default_value" in changes: + ca.value_settings.always_use_default_value = changes["always_use_default_value"] + paths.append("value_settings.always_use_default_value") + if "counting_type" in changes: + ca.counting_type = getattr( + client.enums.ConversionActionCountingTypeEnum, changes["counting_type"] + ) + paths.append("counting_type") + if "phone_call_duration_seconds" in changes: + ca.phone_call_duration_seconds = changes["phone_call_duration_seconds"] + paths.append("phone_call_duration_seconds") + if "include_in_conversions_metric" in changes: + ca.include_in_conversions_metric = changes["include_in_conversions_metric"] + paths.append("include_in_conversions_metric") + if "click_through_window_days" in changes: + ca.click_through_lookback_window_days = changes["click_through_window_days"] + paths.append("click_through_lookback_window_days") + if "view_through_window_days" in changes: + ca.view_through_lookback_window_days = changes["view_through_window_days"] + paths.append("view_through_lookback_window_days") + if "attribution_model" in changes: + ca.attribution_model_settings.attribution_model = getattr( + client.enums.AttributionModelEnum, changes["attribution_model"] + ) + paths.append("attribution_model_settings.attribution_model") + + op.update_mask.CopyFrom(field_mask_pb2.FieldMask(paths=paths)) + response = svc.mutate_conversion_actions( + customer_id=cid, operations=[op] + ) + return {"resource_name": response.results[0].resource_name} + + +def _apply_remove_conversion_action(client: object, cid: str, changes: dict) -> dict: + """Remove a ConversionAction (sets status=REMOVED).""" + svc = client.get_service("ConversionActionService") + op = client.get_type("ConversionActionOperation") + op.remove = svc.conversion_action_path( + cid, changes["conversion_action_id"] + ) + response = svc.mutate_conversion_actions( + customer_id=cid, operations=[op] + ) + return {"resource_name": response.results[0].resource_name} + + +# --------------------------------------------------------------------------- +# Call-conversion CSV upload — ConversionUploadService.UploadCallConversions +# --------------------------------------------------------------------------- + +_EXPECTED_CALL_HEADERS = [ + "Caller's Phone Number", + "Call Start Time", + "Conversion Name", + "Conversion Time", + "Conversion Value", + "Conversion Currency", +] + + +def _normalize_call_timestamp(ts: str) -> str: + """Google Ads API wants 'yyyy-mm-dd HH:MM:SS+|-HH:MM'. + + Our CSV writes ISO 8601 with 'T' separator and trailing 'Z' + (e.g. '2026-02-26T16:49:44.567Z'). Convert: strip fractional + seconds, replace 'T' with space, replace 'Z' with '+00:00'. + """ + s = (ts or "").strip() + if not s: + return s + # Drop fractional seconds if present + if "." in s: + head, tail = s.split(".", 1) + tz = "" + for marker in ("+", "-", "Z"): + idx = tail.find(marker) + if idx >= 0: + tz = tail[idx:] + break + s = head + (tz or "") + s = s.replace("T", " ") + if s.endswith("Z"): + s = s[:-1] + "+00:00" + return s + + +def _parse_call_conversion_csv(csv_path: str) -> tuple[list[dict], list[str]]: + """Read the AdLoop-generated phone-conversions CSV. + + Returns (rows, errors). Rows are dicts keyed by canonical column name. + Skips the optional `Parameters:TimeZone=...` row at the top. + """ + import csv + from pathlib import Path + + errors: list[str] = [] + path = Path(csv_path).expanduser() + if not path.exists(): + return [], [f"CSV not found at {path}"] + + with path.open("r", newline="") as f: + reader = csv.reader(f) + rows_iter = iter(reader) + # First non-Parameters row is the header + header: list[str] | None = None + for raw in rows_iter: + if not raw: + continue + first = (raw[0] or "").strip() + if first.startswith("Parameters:") or first.startswith("#") or first.startswith("###"): + continue + header = [c.strip() for c in raw] + break + if header is None: + return [], ["CSV is empty (no header row found)"] + + missing = [c for c in _EXPECTED_CALL_HEADERS if c not in header] + if missing: + errors.append( + f"CSV missing required columns: {missing}. Got: {header}" + ) + return [], errors + + col = {name: header.index(name) for name in _EXPECTED_CALL_HEADERS} + out: list[dict] = [] + for line_num, raw in enumerate(rows_iter, start=2): + if not raw or all((c or "").strip() == "" for c in raw): + continue + if (raw[0] or "").strip().startswith(("#", "Parameters:")): + continue + try: + value_str = raw[col["Conversion Value"]].strip() + value = float(value_str) if value_str else 0.0 + except (ValueError, IndexError): + errors.append(f"Row {line_num}: invalid Conversion Value") + continue + out.append({ + "caller_id": raw[col["Caller's Phone Number"]].strip(), + "call_start_time": _normalize_call_timestamp( + raw[col["Call Start Time"]] + ), + "conversion_name": raw[col["Conversion Name"]].strip(), + "conversion_time": _normalize_call_timestamp( + raw[col["Conversion Time"]] + ), + "conversion_value": value, + "currency_code": ( + raw[col["Conversion Currency"]].strip().upper() or "USD" + ), + }) + return out, errors + + +def draft_upload_call_conversions( + config: AdLoopConfig, + *, + customer_id: str = "", + csv_path: str, + partial_failure: bool = True, +) -> dict: + """Draft an upload of call conversions from CSV — returns a PREVIEW. + + Reads any CSV matching Google Ads' call-upload schema and previews + what would be sent to ConversionUploadService.UploadCallConversions. + + The CSV must have columns: Caller's Phone Number, Call Start Time, + Conversion Name, Conversion Time, Conversion Value, Conversion Currency. + An optional `Parameters:TimeZone=...` row at the top is ignored. + + The `Conversion Name` value MUST exactly match an existing + conversion action whose type is UPLOAD_CALLS. + + partial_failure (default True) lets Google accept the rows that + parse successfully and report only the bad ones — recommended. + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("upload_call_conversions", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + rows, parse_errors = _parse_call_conversion_csv(csv_path) + if parse_errors and not rows: + return {"error": "CSV parse failed", "details": parse_errors} + + if not rows: + return {"error": "CSV contained zero conversion rows"} + + distinct_actions = sorted({r["conversion_name"] for r in rows}) + total_value = sum(r["conversion_value"] for r in rows) + sample = rows[:3] + + plan = ChangePlan( + operation="upload_call_conversions", + entity_type="call_conversion_batch", + entity_id=str(len(rows)), + customer_id=customer_id, + changes={ + "csv_path": str(csv_path), + "row_count": len(rows), + "total_value": round(total_value, 2), + "currency_hint": rows[0]["currency_code"] if rows else "USD", + "distinct_conversion_actions": distinct_actions, + "partial_failure": bool(partial_failure), + "parse_warnings": parse_errors, + "sample_rows": [ + { + "caller_id": r["caller_id"], + "call_start_time": r["call_start_time"], + "conversion_name": r["conversion_name"], + "conversion_value": r["conversion_value"], + } + for r in sample + ], + }, + ) + store_plan(plan) + return plan.to_preview() + + +def _resolve_conversion_action_ids( + client: object, cid: str, names: list[str] +) -> dict[str, str]: + """Look up conversion_action.resource_name for a list of names. + + Raises ValueError if any name isn't found OR isn't of type UPLOAD_CALLS. + """ + if not names: + return {} + + ga_service = client.get_service("GoogleAdsService") + quoted = ", ".join(f"'{n.replace(chr(39), chr(39) + chr(39))}'" for n in names) + query = ( + "SELECT conversion_action.id, conversion_action.name, " + "conversion_action.resource_name, conversion_action.type, " + "conversion_action.status " + "FROM conversion_action " + f"WHERE conversion_action.name IN ({quoted}) " + "AND conversion_action.status != 'REMOVED'" + ) + response = ga_service.search(customer_id=cid, query=query) + + mapping: dict[str, str] = {} + bad_types: list[str] = [] + for row in response: + ca = row.conversion_action + ca_type = ca.type_.name if hasattr(ca.type_, "name") else str(ca.type_) + if ca_type != "UPLOAD_CALLS": + bad_types.append(f"{ca.name} (type={ca_type})") + continue + mapping[ca.name] = ca.resource_name + + missing = [n for n in names if n not in mapping] + if bad_types: + raise ValueError( + "Some conversion actions exist but are not of type UPLOAD_CALLS " + f"(needed for call uploads): {bad_types}. " + "Create a new conversion action via " + "draft_create_conversion_action(type_='UPLOAD_CALLS', ...) " + "or via Google Ads UI: Tools → Conversions → New → " + "Import → Other data sources → Track conversions from calls." + ) + if missing: + raise ValueError( + f"Conversion action(s) not found: {missing}. " + "Verify the 'Conversion Name' column in the CSV matches exactly." + ) + return mapping + + +def _apply_upload_call_conversions( + client: object, cid: str, changes: dict +) -> dict: + """Execute the call-conversion upload via ConversionUploadService. + + Returns counts of successes / failures plus per-row error details. + """ + rows, parse_errors = _parse_call_conversion_csv(changes["csv_path"]) + if not rows: + return { + "error": "CSV produced zero parseable rows at apply time", + "parse_errors": parse_errors, + } + + distinct = sorted({r["conversion_name"] for r in rows}) + action_resources = _resolve_conversion_action_ids(client, cid, distinct) + + conversion_type = client.get_type("CallConversion") + payload: list = [] + for r in rows: + cc = conversion_type.__class__() + cc.caller_id = r["caller_id"] + cc.call_start_date_time = r["call_start_time"] + cc.conversion_action = action_resources[r["conversion_name"]] + cc.conversion_date_time = r["conversion_time"] + cc.conversion_value = float(r["conversion_value"]) + cc.currency_code = r["currency_code"] + payload.append(cc) + + upload_service = client.get_service("ConversionUploadService") + response = upload_service.upload_call_conversions( + customer_id=cid, + conversions=payload, + partial_failure=bool(changes.get("partial_failure", True)), + ) + + results = list(response.results) + success_count = sum(1 for r in results if r.caller_id) + failure_count = len(results) - success_count + + row_errors: list[dict] = [] + partial = getattr(response, "partial_failure_error", None) + if partial and partial.message: + row_errors.append({ + "type": "partial_failure", + "message": partial.message, + "code": getattr(partial, "code", None), + }) + + return { + "uploaded_total": len(payload), + "success_count": success_count, + "failure_count": failure_count, + "conversion_actions_used": action_resources, + "row_errors": row_errors, + "parse_warnings": parse_errors, + } + + +# --------------------------------------------------------------------------- +# Enhanced Conversions for Leads — UploadClickConversions w/ user_identifiers +# --------------------------------------------------------------------------- + +_EXPECTED_EC_HEADERS = [ + "Email", + "Phone Number", + "First Name", + "Last Name", + "Conversion Name", + "Conversion Time", + "Conversion Value", + "Conversion Currency", +] + + +def _parse_ec_for_leads_csv(csv_path: str) -> tuple[list[dict], list[str]]: + """Parse the AdLoop EC-for-Leads CSV (already SHA-256 hashed PII).""" + import csv + from pathlib import Path + + errors: list[str] = [] + path = Path(csv_path).expanduser() + if not path.exists(): + return [], [f"CSV not found at {path}"] + + with path.open("r", newline="") as f: + reader = csv.reader(f) + rows_iter = iter(reader) + header: list[str] | None = None + for raw in rows_iter: + if not raw: + continue + first = (raw[0] or "").strip() + if first.startswith("Parameters:") or first.startswith("#"): + continue + header = [c.strip() for c in raw] + break + if header is None: + return [], ["CSV is empty"] + + missing = [c for c in _EXPECTED_EC_HEADERS if c not in header] + if missing: + return [], [ + f"CSV missing required columns: {missing}. Got: {header}" + ] + col = {n: header.index(n) for n in _EXPECTED_EC_HEADERS} + out: list[dict] = [] + for line_num, raw in enumerate(rows_iter, start=2): + if not raw or all((c or "").strip() == "" for c in raw): + continue + try: + value_str = raw[col["Conversion Value"]].strip() + value = float(value_str) if value_str else 0.0 + except (ValueError, IndexError): + errors.append(f"Row {line_num}: invalid Conversion Value") + continue + out.append({ + "email_sha256": raw[col["Email"]].strip(), + "phone_sha256": raw[col["Phone Number"]].strip(), + "first_name_sha256": raw[col["First Name"]].strip(), + "last_name_sha256": raw[col["Last Name"]].strip(), + "conversion_name": raw[col["Conversion Name"]].strip(), + "conversion_time": _normalize_call_timestamp( + raw[col["Conversion Time"]] + ), + "conversion_value": value, + "currency_code": ( + raw[col["Conversion Currency"]].strip().upper() or "USD" + ), + }) + return out, errors + + +def draft_upload_enhanced_conversions_for_leads( + config: AdLoopConfig, + *, + customer_id: str = "", + csv_path: str, + partial_failure: bool = True, +) -> dict: + """Draft an Enhanced Conversions for Leads upload — returns PREVIEW. + + Reads any SHA-256-hashed PII CSV matching Google Ads' EC for Leads + schema and previews what will be pushed via + ConversionUploadService.UploadClickConversions with user_identifiers + populated. + + The target conversion action must be of type UPLOAD_CLICKS (EC for + Leads layers user-identifier matching on top of click conversions). + Works retroactively — no "action must be created before call" constraint + like UPLOAD_CALLS has. + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation( + "upload_enhanced_conversions_for_leads", config.safety + ) + except SafetyViolation as e: + return {"error": str(e)} + + rows, parse_errors = _parse_ec_for_leads_csv(csv_path) + if parse_errors and not rows: + return {"error": "CSV parse failed", "details": parse_errors} + if not rows: + return {"error": "CSV contained zero conversion rows"} + + distinct_actions = sorted({r["conversion_name"] for r in rows}) + total_value = sum(r["conversion_value"] for r in rows) + with_email = sum(1 for r in rows if r["email_sha256"]) + with_phone = sum(1 for r in rows if r["phone_sha256"]) + sample = rows[:3] + + plan = ChangePlan( + operation="upload_enhanced_conversions_for_leads", + entity_type="ec_for_leads_batch", + entity_id=str(len(rows)), + customer_id=customer_id, + changes={ + "csv_path": str(csv_path), + "row_count": len(rows), + "total_value": round(total_value, 2), + "currency_hint": rows[0]["currency_code"] if rows else "USD", + "rows_with_email": with_email, + "rows_with_phone": with_phone, + "distinct_conversion_actions": distinct_actions, + "partial_failure": bool(partial_failure), + "parse_warnings": parse_errors, + "sample_rows": [ + { + "email_sha256": r["email_sha256"][:16] + "..." + if r["email_sha256"] else "", + "phone_sha256": r["phone_sha256"][:16] + "..." + if r["phone_sha256"] else "", + "conversion_name": r["conversion_name"], + "conversion_value": r["conversion_value"], + "conversion_time": r["conversion_time"], + } + for r in sample + ], + }, + ) + store_plan(plan) + return plan.to_preview() + + +def _resolve_upload_clicks_action( + client: object, cid: str, names: list[str] +) -> dict[str, str]: + """Look up conversion_action.resource_name for UPLOAD_CLICKS actions.""" + if not names: + return {} + + ga_service = client.get_service("GoogleAdsService") + quoted = ", ".join( + f"'{n.replace(chr(39), chr(39) + chr(39))}'" for n in names + ) + query = ( + "SELECT conversion_action.id, conversion_action.name, " + "conversion_action.resource_name, conversion_action.type, " + "conversion_action.status " + "FROM conversion_action " + f"WHERE conversion_action.name IN ({quoted}) " + "AND conversion_action.status != 'REMOVED'" + ) + response = ga_service.search(customer_id=cid, query=query) + + mapping: dict[str, str] = {} + wrong_type: list[str] = [] + for row in response: + ca = row.conversion_action + ca_type = ca.type_.name if hasattr(ca.type_, "name") else str(ca.type_) + if ca_type != "UPLOAD_CLICKS": + wrong_type.append(f"{ca.name} (type={ca_type})") + continue + mapping[ca.name] = ca.resource_name + + missing = [n for n in names if n not in mapping] + if wrong_type: + raise ValueError( + "Some conversion actions are not of type UPLOAD_CLICKS " + "(required for Enhanced Conversions for Leads uploads): " + f"{wrong_type}. Use an UPLOAD_CLICKS-type action — create one " + "via draft_create_conversion_action(type_='UPLOAD_CLICKS', ...)." + ) + if missing: + raise ValueError( + f"Conversion action(s) not found: {missing}. " + "Verify the 'Conversion Name' column in the CSV." + ) + return mapping + + +def _apply_upload_enhanced_conversions_for_leads( + client: object, cid: str, changes: dict +) -> dict: + """Execute EC-for-Leads upload via ConversionUploadService.""" + rows, parse_errors = _parse_ec_for_leads_csv(changes["csv_path"]) + if not rows: + return { + "error": "CSV produced zero parseable rows at apply time", + "parse_errors": parse_errors, + } + + distinct = sorted({r["conversion_name"] for r in rows}) + action_resources = _resolve_upload_clicks_action(client, cid, distinct) + + click_conv_type = client.get_type("ClickConversion") + user_id_type = client.get_type("UserIdentifier") + payload: list = [] + + for r in rows: + cc = click_conv_type.__class__() + cc.conversion_action = action_resources[r["conversion_name"]] + cc.conversion_date_time = r["conversion_time"] + cc.conversion_value = float(r["conversion_value"]) + cc.currency_code = r["currency_code"] + + # Build user_identifiers — Google matches the hashed email/phone + # to logged-in Google users who clicked our ads. + if r["email_sha256"]: + uid = user_id_type.__class__() + uid.hashed_email = r["email_sha256"] + cc.user_identifiers.append(uid) + if r["phone_sha256"]: + uid = user_id_type.__class__() + uid.hashed_phone_number = r["phone_sha256"] + cc.user_identifiers.append(uid) + # First+Last together as address_info (improves match rate) + if r["first_name_sha256"] and r["last_name_sha256"]: + uid = user_id_type.__class__() + uid.address_info.hashed_first_name = r["first_name_sha256"] + uid.address_info.hashed_last_name = r["last_name_sha256"] + cc.user_identifiers.append(uid) + + if not cc.user_identifiers: + continue + payload.append(cc) + + upload_service = client.get_service("ConversionUploadService") + response = upload_service.upload_click_conversions( + customer_id=cid, + conversions=payload, + partial_failure=bool(changes.get("partial_failure", True)), + ) + + results = list(response.results) + success_count = sum( + 1 for r in results + if getattr(r, "conversion_action", "") or getattr(r, "gclid", "") + or getattr(r, "user_identifiers", None) + ) + failure_count = len(results) - success_count + + row_errors: list[dict] = [] + partial = getattr(response, "partial_failure_error", None) + if partial and partial.message: + row_errors.append({ + "type": "partial_failure", + "message": partial.message, + "code": getattr(partial, "code", None), + }) + + return { + "uploaded_total": len(payload), + "success_count": success_count, + "failure_count": failure_count, + "conversion_actions_used": action_resources, + "row_errors": row_errors, + "parse_warnings": parse_errors, + } diff --git a/src/adloop/ads/write.py b/src/adloop/ads/write.py index b06ab23..787a469 100644 --- a/src/adloop/ads/write.py +++ b/src/adloop/ads/write.py @@ -727,6 +727,149 @@ def update_ad_group( return plan.to_preview() +def update_responsive_search_ad( + config: AdLoopConfig, + *, + customer_id: str = "", + ad_id: str = "", + headlines: list[str | dict] | None = None, + descriptions: list[str | dict] | None = None, + final_url: str = "", + path1: str = "", + path2: str = "", + clear_path1: bool = False, + clear_path2: bool = False, +) -> dict: + """Draft an in-place update on an existing RSA — returns PREVIEW. + + Updates mutable fields on an existing Responsive Search Ad without + creating a new ad (no learning-period reset). The Google Ads API v23 + permits in-place mutation via ``AdService.MutateAds`` of: + ``final_urls``, ``responsive_search_ad.path1``, + ``responsive_search_ad.path2``, ``responsive_search_ad.headlines``, + ``responsive_search_ad.descriptions``. + + Headlines/descriptions are list-replace: when provided, the entire list + swaps in. Google's RSA constraints still apply — 3-15 headlines, + 2-4 descriptions, 30/90 char caps, pin-slot rules — and are validated + here before the plan is stored. Each entry may be a plain string + (unpinned) or ``{"text": "...", "pinned_field": "HEADLINE_1"}``. + + Argument semantics: + - ``headlines`` / ``descriptions`` None or [] -> no change + - ``final_url`` empty -> no change; non-empty -> replaces final_urls + - ``path1`` / ``path2`` empty -> no change; non-empty -> sets value + - ``clear_path1`` / ``clear_path2`` True -> set the path to empty + (overrides the corresponding path string argument) + + At least one mutation must be requested. Call ``confirm_and_apply`` with + the returned plan_id to execute. + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("update_responsive_search_ad", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + errors: list[str] = [] + + if not ad_id: + errors.append("ad_id is required") + elif not str(ad_id).isdigit(): + errors.append("ad_id must be a numeric ID") + + final_url = (final_url or "").strip() + path1 = (path1 or "").strip() + path2 = (path2 or "").strip() + + if path1 and len(path1) > 15: + errors.append(f"path1 must be 15 chars or fewer (got {len(path1)})") + if path2 and len(path2) > 15: + errors.append(f"path2 must be 15 chars or fewer (got {len(path2)})") + + norm_headlines: list[dict] = [] + norm_descriptions: list[dict] = [] + if headlines: + try: + norm_headlines = _normalize_rsa_assets(headlines) + except ValueError as e: + errors.append(str(e)) + if descriptions: + try: + norm_descriptions = _normalize_rsa_assets(descriptions) + except ValueError as e: + errors.append(str(e)) + + if norm_headlines or norm_descriptions: + # Only enforce count on the list being replaced. The other list is + # untouched on the live ad, so its size on the wire is whatever the + # ad already has — not our problem to validate. + errors.extend( + _validate_rsa_assets( + norm_headlines, + norm_descriptions, + enforce_headline_count=bool(norm_headlines), + enforce_description_count=bool(norm_descriptions), + ) + ) + + has_url_change = bool(final_url) + has_path1_change = bool(path1) or clear_path1 + has_path2_change = bool(path2) or clear_path2 + has_headlines_change = bool(norm_headlines) + has_descriptions_change = bool(norm_descriptions) + + if not ( + has_url_change + or has_path1_change + or has_path2_change + or has_headlines_change + or has_descriptions_change + ): + errors.append( + "No changes specified — provide final_url, path1, path2, " + "clear_path1, clear_path2, headlines, or descriptions" + ) + + if errors: + return {"error": "Validation failed", "details": errors} + + if has_url_change: + url_check = _validate_urls([final_url]) + if url_check.get(final_url): + return { + "error": "URL validation failed", + "details": [ + f"final_url '{final_url}' is not reachable: " + f"{url_check[final_url]}. Ads MUST point to working URLs." + ], + } + + changes: dict = {"ad_id": str(ad_id)} + if has_url_change: + changes["final_url"] = final_url + if has_path1_change: + changes["path1"] = "" if clear_path1 else path1 + if has_path2_change: + changes["path2"] = "" if clear_path2 else path2 + if has_headlines_change: + changes["headlines"] = norm_headlines + if has_descriptions_change: + changes["descriptions"] = norm_descriptions + + plan = ChangePlan( + operation="update_responsive_search_ad", + entity_type="ad", + entity_id=str(ad_id), + customer_id=customer_id, + changes=changes, + ) + store_plan(plan) + return plan.to_preview() + + def pause_entity( config: AdLoopConfig, *, @@ -828,11 +971,13 @@ def draft_campaign( ad_group_name: str = "", keywords: list[dict] | None = None, geo_target_ids: list[str] | None = None, + geo_exclude_ids: list[str] | None = None, language_ids: list[str] | None = None, search_partners_enabled: bool = False, display_network_enabled: bool | None = None, display_expansion_enabled: bool | None = None, max_cpc: float = 0, + ad_schedule: list[dict] | None = None, ) -> dict: """Draft a full campaign structure — returns preview, does NOT execute. @@ -885,6 +1030,18 @@ def draft_campaign( if errors: return {"error": "Validation failed", "details": errors} + schedule_validated, schedule_errors = _validate_ad_schedule(ad_schedule or []) + if schedule_errors: + return {"error": "Ad schedule validation failed", "details": schedule_errors} + + geo_exclude_ids = [str(g) for g in (geo_exclude_ids or []) if str(g).strip()] + overlap = set(geo_exclude_ids) & set(str(g) for g in (geo_target_ids or [])) + if overlap: + return { + "error": "geo_exclude_ids overlap with geo_target_ids", + "details": [f"{g} appears in both include and exclude lists" for g in sorted(overlap)], + } + try: check_budget_cap(daily_budget, config.safety) except SafetyViolation as e: @@ -904,10 +1061,12 @@ def draft_campaign( "ad_group_name": ad_group_name or campaign_name, "keywords": keywords, "geo_target_ids": geo_target_ids or [], + "geo_exclude_ids": geo_exclude_ids, "language_ids": language_ids or [], "search_partners_enabled": search_partners_enabled, "display_network_enabled": normalized_display_network_enabled, "max_cpc": max_cpc if max_cpc else None, + "ad_schedule": schedule_validated, }, ) store_plan(plan) @@ -987,16 +1146,22 @@ def update_campaign( target_roas: float = 0, daily_budget: float = 0, geo_target_ids: list[str] | None = None, + geo_exclude_ids: list[str] | None = None, language_ids: list[str] | None = None, search_partners_enabled: bool | None = None, display_network_enabled: bool | None = None, display_expansion_enabled: bool | None = None, max_cpc: float = 0, + ad_schedule: 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). + to change. Geo/language targets, geo exclusions, and ad schedule are + REPLACED entirely when provided (existing entries are removed first). + + Pass an empty list (e.g. ``geo_exclude_ids=[]``) to clear that field. + Pass ``None`` (default) to leave it unchanged. """ from adloop.safety.guards import ( SafetyViolation, @@ -1048,6 +1213,22 @@ def update_campaign( errors.append("geo_target_ids cannot be empty — provide at least one geo target") if language_ids is not None and len(language_ids) == 0: errors.append("language_ids cannot be empty — provide at least one language") + + cleaned_excl: list[str] | None = None + if geo_exclude_ids is not None: + cleaned_excl = [str(g).strip() for g in geo_exclude_ids if str(g).strip()] + if geo_target_ids is not None: + overlap = set(cleaned_excl) & set(str(g) for g in geo_target_ids) + if overlap: + errors.append( + "geo_exclude_ids overlap with geo_target_ids: " + + ", ".join(sorted(overlap)) + ) + + schedule_validated: list[dict] | None = None + if ad_schedule is not None: + schedule_validated, schedule_errors = _validate_ad_schedule(ad_schedule) + errors.extend(schedule_errors) if max_cpc: strategy_for_cap = bs or _campaign_bidding_strategy(config, customer_id, campaign_id) if strategy_for_cap is None: @@ -1059,10 +1240,12 @@ def update_campaign( bs, daily_budget, geo_target_ids is not None, + geo_exclude_ids is not None, language_ids is not None, search_partners_enabled is not None, normalized_display_network_enabled is not None, max_cpc, + ad_schedule is not None, ]) if not has_any_change: errors.append("No changes specified — provide at least one parameter to update") @@ -1116,6 +1299,8 @@ def update_campaign( changes["daily_budget"] = daily_budget if geo_target_ids is not None: changes["geo_target_ids"] = geo_target_ids + if cleaned_excl is not None: + changes["geo_exclude_ids"] = cleaned_excl if language_ids is not None: changes["language_ids"] = language_ids if search_partners_enabled is not None: @@ -1124,6 +1309,8 @@ def update_campaign( changes["display_network_enabled"] = normalized_display_network_enabled if max_cpc: changes["max_cpc"] = max_cpc + if schedule_validated is not None: + changes["ad_schedule"] = schedule_validated plan = ChangePlan( operation="update_campaign", @@ -1150,7 +1337,15 @@ def draft_callouts( campaign_id: str = "", callouts: list[str] | None = None, ) -> dict: - """Draft campaign callout assets.""" + """Draft callout assets — returns a PREVIEW. + + Scope: + - If ``campaign_id`` is provided, the callouts are linked at the + campaign level via ``CampaignAsset``. + - If ``campaign_id`` is empty, the callouts are linked at the + customer/account level via ``CustomerAsset`` and become available + to all eligible campaigns automatically. + """ from adloop.safety.guards import SafetyViolation, check_blocked_operation from adloop.safety.preview import ChangePlan, store_plan @@ -1159,16 +1354,18 @@ def draft_callouts( except SafetyViolation as e: return {"error": str(e)} - validated_callouts, errors = _validate_callouts(campaign_id, callouts or []) + validated_callouts, errors = _validate_callouts(callouts or []) if errors: return {"error": "Validation failed", "details": errors} + scope = "campaign" if campaign_id else "customer" plan = ChangePlan( operation="create_callouts", - entity_type="campaign_asset", - entity_id=campaign_id, + entity_type="campaign_asset" if scope == "campaign" else "customer_asset", + entity_id=campaign_id or customer_id, customer_id=customer_id, changes={ + "scope": scope, "campaign_id": campaign_id, "callouts": validated_callouts, }, @@ -1184,7 +1381,13 @@ def draft_structured_snippets( campaign_id: str = "", snippets: list[dict] | None = None, ) -> dict: - """Draft campaign structured snippet assets.""" + """Draft structured snippet assets — returns a PREVIEW. + + Scope: + - If ``campaign_id`` is provided, snippets attach at the campaign level. + - If ``campaign_id`` is empty, snippets attach at the customer/account + level and apply to all eligible campaigns by default. + """ from adloop.safety.guards import SafetyViolation, check_blocked_operation from adloop.safety.preview import ChangePlan, store_plan @@ -1193,18 +1396,18 @@ def draft_structured_snippets( except SafetyViolation as e: return {"error": str(e)} - validated_snippets, errors = _validate_structured_snippets( - campaign_id, snippets or [] - ) + validated_snippets, errors = _validate_structured_snippets(snippets or []) if errors: return {"error": "Validation failed", "details": errors} + scope = "campaign" if campaign_id else "customer" plan = ChangePlan( operation="create_structured_snippets", - entity_type="campaign_asset", - entity_id=campaign_id, + entity_type="campaign_asset" if scope == "campaign" else "customer_asset", + entity_id=campaign_id or customer_id, customer_id=customer_id, changes={ + "scope": scope, "campaign_id": campaign_id, "snippets": validated_snippets, }, @@ -1219,8 +1422,32 @@ def draft_image_assets( customer_id: str = "", campaign_id: str = "", image_paths: list[str] | None = None, + field_types: list[str] | None = None, ) -> dict: - """Draft campaign image assets from local files.""" + """Draft image assets from local PNG/JPEG/GIF files — returns a PREVIEW. + + Scope: + - If ``campaign_id`` is empty, images attach at the customer/account + level via CustomerAsset. + - If ``campaign_id`` is provided, images attach at that campaign via + CampaignAsset. + + Field type: + Each image gets an AssetFieldType chosen from its aspect ratio + (with a 'logo' filename hint): + 1:1 → SQUARE_MARKETING_IMAGE (or BUSINESS_LOGO if 'logo' in name) + 1.91:1 → MARKETING_IMAGE + 4:1 → LANDSCAPE_LOGO (logo hint required) + 4:5 → PORTRAIT_MARKETING_IMAGE + Pass ``field_types`` (one entry per image_path) to override the + auto-detection. Valid override values: MARKETING_IMAGE, + SQUARE_MARKETING_IMAGE, PORTRAIT_MARKETING_IMAGE, + TALL_PORTRAIT_MARKETING_IMAGE, LOGO, LANDSCAPE_LOGO, BUSINESS_LOGO. + + Note: AD_IMAGE is NOT a valid field type for direct asset linking — + Google's API rejects it. The tool maps to the modern marketing-image + types instead. + """ from adloop.safety.guards import SafetyViolation, check_blocked_operation from adloop.safety.preview import ChangePlan, store_plan @@ -1229,16 +1456,44 @@ def draft_image_assets( except SafetyViolation as e: return {"error": str(e)} - validated_images, errors = _validate_image_assets(campaign_id, image_paths or []) + validated_images, errors = _validate_image_assets(image_paths or []) if errors: return {"error": "Validation failed", "details": errors} + if field_types is not None: + if len(field_types) != len(validated_images): + return { + "error": "Validation failed", + "details": [ + f"field_types has {len(field_types)} entries but " + f"image_paths has {len(validated_images)}" + ], + } + for ft, img in zip(field_types, validated_images): + if ft and str(ft).upper() not in _VALID_IMAGE_FIELD_TYPES: + return { + "error": "Validation failed", + "details": [ + f"field_type {ft!r} is not a supported image asset " + f"field type. Valid: {sorted(_VALID_IMAGE_FIELD_TYPES)}" + ], + } + if ft: + img["field_type"] = str(ft).upper() + + # Compute the field type each image will resolve to and attach to + # the preview so the user can see it before applying. + for img in validated_images: + img["resolved_field_type"] = _detect_image_field_type(img) + + scope = "campaign" if campaign_id else "customer" plan = ChangePlan( operation="create_image_assets", - entity_type="campaign_asset", - entity_id=campaign_id, + entity_type="campaign_asset" if scope == "campaign" else "customer_asset", + entity_id=campaign_id or customer_id, customer_id=customer_id, changes={ + "scope": scope, "campaign_id": campaign_id, "images": validated_images, }, @@ -1254,14 +1509,18 @@ def draft_sitelinks( campaign_id: str = "", sitelinks: list[dict] | None = None, ) -> dict: - """Draft sitelink extensions for a campaign — returns preview, does NOT execute. + """Draft sitelink extensions — returns a PREVIEW, does NOT execute. + + Scope: + - If ``campaign_id`` is provided, sitelinks attach at the campaign level. + - If ``campaign_id`` is empty, sitelinks attach at the customer/account + level and apply to all eligible campaigns by default. sitelinks: list of dicts, each with: - link_text (str, required, max 25 chars) — the clickable text - final_url (str, required) — where the sitelink points - description1 (str, optional, max 35 chars) — first description line - description2 (str, optional, max 35 chars) — second description line - campaign_id: the campaign to attach sitelinks to """ from adloop.safety.guards import SafetyViolation, check_blocked_operation from adloop.safety.preview import ChangePlan, store_plan @@ -1271,8 +1530,6 @@ def draft_sitelinks( except SafetyViolation as e: return {"error": str(e)} - if not campaign_id: - return {"error": "campaign_id is required"} if not sitelinks: return {"error": "At least one sitelink is required"} @@ -1339,12 +1596,17 @@ def draft_sitelinks( f"maximum ad real estate." ) + scope = "campaign" if campaign_id else "customer" plan = ChangePlan( operation="create_sitelinks", - entity_type="campaign_asset", - entity_id=campaign_id, + entity_type="campaign_asset" if scope == "campaign" else "customer_asset", + entity_id=campaign_id or customer_id, customer_id=customer_id, - changes={"campaign_id": campaign_id, "sitelinks": validated}, + changes={ + "scope": scope, + "campaign_id": campaign_id, + "sitelinks": validated, + }, ) store_plan(plan) preview = plan.to_preview() @@ -1353,863 +1615,1729 @@ def draft_sitelinks( return preview -# --------------------------------------------------------------------------- -# confirm_and_apply — the only function that actually mutates Google Ads -# --------------------------------------------------------------------------- +# Country dialing codes for E.164 phone normalization. +_COUNTRY_DIAL_CODES = { + "US": "+1", "CA": "+1", "GB": "+44", "DE": "+49", "FR": "+33", + "IT": "+39", "ES": "+34", "NL": "+31", "BE": "+32", "AT": "+43", + "CH": "+41", "AU": "+61", "NZ": "+64", "IE": "+353", "PT": "+351", +} -def _extract_error_message(exc: Exception) -> str: - """Extract a meaningful error message from Google Ads API exceptions. +def _normalize_phone_e164(phone: str, country_code: str) -> tuple[str, str | None]: + """Return (normalized, error_or_None). Strips formatting, ensures + prefix. - GoogleAdsException.__init__ doesn't call super().__init__(), so str(e) - returns ''. This function digs into the failure proto to surface the - actual error code, message, and trigger values. + Handles two trunk-prefix patterns: + - North America (US/CA): leading "1" before a 10-digit number is the + country code; strip it before re-adding "+1". + - European trunk "0": GB/DE/FR/IT/ES/NL/BE/AT/CH/IE/PT/AU/NZ all use a + leading "0" for domestic dialing that must be removed when adding + the international prefix. """ - try: - from google.ads.googleads.errors import GoogleAdsException - - if isinstance(exc, GoogleAdsException) and exc.failure: - parts = [] - for error in exc.failure.errors: - error_code = error.error_code - code_field = error_code.WhichOneof("error_code") - code_value = getattr(error_code, code_field) if code_field else "UNKNOWN" - line = f"[{code_field}={code_value.name if hasattr(code_value, 'name') else code_value}]" - if error.message: - line += f" {error.message}" - if error.trigger and error.trigger.string_value: - line += f" (trigger: {error.trigger.string_value})" - parts.append(line) - if parts: - msg = "; ".join(parts) - if exc.request_id: - msg += f" [request_id={exc.request_id}]" - return msg - except Exception: - pass - - fallback = str(exc) - return fallback if fallback else repr(exc) + raw = "".join(ch for ch in phone if ch.isdigit() or ch == "+") + if not raw: + return "", "phone_number is empty after stripping formatting" + if raw.startswith("+"): + return raw, None + dial = _COUNTRY_DIAL_CODES.get(country_code.upper()) + if not dial: + return "", ( + f"country_code '{country_code}' is not in the dial-code map; " + f"pass phone in E.164 form (with leading '+')" + ) + cc_upper = country_code.upper() + if cc_upper in ("US", "CA") and len(raw) == 11 and raw.startswith("1"): + raw = raw[1:] + elif cc_upper not in ("US", "CA") and raw.startswith("0"): + raw = raw.lstrip("0") + return f"{dial}{raw}", None -def confirm_and_apply( +def draft_call_asset( config: AdLoopConfig, *, - plan_id: str = "", - dry_run: bool = True, + customer_id: str = "", + phone_number: str = "", + country_code: str = "US", + campaign_id: str = "", + call_conversion_action_id: str = "", + ad_schedule: list[dict] | None = None, ) -> dict: - """Execute a previously previewed change. - - Defaults to dry_run=True. The caller must explicitly pass dry_run=False - to make real changes. + """Draft a call asset (phone extension) — returns a PREVIEW. + + Scope: + - If ``campaign_id`` is provided, the call asset attaches to that + campaign via ``CampaignAsset``. + - If ``campaign_id`` is empty, the call asset attaches at the + customer/account level via ``CustomerAsset``. + + phone_number: human or E.164 (e.g. "+19163393676" or "(916) 339-3676"). + country_code: 2-letter ISO country code used to canonicalize a national + number to E.164. Ignored when phone_number already starts with '+'. + call_conversion_action_id: optional Google Ads conversion action ID to + count calls of qualifying duration (typically ≥60 sec). When omitted, + the call asset uses the default account-level call-conversion settings. + ad_schedule: optional schedule dict list — see add_ad_schedule for shape + (day_of_week, start_hour/minute, end_hour/minute). Used to limit the + hours when the call extension shows. + + Important: Google Ads requires manual phone-number verification before + the call asset can serve. The asset is created in the account but won't + show until verification completes in the Ads UI. """ - from adloop.safety.audit import log_mutation - from adloop.safety.preview import get_plan, remove_plan + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan - plan = get_plan(plan_id) - if plan is None: - return { - "error": f"No pending plan found with id '{plan_id}'. " - "Plans expire when the MCP server restarts.", - } + try: + check_blocked_operation("create_call_asset", config.safety) + except SafetyViolation as e: + return {"error": str(e)} - forced_by_config = bool(config.safety.require_dry_run) and not dry_run - if config.safety.require_dry_run: - dry_run = True + if not phone_number: + return {"error": "phone_number is required"} - if dry_run: - log_mutation( - config.safety.log_file, - operation=plan.operation, - customer_id=plan.customer_id, - entity_type=plan.entity_type, - entity_id=plan.entity_id, - changes=plan.changes, - dry_run=True, - result="dry_run_success", - ) - response = { - "status": "DRY_RUN_SUCCESS", - "plan_id": plan.plan_id, - "operation": plan.operation, - "changes": plan.changes, - } - if forced_by_config: - # The caller passed dry_run=false but safety.require_dry_run - # forced it back on. Tell them exactly why and how to unlock - # real writes — without this, agents (e.g. Claude Code) retry - # in an infinite loop because the old message said to "call - # again with dry_run=false", which they already did. - config_path = config.source_path or "~/.adloop/config.yaml" - response["dry_run_forced_by"] = "config.safety.require_dry_run" - response["config_path"] = config_path - response["remediation"] = ( - f"Edit {config_path}, set 'require_dry_run: false' under " - "'safety:', then restart the AdLoop MCP server. Passing " - "dry_run=false on this tool will keep being overridden " - "until that flag is flipped." - ) - response["message"] = ( - f"dry_run=false was IGNORED because 'safety.require_dry_run: true' " - f"is set in {config_path}. No changes were made. To apply real " - f"changes, flip that flag to false and restart the AdLoop MCP " - f"server — retrying this tool with dry_run=false alone will " - f"never succeed while the flag is on." - ) - else: - response["message"] = ( - "Dry run completed — no changes were made to your Google Ads account. " - "To apply for real, call confirm_and_apply again with dry_run=false." - ) - return response + normalized_phone, phone_err = _normalize_phone_e164(phone_number, country_code) + if phone_err: + return {"error": phone_err} - try: - result = _execute_plan(config, plan) - except Exception as e: - error_message = _extract_error_message(e) - log_mutation( - config.safety.log_file, - operation=plan.operation, - customer_id=plan.customer_id, - entity_type=plan.entity_type, - entity_id=plan.entity_id, - changes=plan.changes, - dry_run=False, - result="error", - error=error_message, - ) - return {"error": error_message, "plan_id": plan.plan_id} + schedule_validated, schedule_errors = _validate_ad_schedule(ad_schedule or []) + if schedule_errors: + return {"error": "Ad schedule validation failed", "details": schedule_errors} - log_mutation( - config.safety.log_file, - operation=plan.operation, - customer_id=plan.customer_id, - entity_type=plan.entity_type, - entity_id=plan.entity_id, - changes=plan.changes, - dry_run=False, - result="success", - ) - remove_plan(plan.plan_id) + scope = "campaign" if campaign_id else "customer" + warnings = [ + "Google Ads requires phone-number verification before call assets serve. " + "Complete verification in Ads UI → Tools → Assets → Calls." + ] - return { - "status": "APPLIED", - "plan_id": plan.plan_id, - "operation": plan.operation, - "result": result, - } + plan = ChangePlan( + operation="create_call_asset", + entity_type="campaign_asset" if scope == "campaign" else "customer_asset", + entity_id=campaign_id or customer_id, + customer_id=customer_id, + changes={ + "scope": scope, + "campaign_id": campaign_id, + "phone_number": normalized_phone, + "country_code": country_code.upper(), + "call_conversion_action_id": call_conversion_action_id, + "ad_schedule": schedule_validated, + }, + ) + store_plan(plan) + preview = plan.to_preview() + preview["warnings"] = warnings + return preview # --------------------------------------------------------------------------- -# Internal validation helpers +# Promotion assets # --------------------------------------------------------------------------- -_VALID_MATCH_TYPES = {"EXACT", "PHRASE", "BROAD"} -_VALID_ENTITY_TYPES = {"campaign", "ad_group", "ad", "keyword"} -_REMOVABLE_ENTITY_TYPES = _VALID_ENTITY_TYPES | { - "negative_keyword", "campaign_asset", "asset", "customer_asset", - "shared_criterion", -} +# Pulled dynamically from the google-ads SDK so we don't drift when +# Google adds new occasions / modifiers in a future API version. +from adloop.ads.enums import enum_names as _enum_names -_SMART_BIDDING_STRATEGIES = { - "MAXIMIZE_CONVERSIONS", - "MAXIMIZE_CONVERSION_VALUE", - "TARGET_CPA", - "TARGET_ROAS", -} +_VALID_PROMOTION_OCCASIONS = _enum_names("PromotionExtensionOccasionEnum") +_VALID_DISCOUNT_MODIFIERS = _enum_names("PromotionExtensionDiscountModifierEnum") -def _campaign_uses_manual_cpc( - config: AdLoopConfig, customer_id: str, campaign_id: str -) -> bool | None: - """Return True when the campaign exists and uses MANUAL_CPC.""" - bidding_strategy = _campaign_bidding_strategy(config, customer_id, campaign_id) - if bidding_strategy is None: - return None - return bidding_strategy == "MANUAL_CPC" +def _validate_promotion_inputs( + *, + promotion_target: str, + final_url: str, + money_off: float, + percent_off: float, + currency_code: str, + promotion_code: str, + orders_over_amount: float, + occasion: str, + discount_modifier: str, + language_code: str, + start_date: str, + end_date: str, + redemption_start_date: str, + redemption_end_date: str, + ad_schedule: list[dict] | None, +) -> tuple[dict, list[str]]: + """Validate every PromotionAsset field. Returns (normalized, errors).""" + errors: list[str] = [] + target = (promotion_target or "").strip() + url = (final_url or "").strip() + if not target: + errors.append("promotion_target is required") + elif len(target) > 20: + errors.append( + f"promotion_target '{target}' is {len(target)} chars (max 20)" + ) -def _campaign_bidding_strategy( - config: AdLoopConfig, customer_id: str, campaign_id: str -) -> str | None: - """Return the bidding strategy type for the campaign, if it exists.""" - from adloop.ads.gaql import execute_query + if not url: + errors.append("final_url is required") - query = f""" - SELECT campaign.bidding_strategy_type - FROM campaign - WHERE campaign.id = {campaign_id} - LIMIT 1 - """ - rows = execute_query(config, customer_id, query) - if not rows: - return None - return rows[0].get("campaign.bidding_strategy_type") + has_money = money_off and money_off > 0 + has_percent = percent_off and percent_off > 0 + if has_money and has_percent: + errors.append( + "Specify exactly one of money_off or percent_off, not both" + ) + elif not has_money and not has_percent: + errors.append( + "One of money_off or percent_off is required (must be > 0)" + ) + if has_percent and (percent_off <= 0 or percent_off > 100): + errors.append(f"percent_off must be in (0, 100]; got {percent_off}") -def _existing_negative_geo_exclusions( - config: AdLoopConfig, customer_id: str, campaign_id: str -) -> list[str]: - """Return geo_target_constant IDs that are currently negative-excluded. + code = (promotion_code or "").strip() + if code and len(code) > 15: + errors.append( + f"promotion_code '{code}' is {len(code)} chars (max 15)" + ) - Used by ``update_campaign`` to surface preserved negative-location - criteria in the preview when ``geo_target_ids`` is being changed. - Negative location criteria survive a positive-geo replacement (issue - #32) — this helper makes that explicit in the preview so users can - see what's staying. Returns an empty list on any query failure; - surfacing exclusions is informational, not safety-critical. - """ - from adloop.ads.gaql import execute_query + has_orders_over = bool(orders_over_amount and orders_over_amount > 0) + if code and has_orders_over: + errors.append( + "promotion_code and orders_over_amount are mutually exclusive " + "(Google Ads PromotionAsset.promotion_trigger is a oneof) — " + "specify exactly one" + ) - query = f""" - SELECT campaign_criterion.location.geo_target_constant - FROM campaign_criterion - WHERE campaign.id = {campaign_id} - AND campaign_criterion.type = 'LOCATION' - AND campaign_criterion.negative = TRUE - """ - try: - rows = execute_query(config, customer_id, query) - except Exception: - return [] + occ = (occasion or "").strip().upper() + if occ and occ not in _VALID_PROMOTION_OCCASIONS: + errors.append( + f"occasion '{occ}' invalid; valid values: " + f"{sorted(_VALID_PROMOTION_OCCASIONS)}" + ) - ids: list[str] = [] - for row in rows: - gtc = row.get("campaign_criterion.location.geo_target_constant") or "" - # gtc looks like "geoTargetConstants/2840" — strip prefix to numeric ID. - if "/" in gtc: - ids.append(gtc.rsplit("/", 1)[-1]) - elif gtc: - ids.append(gtc) - return ids + modifier = (discount_modifier or "").strip().upper() + if modifier and modifier not in _VALID_DISCOUNT_MODIFIERS: + errors.append( + f"discount_modifier '{modifier}' invalid; valid: " + f"{sorted(_VALID_DISCOUNT_MODIFIERS)} (or empty for none)" + ) + for label, value in ( + ("start_date", start_date), + ("end_date", end_date), + ("redemption_start_date", redemption_start_date), + ("redemption_end_date", redemption_end_date), + ): + if value and not _is_valid_iso_date(value): + errors.append(f"{label} '{value}' must be YYYY-MM-DD") -def _ad_group_campaign_bidding_strategy( - config: AdLoopConfig, customer_id: str, ad_group_id: str -) -> str | None: - """Return the bidding strategy type of the campaign owning this ad group. + schedule_validated, schedule_errors = _validate_ad_schedule(ad_schedule or []) + errors.extend(schedule_errors) - Returns the enum name (``MANUAL_CPC``, ``TARGET_SPEND``, - ``MAXIMIZE_CONVERSIONS``, ``TARGET_CPA``, ``TARGET_ROAS``, etc.) or - ``None`` when the ad group can't be resolved. - """ - from adloop.ads.gaql import execute_query + if errors: + return {}, errors + + if url: + url_checks = _validate_urls([url]) + url_err = url_checks.get(url) + if url_err: + errors.append(f"final_url '{url}' is not reachable: {url_err}") + return {}, errors + + normalized: dict = { + "promotion_target": target, + "final_url": url, + "currency_code": (currency_code or "USD").upper(), + "promotion_code": code, + "orders_over_amount": float(orders_over_amount or 0), + "occasion": occ, + "discount_modifier": modifier, + "language_code": (language_code or "en").lower(), + "start_date": start_date or "", + "end_date": end_date or "", + "redemption_start_date": redemption_start_date or "", + "redemption_end_date": redemption_end_date or "", + "ad_schedule": schedule_validated, + } + if has_money: + normalized["money_off"] = float(money_off) + normalized["percent_off"] = 0.0 + else: + normalized["money_off"] = 0.0 + normalized["percent_off"] = float(percent_off) - query = f""" - SELECT campaign.bidding_strategy_type - FROM ad_group - WHERE ad_group.id = {ad_group_id} - LIMIT 1 + return normalized, [] + + +def _is_valid_iso_date(value: str) -> bool: + """True if value parses as a YYYY-MM-DD calendar date.""" + from datetime import datetime + + try: + datetime.strptime(value, "%Y-%m-%d") + return True + except (TypeError, ValueError): + return False + + +def draft_promotion( + config: AdLoopConfig, + *, + customer_id: str = "", + promotion_target: str = "", + final_url: str = "", + money_off: float = 0, + percent_off: float = 0, + currency_code: str = "USD", + promotion_code: str = "", + orders_over_amount: float = 0, + occasion: str = "", + discount_modifier: str = "", + language_code: str = "en", + start_date: str = "", + end_date: str = "", + redemption_start_date: str = "", + redemption_end_date: str = "", + campaign_id: str = "", + ad_schedule: list[dict] | None = None, +) -> dict: + """Draft a promotion extension asset — returns a PREVIEW. + + Creates a PromotionAsset and links it at campaign or customer scope. + Exactly one of money_off / percent_off must be provided. + + Scope: + - campaign_id provided → CampaignAsset link. + - campaign_id empty → CustomerAsset link (account-level). + + Required: + promotion_target: what the promotion is for, e.g. "Window Tint" + (max 20 chars; this is the label Google shows in the ad). + final_url: landing page for the promotion (must return 2xx/3xx). + money_off OR percent_off: the discount amount. + + Optional: + currency_code: ISO 4217 (default USD). Used for money_off and + orders_over_amount. + promotion_code: optional coupon code (max 15 chars). + orders_over_amount: minimum order amount that unlocks the promo. + occasion: optional event tag — e.g. BLACK_FRIDAY, SUMMER_SALE. + See PromotionExtensionOccasion enum for the full list. + discount_modifier: optional modifier; "UP_TO" surfaces as + "Up to $X off" instead of "$X off". + language_code: BCP-47 (default "en"). + start_date / end_date: YYYY-MM-DD. Leave blank for always-on. + redemption_start_date / redemption_end_date: YYYY-MM-DD. + ad_schedule: optional list of {day_of_week, start_hour, end_hour, + start_minute, end_minute} entries restricting when the promo + shows. + + Call confirm_and_apply with the returned plan_id to execute. """ - rows = execute_query(config, customer_id, query) - if not rows: - return None - return rows[0].get("campaign.bidding_strategy_type") + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + try: + check_blocked_operation("create_promotion", config.safety) + except SafetyViolation as e: + return {"error": str(e)} -def _validate_callouts( - campaign_id: str, callouts: list[str] -) -> tuple[list[str], list[str]]: - errors = [] - validated = [] + normalized, errors = _validate_promotion_inputs( + promotion_target=promotion_target, + final_url=final_url, + money_off=money_off, + percent_off=percent_off, + currency_code=currency_code, + promotion_code=promotion_code, + orders_over_amount=orders_over_amount, + occasion=occasion, + discount_modifier=discount_modifier, + language_code=language_code, + start_date=start_date, + end_date=end_date, + redemption_start_date=redemption_start_date, + redemption_end_date=redemption_end_date, + ad_schedule=ad_schedule, + ) + if errors: + return {"error": "Validation failed", "details": errors} - if not campaign_id: - errors.append("campaign_id is required") - if not callouts: - errors.append("At least one callout is required") + scope = "campaign" if campaign_id else "customer" + plan = ChangePlan( + operation="create_promotion", + entity_type="campaign_asset" if scope == "campaign" else "customer_asset", + entity_id=campaign_id or customer_id, + customer_id=customer_id, + changes={ + "scope": scope, + "campaign_id": campaign_id, + "promotion": normalized, + }, + ) + store_plan(plan) + return plan.to_preview() - for index, callout in enumerate(callouts): - text = callout.strip() - if not text: - errors.append(f"Callout {index + 1}: text is required") - elif len(text) > 25: - errors.append( - f"Callout {index + 1}: '{text}' is {len(text)} chars (max 25)" - ) - else: - validated.append(text) - return validated, errors +def update_promotion( + config: AdLoopConfig, + *, + customer_id: str = "", + asset_id: str = "", + campaign_id: str = "", + promotion_target: str = "", + final_url: str = "", + money_off: float = 0, + percent_off: float = 0, + currency_code: str = "USD", + promotion_code: str = "", + orders_over_amount: float = 0, + occasion: str = "", + discount_modifier: str = "", + language_code: str = "en", + start_date: str = "", + end_date: str = "", + redemption_start_date: str = "", + redemption_end_date: str = "", + ad_schedule: list[dict] | None = None, +) -> dict: + """Update a promotion via swap — returns a PREVIEW. + PromotionAsset fields are immutable once created in the Google Ads + API, so "update" is implemented as a swap: + 1. Create a new PromotionAsset with the updated values. + 2. Link the new asset at the same scope. + 3. Unlink the old asset. -def _validate_structured_snippets( - campaign_id: str, snippets: list[dict] -) -> tuple[list[dict], list[str]]: - errors = [] - validated = [] + The old Asset row itself stays in the account (orphaned). The Ads + API does not support hard-deleting Asset rows; Google reclaims + orphaned assets in due course. - if not campaign_id: - errors.append("campaign_id is required") - if not snippets: - errors.append("At least one structured snippet is required") + asset_id: numeric ID of the existing PromotionAsset to replace. + Find it via: SELECT asset.id, asset.name, asset.promotion_asset.promotion_target + FROM asset WHERE asset.type = 'PROMOTION' + campaign_id: pass to scope BOTH the new and old links to that campaign. + Leave empty for customer/account-level scope (matches CustomerAsset + behavior of the original promotion). - for index, snippet in enumerate(snippets): - header = snippet.get("header", "").strip() - values = [value.strip() for value in snippet.get("values", [])] + All other fields: see draft_promotion docstring. - if header not in _STRUCTURED_SNIPPET_HEADERS: - errors.append( - f"Structured snippet {index + 1}: header must be one of " - f"{sorted(_STRUCTURED_SNIPPET_HEADERS)}" - ) - if len(values) < 3 or len(values) > 10: - errors.append( - f"Structured snippet {index + 1}: values must contain 3-10 items" - ) - for value_index, value in enumerate(values): - if not value: - errors.append( - f"Structured snippet {index + 1}: value {value_index + 1} is required" - ) - elif len(value) > 25: - errors.append( - f"Structured snippet {index + 1}: value '{value}' is " - f"{len(value)} chars (max 25)" - ) + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan - validated.append({"header": header, "values": values}) + try: + check_blocked_operation("update_promotion", config.safety) + except SafetyViolation as e: + return {"error": str(e)} - return validated, errors + if not asset_id: + return {"error": "asset_id is required (the existing PromotionAsset to replace)"} + + normalized, errors = _validate_promotion_inputs( + promotion_target=promotion_target, + final_url=final_url, + money_off=money_off, + percent_off=percent_off, + currency_code=currency_code, + promotion_code=promotion_code, + orders_over_amount=orders_over_amount, + occasion=occasion, + discount_modifier=discount_modifier, + language_code=language_code, + start_date=start_date, + end_date=end_date, + redemption_start_date=redemption_start_date, + redemption_end_date=redemption_end_date, + ad_schedule=ad_schedule, + ) + if errors: + return {"error": "Validation failed", "details": errors} + scope = "campaign" if campaign_id else "customer" + warnings = [ + "Update is a swap: a new PromotionAsset is created and linked, " + "the old link is unlinked. The old Asset row stays in the account " + "(orphaned) — Google Ads API does not support deleting Asset rows." + ] -def _validate_image_assets( - campaign_id: str, image_paths: list[str] -) -> tuple[list[dict[str, object]], list[str]]: - errors = [] - validated = [] + plan = ChangePlan( + operation="update_promotion", + entity_type="campaign_asset" if scope == "campaign" else "customer_asset", + entity_id=campaign_id or customer_id, + customer_id=customer_id, + changes={ + "scope": scope, + "campaign_id": campaign_id, + "old_asset_id": asset_id, + "promotion": normalized, + }, + ) + store_plan(plan) + preview = plan.to_preview() + preview["warnings"] = warnings + return preview - if not campaign_id: - errors.append("campaign_id is required") - if not image_paths: - errors.append("At least one image path is required") - for index, image_path in enumerate(image_paths): - try: - validated.append(_parse_image_metadata(image_path)) - except ValueError as exc: - errors.append(f"Image {index + 1}: {exc}") +# --------------------------------------------------------------------------- +# In-place asset updates (call asset, sitelink, callout) +# --------------------------------------------------------------------------- - return validated, errors +_VALID_CALL_REPORTING_STATES = _enum_names("CallConversionReportingStateEnum") -def _check_broad_match_safety( + +def update_call_asset( config: AdLoopConfig, - customer_id: str, - ad_group_id: str, - keywords: list[dict], -) -> list[str]: - """Warn if BROAD match keywords are being added to a non-Smart Bidding campaign.""" - has_broad = any( - (kw.get("match_type") or "").upper() == "BROAD" for kw in keywords - ) - if not has_broad: - return [] + *, + customer_id: str = "", + asset_id: str, + phone_number: str = "", + country_code: str = "", + call_conversion_action_id: str = "", + call_conversion_reporting_state: str = "", + ad_schedule: list[dict] | None = None, +) -> dict: + """Update an existing CallAsset in place — returns a PREVIEW. - try: - from adloop.ads.gaql import execute_query + Use this to: + - re-point a CallAsset at a specific conversion action (e.g. 'Calls + from Ads (>=90s)') with USE_RESOURCE_LEVEL_CALL_CONVERSION_ACTION + - change the phone number / country code + - replace the ad-schedule windows - query = f""" - SELECT campaign.bidding_strategy_type, campaign.name - FROM ad_group - WHERE ad_group.id = {ad_group_id} - """ - rows = execute_query(config, customer_id, query) - if not rows: - return [] + Pass only the fields you want to change. Empty strings/None are + treated as "do not change". - bidding = rows[0].get("campaign.bidding_strategy_type", "") - campaign_name = rows[0].get("campaign.name", "") + asset_id: numeric ID of the existing call asset. + call_conversion_reporting_state: one of + DISABLED | USE_ACCOUNT_LEVEL_CALL_CONVERSION_ACTION | + USE_RESOURCE_LEVEL_CALL_CONVERSION_ACTION + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan - if bidding not in _SMART_BIDDING_STRATEGIES: - return [ - f"DANGEROUS: Adding BROAD match keywords to campaign " - f"'{campaign_name}' which uses {bidding} bidding. " - f"Broad Match without Smart Bidding (tCPA/tROAS/Maximize Conversions) " - f"leads to irrelevant matches and wasted budget. " - f"Use PHRASE or EXACT match instead, or switch the campaign " - f"to Smart Bidding first." - ] - except Exception: - pass + try: + check_blocked_operation("update_call_asset", config.safety) + except SafetyViolation as e: + return {"error": str(e)} - return [] + if not asset_id: + return {"error": "asset_id is required"} + errors: list[str] = [] + normalized_phone = "" + if phone_number: + cc = (country_code or "US").upper() + normalized_phone, phone_err = _normalize_phone_e164(phone_number, cc) + if phone_err: + errors.append(phone_err) + + if call_conversion_reporting_state and ( + call_conversion_reporting_state not in _VALID_CALL_REPORTING_STATES + ): + errors.append( + f"call_conversion_reporting_state '{call_conversion_reporting_state}'" + f" invalid; valid: {sorted(_VALID_CALL_REPORTING_STATES)}" + ) -def _validate_rsa( - ad_group_id: str, - headlines: list[dict], - descriptions: list[dict], - final_url: str, -) -> list[str]: - errors = [] - if not ad_group_id: - errors.append("ad_group_id is required") - if not final_url: - errors.append("final_url is required") - if len(headlines) < 3: - errors.append(f"Need at least 3 headlines, got {len(headlines)}") - if len(headlines) > 15: - errors.append(f"Maximum 15 headlines, got {len(headlines)}") - if len(descriptions) < 2: - errors.append(f"Need at least 2 descriptions, got {len(descriptions)}") - if len(descriptions) > 4: - errors.append(f"Maximum 4 descriptions, got {len(descriptions)}") - - headline_pin_counts: dict[str, int] = {} - for i, h in enumerate(headlines): - text = h["text"] - pin = h["pinned_field"] - if len(text) > 30: - errors.append( - f"Headline {i + 1} exceeds 30 chars ({len(text)}): '{text}'" - ) - if pin is not None: - if pin not in _VALID_HEADLINE_PINS: - errors.append( - f"Headline {i + 1} pinned_field '{pin}' invalid; " - f"must be one of {sorted(_VALID_HEADLINE_PINS)} or null" - ) - else: - headline_pin_counts[pin] = headline_pin_counts.get(pin, 0) + 1 - for pin, count in headline_pin_counts.items(): - if count > 2: - errors.append(f"At most 2 headlines may pin to {pin}; got {count}") - - description_pin_counts: dict[str, int] = {} - for i, d in enumerate(descriptions): - text = d["text"] - pin = d["pinned_field"] - if len(text) > 90: - errors.append( - f"Description {i + 1} exceeds 90 chars ({len(text)}): '{text}'" - ) - if pin is not None: - if pin not in _VALID_DESCRIPTION_PINS: - errors.append( - f"Description {i + 1} pinned_field '{pin}' invalid; " - f"must be one of {sorted(_VALID_DESCRIPTION_PINS)} or null" - ) - else: - description_pin_counts[pin] = description_pin_counts.get(pin, 0) + 1 - for pin, count in description_pin_counts.items(): - if count > 1: - errors.append(f"At most 1 description may pin to {pin}; got {count}") - - return errors + schedule_validated, schedule_errors = _validate_ad_schedule(ad_schedule or []) + errors.extend(schedule_errors) + if errors: + return {"error": "Validation failed", "details": errors} -_VALID_BIDDING_STRATEGIES = { - "MAXIMIZE_CONVERSIONS", - "MAXIMIZE_CONVERSION_VALUE", - "TARGET_CPA", - "TARGET_ROAS", - "TARGET_SPEND", - "MANUAL_CPC", -} + changes: dict = {"asset_id": str(asset_id)} + if normalized_phone: + changes["phone_number"] = normalized_phone + if country_code: + changes["country_code"] = country_code.upper() + if call_conversion_action_id: + changes["call_conversion_action_id"] = str(call_conversion_action_id) + if call_conversion_reporting_state: + changes["call_conversion_reporting_state"] = call_conversion_reporting_state + if ad_schedule is not None: + changes["ad_schedule"] = schedule_validated + + if len(changes) == 1: + return {"error": "No fields to update"} -_VALID_CHANNEL_TYPES = {"SEARCH", "DISPLAY", "SHOPPING", "VIDEO", "PERFORMANCE_MAX"} + plan = ChangePlan( + operation="update_call_asset", + entity_type="asset", + entity_id=str(asset_id), + customer_id=customer_id, + changes=changes, + ) + store_plan(plan) + return plan.to_preview() -def _validate_campaign( +def update_sitelink( config: AdLoopConfig, *, - campaign_name: str, - daily_budget: float, - bidding_strategy: str, - target_cpa: float, - target_roas: float, - channel_type: str, - keywords: list[dict] | None, - geo_target_ids: list[str] | None, - language_ids: list[str] | None, customer_id: str = "", - search_partners_enabled: bool = False, - display_network_enabled: bool = False, - max_cpc: float = 0, -) -> tuple[list[str], list[str]]: - """Validate campaign draft inputs. Returns (errors, warnings).""" - errors = [] - warnings = [] + asset_id: str, + link_text: str = "", + final_url: str = "", + description1: str = "", + description2: str = "", +) -> dict: + """Update an existing SitelinkAsset in place — returns a PREVIEW. - if not campaign_name or not campaign_name.strip(): - errors.append("campaign_name is required") - if daily_budget <= 0: - errors.append("daily_budget must be greater than 0") - if not geo_target_ids: + Pass only the fields you want to change. Empty string = "do not change". + + asset_id: numeric ID of the existing sitelink asset. + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("update_sitelink", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + if not asset_id: + return {"error": "asset_id is required"} + + errors: list[str] = [] + if link_text and len(link_text) > 25: errors.append( - "geo_target_ids is required — campaigns must target at least one " - "country/region (e.g. ['2276'] for Germany, ['2840'] for USA)" + f"link_text '{link_text}' is {len(link_text)} chars (max 25)" ) - if not language_ids: + if description1 and len(description1) > 35: errors.append( - "language_ids is required — campaigns must target at least one " - "language (e.g. ['1001'] for German, ['1000'] for English)" + f"description1 is {len(description1)} chars (max 35)" ) - - bs = bidding_strategy.upper() - if bs not in _VALID_BIDDING_STRATEGIES: + if description2 and len(description2) > 35: errors.append( - f"bidding_strategy must be one of {sorted(_VALID_BIDDING_STRATEGIES)}, " - f"got '{bidding_strategy}'" + f"description2 is {len(description2)} chars (max 35)" ) - if bs == "TARGET_CPA" and not target_cpa: - errors.append("target_cpa is required when bidding_strategy is TARGET_CPA") - if bs == "TARGET_ROAS" and not target_roas: - errors.append("target_roas is required when bidding_strategy is TARGET_ROAS") + if errors: + return {"error": "Validation failed", "details": errors} - ct = channel_type.upper() - if ct not in _VALID_CHANNEL_TYPES: - errors.append( - f"channel_type must be one of {sorted(_VALID_CHANNEL_TYPES)}, " - f"got '{channel_type}'" - ) - if ct != "SEARCH" and search_partners_enabled: - errors.append("search_partners_enabled is only supported for SEARCH campaigns") - if ct != "SEARCH" and display_network_enabled: - errors.append("display_network_enabled is only supported for SEARCH campaigns") - if max_cpc < 0: - errors.append("max_cpc cannot be negative") - if max_cpc and bs not in {"MANUAL_CPC", "TARGET_SPEND"}: - errors.append("max_cpc requires MANUAL_CPC or TARGET_SPEND bidding_strategy") + if final_url: + url_checks = _validate_urls([final_url]) + url_err = url_checks.get(final_url) + if url_err: + return { + "error": "URL validation failed", + "details": [f"'{final_url}' is not reachable: {url_err}"], + } + + changes: dict = {"asset_id": str(asset_id)} + if link_text: + changes["link_text"] = link_text + if final_url: + changes["final_url"] = final_url + if description1: + changes["description1"] = description1 + if description2: + changes["description2"] = description2 + + if len(changes) == 1: + return {"error": "No fields to update"} - if keywords: - has_broad = any( - (kw.get("match_type") or "").upper() == "BROAD" for kw in keywords - ) - if has_broad and bs not in _SMART_BIDDING_STRATEGIES: - errors.append( - f"BROAD match keywords require Smart Bidding " - f"(tCPA/tROAS/Maximize Conversions). " - f"'{bidding_strategy}' is not a Smart Bidding strategy. " - f"Use PHRASE or EXACT match instead." - ) - for i, kw in enumerate(keywords): - if not kw.get("text"): - errors.append(f"Keyword {i + 1} has no text") - mt = (kw.get("match_type") or "").upper() - if mt not in _VALID_MATCH_TYPES: - errors.append( - f"Keyword {i + 1} has invalid match_type '{mt}' " - "(must be EXACT, PHRASE, or BROAD)" - ) + plan = ChangePlan( + operation="update_sitelink", + entity_type="asset", + entity_id=str(asset_id), + customer_id=customer_id, + changes=changes, + ) + store_plan(plan) + return plan.to_preview() - if target_cpa > 0 and daily_budget < 5 * target_cpa: - from adloop.ads.currency import format_currency, get_currency_code - currency_code = get_currency_code(config, customer_id) - warnings.append( - f"Daily budget {format_currency(daily_budget, currency_code)} is less than 5x target CPA " - f"{format_currency(target_cpa, currency_code)}. Google recommends at least 5x target CPA " - f"({format_currency(5 * target_cpa, currency_code)}/day) for sufficient learning data." - ) - if bs == "MANUAL_CPC": - warnings.append( - "MANUAL_CPC bidding requires constant monitoring. Consider using " - "MAXIMIZE_CONVERSIONS or TARGET_CPA for automated optimization." - ) +def update_callout( + config: AdLoopConfig, + *, + customer_id: str = "", + asset_id: str, + callout_text: str, +) -> dict: + """Update an existing CalloutAsset's text in place — returns a PREVIEW. - return errors, warnings + asset_id: numeric ID of the existing callout asset. + callout_text: new callout text (max 25 chars). + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + try: + check_blocked_operation("update_callout", config.safety) + except SafetyViolation as e: + return {"error": str(e)} -def _validate_keywords(ad_group_id: str, keywords: list[dict]) -> list[str]: - errors = [] - if not ad_group_id: - errors.append("ad_group_id is required") - if not keywords: - errors.append("At least one keyword is required") - for i, kw in enumerate(keywords): - if not kw.get("text"): - errors.append(f"Keyword {i + 1} has no text") - mt = (kw.get("match_type") or "").upper() - if mt not in _VALID_MATCH_TYPES: - errors.append( - f"Keyword {i + 1} has invalid match_type '{mt}' " - "(must be EXACT, PHRASE, or BROAD)" - ) - return errors + if not asset_id: + return {"error": "asset_id is required"} + text = (callout_text or "").strip() + if not text: + return {"error": "callout_text is required"} + if len(text) > 25: + return { + "error": "Validation failed", + "details": [f"callout_text is {len(text)} chars (max 25)"], + } + plan = ChangePlan( + operation="update_callout", + entity_type="asset", + entity_id=str(asset_id), + customer_id=customer_id, + changes={"asset_id": str(asset_id), "callout_text": text}, + ) + store_plan(plan) + return plan.to_preview() -def _validate_ad_group( - *, - campaign_id: str, - ad_group_name: str, - keywords: list[dict] | None, - cpc_bid_micros: int, -) -> list[str]: - """Validate inputs for draft_ad_group.""" - errors = [] - if not campaign_id: - errors.append("campaign_id is required") - if not ad_group_name or not ad_group_name.strip(): - errors.append("ad_group_name is required") - if cpc_bid_micros < 0: - errors.append("cpc_bid_micros must be >= 0") - if keywords: - for i, kw in enumerate(keywords): - if not kw.get("text"): - errors.append(f"Keyword {i + 1} has no text") - mt = (kw.get("match_type") or "").upper() - if mt not in _VALID_MATCH_TYPES: - errors.append( - f"Keyword {i + 1} has invalid match_type '{mt}' " - "(must be EXACT, PHRASE, or BROAD)" - ) - return errors +# --------------------------------------------------------------------------- +# link_asset_to_customer — promote existing assets to customer/account scope +# --------------------------------------------------------------------------- + +# AssetFieldType values that are valid for CustomerAsset (account-level). +# Asset types like SITELINK/CALLOUT/etc. are also valid here, but this tool +# is intended for "promote existing asset" use cases — typically images, +# logos, and business name assets that already exist in the account from +# legacy campaigns. +_VALID_CUSTOMER_ASSET_FIELD_TYPES = { + "SITELINK", "CALLOUT", "STRUCTURED_SNIPPET", "PROMOTION", "PRICE", + "CALL", "MOBILE_APP", "HOTEL_CALLOUT", "BUSINESS_LOGO", "BUSINESS_NAME", + "AD_IMAGE", "MARKETING_IMAGE", "SQUARE_MARKETING_IMAGE", + "PORTRAIT_MARKETING_IMAGE", "LOGO", "LANDSCAPE_LOGO", + "YOUTUBE_VIDEO", "MEDIA_BUNDLE", "BOOK_ON_GOOGLE", "LEAD_FORM", + "HEADLINE", "DESCRIPTION", "LONG_HEADLINE", +} -def _preflight_ad_group_checks( + +def link_asset_to_customer( config: AdLoopConfig, - customer_id: str, - campaign_id: str, - ad_group_name: str, - keywords: list[dict], - cpc_bid_micros: int, -) -> tuple[list[str], list[str]]: - """Run pre-flight checks before creating an ad group. + *, + customer_id: str = "", + links: list[dict] | None = None, +) -> dict: + """Link EXISTING assets to the customer (account) — returns a PREVIEW. - Returns (errors, warnings). Errors block the draft; warnings are informational. + Use this to "promote" assets that already exist in the account + (typically attached to legacy campaigns) so they apply at the account + level and inherit to every eligible campaign automatically. - Checks performed: - 1. Campaign must be a SEARCH campaign (error if not). - 2. Warn if cpc_bid_micros is set but campaign uses Smart Bidding (ignored). - 3. Warn if BROAD match keywords + non-Smart Bidding campaign. - 4. Warn if an ad group with the same name already exists in the campaign. + Unlike draft_image_assets / draft_callouts / etc., this tool does NOT + create new Asset rows — it only adds CustomerAsset link rows pointing + to assets you already have. Find candidate asset_ids via: + SELECT asset.id, asset.type, asset.name FROM asset + + Args: + links: list of dicts, each with: + - asset_id (str, required) — numeric asset ID + - field_type (str, required) — AssetFieldType, e.g. + BUSINESS_LOGO, AD_IMAGE, MARKETING_IMAGE, BUSINESS_NAME, + SITELINK, CALLOUT, CALL, PROMOTION, etc. + + Call confirm_and_apply with the returned plan_id to execute. """ - errors: list[str] = [] - warnings: list[str] = [] + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan try: - from adloop.ads.gaql import execute_query + check_blocked_operation("link_asset_to_customer", config.safety) + except SafetyViolation as e: + return {"error": str(e)} - # Query 1: campaign info (type, bidding, name) - campaign_query = f""" - SELECT campaign.advertising_channel_type, - campaign.bidding_strategy_type, - campaign.name - FROM campaign - WHERE campaign.id = {campaign_id} - """ - rows = execute_query(config, customer_id, campaign_query) - if not rows: - errors.append( - f"Campaign {campaign_id} not found. Verify the campaign ID " - "using get_campaign_performance." - ) - return errors, warnings - - row = rows[0] - channel_type = row.get("campaign.advertising_channel_type", "") - bidding = row.get("campaign.bidding_strategy_type", "") - campaign_name = row.get("campaign.name", "") + if not links: + return {"error": "At least one link is required"} - # Check 1: campaign type must be SEARCH - if channel_type and channel_type != "SEARCH": + errors: list[str] = [] + validated: list[dict] = [] + for i, item in enumerate(links): + if not isinstance(item, dict): + errors.append(f"Link {i + 1}: must be a dict, got {type(item).__name__}") + continue + asset_id = str(item.get("asset_id", "")).strip() + field_type = str(item.get("field_type", "")).strip().upper() + if not asset_id: + errors.append(f"Link {i + 1}: asset_id is required") + continue + if not asset_id.isdigit(): errors.append( - f"Campaign '{campaign_name}' is a {channel_type} campaign. " - "draft_ad_group only supports SEARCH campaigns." - ) - - # Check 2: cpc_bid_micros on Smart Bidding is ignored - if cpc_bid_micros and bidding in _SMART_BIDDING_STRATEGIES: - warnings.append( - f"Campaign '{campaign_name}' uses {bidding} (Smart Bidding). " - "The cpc_bid_micros value will be ignored — Smart Bidding " - "sets bids automatically." - ) - - # Check 3: BROAD match + non-Smart Bidding - has_broad = any( - (kw.get("match_type") or "").upper() == "BROAD" for kw in keywords - ) - if has_broad and bidding not in _SMART_BIDDING_STRATEGIES: - warnings.append( - f"DANGEROUS: Adding BROAD match keywords to campaign " - f"'{campaign_name}' which uses {bidding} bidding. " - f"Broad Match without Smart Bidding (tCPA/tROAS/Maximize " - f"Conversions) leads to irrelevant matches and wasted budget. " - f"Use PHRASE or EXACT match instead, or switch the campaign " - f"to Smart Bidding first." + f"Link {i + 1}: asset_id '{asset_id}' must be numeric" ) - - # Check 4: existing ad groups (duplicate name check) - ag_query = f""" - SELECT ad_group.name - FROM ad_group - WHERE campaign.id = {campaign_id} - """ - ag_rows = execute_query(config, customer_id, ag_query) - existing_names = {r.get("ad_group.name", "") for r in ag_rows} - if ad_group_name in existing_names: - warnings.append( - f"An ad group named '{ad_group_name}' already exists in " - f"campaign '{campaign_name}'. This will create a duplicate. " - f"Consider using a different name to avoid confusion." + continue + if not field_type: + errors.append(f"Link {i + 1}: field_type is required") + continue + if field_type not in _VALID_CUSTOMER_ASSET_FIELD_TYPES: + errors.append( + f"Link {i + 1}: field_type '{field_type}' is not valid for " + f"CustomerAsset; valid: " + f"{sorted(_VALID_CUSTOMER_ASSET_FIELD_TYPES)}" ) + continue + validated.append({"asset_id": asset_id, "field_type": field_type}) - except Exception as exc: - # Surface preflight failures as warnings so users know checks - # were skipped, rather than silently producing a clean preview. - warnings.append( - f"Preflight checks could not complete ({exc}). " - "The draft will proceed, but some validations were skipped. " - "Full validation happens at confirm_and_apply time." - ) - - return errors, warnings - - -def _draft_status_change( - config: AdLoopConfig, - operation: str, - customer_id: str, - entity_type: str, - entity_id: str, - target_status: str, -) -> dict: - from adloop.safety.guards import SafetyViolation, check_blocked_operation - from adloop.safety.preview import ChangePlan, store_plan - - try: - check_blocked_operation(operation, config.safety) - except SafetyViolation as e: - return {"error": str(e)} - - errors = [] - if entity_type not in _VALID_ENTITY_TYPES: - errors.append( - f"entity_type must be one of {_VALID_ENTITY_TYPES}, got '{entity_type}'" - ) - if not entity_id: - errors.append("entity_id is required") if errors: return {"error": "Validation failed", "details": errors} plan = ChangePlan( - operation=operation, - entity_type=entity_type, - entity_id=entity_id, + operation="link_asset_to_customer", + entity_type="customer_asset", + entity_id=customer_id, customer_id=customer_id, - changes={"target_status": target_status}, + changes={"links": validated}, ) store_plan(plan) return plan.to_preview() # --------------------------------------------------------------------------- -# Execution — actual Google Ads API mutate calls +# confirm_and_apply — the only function that actually mutates Google Ads # --------------------------------------------------------------------------- -_MUTATE_RESPONSE_RESULT_FIELDS = [ - "campaign_budget_result", - "campaign_result", - "ad_group_result", - "ad_group_ad_result", - "ad_group_criterion_result", - "campaign_criterion_result", - "asset_result", - "campaign_asset_result", - "customer_asset_result", -] - - -def _extract_resource_name(resp: object) -> str: - """Extract the resource_name from a MutateOperationResponse. +def _extract_error_message(exc: Exception) -> str: + """Extract a meaningful error message from Google Ads API exceptions. - Uses direct field access instead of WhichOneof, which doesn't work on - proto-plus wrapped messages returned by the google-ads library. + GoogleAdsException.__init__ doesn't call super().__init__(), so str(e) + returns ''. This function digs into the failure proto to surface the + actual error code, message, and trigger values. """ - for field in _MUTATE_RESPONSE_RESULT_FIELDS: - try: - result = getattr(resp, field, None) - if result and result.resource_name: - return result.resource_name - except Exception: - continue - return "" - - -def _execute_plan(config: AdLoopConfig, plan: object) -> dict: - """Dispatch to the right Google Ads mutate call based on plan.operation.""" - from adloop.ads.client import get_ads_client, normalize_customer_id - - client = get_ads_client(config) - cid = normalize_customer_id(plan.customer_id) - - dispatch = { - "create_campaign": _apply_create_campaign, - "create_ad_group": _apply_create_ad_group, - "update_campaign": _apply_update_campaign, - "update_ad_group": _apply_update_ad_group, - "create_responsive_search_ad": _apply_create_rsa, - "add_keywords": _apply_add_keywords, - "add_negative_keywords": _apply_add_negative_keywords, - "create_negative_keyword_list": _apply_create_negative_keyword_list, - "add_to_negative_keyword_list": _apply_add_to_negative_keyword_list, - "attach_shared_set_to_campaigns": _apply_attach_shared_set_to_campaigns, - "detach_shared_set_from_campaigns": _apply_detach_shared_set_from_campaigns, - "pause_entity": _apply_status_change, - "enable_entity": _apply_status_change, - "remove_entity": _apply_remove, - "create_callouts": _apply_create_callouts, - "create_structured_snippets": _apply_create_structured_snippets, - "create_image_assets": _apply_create_image_assets, - "create_sitelinks": _apply_create_sitelinks, - } - - handler = dispatch.get(plan.operation) - if handler is None: - raise ValueError(f"Unknown operation: {plan.operation}") - - if plan.operation in ("pause_entity", "enable_entity"): - return handler( - client, - cid, - plan.entity_type, - plan.entity_id, - plan.changes["target_status"], - ) - - if plan.operation == "remove_entity": - return handler(client, cid, plan.entity_type, plan.entity_id) - - return handler(client, cid, plan.changes) - + try: + from google.ads.googleads.errors import GoogleAdsException -def _apply_update_ad_group(client: object, cid: str, changes: dict) -> dict: - """Update an ad group's name and/or manual CPC bid.""" - from google.protobuf import field_mask_pb2 + if isinstance(exc, GoogleAdsException) and exc.failure: + parts = [] + for error in exc.failure.errors: + error_code = error.error_code + code_field = error_code.WhichOneof("error_code") + code_value = getattr(error_code, code_field) if code_field else "UNKNOWN" + line = f"[{code_field}={code_value.name if hasattr(code_value, 'name') else code_value}]" + if error.message: + line += f" {error.message}" + if error.trigger and error.trigger.string_value: + line += f" (trigger: {error.trigger.string_value})" + parts.append(line) + if parts: + msg = "; ".join(parts) + if exc.request_id: + msg += f" [request_id={exc.request_id}]" + return msg + except Exception: + pass - service = client.get_service("AdGroupService") - operation = client.get_type("AdGroupOperation") - ad_group = operation.update - ad_group.resource_name = service.ad_group_path(cid, changes["ad_group_id"]) + fallback = str(exc) + return fallback if fallback else repr(exc) - field_paths = [] - if changes.get("ad_group_name"): - ad_group.name = changes["ad_group_name"] - field_paths.append("name") - if changes.get("max_cpc"): - ad_group.cpc_bid_micros = int(changes["max_cpc"] * 1_000_000) - field_paths.append("cpc_bid_micros") - operation.update_mask = field_mask_pb2.FieldMask(paths=field_paths) - response = service.mutate_ad_groups(customer_id=cid, operations=[operation]) - return {"resource_name": response.results[0].resource_name} +def confirm_and_apply( + config: AdLoopConfig, + *, + plan_id: str = "", + dry_run: bool = True, +) -> dict: + """Execute a previously previewed change. + Defaults to dry_run=True. The caller must explicitly pass dry_run=False + to make real changes. + """ + from adloop.safety.audit import log_mutation + from adloop.safety.preview import get_plan, remove_plan -def _apply_create_campaign(client: object, cid: str, changes: dict) -> dict: - """Create campaign + budget + ad group + optional keywords atomically.""" - service = client.get_service("GoogleAdsService") - campaign_service = client.get_service("CampaignService") - budget_service = client.get_service("CampaignBudgetService") - ad_group_service = client.get_service("AdGroupService") + plan = get_plan(plan_id) + if plan is None: + return { + "error": f"No pending plan found with id '{plan_id}'. " + "Plans expire when the MCP server restarts.", + } - operations = [] + forced_by_config = bool(config.safety.require_dry_run) and not dry_run + if config.safety.require_dry_run: + dry_run = True - # 1. CampaignBudget (temp ID: -1) - budget_op = client.get_type("MutateOperation") - budget = budget_op.campaign_budget_operation.create + if dry_run: + log_mutation( + config.safety.log_file, + operation=plan.operation, + customer_id=plan.customer_id, + entity_type=plan.entity_type, + entity_id=plan.entity_id, + changes=plan.changes, + dry_run=True, + result="dry_run_success", + ) + response = { + "status": "DRY_RUN_SUCCESS", + "plan_id": plan.plan_id, + "operation": plan.operation, + "changes": plan.changes, + } + if forced_by_config: + # The caller passed dry_run=false but safety.require_dry_run + # forced it back on. Tell them exactly why and how to unlock + # real writes — without this, agents (e.g. Claude Code) retry + # in an infinite loop because the old message said to "call + # again with dry_run=false", which they already did. + config_path = config.source_path or "~/.adloop/config.yaml" + response["dry_run_forced_by"] = "config.safety.require_dry_run" + response["config_path"] = config_path + response["remediation"] = ( + f"Edit {config_path}, set 'require_dry_run: false' under " + "'safety:', then restart the AdLoop MCP server. Passing " + "dry_run=false on this tool will keep being overridden " + "until that flag is flipped." + ) + response["message"] = ( + f"dry_run=false was IGNORED because 'safety.require_dry_run: true' " + f"is set in {config_path}. No changes were made. To apply real " + f"changes, flip that flag to false and restart the AdLoop MCP " + f"server — retrying this tool with dry_run=false alone will " + f"never succeed while the flag is on." + ) + else: + response["message"] = ( + "Dry run completed — no changes were made to your Google Ads account. " + "To apply for real, call confirm_and_apply again with dry_run=false." + ) + return response + + try: + result = _execute_plan(config, plan) + except Exception as e: + error_message = _extract_error_message(e) + log_mutation( + config.safety.log_file, + operation=plan.operation, + customer_id=plan.customer_id, + entity_type=plan.entity_type, + entity_id=plan.entity_id, + changes=plan.changes, + dry_run=False, + result="error", + error=error_message, + ) + return {"error": error_message, "plan_id": plan.plan_id} + + log_mutation( + config.safety.log_file, + operation=plan.operation, + customer_id=plan.customer_id, + entity_type=plan.entity_type, + entity_id=plan.entity_id, + changes=plan.changes, + dry_run=False, + result="success", + ) + remove_plan(plan.plan_id) + + return { + "status": "APPLIED", + "plan_id": plan.plan_id, + "operation": plan.operation, + "result": result, + } + + +# --------------------------------------------------------------------------- +# Internal validation helpers +# --------------------------------------------------------------------------- + +_VALID_MATCH_TYPES = {"EXACT", "PHRASE", "BROAD"} +_VALID_ENTITY_TYPES = {"campaign", "ad_group", "ad", "keyword"} +_REMOVABLE_ENTITY_TYPES = _VALID_ENTITY_TYPES | { + "negative_keyword", "campaign_asset", "asset", "customer_asset", + "shared_criterion", +} + +_SMART_BIDDING_STRATEGIES = { + "MAXIMIZE_CONVERSIONS", + "MAXIMIZE_CONVERSION_VALUE", + "TARGET_CPA", + "TARGET_ROAS", +} + + +def _campaign_uses_manual_cpc( + config: AdLoopConfig, customer_id: str, campaign_id: str +) -> bool | None: + """Return True when the campaign exists and uses MANUAL_CPC.""" + bidding_strategy = _campaign_bidding_strategy(config, customer_id, campaign_id) + if bidding_strategy is None: + return None + return bidding_strategy == "MANUAL_CPC" + + +def _campaign_bidding_strategy( + config: AdLoopConfig, customer_id: str, campaign_id: str +) -> str | None: + """Return the bidding strategy type for the campaign, if it exists.""" + from adloop.ads.gaql import execute_query + + query = f""" + SELECT campaign.bidding_strategy_type + FROM campaign + WHERE campaign.id = {campaign_id} + LIMIT 1 + """ + rows = execute_query(config, customer_id, query) + if not rows: + return None + return rows[0].get("campaign.bidding_strategy_type") + + +def _existing_negative_geo_exclusions( + config: AdLoopConfig, customer_id: str, campaign_id: str +) -> list[str]: + """Return geo_target_constant IDs that are currently negative-excluded. + + Used by ``update_campaign`` to surface preserved negative-location + criteria in the preview when ``geo_target_ids`` is being changed. + Negative location criteria survive a positive-geo replacement (issue + #32) — this helper makes that explicit in the preview so users can + see what's staying. Returns an empty list on any query failure; + surfacing exclusions is informational, not safety-critical. + """ + from adloop.ads.gaql import execute_query + + query = f""" + SELECT campaign_criterion.location.geo_target_constant + FROM campaign_criterion + WHERE campaign.id = {campaign_id} + AND campaign_criterion.type = 'LOCATION' + AND campaign_criterion.negative = TRUE + """ + try: + rows = execute_query(config, customer_id, query) + except Exception: + return [] + + ids: list[str] = [] + for row in rows: + gtc = row.get("campaign_criterion.location.geo_target_constant") or "" + # gtc looks like "geoTargetConstants/2840" — strip prefix to numeric ID. + if "/" in gtc: + ids.append(gtc.rsplit("/", 1)[-1]) + elif gtc: + ids.append(gtc) + return ids + + +def _ad_group_campaign_bidding_strategy( + config: AdLoopConfig, customer_id: str, ad_group_id: str +) -> str | None: + """Return the bidding strategy type of the campaign owning this ad group. + + Returns the enum name (``MANUAL_CPC``, ``TARGET_SPEND``, + ``MAXIMIZE_CONVERSIONS``, ``TARGET_CPA``, ``TARGET_ROAS``, etc.) or + ``None`` when the ad group can't be resolved. + """ + from adloop.ads.gaql import execute_query + + query = f""" + SELECT campaign.bidding_strategy_type + FROM ad_group + WHERE ad_group.id = {ad_group_id} + LIMIT 1 + """ + rows = execute_query(config, customer_id, query) + if not rows: + return None + return rows[0].get("campaign.bidding_strategy_type") + + +_VALID_DAYS_OF_WEEK = { + "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", + "SATURDAY", "SUNDAY", +} +_VALID_MINUTES = {0, 15, 30, 45} + + +def _validate_ad_schedule( + schedule: list[dict], +) -> tuple[list[dict], list[str]]: + """Validate ad schedule entries. Returns (validated, errors). + + Each entry: {day_of_week, start_hour, end_hour, start_minute=0, end_minute=0}. + Google Ads only accepts minutes in {0, 15, 30, 45}, hours in 0-24, and + requires end > start. Day-of-week strings are normalized to upper-case. + """ + errors = [] + validated = [] + for i, entry in enumerate(schedule or []): + if not isinstance(entry, dict): + errors.append(f"ad_schedule[{i}]: must be a dict") + continue + day = str(entry.get("day_of_week", "")).strip().upper() + if day not in _VALID_DAYS_OF_WEEK: + errors.append( + f"ad_schedule[{i}]: day_of_week must be one of {sorted(_VALID_DAYS_OF_WEEK)}" + ) + continue + try: + start_hour = int(entry.get("start_hour", -1)) + end_hour = int(entry.get("end_hour", -1)) + start_minute = int(entry.get("start_minute", 0)) + end_minute = int(entry.get("end_minute", 0)) + except (TypeError, ValueError): + errors.append(f"ad_schedule[{i}]: hour/minute values must be integers") + continue + if not (0 <= start_hour <= 23): + errors.append(f"ad_schedule[{i}]: start_hour must be in 0..23") + if not (0 <= end_hour <= 24): + errors.append(f"ad_schedule[{i}]: end_hour must be in 0..24") + if start_minute not in _VALID_MINUTES: + errors.append( + f"ad_schedule[{i}]: start_minute must be one of {sorted(_VALID_MINUTES)}" + ) + if end_minute not in _VALID_MINUTES: + errors.append( + f"ad_schedule[{i}]: end_minute must be one of {sorted(_VALID_MINUTES)}" + ) + if (end_hour, end_minute) <= (start_hour, start_minute): + errors.append( + f"ad_schedule[{i}]: end ({end_hour}:{end_minute:02d}) must be after " + f"start ({start_hour}:{start_minute:02d})" + ) + validated.append({ + "day_of_week": day, + "start_hour": start_hour, + "start_minute": start_minute, + "end_hour": end_hour, + "end_minute": end_minute, + }) + return validated, errors + + +def _validate_callouts( + callouts: list[str], +) -> tuple[list[str], list[str]]: + errors = [] + validated = [] + + if not callouts: + errors.append("At least one callout is required") + + for index, callout in enumerate(callouts): + text = callout.strip() + if not text: + errors.append(f"Callout {index + 1}: text is required") + elif len(text) > 25: + errors.append( + f"Callout {index + 1}: '{text}' is {len(text)} chars (max 25)" + ) + else: + validated.append(text) + + return validated, errors + + +def _validate_structured_snippets( + snippets: list[dict], +) -> tuple[list[dict], list[str]]: + errors = [] + validated = [] + + if not snippets: + errors.append("At least one structured snippet is required") + + for index, snippet in enumerate(snippets): + header = snippet.get("header", "").strip() + values = [value.strip() for value in snippet.get("values", [])] + + if header not in _STRUCTURED_SNIPPET_HEADERS: + errors.append( + f"Structured snippet {index + 1}: header must be one of " + f"{sorted(_STRUCTURED_SNIPPET_HEADERS)}" + ) + if len(values) < 3 or len(values) > 10: + errors.append( + f"Structured snippet {index + 1}: values must contain 3-10 items" + ) + for value_index, value in enumerate(values): + if not value: + errors.append( + f"Structured snippet {index + 1}: value {value_index + 1} is required" + ) + elif len(value) > 25: + errors.append( + f"Structured snippet {index + 1}: value '{value}' is " + f"{len(value)} chars (max 25)" + ) + + validated.append({"header": header, "values": values}) + + return validated, errors + + +def _validate_image_assets( + image_paths: list[str], +) -> tuple[list[dict[str, object]], list[str]]: + errors = [] + validated = [] + + if not image_paths: + errors.append("At least one image path is required") + + for index, image_path in enumerate(image_paths): + try: + validated.append(_parse_image_metadata(image_path)) + except ValueError as exc: + errors.append(f"Image {index + 1}: {exc}") + + return validated, errors + + +def _check_broad_match_safety( + config: AdLoopConfig, + customer_id: str, + ad_group_id: str, + keywords: list[dict], +) -> list[str]: + """Warn if BROAD match keywords are being added to a non-Smart Bidding campaign.""" + has_broad = any( + (kw.get("match_type") or "").upper() == "BROAD" for kw in keywords + ) + if not has_broad: + return [] + + try: + from adloop.ads.gaql import execute_query + + query = f""" + SELECT campaign.bidding_strategy_type, campaign.name + FROM ad_group + WHERE ad_group.id = {ad_group_id} + """ + rows = execute_query(config, customer_id, query) + if not rows: + return [] + + bidding = rows[0].get("campaign.bidding_strategy_type", "") + campaign_name = rows[0].get("campaign.name", "") + + if bidding not in _SMART_BIDDING_STRATEGIES: + return [ + f"DANGEROUS: Adding BROAD match keywords to campaign " + f"'{campaign_name}' which uses {bidding} bidding. " + f"Broad Match without Smart Bidding (tCPA/tROAS/Maximize Conversions) " + f"leads to irrelevant matches and wasted budget. " + f"Use PHRASE or EXACT match instead, or switch the campaign " + f"to Smart Bidding first." + ] + except Exception: + pass + + return [] + + +def _validate_rsa_assets( + headlines: list[dict], + descriptions: list[dict], + enforce_headline_count: bool = True, + enforce_description_count: bool = True, +) -> list[str]: + """Validate RSA headline/description content: count, char limits, pin slots. + + Shared by ``_validate_rsa`` (full RSA create) and + ``update_responsive_search_ad`` (in-place replace of headlines/descriptions). + Google enforces 3-15 headlines / 2-4 descriptions whenever the list is + sent. The update path uses ``enforce_*_count=False`` for the list it is + NOT replacing, since omitted lists are untouched on the live ad — only + the supplied list is gated on count. + """ + errors: list[str] = [] + if enforce_headline_count: + if len(headlines) < 3: + errors.append(f"Need at least 3 headlines, got {len(headlines)}") + if len(headlines) > 15: + errors.append(f"Maximum 15 headlines, got {len(headlines)}") + if enforce_description_count: + if len(descriptions) < 2: + errors.append(f"Need at least 2 descriptions, got {len(descriptions)}") + if len(descriptions) > 4: + errors.append(f"Maximum 4 descriptions, got {len(descriptions)}") + + headline_pin_counts: dict[str, int] = {} + for i, h in enumerate(headlines): + text = h["text"] + pin = h["pinned_field"] + if len(text) > 30: + errors.append( + f"Headline {i + 1} exceeds 30 chars ({len(text)}): '{text}'" + ) + if pin is not None: + if pin not in _VALID_HEADLINE_PINS: + errors.append( + f"Headline {i + 1} pinned_field '{pin}' invalid; " + f"must be one of {sorted(_VALID_HEADLINE_PINS)} or null" + ) + else: + headline_pin_counts[pin] = headline_pin_counts.get(pin, 0) + 1 + for pin, count in headline_pin_counts.items(): + if count > 2: + errors.append(f"At most 2 headlines may pin to {pin}; got {count}") + + description_pin_counts: dict[str, int] = {} + for i, d in enumerate(descriptions): + text = d["text"] + pin = d["pinned_field"] + if len(text) > 90: + errors.append( + f"Description {i + 1} exceeds 90 chars ({len(text)}): '{text}'" + ) + if pin is not None: + if pin not in _VALID_DESCRIPTION_PINS: + errors.append( + f"Description {i + 1} pinned_field '{pin}' invalid; " + f"must be one of {sorted(_VALID_DESCRIPTION_PINS)} or null" + ) + else: + description_pin_counts[pin] = description_pin_counts.get(pin, 0) + 1 + for pin, count in description_pin_counts.items(): + if count > 1: + errors.append(f"At most 1 description may pin to {pin}; got {count}") + + return errors + + +def _validate_rsa( + ad_group_id: str, + headlines: list[dict], + descriptions: list[dict], + final_url: str, +) -> list[str]: + errors = [] + if not ad_group_id: + errors.append("ad_group_id is required") + if not final_url: + errors.append("final_url is required") + errors.extend(_validate_rsa_assets(headlines, descriptions)) + + return errors + + +_VALID_BIDDING_STRATEGIES = { + "MAXIMIZE_CONVERSIONS", + "MAXIMIZE_CONVERSION_VALUE", + "TARGET_CPA", + "TARGET_ROAS", + "TARGET_SPEND", + "MANUAL_CPC", +} + +_VALID_CHANNEL_TYPES = {"SEARCH", "DISPLAY", "SHOPPING", "VIDEO", "PERFORMANCE_MAX"} + + +def _validate_campaign( + config: AdLoopConfig, + *, + campaign_name: str, + daily_budget: float, + bidding_strategy: str, + target_cpa: float, + target_roas: float, + channel_type: str, + keywords: list[dict] | None, + geo_target_ids: list[str] | None, + language_ids: list[str] | None, + customer_id: str = "", + search_partners_enabled: bool = False, + display_network_enabled: bool = False, + max_cpc: float = 0, +) -> tuple[list[str], list[str]]: + """Validate campaign draft inputs. Returns (errors, warnings).""" + errors = [] + warnings = [] + + if not campaign_name or not campaign_name.strip(): + errors.append("campaign_name is required") + if daily_budget <= 0: + errors.append("daily_budget must be greater than 0") + if not geo_target_ids: + errors.append( + "geo_target_ids is required — campaigns must target at least one " + "country/region (e.g. ['2276'] for Germany, ['2840'] for USA)" + ) + if not language_ids: + errors.append( + "language_ids is required — campaigns must target at least one " + "language (e.g. ['1001'] for German, ['1000'] for English)" + ) + + bs = bidding_strategy.upper() + if bs not in _VALID_BIDDING_STRATEGIES: + errors.append( + f"bidding_strategy must be one of {sorted(_VALID_BIDDING_STRATEGIES)}, " + f"got '{bidding_strategy}'" + ) + if bs == "TARGET_CPA" and not target_cpa: + errors.append("target_cpa is required when bidding_strategy is TARGET_CPA") + if bs == "TARGET_ROAS" and not target_roas: + errors.append("target_roas is required when bidding_strategy is TARGET_ROAS") + + ct = channel_type.upper() + if ct not in _VALID_CHANNEL_TYPES: + errors.append( + f"channel_type must be one of {sorted(_VALID_CHANNEL_TYPES)}, " + f"got '{channel_type}'" + ) + if ct != "SEARCH" and search_partners_enabled: + errors.append("search_partners_enabled is only supported for SEARCH campaigns") + if ct != "SEARCH" and display_network_enabled: + errors.append("display_network_enabled is only supported for SEARCH campaigns") + if max_cpc < 0: + errors.append("max_cpc cannot be negative") + if max_cpc and bs not in {"MANUAL_CPC", "TARGET_SPEND"}: + errors.append("max_cpc requires MANUAL_CPC or TARGET_SPEND bidding_strategy") + + if keywords: + has_broad = any( + (kw.get("match_type") or "").upper() == "BROAD" for kw in keywords + ) + if has_broad and bs not in _SMART_BIDDING_STRATEGIES: + errors.append( + f"BROAD match keywords require Smart Bidding " + f"(tCPA/tROAS/Maximize Conversions). " + f"'{bidding_strategy}' is not a Smart Bidding strategy. " + f"Use PHRASE or EXACT match instead." + ) + for i, kw in enumerate(keywords): + if not kw.get("text"): + errors.append(f"Keyword {i + 1} has no text") + mt = (kw.get("match_type") or "").upper() + if mt not in _VALID_MATCH_TYPES: + errors.append( + f"Keyword {i + 1} has invalid match_type '{mt}' " + "(must be EXACT, PHRASE, or BROAD)" + ) + + if target_cpa > 0 and daily_budget < 5 * target_cpa: + from adloop.ads.currency import format_currency, get_currency_code + currency_code = get_currency_code(config, customer_id) + warnings.append( + f"Daily budget {format_currency(daily_budget, currency_code)} is less than 5x target CPA " + f"{format_currency(target_cpa, currency_code)}. Google recommends at least 5x target CPA " + f"({format_currency(5 * target_cpa, currency_code)}/day) for sufficient learning data." + ) + + if bs == "MANUAL_CPC": + warnings.append( + "MANUAL_CPC bidding requires constant monitoring. Consider using " + "MAXIMIZE_CONVERSIONS or TARGET_CPA for automated optimization." + ) + + return errors, warnings + + +def _validate_keywords(ad_group_id: str, keywords: list[dict]) -> list[str]: + errors = [] + if not ad_group_id: + errors.append("ad_group_id is required") + if not keywords: + errors.append("At least one keyword is required") + for i, kw in enumerate(keywords): + if not kw.get("text"): + errors.append(f"Keyword {i + 1} has no text") + mt = (kw.get("match_type") or "").upper() + if mt not in _VALID_MATCH_TYPES: + errors.append( + f"Keyword {i + 1} has invalid match_type '{mt}' " + "(must be EXACT, PHRASE, or BROAD)" + ) + return errors + + +def _validate_ad_group( + *, + campaign_id: str, + ad_group_name: str, + keywords: list[dict] | None, + cpc_bid_micros: int, +) -> list[str]: + """Validate inputs for draft_ad_group.""" + errors = [] + if not campaign_id: + errors.append("campaign_id is required") + if not ad_group_name or not ad_group_name.strip(): + errors.append("ad_group_name is required") + if cpc_bid_micros < 0: + errors.append("cpc_bid_micros must be >= 0") + if keywords: + for i, kw in enumerate(keywords): + if not kw.get("text"): + errors.append(f"Keyword {i + 1} has no text") + mt = (kw.get("match_type") or "").upper() + if mt not in _VALID_MATCH_TYPES: + errors.append( + f"Keyword {i + 1} has invalid match_type '{mt}' " + "(must be EXACT, PHRASE, or BROAD)" + ) + return errors + + +def _preflight_ad_group_checks( + config: AdLoopConfig, + customer_id: str, + campaign_id: str, + ad_group_name: str, + keywords: list[dict], + cpc_bid_micros: int, +) -> tuple[list[str], list[str]]: + """Run pre-flight checks before creating an ad group. + + Returns (errors, warnings). Errors block the draft; warnings are informational. + + Checks performed: + 1. Campaign must be a SEARCH campaign (error if not). + 2. Warn if cpc_bid_micros is set but campaign uses Smart Bidding (ignored). + 3. Warn if BROAD match keywords + non-Smart Bidding campaign. + 4. Warn if an ad group with the same name already exists in the campaign. + """ + errors: list[str] = [] + warnings: list[str] = [] + + try: + from adloop.ads.gaql import execute_query + + # Query 1: campaign info (type, bidding, name) + campaign_query = f""" + SELECT campaign.advertising_channel_type, + campaign.bidding_strategy_type, + campaign.name + FROM campaign + WHERE campaign.id = {campaign_id} + """ + rows = execute_query(config, customer_id, campaign_query) + if not rows: + errors.append( + f"Campaign {campaign_id} not found. Verify the campaign ID " + "using get_campaign_performance." + ) + return errors, warnings + + row = rows[0] + channel_type = row.get("campaign.advertising_channel_type", "") + bidding = row.get("campaign.bidding_strategy_type", "") + campaign_name = row.get("campaign.name", "") + + # Check 1: campaign type must be SEARCH + if channel_type and channel_type != "SEARCH": + errors.append( + f"Campaign '{campaign_name}' is a {channel_type} campaign. " + "draft_ad_group only supports SEARCH campaigns." + ) + + # Check 2: cpc_bid_micros on Smart Bidding is ignored + if cpc_bid_micros and bidding in _SMART_BIDDING_STRATEGIES: + warnings.append( + f"Campaign '{campaign_name}' uses {bidding} (Smart Bidding). " + "The cpc_bid_micros value will be ignored — Smart Bidding " + "sets bids automatically." + ) + + # Check 3: BROAD match + non-Smart Bidding + has_broad = any( + (kw.get("match_type") or "").upper() == "BROAD" for kw in keywords + ) + if has_broad and bidding not in _SMART_BIDDING_STRATEGIES: + warnings.append( + f"DANGEROUS: Adding BROAD match keywords to campaign " + f"'{campaign_name}' which uses {bidding} bidding. " + f"Broad Match without Smart Bidding (tCPA/tROAS/Maximize " + f"Conversions) leads to irrelevant matches and wasted budget. " + f"Use PHRASE or EXACT match instead, or switch the campaign " + f"to Smart Bidding first." + ) + + # Check 4: existing ad groups (duplicate name check) + ag_query = f""" + SELECT ad_group.name + FROM ad_group + WHERE campaign.id = {campaign_id} + """ + ag_rows = execute_query(config, customer_id, ag_query) + existing_names = {r.get("ad_group.name", "") for r in ag_rows} + if ad_group_name in existing_names: + warnings.append( + f"An ad group named '{ad_group_name}' already exists in " + f"campaign '{campaign_name}'. This will create a duplicate. " + f"Consider using a different name to avoid confusion." + ) + + except Exception as exc: + # Surface preflight failures as warnings so users know checks + # were skipped, rather than silently producing a clean preview. + warnings.append( + f"Preflight checks could not complete ({exc}). " + "The draft will proceed, but some validations were skipped. " + "Full validation happens at confirm_and_apply time." + ) + + return errors, warnings + + +def _draft_status_change( + config: AdLoopConfig, + operation: str, + customer_id: str, + entity_type: str, + entity_id: str, + target_status: str, +) -> dict: + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation(operation, config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + errors = [] + if entity_type not in _VALID_ENTITY_TYPES: + errors.append( + f"entity_type must be one of {_VALID_ENTITY_TYPES}, got '{entity_type}'" + ) + if not entity_id: + errors.append("entity_id is required") + if errors: + return {"error": "Validation failed", "details": errors} + + plan = ChangePlan( + operation=operation, + entity_type=entity_type, + entity_id=entity_id, + customer_id=customer_id, + changes={"target_status": target_status}, + ) + store_plan(plan) + return plan.to_preview() + + +# --------------------------------------------------------------------------- +# Execution — actual Google Ads API mutate calls +# --------------------------------------------------------------------------- + + +_MUTATE_RESPONSE_RESULT_FIELDS = [ + "campaign_budget_result", + "campaign_result", + "ad_group_result", + "ad_group_ad_result", + "ad_group_criterion_result", + "campaign_criterion_result", + "asset_result", + "campaign_asset_result", + "customer_asset_result", +] + + +def _extract_resource_name(resp: object) -> str: + """Extract the resource_name from a MutateOperationResponse. + + Uses direct field access instead of WhichOneof, which doesn't work on + proto-plus wrapped messages returned by the google-ads library. + """ + for field in _MUTATE_RESPONSE_RESULT_FIELDS: + try: + result = getattr(resp, field, None) + if result and result.resource_name: + return result.resource_name + except Exception: + continue + return "" + + +def _execute_plan(config: AdLoopConfig, plan: object) -> dict: + """Dispatch to the right Google Ads mutate call based on plan.operation.""" + from adloop.ads.client import get_ads_client, normalize_customer_id + + client = get_ads_client(config) + cid = normalize_customer_id(plan.customer_id) + + dispatch = { + "create_campaign": _apply_create_campaign, + "create_ad_group": _apply_create_ad_group, + "update_campaign": _apply_update_campaign, + "update_ad_group": _apply_update_ad_group, + "create_responsive_search_ad": _apply_create_rsa, + "update_responsive_search_ad": _apply_update_rsa, + "add_keywords": _apply_add_keywords, + "add_negative_keywords": _apply_add_negative_keywords, + "create_negative_keyword_list": _apply_create_negative_keyword_list, + "add_to_negative_keyword_list": _apply_add_to_negative_keyword_list, + "attach_shared_set_to_campaigns": _apply_attach_shared_set_to_campaigns, + "detach_shared_set_from_campaigns": _apply_detach_shared_set_from_campaigns, + "pause_entity": _apply_status_change, + "enable_entity": _apply_status_change, + "remove_entity": _apply_remove, + "create_callouts": _apply_create_callouts, + "create_structured_snippets": _apply_create_structured_snippets, + "create_image_assets": _apply_create_image_assets, + "create_sitelinks": _apply_create_sitelinks, + "create_call_asset": _apply_create_call_asset, + "create_location_asset": _apply_create_location_asset, + "create_business_name_asset": _apply_create_business_name_asset, + "create_promotion": _apply_create_promotion, + "update_promotion": _apply_update_promotion, + "link_asset_to_customer": _apply_link_asset_to_customer, + "update_call_asset": _apply_update_call_asset, + "update_sitelink": _apply_update_sitelink, + "update_callout": _apply_update_callout, + "create_conversion_action": _apply_create_conversion_action_route, + "update_conversion_action": _apply_update_conversion_action_route, + "remove_conversion_action": _apply_remove_conversion_action_route, + "upload_call_conversions": _apply_upload_call_conversions_route, + "upload_enhanced_conversions_for_leads": + _apply_upload_enhanced_conversions_for_leads_route, + "add_ad_schedule": _apply_add_ad_schedule, + "add_geo_exclusions": _apply_add_geo_exclusions, + } + + handler = dispatch.get(plan.operation) + if handler is None: + raise ValueError(f"Unknown operation: {plan.operation}") + + if plan.operation in ("pause_entity", "enable_entity"): + return handler( + client, + cid, + plan.entity_type, + plan.entity_id, + plan.changes["target_status"], + ) + + if plan.operation == "remove_entity": + return handler(client, cid, plan.entity_type, plan.entity_id) + + return handler(client, cid, plan.changes) + + +def _apply_update_ad_group(client: object, cid: str, changes: dict) -> dict: + """Update an ad group's name and/or manual CPC bid.""" + from google.protobuf import field_mask_pb2 + + service = client.get_service("AdGroupService") + operation = client.get_type("AdGroupOperation") + ad_group = operation.update + ad_group.resource_name = service.ad_group_path(cid, changes["ad_group_id"]) + + field_paths = [] + if changes.get("ad_group_name"): + ad_group.name = changes["ad_group_name"] + field_paths.append("name") + if changes.get("max_cpc"): + ad_group.cpc_bid_micros = int(changes["max_cpc"] * 1_000_000) + field_paths.append("cpc_bid_micros") + + operation.update_mask = field_mask_pb2.FieldMask(paths=field_paths) + response = service.mutate_ad_groups(customer_id=cid, operations=[operation]) + return {"resource_name": response.results[0].resource_name} + + +def _apply_create_campaign(client: object, cid: str, changes: dict) -> dict: + """Create campaign + budget + ad group + optional keywords atomically.""" + service = client.get_service("GoogleAdsService") + campaign_service = client.get_service("CampaignService") + budget_service = client.get_service("CampaignBudgetService") + ad_group_service = client.get_service("AdGroupService") + + operations = [] + + # 1. CampaignBudget (temp ID: -1) + budget_op = client.get_type("MutateOperation") + budget = budget_op.campaign_budget_operation.create budget.resource_name = budget_service.campaign_budget_path(cid, "-1") budget.name = f"Budget - {changes['campaign_name']}" budget.amount_micros = int(changes["daily_budget"] * 1_000_000) @@ -2217,742 +3345,1850 @@ def _apply_create_campaign(client: object, cid: str, changes: dict) -> dict: budget.explicitly_shared = False operations.append(budget_op) - # 2. Campaign (temp ID: -2, references budget -1) - campaign_op = client.get_type("MutateOperation") - campaign = campaign_op.campaign_operation.create - campaign.resource_name = campaign_service.campaign_path(cid, "-2") - campaign.name = changes["campaign_name"] - campaign.campaign_budget = budget_service.campaign_budget_path(cid, "-1") - campaign.status = client.enums.CampaignStatusEnum.PAUSED + # 2. Campaign (temp ID: -2, references budget -1) + campaign_op = client.get_type("MutateOperation") + campaign = campaign_op.campaign_operation.create + campaign.resource_name = campaign_service.campaign_path(cid, "-2") + campaign.name = changes["campaign_name"] + campaign.campaign_budget = budget_service.campaign_budget_path(cid, "-1") + campaign.status = client.enums.CampaignStatusEnum.PAUSED + + channel = changes.get("channel_type", "SEARCH") + campaign.advertising_channel_type = getattr( + client.enums.AdvertisingChannelTypeEnum, channel + ) + + bs = changes["bidding_strategy"] + if bs == "MAXIMIZE_CONVERSIONS": + campaign.maximize_conversions.target_cpa_micros = 0 + if changes.get("target_cpa"): + campaign.maximize_conversions.target_cpa_micros = int( + changes["target_cpa"] * 1_000_000 + ) + elif bs == "TARGET_CPA": + campaign.maximize_conversions.target_cpa_micros = int( + changes["target_cpa"] * 1_000_000 + ) + elif bs == "MAXIMIZE_CONVERSION_VALUE": + campaign.maximize_conversion_value.target_roas = 0 + if changes.get("target_roas"): + campaign.maximize_conversion_value.target_roas = changes["target_roas"] + elif bs == "TARGET_ROAS": + campaign.maximize_conversion_value.target_roas = changes["target_roas"] + elif bs == "TARGET_SPEND": + campaign.target_spend.target_spend_micros = 0 + if changes.get("max_cpc"): + campaign.target_spend.cpc_bid_ceiling_micros = int( + changes["max_cpc"] * 1_000_000 + ) + elif bs == "MANUAL_CPC": + campaign.manual_cpc.enhanced_cpc_enabled = False + + campaign.network_settings.target_google_search = True + campaign.network_settings.target_search_network = changes.get( + "search_partners_enabled", False + ) + campaign.network_settings.target_content_network = changes.get( + "display_network_enabled", False + ) + + # EU political advertising declaration — required for campaigns that may + # serve in EU countries. This is an ENUM, not a bool. Value 3 means + # "does not contain EU political advertising" (the default for most users). + # Setting False/0 maps to UNSPECIFIED which proto3 strips from the wire. + campaign.contains_eu_political_advertising = ( + client.enums.EuPoliticalAdvertisingStatusEnum.DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING + ) + + operations.append(campaign_op) + + # 3. AdGroup (temp ID: -3, references campaign -2) + ag_op = client.get_type("MutateOperation") + ad_group = ag_op.ad_group_operation.create + ad_group.resource_name = ad_group_service.ad_group_path(cid, "-3") + ad_group.name = changes.get("ad_group_name", changes["campaign_name"]) + ad_group.campaign = campaign_service.campaign_path(cid, "-2") + ad_group.status = client.enums.AdGroupStatusEnum.ENABLED + ad_group.type_ = client.enums.AdGroupTypeEnum.SEARCH_STANDARD + if bs == "MANUAL_CPC" and changes.get("max_cpc"): + ad_group.cpc_bid_micros = int(changes["max_cpc"] * 1_000_000) + operations.append(ag_op) + + # 4. Keywords (reference ad_group -3) + kw_list = changes.get("keywords") or [] + for kw in kw_list: + kw_op = client.get_type("MutateOperation") + criterion = kw_op.ad_group_criterion_operation.create + criterion.ad_group = ad_group_service.ad_group_path(cid, "-3") + criterion.keyword.text = kw["text"] + criterion.keyword.match_type = getattr( + client.enums.KeywordMatchTypeEnum, kw["match_type"].upper() + ) + operations.append(kw_op) + + # 5. Geo targeting (CampaignCriterion referencing campaign -2) + for geo_id in changes.get("geo_target_ids") or []: + geo_op = client.get_type("MutateOperation") + geo_criterion = geo_op.campaign_criterion_operation.create + geo_criterion.campaign = campaign_service.campaign_path(cid, "-2") + geo_criterion.location.geo_target_constant = ( + f"geoTargetConstants/{geo_id}" + ) + operations.append(geo_op) + + # 6. Language targeting (CampaignCriterion referencing campaign -2) + for lang_id in changes.get("language_ids") or []: + lang_op = client.get_type("MutateOperation") + lang_criterion = lang_op.campaign_criterion_operation.create + lang_criterion.campaign = campaign_service.campaign_path(cid, "-2") + lang_criterion.language.language_constant = ( + f"languageConstants/{lang_id}" + ) + operations.append(lang_op) + + # 7. Geo exclusions (negative CampaignCriterion location records) + for geo_id in changes.get("geo_exclude_ids") or []: + op = client.get_type("MutateOperation") + crit = op.campaign_criterion_operation.create + crit.campaign = campaign_service.campaign_path(cid, "-2") + crit.location.geo_target_constant = f"geoTargetConstants/{geo_id}" + crit.negative = True + operations.append(op) + + # 8. Ad schedule (CampaignCriterion AdScheduleInfo records) + for entry in changes.get("ad_schedule") or []: + op = client.get_type("MutateOperation") + crit = op.campaign_criterion_operation.create + crit.campaign = campaign_service.campaign_path(cid, "-2") + _populate_ad_schedule_info(client, crit.ad_schedule, entry) + operations.append(op) + + response = service.mutate(customer_id=cid, mutate_operations=operations) + + results = {} + num_keywords = len(kw_list) + num_geo = len(changes.get("geo_target_ids") or []) + num_lang = len(changes.get("language_ids") or []) + num_excl = len(changes.get("geo_exclude_ids") or []) + num_sched = len(changes.get("ad_schedule") or []) + for i, resp in enumerate(response.mutate_operation_responses): + rn = _extract_resource_name(resp) + if not rn: + continue + if i == 0: + results["campaign_budget"] = rn + elif i == 1: + results["campaign"] = rn + elif i == 2: + results["ad_group"] = rn + elif i < 3 + num_keywords: + results.setdefault("keywords", []).append(rn) + elif i < 3 + num_keywords + num_geo: + results.setdefault("geo_targets", []).append(rn) + elif i < 3 + num_keywords + num_geo + num_lang: + results.setdefault("language_targets", []).append(rn) + elif i < 3 + num_keywords + num_geo + num_lang + num_excl: + results.setdefault("geo_excludes", []).append(rn) + elif i < 3 + num_keywords + num_geo + num_lang + num_excl + num_sched: + results.setdefault("ad_schedule", []).append(rn) + + return results + + +def _apply_create_ad_group(client: object, cid: str, changes: dict) -> dict: + """Create ad group + optional keywords in an existing campaign atomically.""" + service = client.get_service("GoogleAdsService") + campaign_service = client.get_service("CampaignService") + ad_group_service = client.get_service("AdGroupService") + + operations: list = [] + + # 1. AdGroup (temp ID: -1, references existing campaign) + ag_op = client.get_type("MutateOperation") + ad_group = ag_op.ad_group_operation.create + ad_group.resource_name = ad_group_service.ad_group_path(cid, "-1") + ad_group.name = changes["ad_group_name"] + ad_group.campaign = campaign_service.campaign_path(cid, changes["campaign_id"]) + ad_group.status = client.enums.AdGroupStatusEnum.ENABLED + ad_group.type_ = client.enums.AdGroupTypeEnum.SEARCH_STANDARD + if changes.get("cpc_bid_micros"): + ad_group.cpc_bid_micros = changes["cpc_bid_micros"] + operations.append(ag_op) + + # 2. Keywords (reference ad_group -1) + kw_list = changes.get("keywords") or [] + for kw in kw_list: + kw_op = client.get_type("MutateOperation") + criterion = kw_op.ad_group_criterion_operation.create + criterion.ad_group = ad_group_service.ad_group_path(cid, "-1") + criterion.keyword.text = kw["text"] + criterion.keyword.match_type = getattr( + client.enums.KeywordMatchTypeEnum, kw["match_type"].upper() + ) + operations.append(kw_op) + + response = service.mutate(customer_id=cid, mutate_operations=operations) + + results: dict = {} + for i, resp in enumerate(response.mutate_operation_responses): + rn = _extract_resource_name(resp) + if rn: + if i == 0: + results["ad_group"] = rn + else: + results.setdefault("keywords", []).append(rn) + + return results + + +def _apply_update_campaign(client: object, cid: str, changes: dict) -> dict: + """Update an existing campaign's settings.""" + from google.protobuf import field_mask_pb2 + + service = client.get_service("GoogleAdsService") + campaign_service = client.get_service("CampaignService") + operations = [] + field_paths = [] + + campaign_id = changes["campaign_id"] + resource_name = campaign_service.campaign_path(cid, campaign_id) + + # Bid strategy and campaign-level setting changes + bs = changes.get("bidding_strategy") + search_partners_enabled = changes.get("search_partners_enabled") + display_network_enabled = changes.get("display_network_enabled") + if ( + bs + or search_partners_enabled is not None + or display_network_enabled is not None + or changes.get("max_cpc") + ): + campaign_op = client.get_type("MutateOperation") + campaign = campaign_op.campaign_operation.update + campaign.resource_name = resource_name + + if bs == "MAXIMIZE_CONVERSIONS": + campaign.maximize_conversions.target_cpa_micros = 0 + if changes.get("target_cpa"): + campaign.maximize_conversions.target_cpa_micros = int( + changes["target_cpa"] * 1_000_000 + ) + field_paths.append("maximize_conversions.target_cpa_micros") + elif bs == "TARGET_CPA": + campaign.maximize_conversions.target_cpa_micros = int( + changes["target_cpa"] * 1_000_000 + ) + field_paths.append("maximize_conversions.target_cpa_micros") + elif bs == "MAXIMIZE_CONVERSION_VALUE": + campaign.maximize_conversion_value.target_roas = 0 + if changes.get("target_roas"): + campaign.maximize_conversion_value.target_roas = changes[ + "target_roas" + ] + field_paths.append("maximize_conversion_value.target_roas") + elif bs == "TARGET_ROAS": + campaign.maximize_conversion_value.target_roas = changes["target_roas"] + field_paths.append("maximize_conversion_value.target_roas") + elif bs == "TARGET_SPEND": + campaign.target_spend.target_spend_micros = 0 + field_paths.append("target_spend.target_spend_micros") + elif bs == "MANUAL_CPC": + campaign.manual_cpc.enhanced_cpc_enabled = False + field_paths.append("manual_cpc.enhanced_cpc_enabled") + + if changes.get("max_cpc"): + campaign.target_spend.cpc_bid_ceiling_micros = int( + changes["max_cpc"] * 1_000_000 + ) + field_paths.append("target_spend.cpc_bid_ceiling_micros") + + if search_partners_enabled is not None: + campaign.network_settings.target_search_network = search_partners_enabled + field_paths.append("network_settings.target_search_network") + if display_network_enabled is not None: + campaign.network_settings.target_content_network = display_network_enabled + field_paths.append("network_settings.target_content_network") + + if field_paths: + campaign_op.campaign_operation.update_mask.CopyFrom( + field_mask_pb2.FieldMask(paths=field_paths) + ) + operations.append(campaign_op) + + # Budget change — requires finding the budget resource name first + new_budget = changes.get("daily_budget") + if new_budget: + budget_query = f""" + SELECT campaign.campaign_budget + FROM campaign + WHERE campaign.id = {campaign_id} + """ + rows = list(service.search(customer_id=cid, query=budget_query)) + if not rows: + raise ValueError(f"Campaign {campaign_id} not found") + budget_rn = rows[0].campaign.campaign_budget + + budget_op = client.get_type("MutateOperation") + budget = budget_op.campaign_budget_operation.update + budget.resource_name = budget_rn + budget.amount_micros = int(new_budget * 1_000_000) + budget_op.campaign_budget_operation.update_mask.CopyFrom( + field_mask_pb2.FieldMask(paths=["amount_micros"]) + ) + operations.append(budget_op) + + # Geo targeting — replace POSITIVE location criteria, preserve NEGATIVE + # location exclusions. The previous implementation filtered on + # campaign_criterion.type = 'LOCATION' alone, which swept up negative + # exclusions (e.g. excluding the USA from a worldwide campaign) and + # silently removed them when the user added or swapped a positive geo. + # That's a data-safety bug — users wouldn't notice the exclusion was + # gone until traffic from the excluded region started showing up in + # reports (issue #32). Restrict the removal scope with + # ``campaign_criterion.negative = FALSE`` so negatives survive. + geo_ids = changes.get("geo_target_ids") + if geo_ids is not None: + existing_positive_geo = f""" + SELECT campaign_criterion.resource_name + FROM campaign_criterion + WHERE campaign.id = {campaign_id} + AND campaign_criterion.type = 'LOCATION' + AND campaign_criterion.negative = FALSE + """ + for row in service.search(customer_id=cid, query=existing_positive_geo): + rm_op = client.get_type("MutateOperation") + rm_op.campaign_criterion_operation.remove = ( + row.campaign_criterion.resource_name + ) + operations.append(rm_op) + + for geo_id in geo_ids: + add_op = client.get_type("MutateOperation") + criterion = add_op.campaign_criterion_operation.create + criterion.campaign = resource_name + criterion.location.geo_target_constant = ( + f"geoTargetConstants/{geo_id}" + ) + operations.append(add_op) + + # Language targeting — remove existing, add new + lang_ids = changes.get("language_ids") + if lang_ids is not None: + existing_lang = f""" + SELECT campaign_criterion.resource_name + FROM campaign_criterion + WHERE campaign.id = {campaign_id} + AND campaign_criterion.type = 'LANGUAGE' + """ + for row in service.search(customer_id=cid, query=existing_lang): + rm_op = client.get_type("MutateOperation") + rm_op.campaign_criterion_operation.remove = ( + row.campaign_criterion.resource_name + ) + operations.append(rm_op) + + for lang_id in lang_ids: + add_op = client.get_type("MutateOperation") + criterion = add_op.campaign_criterion_operation.create + criterion.campaign = resource_name + criterion.language.language_constant = ( + f"languageConstants/{lang_id}" + ) + operations.append(add_op) + + # Geo exclusions — remove existing negative-location criteria, add new + excl_ids = changes.get("geo_exclude_ids") + if excl_ids is not None: + existing_excl = f""" + SELECT campaign_criterion.resource_name + FROM campaign_criterion + WHERE campaign.id = {campaign_id} + AND campaign_criterion.type = 'LOCATION' + AND campaign_criterion.negative = TRUE + """ + for row in service.search(customer_id=cid, query=existing_excl): + rm_op = client.get_type("MutateOperation") + rm_op.campaign_criterion_operation.remove = ( + row.campaign_criterion.resource_name + ) + operations.append(rm_op) + + for geo_id in excl_ids: + add_op = client.get_type("MutateOperation") + criterion = add_op.campaign_criterion_operation.create + criterion.campaign = resource_name + criterion.location.geo_target_constant = ( + f"geoTargetConstants/{geo_id}" + ) + criterion.negative = True + operations.append(add_op) + + # Ad schedule — remove existing schedule criteria, add new + schedule = changes.get("ad_schedule") + if schedule is not None: + existing_sched = f""" + SELECT campaign_criterion.resource_name + FROM campaign_criterion + WHERE campaign.id = {campaign_id} + AND campaign_criterion.type = 'AD_SCHEDULE' + """ + for row in service.search(customer_id=cid, query=existing_sched): + rm_op = client.get_type("MutateOperation") + rm_op.campaign_criterion_operation.remove = ( + row.campaign_criterion.resource_name + ) + operations.append(rm_op) + + for entry in schedule: + add_op = client.get_type("MutateOperation") + criterion = add_op.campaign_criterion_operation.create + criterion.campaign = resource_name + _populate_ad_schedule_info(client, criterion.ad_schedule, entry) + operations.append(add_op) + + if not operations: + return {"message": "No changes to apply"} + + response = service.mutate(customer_id=cid, mutate_operations=operations) + + results = {"updated": []} + for resp in response.mutate_operation_responses: + rn = _extract_resource_name(resp) + if rn: + results["updated"].append(rn) + return results + + +def _apply_create_rsa(client: object, cid: str, changes: dict) -> dict: + service = client.get_service("AdGroupAdService") + operation = client.get_type("AdGroupAdOperation") + ad_group_ad = operation.create + + ad_group_ad.ad_group = client.get_service("AdGroupService").ad_group_path( + cid, changes["ad_group_id"] + ) + # Create as PAUSED for safety — user can enable separately + ad_group_ad.status = client.enums.AdGroupAdStatusEnum.PAUSED + + ad = ad_group_ad.ad + ad.final_urls.append(changes["final_url"]) + + for entry in changes["headlines"]: + asset = client.get_type("AdTextAsset") + asset.text = entry["text"] + if entry.get("pinned_field"): + asset.pinned_field = client.enums.ServedAssetFieldTypeEnum[ + entry["pinned_field"] + ] + ad.responsive_search_ad.headlines.append(asset) + + for entry in changes["descriptions"]: + asset = client.get_type("AdTextAsset") + asset.text = entry["text"] + if entry.get("pinned_field"): + asset.pinned_field = client.enums.ServedAssetFieldTypeEnum[ + entry["pinned_field"] + ] + ad.responsive_search_ad.descriptions.append(asset) + + if changes.get("path1"): + ad.responsive_search_ad.path1 = changes["path1"] + if changes.get("path2"): + ad.responsive_search_ad.path2 = changes["path2"] + + response = service.mutate_ad_group_ads( + customer_id=cid, operations=[operation] + ) + return {"resource_name": response.results[0].resource_name} + + +def _apply_update_rsa(client: object, cid: str, changes: dict) -> dict: + """Update mutable fields on an existing RSA in place. + + Builds a sparse AdOperation.update with only the fields the caller asked + to change, attached to a FieldMask so Google Ads ignores everything else. + Verified mutable on RSAs in API v23 via ``AdService.MutateAds``: + ``final_urls``, ``responsive_search_ad.path1``, + ``responsive_search_ad.path2``, ``responsive_search_ad.headlines``, + ``responsive_search_ad.descriptions``. Headlines/descriptions are + list-replace — the supplied list fully replaces the existing one. + """ + from google.protobuf import field_mask_pb2 + + service = client.get_service("AdService") + operation = client.get_type("AdOperation") + ad = operation.update + ad.resource_name = service.ad_path(cid, changes["ad_id"]) + + field_paths: list[str] = [] + + if "final_url" in changes: + ad.final_urls.append(changes["final_url"]) + field_paths.append("final_urls") + + if "path1" in changes: + ad.responsive_search_ad.path1 = changes["path1"] + field_paths.append("responsive_search_ad.path1") + + if "path2" in changes: + ad.responsive_search_ad.path2 = changes["path2"] + field_paths.append("responsive_search_ad.path2") + + if "headlines" in changes: + for entry in changes["headlines"]: + asset = client.get_type("AdTextAsset") + asset.text = entry["text"] + if entry.get("pinned_field"): + asset.pinned_field = client.enums.ServedAssetFieldTypeEnum[ + entry["pinned_field"] + ] + ad.responsive_search_ad.headlines.append(asset) + field_paths.append("responsive_search_ad.headlines") + + if "descriptions" in changes: + for entry in changes["descriptions"]: + asset = client.get_type("AdTextAsset") + asset.text = entry["text"] + if entry.get("pinned_field"): + asset.pinned_field = client.enums.ServedAssetFieldTypeEnum[ + entry["pinned_field"] + ] + ad.responsive_search_ad.descriptions.append(asset) + field_paths.append("responsive_search_ad.descriptions") + + operation.update_mask = field_mask_pb2.FieldMask(paths=field_paths) + response = service.mutate_ads(customer_id=cid, operations=[operation]) + return {"resource_name": response.results[0].resource_name} + + +def _apply_add_keywords(client: object, cid: str, changes: dict) -> dict: + service = client.get_service("AdGroupCriterionService") + ad_group_path = client.get_service("AdGroupService").ad_group_path( + cid, changes["ad_group_id"] + ) + + operations = [] + for kw in changes["keywords"]: + operation = client.get_type("AdGroupCriterionOperation") + criterion = operation.create + criterion.ad_group = ad_group_path + criterion.keyword.text = kw["text"] + criterion.keyword.match_type = getattr( + client.enums.KeywordMatchTypeEnum, kw["match_type"].upper() + ) + operations.append(operation) + + response = service.mutate_ad_group_criteria( + customer_id=cid, operations=operations + ) + return {"resource_names": [r.resource_name for r in response.results]} + + +def _apply_add_negative_keywords(client: object, cid: str, changes: dict) -> dict: + service = client.get_service("CampaignCriterionService") + campaign_path = client.get_service("CampaignService").campaign_path( + cid, changes["campaign_id"] + ) + + operations = [] + for kw_text in changes["keywords"]: + operation = client.get_type("CampaignCriterionOperation") + criterion = operation.create + criterion.campaign = campaign_path + criterion.negative = True + criterion.keyword.text = kw_text + criterion.keyword.match_type = getattr( + client.enums.KeywordMatchTypeEnum, changes["match_type"] + ) + operations.append(operation) + + response = service.mutate_campaign_criteria( + customer_id=cid, operations=operations + ) + return {"resource_names": [r.resource_name for r in response.results]} + + +def _resolve_ad_entity_id(client: object, cid: str, entity_id: str) -> str: + """Ensure ad entity_id is in 'adGroupId~adId' composite format. + + If only a bare ad ID is given, queries the API to find the ad group. + """ + if "~" in entity_id: + return entity_id + + ga_service = client.get_service("GoogleAdsService") + query = ( + f"SELECT ad_group.id, ad_group_ad.ad.id " + f"FROM ad_group_ad " + f"WHERE ad_group_ad.ad.id = {entity_id} " + f"LIMIT 1" + ) + response = ga_service.search(customer_id=cid, query=query) + for row in response: + ag_id = row.ad_group.id + return f"{ag_id}~{entity_id}" + + raise ValueError( + f"Ad ID {entity_id} not found. Pass the composite ID as " + f"'adGroupId~adId' (e.g. '12345678~{entity_id}')." + ) + + +def _apply_remove( + client: object, + cid: str, + entity_type: str, + entity_id: str, +) -> dict: + """Remove an entity via the REMOVE mutate operation (irreversible).""" + if entity_type == "campaign": + service = client.get_service("CampaignService") + operation = client.get_type("CampaignOperation") + operation.remove = service.campaign_path(cid, entity_id) + response = service.mutate_campaigns( + customer_id=cid, operations=[operation] + ) + + elif entity_type == "ad_group": + service = client.get_service("AdGroupService") + operation = client.get_type("AdGroupOperation") + operation.remove = service.ad_group_path(cid, entity_id) + response = service.mutate_ad_groups( + customer_id=cid, operations=[operation] + ) + + elif entity_type == "ad": + resolved_id = _resolve_ad_entity_id(client, cid, entity_id) + service = client.get_service("AdGroupAdService") + operation = client.get_type("AdGroupAdOperation") + operation.remove = f"customers/{cid}/adGroupAds/{resolved_id}" + response = service.mutate_ad_group_ads( + customer_id=cid, operations=[operation] + ) + + elif entity_type == "keyword": + service = client.get_service("AdGroupCriterionService") + operation = client.get_type("AdGroupCriterionOperation") + operation.remove = f"customers/{cid}/adGroupCriteria/{entity_id}" + response = service.mutate_ad_group_criteria( + customer_id=cid, operations=[operation] + ) - channel = changes.get("channel_type", "SEARCH") - campaign.advertising_channel_type = getattr( - client.enums.AdvertisingChannelTypeEnum, channel - ) + elif entity_type == "negative_keyword": + service = client.get_service("CampaignCriterionService") + operation = client.get_type("CampaignCriterionOperation") + operation.remove = f"customers/{cid}/campaignCriteria/{entity_id}" + response = service.mutate_campaign_criteria( + customer_id=cid, operations=[operation] + ) - bs = changes["bidding_strategy"] - if bs == "MAXIMIZE_CONVERSIONS": - campaign.maximize_conversions.target_cpa_micros = 0 - if changes.get("target_cpa"): - campaign.maximize_conversions.target_cpa_micros = int( - changes["target_cpa"] * 1_000_000 + elif entity_type == "shared_criterion": + if "~" not in entity_id: + raise ValueError( + f"shared_criterion entity_id must be " + f"'sharedSetId~criterionId', got '{entity_id}'" ) - elif bs == "TARGET_CPA": - campaign.maximize_conversions.target_cpa_micros = int( - changes["target_cpa"] * 1_000_000 + service = client.get_service("SharedCriterionService") + operation = client.get_type("SharedCriterionOperation") + operation.remove = f"customers/{cid}/sharedCriteria/{entity_id}" + response = service.mutate_shared_criteria( + customer_id=cid, operations=[operation] ) - elif bs == "MAXIMIZE_CONVERSION_VALUE": - campaign.maximize_conversion_value.target_roas = 0 - if changes.get("target_roas"): - campaign.maximize_conversion_value.target_roas = changes["target_roas"] - elif bs == "TARGET_ROAS": - campaign.maximize_conversion_value.target_roas = changes["target_roas"] - elif bs == "TARGET_SPEND": - campaign.target_spend.target_spend_micros = 0 - if changes.get("max_cpc"): - campaign.target_spend.cpc_bid_ceiling_micros = int( - changes["max_cpc"] * 1_000_000 + + elif entity_type == "campaign_asset": + parts = entity_id.split("~") + if len(parts) != 3: + raise ValueError( + f"campaign_asset entity_id must be " + f"'campaignId~assetId~fieldType', got '{entity_id}'" ) - elif bs == "MANUAL_CPC": - campaign.manual_cpc.enhanced_cpc_enabled = False + resource_name = f"customers/{cid}/campaignAssets/{entity_id}" + ga_service = client.get_service("GoogleAdsService") + op = client.get_type("MutateOperation") + op.campaign_asset_operation.remove = resource_name + response = ga_service.mutate( + customer_id=cid, mutate_operations=[op] + ) + resp_inner = response.mutate_operation_responses[0] + if resp_inner.campaign_asset_result.resource_name: + return {"resource_name": resp_inner.campaign_asset_result.resource_name} + return {"resource_name": resource_name, "status": "removed"} - campaign.network_settings.target_google_search = True - campaign.network_settings.target_search_network = changes.get( - "search_partners_enabled", False - ) - campaign.network_settings.target_content_network = changes.get( - "display_network_enabled", False - ) + elif entity_type == "asset": + service = client.get_service("AssetService") + operation = client.get_type("AssetOperation") + operation.remove = service.asset_path(cid, entity_id) + response = service.mutate_assets( + customer_id=cid, operations=[operation] + ) - # EU political advertising declaration — required for campaigns that may - # serve in EU countries. This is an ENUM, not a bool. Value 3 means - # "does not contain EU political advertising" (the default for most users). - # Setting False/0 maps to UNSPECIFIED which proto3 strips from the wire. - campaign.contains_eu_political_advertising = ( - client.enums.EuPoliticalAdvertisingStatusEnum.DOES_NOT_CONTAIN_EU_POLITICAL_ADVERTISING - ) + elif entity_type == "customer_asset": + parts = entity_id.split("~") + if len(parts) != 2: + raise ValueError( + f"customer_asset entity_id must be " + f"'assetId~fieldType', got '{entity_id}'" + ) + resource_name = f"customers/{cid}/customerAssets/{entity_id}" + ga_service = client.get_service("GoogleAdsService") + op = client.get_type("MutateOperation") + op.customer_asset_operation.remove = resource_name + response = ga_service.mutate( + customer_id=cid, mutate_operations=[op] + ) + resp_inner = response.mutate_operation_responses[0] + if resp_inner.customer_asset_result.resource_name: + return {"resource_name": resp_inner.customer_asset_result.resource_name} + return {"resource_name": resource_name, "status": "removed"} - operations.append(campaign_op) + else: + raise ValueError(f"Cannot remove entity_type: {entity_type}") - # 3. AdGroup (temp ID: -3, references campaign -2) - ag_op = client.get_type("MutateOperation") - ad_group = ag_op.ad_group_operation.create - ad_group.resource_name = ad_group_service.ad_group_path(cid, "-3") - ad_group.name = changes.get("ad_group_name", changes["campaign_name"]) - ad_group.campaign = campaign_service.campaign_path(cid, "-2") - ad_group.status = client.enums.AdGroupStatusEnum.ENABLED - ad_group.type_ = client.enums.AdGroupTypeEnum.SEARCH_STANDARD - if bs == "MANUAL_CPC" and changes.get("max_cpc"): - ad_group.cpc_bid_micros = int(changes["max_cpc"] * 1_000_000) - operations.append(ag_op) + return {"resource_name": response.results[0].resource_name} - # 4. Keywords (reference ad_group -3) - kw_list = changes.get("keywords") or [] - for kw in kw_list: - kw_op = client.get_type("MutateOperation") - criterion = kw_op.ad_group_criterion_operation.create - criterion.ad_group = ad_group_service.ad_group_path(cid, "-3") - criterion.keyword.text = kw["text"] - criterion.keyword.match_type = getattr( - client.enums.KeywordMatchTypeEnum, kw["match_type"].upper() - ) - operations.append(kw_op) - # 5. Geo targeting (CampaignCriterion referencing campaign -2) - for geo_id in changes.get("geo_target_ids") or []: - geo_op = client.get_type("MutateOperation") - geo_criterion = geo_op.campaign_criterion_operation.create - geo_criterion.campaign = campaign_service.campaign_path(cid, "-2") - geo_criterion.location.geo_target_constant = ( - f"geoTargetConstants/{geo_id}" - ) - operations.append(geo_op) +def _apply_status_change( + client: object, + cid: str, + entity_type: str, + entity_id: str, + status: str, +) -> dict: + """Update the status of a campaign, ad group, ad, or keyword.""" + if entity_type == "campaign": + service = client.get_service("CampaignService") + operation = client.get_type("CampaignOperation") + entity = operation.update + entity.resource_name = service.campaign_path(cid, entity_id) + entity.status = getattr(client.enums.CampaignStatusEnum, status) + mutate = service.mutate_campaigns - # 6. Language targeting (CampaignCriterion referencing campaign -2) - for lang_id in changes.get("language_ids") or []: - lang_op = client.get_type("MutateOperation") - lang_criterion = lang_op.campaign_criterion_operation.create - lang_criterion.campaign = campaign_service.campaign_path(cid, "-2") - lang_criterion.language.language_constant = ( - f"languageConstants/{lang_id}" + elif entity_type == "ad_group": + service = client.get_service("AdGroupService") + operation = client.get_type("AdGroupOperation") + entity = operation.update + entity.resource_name = service.ad_group_path(cid, entity_id) + entity.status = getattr(client.enums.AdGroupStatusEnum, status) + mutate = service.mutate_ad_groups + + elif entity_type == "ad": + resolved_id = _resolve_ad_entity_id(client, cid, entity_id) + service = client.get_service("AdGroupAdService") + operation = client.get_type("AdGroupAdOperation") + entity = operation.update + entity.resource_name = f"customers/{cid}/adGroupAds/{resolved_id}" + entity.status = getattr(client.enums.AdGroupAdStatusEnum, status) + mutate = service.mutate_ad_group_ads + + elif entity_type == "keyword": + service = client.get_service("AdGroupCriterionService") + operation = client.get_type("AdGroupCriterionOperation") + entity = operation.update + entity.resource_name = f"customers/{cid}/adGroupCriteria/{entity_id}" + entity.status = getattr( + client.enums.AdGroupCriterionStatusEnum, status ) - operations.append(lang_op) + mutate = service.mutate_ad_group_criteria - response = service.mutate(customer_id=cid, mutate_operations=operations) + else: + raise ValueError(f"Unknown entity_type: {entity_type}") + + # Build field mask for the status field only + from google.protobuf import field_mask_pb2 + + operation.update_mask = field_mask_pb2.FieldMask(paths=["status"]) + + response = mutate(customer_id=cid, operations=[operation]) + return {"resource_name": response.results[0].resource_name} + + +def _apply_campaign_assets( + client: object, + cid: str, + campaign_id: str, + assets: list[dict], + field_type: object, + populate_asset: object, +) -> dict: + """Create assets and link them to a campaign via CampaignAsset (legacy alias).""" + return _apply_assets( + client, + cid, + assets, + field_type, + populate_asset, + scope="campaign", + campaign_id=campaign_id, + ) + + +def _apply_assets( + client: object, + cid: str, + assets: list[dict], + field_type: object, + populate_asset: object, + *, + scope: str = "campaign", + campaign_id: str = "", +) -> dict: + """Create Asset rows + link them at campaign or customer scope. + + scope: + - "campaign" → CampaignAsset (requires campaign_id) + - "customer" → CustomerAsset (account-level, applies to all eligible + campaigns by default) + """ + asset_service = client.get_service("AssetService") + googleads_service = client.get_service("GoogleAdsService") + operations = [] + + for i, payload in enumerate(assets): + op = client.get_type("MutateOperation") + asset = op.asset_operation.create + asset.resource_name = asset_service.asset_path(cid, str(-(i + 1))) + populate_asset(asset, payload) + operations.append(op) + + if scope == "campaign": + if not campaign_id: + raise ValueError("campaign_id is required for campaign-scope assets") + for i in range(len(assets)): + op = client.get_type("MutateOperation") + ca = op.campaign_asset_operation.create + ca.asset = asset_service.asset_path(cid, str(-(i + 1))) + ca.campaign = googleads_service.campaign_path(cid, campaign_id) + ca.field_type = field_type + operations.append(op) + elif scope == "customer": + for i in range(len(assets)): + op = client.get_type("MutateOperation") + cust_asset = op.customer_asset_operation.create + cust_asset.asset = asset_service.asset_path(cid, str(-(i + 1))) + cust_asset.field_type = field_type + operations.append(op) + else: + raise ValueError(f"Unknown asset scope: {scope}") + + response = googleads_service.mutate( + customer_id=cid, mutate_operations=operations + ) + + if scope == "campaign": + results = {"assets": [], "campaign_assets": []} + link_key = "campaign_assets" + else: + results = {"assets": [], "customer_assets": []} + link_key = "customer_assets" - results = {} - num_keywords = len(kw_list) - num_geo = len(changes.get("geo_target_ids") or []) - num_lang = len(changes.get("language_ids") or []) + num_assets = len(assets) for i, resp in enumerate(response.mutate_operation_responses): - rn = _extract_resource_name(resp) - if rn: - if i == 0: - results["campaign_budget"] = rn - elif i == 1: - results["campaign"] = rn - elif i == 2: - results["ad_group"] = rn - elif i < 3 + num_keywords: - results.setdefault("keywords", []).append(rn) - elif i < 3 + num_keywords + num_geo: - results.setdefault("geo_targets", []).append(rn) + resource = None + if resp.asset_result.resource_name: + resource = resp.asset_result.resource_name + elif scope == "campaign" and resp.campaign_asset_result.resource_name: + resource = resp.campaign_asset_result.resource_name + elif scope == "customer" and resp.customer_asset_result.resource_name: + resource = resp.customer_asset_result.resource_name + + if resource: + if i < num_assets: + results["assets"].append(resource) else: - results.setdefault("language_targets", []).append(rn) + results[link_key].append(resource) return results -def _apply_create_ad_group(client: object, cid: str, changes: dict) -> dict: - """Create ad group + optional keywords in an existing campaign atomically.""" - service = client.get_service("GoogleAdsService") - campaign_service = client.get_service("CampaignService") - ad_group_service = client.get_service("AdGroupService") +def _apply_create_callouts(client: object, cid: str, changes: dict) -> dict: + """Create callout assets at customer or campaign scope.""" - operations: list = [] + def populate(asset: object, payload: dict) -> None: + asset.callout_asset.callout_text = payload["callout_text"] - # 1. AdGroup (temp ID: -1, references existing campaign) - ag_op = client.get_type("MutateOperation") - ad_group = ag_op.ad_group_operation.create - ad_group.resource_name = ad_group_service.ad_group_path(cid, "-1") - ad_group.name = changes["ad_group_name"] - ad_group.campaign = campaign_service.campaign_path(cid, changes["campaign_id"]) - ad_group.status = client.enums.AdGroupStatusEnum.ENABLED - ad_group.type_ = client.enums.AdGroupTypeEnum.SEARCH_STANDARD - if changes.get("cpc_bid_micros"): - ad_group.cpc_bid_micros = changes["cpc_bid_micros"] - operations.append(ag_op) + assets = [{"callout_text": text} for text in changes["callouts"]] + return _apply_assets( + client, + cid, + assets, + client.enums.AssetFieldTypeEnum.CALLOUT, + populate, + scope=changes.get("scope", "campaign"), + campaign_id=changes.get("campaign_id", ""), + ) - # 2. Keywords (reference ad_group -1) - kw_list = changes.get("keywords") or [] - for kw in kw_list: - kw_op = client.get_type("MutateOperation") - criterion = kw_op.ad_group_criterion_operation.create - criterion.ad_group = ad_group_service.ad_group_path(cid, "-1") - criterion.keyword.text = kw["text"] - criterion.keyword.match_type = getattr( - client.enums.KeywordMatchTypeEnum, kw["match_type"].upper() - ) - operations.append(kw_op) - response = service.mutate(customer_id=cid, mutate_operations=operations) +def _apply_create_structured_snippets( + client: object, cid: str, changes: dict +) -> dict: + """Create structured snippet assets at customer or campaign scope.""" - results: dict = {} - for i, resp in enumerate(response.mutate_operation_responses): - rn = _extract_resource_name(resp) - if rn: - if i == 0: - results["ad_group"] = rn - else: - results.setdefault("keywords", []).append(rn) + def populate(asset: object, payload: dict) -> None: + asset.structured_snippet_asset.header = payload["header"] + asset.structured_snippet_asset.values.extend(payload["values"]) - return results + return _apply_assets( + client, + cid, + changes["snippets"], + client.enums.AssetFieldTypeEnum.STRUCTURED_SNIPPET, + populate, + scope=changes.get("scope", "campaign"), + campaign_id=changes.get("campaign_id", ""), + ) -def _apply_update_campaign(client: object, cid: str, changes: dict) -> dict: - """Update an existing campaign's settings.""" - from google.protobuf import field_mask_pb2 +_VALID_IMAGE_FIELD_TYPES = { + "MARKETING_IMAGE", + "SQUARE_MARKETING_IMAGE", + "PORTRAIT_MARKETING_IMAGE", + "TALL_PORTRAIT_MARKETING_IMAGE", + "LOGO", + "LANDSCAPE_LOGO", + "BUSINESS_LOGO", +} - service = client.get_service("GoogleAdsService") - campaign_service = client.get_service("CampaignService") - operations = [] - field_paths = [] - campaign_id = changes["campaign_id"] - resource_name = campaign_service.campaign_path(cid, campaign_id) +def _detect_image_field_type(payload: dict) -> str: + """Pick the best AssetFieldType (string) for an image based on aspect + ratio and a filename hint. - # Bid strategy and campaign-level setting changes - bs = changes.get("bidding_strategy") - search_partners_enabled = changes.get("search_partners_enabled") - display_network_enabled = changes.get("display_network_enabled") - if ( - bs - or search_partners_enabled is not None - or display_network_enabled is not None - or changes.get("max_cpc") - ): - campaign_op = client.get_type("MutateOperation") - campaign = campaign_op.campaign_operation.update - campaign.resource_name = resource_name + Google rejects ``AD_IMAGE`` for direct campaign/customer asset links — + image extensions need ``MARKETING_IMAGE``, ``SQUARE_MARKETING_IMAGE``, + or one of the LOGO variants. This helper picks the field type the + asset link service will actually accept. - if bs == "MAXIMIZE_CONVERSIONS": - campaign.maximize_conversions.target_cpa_micros = 0 - if changes.get("target_cpa"): - campaign.maximize_conversions.target_cpa_micros = int( - changes["target_cpa"] * 1_000_000 - ) - field_paths.append("maximize_conversions.target_cpa_micros") - elif bs == "TARGET_CPA": - campaign.maximize_conversions.target_cpa_micros = int( - changes["target_cpa"] * 1_000_000 + payload may include an explicit ``"field_type"`` to override detection. + """ + explicit = payload.get("field_type") + if explicit: + upper = str(explicit).upper() + if upper not in _VALID_IMAGE_FIELD_TYPES: + raise ValueError( + f"field_type '{explicit}' is not a supported image asset " + f"field type. Valid: {sorted(_VALID_IMAGE_FIELD_TYPES)}" ) - field_paths.append("maximize_conversions.target_cpa_micros") - elif bs == "MAXIMIZE_CONVERSION_VALUE": - campaign.maximize_conversion_value.target_roas = 0 - if changes.get("target_roas"): - campaign.maximize_conversion_value.target_roas = changes[ - "target_roas" - ] - field_paths.append("maximize_conversion_value.target_roas") - elif bs == "TARGET_ROAS": - campaign.maximize_conversion_value.target_roas = changes["target_roas"] - field_paths.append("maximize_conversion_value.target_roas") - elif bs == "TARGET_SPEND": - campaign.target_spend.target_spend_micros = 0 - field_paths.append("target_spend.target_spend_micros") - elif bs == "MANUAL_CPC": - campaign.manual_cpc.enhanced_cpc_enabled = False - field_paths.append("manual_cpc.enhanced_cpc_enabled") + return upper - if changes.get("max_cpc"): - campaign.target_spend.cpc_bid_ceiling_micros = int( - changes["max_cpc"] * 1_000_000 - ) - field_paths.append("target_spend.cpc_bid_ceiling_micros") + width = int(payload.get("width", 0)) + height = int(payload.get("height", 0)) + name_lower = str(payload.get("name", "")).lower() + path_lower = str(payload.get("path", "")).lower() + is_logo_hint = "logo" in name_lower or "logo" in path_lower - if search_partners_enabled is not None: - campaign.network_settings.target_search_network = search_partners_enabled - field_paths.append("network_settings.target_search_network") - if display_network_enabled is not None: - campaign.network_settings.target_content_network = display_network_enabled - field_paths.append("network_settings.target_content_network") + if width <= 0 or height <= 0: + return "MARKETING_IMAGE" - if field_paths: - campaign_op.campaign_operation.update_mask.CopyFrom( - field_mask_pb2.FieldMask(paths=field_paths) - ) - operations.append(campaign_op) + ratio = width / height - # Budget change — requires finding the budget resource name first - new_budget = changes.get("daily_budget") - if new_budget: - budget_query = f""" - SELECT campaign.campaign_budget - FROM campaign - WHERE campaign.id = {campaign_id} - """ - rows = list(service.search(customer_id=cid, query=budget_query)) - if not rows: - raise ValueError(f"Campaign {campaign_id} not found") - budget_rn = rows[0].campaign.campaign_budget + if 0.95 <= ratio <= 1.05: + return "BUSINESS_LOGO" if is_logo_hint else "SQUARE_MARKETING_IMAGE" + if 3.5 <= ratio <= 4.5 and is_logo_hint: + return "LANDSCAPE_LOGO" + if 1.65 <= ratio <= 2.15: + return "MARKETING_IMAGE" + if 0.7 <= ratio <= 0.85: + return "PORTRAIT_MARKETING_IMAGE" + if 0.4 <= ratio < 0.7: + return "TALL_PORTRAIT_MARKETING_IMAGE" + # Fallback: treat anything wider than tall as marketing image + return "MARKETING_IMAGE" if ratio >= 1.0 else "PORTRAIT_MARKETING_IMAGE" - budget_op = client.get_type("MutateOperation") - budget = budget_op.campaign_budget_operation.update - budget.resource_name = budget_rn - budget.amount_micros = int(new_budget * 1_000_000) - budget_op.campaign_budget_operation.update_mask.CopyFrom( - field_mask_pb2.FieldMask(paths=["amount_micros"]) + +def _apply_create_image_assets(client: object, cid: str, changes: dict) -> dict: + """Create image assets from local files and link them at customer or + campaign scope. + + Field type is auto-detected per image from aspect ratio (with a 'logo' + filename hint), or you can override per-image via ``payload['field_type']``. + """ + asset_service = client.get_service("AssetService") + googleads_service = client.get_service("GoogleAdsService") + images = changes["images"] + scope = changes.get("scope", "campaign") + campaign_id = changes.get("campaign_id", "") + + if scope == "campaign" and not campaign_id: + raise ValueError("campaign_id is required for campaign-scope image assets") + + operations: list = [] + + # Phase 1 — create Asset rows + for i, payload in enumerate(images): + op = client.get_type("MutateOperation") + asset = op.asset_operation.create + asset.resource_name = asset_service.asset_path(cid, str(-(i + 1))) + image_path = Path(str(payload["path"])) + image_bytes = image_path.read_bytes() + mime_type_name = _VALID_IMAGE_MIME_TYPES[str(payload["mime_type"])] + asset.name = str( + payload.get("name") or _build_image_asset_name(image_path, image_bytes) ) - operations.append(budget_op) + asset.type_ = client.enums.AssetTypeEnum.IMAGE + asset.image_asset.data = image_bytes + asset.image_asset.mime_type = getattr( + client.enums.MimeTypeEnum, mime_type_name + ) + asset.image_asset.full_size.width_pixels = int(payload["width"]) + asset.image_asset.full_size.height_pixels = int(payload["height"]) + operations.append(op) - # Geo targeting — replace POSITIVE location criteria, preserve NEGATIVE - # location exclusions. The previous implementation filtered on - # campaign_criterion.type = 'LOCATION' alone, which swept up negative - # exclusions (e.g. excluding the USA from a worldwide campaign) and - # silently removed them when the user added or swapped a positive geo. - # That's a data-safety bug — users wouldn't notice the exclusion was - # gone until traffic from the excluded region started showing up in - # reports (issue #32). Restrict the removal scope with - # ``campaign_criterion.negative = FALSE`` so negatives survive. - geo_ids = changes.get("geo_target_ids") - if geo_ids is not None: - existing_positive_geo = f""" - SELECT campaign_criterion.resource_name - FROM campaign_criterion - WHERE campaign.id = {campaign_id} - AND campaign_criterion.type = 'LOCATION' - AND campaign_criterion.negative = FALSE - """ - for row in service.search(customer_id=cid, query=existing_positive_geo): - rm_op = client.get_type("MutateOperation") - rm_op.campaign_criterion_operation.remove = ( - row.campaign_criterion.resource_name - ) - operations.append(rm_op) + # Phase 2 — link each asset with its detected/explicit field type + for i, payload in enumerate(images): + ft_name = _detect_image_field_type(payload) + ft_enum = getattr(client.enums.AssetFieldTypeEnum, ft_name) + op = client.get_type("MutateOperation") + if scope == "campaign": + link = op.campaign_asset_operation.create + link.asset = asset_service.asset_path(cid, str(-(i + 1))) + link.campaign = googleads_service.campaign_path(cid, campaign_id) + link.field_type = ft_enum + elif scope == "customer": + link = op.customer_asset_operation.create + link.asset = asset_service.asset_path(cid, str(-(i + 1))) + link.field_type = ft_enum + else: + raise ValueError(f"Unknown asset scope: {scope}") + operations.append(op) - for geo_id in geo_ids: - add_op = client.get_type("MutateOperation") - criterion = add_op.campaign_criterion_operation.create - criterion.campaign = resource_name - criterion.location.geo_target_constant = ( - f"geoTargetConstants/{geo_id}" - ) - operations.append(add_op) + response = googleads_service.mutate( + customer_id=cid, mutate_operations=operations + ) - # Language targeting — remove existing, add new - lang_ids = changes.get("language_ids") - if lang_ids is not None: - existing_lang = f""" - SELECT campaign_criterion.resource_name - FROM campaign_criterion - WHERE campaign.id = {campaign_id} - AND campaign_criterion.type = 'LANGUAGE' - """ - for row in service.search(customer_id=cid, query=existing_lang): - rm_op = client.get_type("MutateOperation") - rm_op.campaign_criterion_operation.remove = ( - row.campaign_criterion.resource_name - ) - operations.append(rm_op) + results: dict = { + "assets": [], + "campaign_assets": [] if scope == "campaign" else None, + "customer_assets": [] if scope == "customer" else None, + "field_types": [_detect_image_field_type(p) for p in images], + } + # Drop the None side + if scope == "campaign": + results.pop("customer_assets", None) + else: + results.pop("campaign_assets", None) + + num_images = len(images) + link_key = "campaign_assets" if scope == "campaign" else "customer_assets" + for i, resp in enumerate(response.mutate_operation_responses): + resource = None + if resp.asset_result.resource_name: + resource = resp.asset_result.resource_name + elif scope == "campaign" and resp.campaign_asset_result.resource_name: + resource = resp.campaign_asset_result.resource_name + elif scope == "customer" and resp.customer_asset_result.resource_name: + resource = resp.customer_asset_result.resource_name + if resource: + if i < num_images: + results["assets"].append(resource) + else: + results[link_key].append(resource) + return results + + +def draft_location_asset( + config: AdLoopConfig, + *, + customer_id: str = "", + business_profile_account_id: str = "", + asset_set_name: str = "", + campaign_id: str = "", + label_filters: list[str] | None = None, + listing_id_filters: list[str] | None = None, +) -> dict: + """Draft a Google Business Profile-backed location AssetSet — PREVIEW. + + Creates an ``AssetSet`` of type LOCATION_SYNC that pulls locations from a + linked Google Business Profile and exposes them as location assets. The + set is attached at the customer level (so all eligible campaigns get it + by default). Optionally also creates a ``CampaignAssetSet`` link to a + specific campaign. + + Required preflight: the Google Business Profile must already be linked + in Google Ads → Tools → Linked accounts → Business Profile. + + business_profile_account_id: numeric Business Profile (LBC) account ID, + e.g. "1234567890". Find via GBP admin. + asset_set_name: optional name for the AssetSet. Defaults to + "GBP Locations - ". + label_filters: optional list of GBP location labels to limit sync. + listing_id_filters: optional list of GBP listing IDs to limit sync. + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan - for lang_id in lang_ids: - add_op = client.get_type("MutateOperation") - criterion = add_op.campaign_criterion_operation.create - criterion.campaign = resource_name - criterion.language.language_constant = ( - f"languageConstants/{lang_id}" + try: + check_blocked_operation("create_location_asset", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + if not business_profile_account_id: + return { + "error": ( + "business_profile_account_id is required (numeric GBP/LBC account ID). " + "Find it in Google Business Profile admin." ) - operations.append(add_op) + } - if not operations: - return {"message": "No changes to apply"} + name = asset_set_name or f"GBP Locations - {business_profile_account_id}" + warnings = [ + "The Google Business Profile must already be linked at Tools → Linked " + "accounts → Business Profile in Google Ads. If it isn't, this tool " + "will fail at apply time." + ] - response = service.mutate(customer_id=cid, mutate_operations=operations) + plan = ChangePlan( + operation="create_location_asset", + entity_type="asset_set", + entity_id=customer_id, + customer_id=customer_id, + changes={ + "scope": "campaign" if campaign_id else "customer", + "campaign_id": campaign_id, + "business_profile_account_id": str(business_profile_account_id), + "asset_set_name": name, + "label_filters": list(label_filters or []), + "listing_id_filters": [str(x) for x in (listing_id_filters or [])], + }, + ) + store_plan(plan) + preview = plan.to_preview() + preview["warnings"] = warnings + return preview - results = {"updated": []} - for resp in response.mutate_operation_responses: - rn = _extract_resource_name(resp) - if rn: - results["updated"].append(rn) - return results +def _apply_create_location_asset( + client: object, cid: str, changes: dict +) -> dict: + """Create a LOCATION_SYNC AssetSet linked to a Google Business Profile. -def _apply_create_rsa(client: object, cid: str, changes: dict) -> dict: - service = client.get_service("AdGroupAdService") - operation = client.get_type("AdGroupAdOperation") - ad_group_ad = operation.create + Steps: + 1. Create AssetSet (type=LOCATION_SYNC, + location_set.business_profile_location_set.business_account_id=). + 2. Create CustomerAssetSet linking the set to the customer (customer scope). + 3. Or create CampaignAssetSet linking it to one campaign (campaign scope). - ad_group_ad.ad_group = client.get_service("AdGroupService").ad_group_path( - cid, changes["ad_group_id"] + Field model: ``LOCATION_SYNC`` AssetSets carry a ``location_set`` oneof. + For Google Business Profile, the ``business_profile_location_set`` variant + holds the GBP/LBC account ID and optional listing/label filters. + """ + asset_set_service = client.get_service("AssetSetService") + + # Step 1 — create AssetSet + set_op = client.get_type("AssetSetOperation") + asset_set = set_op.create + asset_set.name = changes["asset_set_name"] + asset_set.type_ = client.enums.AssetSetTypeEnum.LOCATION_SYNC + bpls = asset_set.location_set.business_profile_location_set + # business_account_id is exposed as STRING by proto-plus even though the + # value is a numeric GBP/LBC account id. + bpls.business_account_id = str(changes["business_profile_account_id"]) + for label in changes.get("label_filters") or []: + bpls.label_filters.append(label) + for listing_id in changes.get("listing_id_filters") or []: + bpls.listing_id_filters.append(int(listing_id)) + + set_response = asset_set_service.mutate_asset_sets( + customer_id=cid, operations=[set_op] ) - # Create as PAUSED for safety — user can enable separately - ad_group_ad.status = client.enums.AdGroupAdStatusEnum.PAUSED + asset_set_resource = set_response.results[0].resource_name + + result = {"asset_set": asset_set_resource} + + # Step 2/3 — link to customer or campaign + scope = changes.get("scope", "customer") + if scope == "customer": + cas_service = client.get_service("CustomerAssetSetService") + cas_op = client.get_type("CustomerAssetSetOperation") + cas_op.create.asset_set = asset_set_resource + cas_response = cas_service.mutate_customer_asset_sets( + customer_id=cid, operations=[cas_op] + ) + result["customer_asset_set"] = cas_response.results[0].resource_name + elif scope == "campaign": + if not changes.get("campaign_id"): + raise ValueError("campaign_id required for campaign-scope location asset") + campaign_service = client.get_service("CampaignService") + cas_service = client.get_service("CampaignAssetSetService") + cas_op = client.get_type("CampaignAssetSetOperation") + cas_op.create.asset_set = asset_set_resource + cas_op.create.campaign = campaign_service.campaign_path( + cid, changes["campaign_id"] + ) + cas_response = cas_service.mutate_campaign_asset_sets( + customer_id=cid, operations=[cas_op] + ) + result["campaign_asset_set"] = cas_response.results[0].resource_name + else: + raise ValueError(f"Unknown scope: {scope}") - ad = ad_group_ad.ad - ad.final_urls.append(changes["final_url"]) + return result - for entry in changes["headlines"]: - asset = client.get_type("AdTextAsset") - asset.text = entry["text"] - if entry.get("pinned_field"): - asset.pinned_field = client.enums.ServedAssetFieldTypeEnum[ - entry["pinned_field"] - ] - ad.responsive_search_ad.headlines.append(asset) - for entry in changes["descriptions"]: - asset = client.get_type("AdTextAsset") - asset.text = entry["text"] - if entry.get("pinned_field"): - asset.pinned_field = client.enums.ServedAssetFieldTypeEnum[ - entry["pinned_field"] - ] - ad.responsive_search_ad.descriptions.append(asset) +def draft_business_name_asset( + config: AdLoopConfig, + *, + customer_id: str = "", + campaign_id: str = "", + business_name: str = "", +) -> dict: + """Draft a business-name asset — returns a PREVIEW. - if changes.get("path1"): - ad.responsive_search_ad.path1 = changes["path1"] - if changes.get("path2"): - ad.responsive_search_ad.path2 = changes["path2"] + Creates a TEXT asset and links it as ``BUSINESS_NAME`` at customer or + campaign scope. Google shows the business name alongside ads (and on + image-rich placements like the maps card / local pack) so users can + recognize the brand at a glance. - response = service.mutate_ad_group_ads( - customer_id=cid, operations=[operation] + Scope: + - If ``campaign_id`` is empty (default), the asset is linked at the + customer/account level via CustomerAsset. + - If ``campaign_id`` is provided, the asset is scoped to that + single campaign via CampaignAsset. + + business_name: max 25 characters per Google Ads policy. + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("create_business_name_asset", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + text = (business_name or "").strip() + if not text: + return {"error": "business_name is required"} + if len(text) > 25: + return { + "error": "Validation failed", + "details": [ + f"business_name '{text}' is {len(text)} chars (max 25)" + ], + } + + scope = "campaign" if campaign_id else "customer" + plan = ChangePlan( + operation="create_business_name_asset", + entity_type="campaign_asset" if scope == "campaign" else "customer_asset", + entity_id=campaign_id or customer_id, + customer_id=customer_id, + changes={ + "scope": scope, + "campaign_id": campaign_id, + "business_name": text, + }, ) - return {"resource_name": response.results[0].resource_name} + store_plan(plan) + return plan.to_preview() -def _apply_add_keywords(client: object, cid: str, changes: dict) -> dict: - service = client.get_service("AdGroupCriterionService") - ad_group_path = client.get_service("AdGroupService").ad_group_path( - cid, changes["ad_group_id"] +def _apply_create_business_name_asset( + client: object, cid: str, changes: dict +) -> dict: + """Create a TEXT asset and link as BUSINESS_NAME at customer or campaign scope.""" + asset_service = client.get_service("AssetService") + googleads_service = client.get_service("GoogleAdsService") + operations: list = [] + + # 1) Create Asset (TEXT) + op = client.get_type("MutateOperation") + asset = op.asset_operation.create + asset.resource_name = asset_service.asset_path(cid, "-1") + asset.type_ = client.enums.AssetTypeEnum.TEXT + asset.text_asset.text = changes["business_name"] + operations.append(op) + + # 2) Link as BUSINESS_NAME + scope = changes.get("scope", "customer") + link_op = client.get_type("MutateOperation") + if scope == "campaign": + if not changes.get("campaign_id"): + raise ValueError("campaign_id required for campaign-scope business_name asset") + link = link_op.campaign_asset_operation.create + link.asset = asset_service.asset_path(cid, "-1") + link.campaign = googleads_service.campaign_path(cid, changes["campaign_id"]) + link.field_type = client.enums.AssetFieldTypeEnum.BUSINESS_NAME + elif scope == "customer": + link = link_op.customer_asset_operation.create + link.asset = asset_service.asset_path(cid, "-1") + link.field_type = client.enums.AssetFieldTypeEnum.BUSINESS_NAME + else: + raise ValueError(f"Unknown scope: {scope}") + operations.append(link_op) + + response = googleads_service.mutate( + customer_id=cid, mutate_operations=operations ) - operations = [] - for kw in changes["keywords"]: - operation = client.get_type("AdGroupCriterionOperation") - criterion = operation.create - criterion.ad_group = ad_group_path - criterion.keyword.text = kw["text"] - criterion.keyword.match_type = getattr( - client.enums.KeywordMatchTypeEnum, kw["match_type"].upper() - ) - operations.append(operation) + result = {"asset": "", "link": ""} + for resp in response.mutate_operation_responses: + if resp.asset_result.resource_name and not result["asset"]: + result["asset"] = resp.asset_result.resource_name + elif scope == "campaign" and resp.campaign_asset_result.resource_name: + result["link"] = resp.campaign_asset_result.resource_name + elif scope == "customer" and resp.customer_asset_result.resource_name: + result["link"] = resp.customer_asset_result.resource_name + return result - response = service.mutate_ad_group_criteria( + +def add_geo_exclusions( + config: AdLoopConfig, + *, + customer_id: str = "", + campaign_id: str = "", + geo_target_ids: list[str] | None = None, +) -> dict: + """Draft negative geo CampaignCriterion records — returns a PREVIEW. + + Adds excluded locations so the campaign does not serve to users in those + geos, even if they would otherwise match an included geo. + + geo_target_ids: list of geoTargetConstant IDs (e.g. ["1014962"] for + Los Angeles). Look up IDs via geo_target_constant in run_gaql. + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("add_geo_exclusions", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + if not campaign_id: + return {"error": "campaign_id is required"} + cleaned = [str(g).strip() for g in (geo_target_ids or []) if str(g).strip()] + if not cleaned: + return {"error": "At least one geo_target_id is required"} + + plan = ChangePlan( + operation="add_geo_exclusions", + entity_type="campaign_criterion", + entity_id=campaign_id, + customer_id=customer_id, + changes={ + "campaign_id": campaign_id, + "geo_target_ids": cleaned, + }, + ) + store_plan(plan) + return plan.to_preview() + + +def _apply_add_geo_exclusions(client: object, cid: str, changes: dict) -> dict: + """Add negative location CampaignCriterion records to a campaign.""" + campaign_service = client.get_service("CampaignService") + crit_service = client.get_service("CampaignCriterionService") + operations = [] + for geo_id in changes["geo_target_ids"]: + op = client.get_type("CampaignCriterionOperation") + crit = op.create + crit.campaign = campaign_service.campaign_path(cid, changes["campaign_id"]) + crit.location.geo_target_constant = f"geoTargetConstants/{geo_id}" + crit.negative = True + operations.append(op) + response = crit_service.mutate_campaign_criteria( customer_id=cid, operations=operations ) - return {"resource_names": [r.resource_name for r in response.results]} + return { + "campaign_criteria": [r.resource_name for r in response.results], + } -def _apply_add_negative_keywords(client: object, cid: str, changes: dict) -> dict: - service = client.get_service("CampaignCriterionService") - campaign_path = client.get_service("CampaignService").campaign_path( - cid, changes["campaign_id"] +def add_ad_schedule( + config: AdLoopConfig, + *, + customer_id: str = "", + campaign_id: str = "", + schedule: list[dict] | None = None, +) -> dict: + """Draft ad schedule additions for a campaign — returns a PREVIEW. + + Creates ``CampaignCriterion`` records of type AD_SCHEDULE so the + campaign only serves during the specified hours/days. + + schedule: list of dicts with keys: + - day_of_week: MONDAY..SUNDAY + - start_hour: 0..23 + - end_hour: 0..24 (must be > start) + - start_minute / end_minute: 0, 15, 30, or 45 (default 0) + + Note: ad-schedule hours follow the account's configured time zone. + Adding a schedule is additive — it does NOT replace existing schedule + criteria. Pause/remove existing schedule entries first if you want a + clean slate. + """ + from adloop.safety.guards import SafetyViolation, check_blocked_operation + from adloop.safety.preview import ChangePlan, store_plan + + try: + check_blocked_operation("add_ad_schedule", config.safety) + except SafetyViolation as e: + return {"error": str(e)} + + if not campaign_id: + return {"error": "campaign_id is required"} + validated, errors = _validate_ad_schedule(schedule or []) + if errors: + return {"error": "Validation failed", "details": errors} + if not validated: + return {"error": "At least one schedule entry is required"} + + plan = ChangePlan( + operation="add_ad_schedule", + entity_type="campaign_criterion", + entity_id=campaign_id, + customer_id=customer_id, + changes={ + "campaign_id": campaign_id, + "schedule": validated, + }, ) + store_plan(plan) + return plan.to_preview() + +def _apply_add_ad_schedule(client: object, cid: str, changes: dict) -> dict: + """Add AdScheduleInfo CampaignCriterion records to a campaign.""" + campaign_service = client.get_service("CampaignService") + crit_service = client.get_service("CampaignCriterionService") operations = [] - for kw_text in changes["keywords"]: - operation = client.get_type("CampaignCriterionOperation") - criterion = operation.create - criterion.campaign = campaign_path - criterion.negative = True - criterion.keyword.text = kw_text - criterion.keyword.match_type = getattr( - client.enums.KeywordMatchTypeEnum, changes["match_type"] + for entry in changes["schedule"]: + op = client.get_type("CampaignCriterionOperation") + crit = op.create + crit.campaign = campaign_service.campaign_path( + cid, changes["campaign_id"] ) - operations.append(operation) - - response = service.mutate_campaign_criteria( + _populate_ad_schedule_info(client, crit.ad_schedule, entry) + operations.append(op) + response = crit_service.mutate_campaign_criteria( customer_id=cid, operations=operations ) - return {"resource_names": [r.resource_name for r in response.results]} + return { + "campaign_criteria": [r.resource_name for r in response.results], + } -def _resolve_ad_entity_id(client: object, cid: str, entity_id: str) -> str: - """Ensure ad entity_id is in 'adGroupId~adId' composite format. +_AD_SCHEDULE_DAY_ENUM = { + "MONDAY": "MONDAY", + "TUESDAY": "TUESDAY", + "WEDNESDAY": "WEDNESDAY", + "THURSDAY": "THURSDAY", + "FRIDAY": "FRIDAY", + "SATURDAY": "SATURDAY", + "SUNDAY": "SUNDAY", +} +_MINUTE_TO_ENUM = {0: "ZERO", 15: "FIFTEEN", 30: "THIRTY", 45: "FORTY_FIVE"} - If only a bare ad ID is given, queries the API to find the ad group. - """ - if "~" in entity_id: - return entity_id - ga_service = client.get_service("GoogleAdsService") - query = ( - f"SELECT ad_group.id, ad_group_ad.ad.id " - f"FROM ad_group_ad " - f"WHERE ad_group_ad.ad.id = {entity_id} " - f"LIMIT 1" +def _populate_ad_schedule_info(client: object, info: object, entry: dict) -> None: + """Set fields on an AdScheduleInfo proto from a validated entry.""" + info.day_of_week = getattr( + client.enums.DayOfWeekEnum, _AD_SCHEDULE_DAY_ENUM[entry["day_of_week"]] ) - response = ga_service.search(customer_id=cid, query=query) - for row in response: - ag_id = row.ad_group.id - return f"{ag_id}~{entity_id}" - - raise ValueError( - f"Ad ID {entity_id} not found. Pass the composite ID as " - f"'adGroupId~adId' (e.g. '12345678~{entity_id}')." + info.start_hour = int(entry["start_hour"]) + info.end_hour = int(entry["end_hour"]) + info.start_minute = getattr( + client.enums.MinuteOfHourEnum, _MINUTE_TO_ENUM[int(entry["start_minute"])] + ) + info.end_minute = getattr( + client.enums.MinuteOfHourEnum, _MINUTE_TO_ENUM[int(entry["end_minute"])] ) -def _apply_remove( - client: object, - cid: str, - entity_type: str, - entity_id: str, -) -> dict: - """Remove an entity via the REMOVE mutate operation (irreversible).""" - if entity_type == "campaign": - service = client.get_service("CampaignService") - operation = client.get_type("CampaignOperation") - operation.remove = service.campaign_path(cid, entity_id) - response = service.mutate_campaigns( - customer_id=cid, operations=[operation] - ) +def _apply_create_call_asset(client: object, cid: str, changes: dict) -> dict: + """Create a CallAsset at customer or campaign scope.""" + asset_service = client.get_service("AssetService") + googleads_service = client.get_service("GoogleAdsService") + operations = [] - elif entity_type == "ad_group": - service = client.get_service("AdGroupService") - operation = client.get_type("AdGroupOperation") - operation.remove = service.ad_group_path(cid, entity_id) - response = service.mutate_ad_groups( - customer_id=cid, operations=[operation] + op = client.get_type("MutateOperation") + asset = op.asset_operation.create + asset.resource_name = asset_service.asset_path(cid, "-1") + asset.call_asset.country_code = changes["country_code"] + asset.call_asset.phone_number = changes["phone_number"] + if changes.get("call_conversion_action_id"): + ca_service = client.get_service("ConversionActionService") + asset.call_asset.call_conversion_action = ca_service.conversion_action_path( + cid, str(changes["call_conversion_action_id"]) ) - - elif entity_type == "ad": - resolved_id = _resolve_ad_entity_id(client, cid, entity_id) - service = client.get_service("AdGroupAdService") - operation = client.get_type("AdGroupAdOperation") - operation.remove = f"customers/{cid}/adGroupAds/{resolved_id}" - response = service.mutate_ad_group_ads( - customer_id=cid, operations=[operation] + asset.call_asset.call_conversion_reporting_state = ( + client.enums.CallConversionReportingStateEnum.USE_RESOURCE_LEVEL_CALL_CONVERSION_ACTION ) + for entry in changes.get("ad_schedule") or []: + info = client.get_type("AdScheduleInfo") + _populate_ad_schedule_info(client, info, entry) + asset.call_asset.ad_schedule_targets.append(info) + operations.append(op) + + scope = changes.get("scope", "campaign") + if scope == "campaign": + if not changes.get("campaign_id"): + raise ValueError("campaign_id required for campaign-scope call asset") + link_op = client.get_type("MutateOperation") + ca = link_op.campaign_asset_operation.create + ca.asset = asset_service.asset_path(cid, "-1") + ca.campaign = googleads_service.campaign_path(cid, changes["campaign_id"]) + ca.field_type = client.enums.AssetFieldTypeEnum.CALL + operations.append(link_op) + elif scope == "customer": + link_op = client.get_type("MutateOperation") + cust_asset = link_op.customer_asset_operation.create + cust_asset.asset = asset_service.asset_path(cid, "-1") + cust_asset.field_type = client.enums.AssetFieldTypeEnum.CALL + operations.append(link_op) + else: + raise ValueError(f"Unknown scope: {scope}") - elif entity_type == "keyword": - service = client.get_service("AdGroupCriterionService") - operation = client.get_type("AdGroupCriterionOperation") - operation.remove = f"customers/{cid}/adGroupCriteria/{entity_id}" - response = service.mutate_ad_group_criteria( - customer_id=cid, operations=[operation] - ) + response = googleads_service.mutate( + customer_id=cid, mutate_operations=operations + ) - elif entity_type == "negative_keyword": - service = client.get_service("CampaignCriterionService") - operation = client.get_type("CampaignCriterionOperation") - operation.remove = f"customers/{cid}/campaignCriteria/{entity_id}" - response = service.mutate_campaign_criteria( - customer_id=cid, operations=[operation] - ) + result = {"asset": "", "link": ""} + for resp in response.mutate_operation_responses: + if resp.asset_result.resource_name and not result["asset"]: + result["asset"] = resp.asset_result.resource_name + elif scope == "campaign" and resp.campaign_asset_result.resource_name: + result["link"] = resp.campaign_asset_result.resource_name + elif scope == "customer" and resp.customer_asset_result.resource_name: + result["link"] = resp.customer_asset_result.resource_name + return result - elif entity_type == "shared_criterion": - if "~" not in entity_id: - raise ValueError( - f"shared_criterion entity_id must be " - f"'sharedSetId~criterionId', got '{entity_id}'" - ) - service = client.get_service("SharedCriterionService") - operation = client.get_type("SharedCriterionOperation") - operation.remove = f"customers/{cid}/sharedCriteria/{entity_id}" - response = service.mutate_shared_criteria( - customer_id=cid, operations=[operation] - ) - elif entity_type == "campaign_asset": - parts = entity_id.split("~") - if len(parts) != 3: - raise ValueError( - f"campaign_asset entity_id must be " - f"'campaignId~assetId~fieldType', got '{entity_id}'" - ) - resource_name = f"customers/{cid}/campaignAssets/{entity_id}" - ga_service = client.get_service("GoogleAdsService") - op = client.get_type("MutateOperation") - op.campaign_asset_operation.remove = resource_name - response = ga_service.mutate( - customer_id=cid, mutate_operations=[op] - ) - resp_inner = response.mutate_operation_responses[0] - if resp_inner.campaign_asset_result.resource_name: - return {"resource_name": resp_inner.campaign_asset_result.resource_name} - return {"resource_name": resource_name, "status": "removed"} +def _apply_create_conversion_action_route(client, cid, changes): + from adloop.ads.conversion_actions import _apply_create_conversion_action + return _apply_create_conversion_action(client, cid, changes) - elif entity_type == "asset": - service = client.get_service("AssetService") - operation = client.get_type("AssetOperation") - operation.remove = service.asset_path(cid, entity_id) - response = service.mutate_assets( - customer_id=cid, operations=[operation] - ) - elif entity_type == "customer_asset": - parts = entity_id.split("~") - if len(parts) != 2: - raise ValueError( - f"customer_asset entity_id must be " - f"'assetId~fieldType', got '{entity_id}'" - ) - resource_name = f"customers/{cid}/customerAssets/{entity_id}" - ga_service = client.get_service("GoogleAdsService") - op = client.get_type("MutateOperation") - op.customer_asset_operation.remove = resource_name - response = ga_service.mutate( - customer_id=cid, mutate_operations=[op] - ) - resp_inner = response.mutate_operation_responses[0] - if resp_inner.customer_asset_result.resource_name: - return {"resource_name": resp_inner.customer_asset_result.resource_name} - return {"resource_name": resource_name, "status": "removed"} +def _apply_update_conversion_action_route(client, cid, changes): + from adloop.ads.conversion_actions import _apply_update_conversion_action + return _apply_update_conversion_action(client, cid, changes) - else: - raise ValueError(f"Cannot remove entity_type: {entity_type}") - return {"resource_name": response.results[0].resource_name} +def _apply_remove_conversion_action_route(client, cid, changes): + from adloop.ads.conversion_actions import _apply_remove_conversion_action + return _apply_remove_conversion_action(client, cid, changes) -def _apply_status_change( - client: object, - cid: str, - entity_type: str, - entity_id: str, - status: str, -) -> dict: - """Update the status of a campaign, ad group, ad, or keyword.""" - if entity_type == "campaign": - service = client.get_service("CampaignService") - operation = client.get_type("CampaignOperation") - entity = operation.update - entity.resource_name = service.campaign_path(cid, entity_id) - entity.status = getattr(client.enums.CampaignStatusEnum, status) - mutate = service.mutate_campaigns +def _apply_upload_call_conversions_route(client, cid, changes): + from adloop.ads.conversion_actions import _apply_upload_call_conversions + return _apply_upload_call_conversions(client, cid, changes) - elif entity_type == "ad_group": - service = client.get_service("AdGroupService") - operation = client.get_type("AdGroupOperation") - entity = operation.update - entity.resource_name = service.ad_group_path(cid, entity_id) - entity.status = getattr(client.enums.AdGroupStatusEnum, status) - mutate = service.mutate_ad_groups - elif entity_type == "ad": - resolved_id = _resolve_ad_entity_id(client, cid, entity_id) - service = client.get_service("AdGroupAdService") - operation = client.get_type("AdGroupAdOperation") - entity = operation.update - entity.resource_name = f"customers/{cid}/adGroupAds/{resolved_id}" - entity.status = getattr(client.enums.AdGroupAdStatusEnum, status) - mutate = service.mutate_ad_group_ads +def _apply_upload_enhanced_conversions_for_leads_route(client, cid, changes): + from adloop.ads.conversion_actions import ( + _apply_upload_enhanced_conversions_for_leads, + ) + return _apply_upload_enhanced_conversions_for_leads(client, cid, changes) - elif entity_type == "keyword": - service = client.get_service("AdGroupCriterionService") - operation = client.get_type("AdGroupCriterionOperation") - entity = operation.update - entity.resource_name = f"customers/{cid}/adGroupCriteria/{entity_id}" - entity.status = getattr( - client.enums.AdGroupCriterionStatusEnum, status + +def _apply_update_call_asset(client: object, cid: str, changes: dict) -> dict: + """In-place update of an existing CallAsset.""" + from google.protobuf import field_mask_pb2 + + asset_service = client.get_service("AssetService") + op = client.get_type("AssetOperation") + asset = op.update + asset.resource_name = asset_service.asset_path(cid, changes["asset_id"]) + + paths: list[str] = [] + if "phone_number" in changes: + asset.call_asset.phone_number = changes["phone_number"] + paths.append("call_asset.phone_number") + if "country_code" in changes: + asset.call_asset.country_code = changes["country_code"] + paths.append("call_asset.country_code") + if "call_conversion_action_id" in changes: + ca_service = client.get_service("ConversionActionService") + asset.call_asset.call_conversion_action = ca_service.conversion_action_path( + cid, changes["call_conversion_action_id"] ) - mutate = service.mutate_ad_group_criteria + paths.append("call_asset.call_conversion_action") + if "call_conversion_reporting_state" in changes: + asset.call_asset.call_conversion_reporting_state = getattr( + client.enums.CallConversionReportingStateEnum, + changes["call_conversion_reporting_state"], + ) + paths.append("call_asset.call_conversion_reporting_state") + if "ad_schedule" in changes: + # Replace the schedule list entirely + for entry in changes["ad_schedule"]: + info = client.get_type("AdScheduleInfo") + _populate_ad_schedule_info(client, info, entry) + asset.call_asset.ad_schedule_targets.append(info) + paths.append("call_asset.ad_schedule_targets") + + op.update_mask.CopyFrom(field_mask_pb2.FieldMask(paths=paths)) + response = asset_service.mutate_assets(customer_id=cid, operations=[op]) + return {"resource_name": response.results[0].resource_name} - else: - raise ValueError(f"Unknown entity_type: {entity_type}") - # Build field mask for the status field only +def _apply_update_sitelink(client: object, cid: str, changes: dict) -> dict: + """In-place update of an existing SitelinkAsset.""" from google.protobuf import field_mask_pb2 - operation.update_mask = field_mask_pb2.FieldMask(paths=["status"]) - - response = mutate(customer_id=cid, operations=[operation]) + asset_service = client.get_service("AssetService") + op = client.get_type("AssetOperation") + asset = op.update + asset.resource_name = asset_service.asset_path(cid, changes["asset_id"]) + + paths: list[str] = [] + if "link_text" in changes: + asset.sitelink_asset.link_text = changes["link_text"] + paths.append("sitelink_asset.link_text") + if "description1" in changes: + asset.sitelink_asset.description1 = changes["description1"] + paths.append("sitelink_asset.description1") + if "description2" in changes: + asset.sitelink_asset.description2 = changes["description2"] + paths.append("sitelink_asset.description2") + if "final_url" in changes: + asset.final_urls.append(changes["final_url"]) + paths.append("final_urls") + + op.update_mask.CopyFrom(field_mask_pb2.FieldMask(paths=paths)) + response = asset_service.mutate_assets(customer_id=cid, operations=[op]) return {"resource_name": response.results[0].resource_name} -def _apply_campaign_assets( - client: object, - cid: str, - campaign_id: str, - assets: list[dict], - field_type: object, - populate_asset: object, -) -> dict: - """Create assets and link them to a campaign via CampaignAsset.""" +def _apply_update_callout(client: object, cid: str, changes: dict) -> dict: + """In-place update of an existing CalloutAsset's text.""" + from google.protobuf import field_mask_pb2 + asset_service = client.get_service("AssetService") - googleads_service = client.get_service("GoogleAdsService") - operations = [] + op = client.get_type("AssetOperation") + asset = op.update + asset.resource_name = asset_service.asset_path(cid, changes["asset_id"]) + asset.callout_asset.callout_text = changes["callout_text"] + op.update_mask.CopyFrom(field_mask_pb2.FieldMask( + paths=["callout_asset.callout_text"] + )) + response = asset_service.mutate_assets(customer_id=cid, operations=[op]) + return {"resource_name": response.results[0].resource_name} - for i, payload in enumerate(assets): - op = client.get_type("MutateOperation") - asset = op.asset_operation.create - asset.resource_name = asset_service.asset_path(cid, str(-(i + 1))) - populate_asset(asset, payload) - operations.append(op) - for i in range(len(assets)): - op = client.get_type("MutateOperation") - ca = op.campaign_asset_operation.create - ca.asset = asset_service.asset_path(cid, str(-(i + 1))) - ca.campaign = googleads_service.campaign_path(cid, campaign_id) - ca.field_type = field_type - operations.append(op) +def _populate_promotion_asset(client: object, asset: object, promo: dict) -> None: + """Fill an Asset proto with PromotionAsset fields from a normalized dict.""" + p = asset.promotion_asset + p.promotion_target = promo["promotion_target"] + if promo.get("money_off"): + p.money_amount_off.amount_micros = int( + float(promo["money_off"]) * 1_000_000 + ) + p.money_amount_off.currency_code = promo["currency_code"] + elif promo.get("percent_off"): + p.percent_off = int(float(promo["percent_off"]) * 1_000_000) - response = googleads_service.mutate( - customer_id=cid, mutate_operations=operations - ) + if promo.get("promotion_code"): + p.promotion_code = promo["promotion_code"] - results = {"assets": [], "campaign_assets": []} - num_assets = len(assets) - for i, resp in enumerate(response.mutate_operation_responses): - resource = None - if resp.asset_result.resource_name: - resource = resp.asset_result.resource_name - elif resp.campaign_asset_result.resource_name: - resource = resp.campaign_asset_result.resource_name + if promo.get("orders_over_amount"): + p.orders_over_amount.amount_micros = int( + float(promo["orders_over_amount"]) * 1_000_000 + ) + p.orders_over_amount.currency_code = promo["currency_code"] - if resource: - if i < num_assets: - results["assets"].append(resource) - else: - results["campaign_assets"].append(resource) + if promo.get("occasion"): + p.occasion = getattr( + client.enums.PromotionExtensionOccasionEnum, promo["occasion"] + ) - return results + if promo.get("discount_modifier"): + p.discount_modifier = getattr( + client.enums.PromotionExtensionDiscountModifierEnum, + promo["discount_modifier"], + ) + p.language_code = promo.get("language_code") or "en" + if promo.get("start_date"): + p.start_date = promo["start_date"] + if promo.get("end_date"): + p.end_date = promo["end_date"] + if promo.get("redemption_start_date"): + p.redemption_start_date = promo["redemption_start_date"] + if promo.get("redemption_end_date"): + p.redemption_end_date = promo["redemption_end_date"] -def _apply_create_callouts(client: object, cid: str, changes: dict) -> dict: - """Create callout assets and link them to a campaign.""" + for entry in promo.get("ad_schedule") or []: + info = client.get_type("AdScheduleInfo") + _populate_ad_schedule_info(client, info, entry) + p.ad_schedule_targets.append(info) + + asset.final_urls.append(promo["final_url"]) + + +def _apply_create_promotion(client: object, cid: str, changes: dict) -> dict: + """Create a PromotionAsset and link it at customer or campaign scope.""" def populate(asset: object, payload: dict) -> None: - asset.callout_asset.callout_text = payload["callout_text"] + _populate_promotion_asset(client, asset, payload) - assets = [{"callout_text": text} for text in changes["callouts"]] - return _apply_campaign_assets( + return _apply_assets( client, cid, - changes["campaign_id"], - assets, - client.enums.AssetFieldTypeEnum.CALLOUT, + [changes["promotion"]], + client.enums.AssetFieldTypeEnum.PROMOTION, populate, + scope=changes.get("scope", "campaign"), + campaign_id=changes.get("campaign_id", ""), ) -def _apply_create_structured_snippets( - client: object, cid: str, changes: dict -) -> dict: - """Create structured snippet assets and link them to a campaign.""" +def _apply_update_promotion(client: object, cid: str, changes: dict) -> dict: + """Swap a PromotionAsset: create new + link, then unlink old. - def populate(asset: object, payload: dict) -> None: - asset.structured_snippet_asset.header = payload["header"] - asset.structured_snippet_asset.values.extend(payload["values"]) + Steps (each is its own MutateOperation, batched into one mutate call): + 1. Create a new Asset with the new promotion fields. + 2. Link the new Asset (CampaignAsset or CustomerAsset). + 3. Remove the old link (CampaignAsset/CustomerAsset matching old asset_id). + 4. Optionally remove the old Asset row. + """ + asset_service = client.get_service("AssetService") + googleads_service = client.get_service("GoogleAdsService") + campaign_service = client.get_service("CampaignService") - return _apply_campaign_assets( - client, - cid, - changes["campaign_id"], - changes["snippets"], - client.enums.AssetFieldTypeEnum.STRUCTURED_SNIPPET, - populate, + scope = changes.get("scope", "campaign") + campaign_id = changes.get("campaign_id", "") + old_asset_id = str(changes["old_asset_id"]) + promo = changes["promotion"] + + operations = [] + + # 1. Create new Asset + create_op = client.get_type("MutateOperation") + new_asset = create_op.asset_operation.create + new_asset.resource_name = asset_service.asset_path(cid, "-1") + _populate_promotion_asset(client, new_asset, promo) + operations.append(create_op) + + # 2. Link the new asset + if scope == "campaign": + if not campaign_id: + raise ValueError("campaign_id required for campaign-scope update") + link_op = client.get_type("MutateOperation") + ca = link_op.campaign_asset_operation.create + ca.asset = asset_service.asset_path(cid, "-1") + ca.campaign = campaign_service.campaign_path(cid, campaign_id) + ca.field_type = client.enums.AssetFieldTypeEnum.PROMOTION + operations.append(link_op) + elif scope == "customer": + link_op = client.get_type("MutateOperation") + cust = link_op.customer_asset_operation.create + cust.asset = asset_service.asset_path(cid, "-1") + cust.field_type = client.enums.AssetFieldTypeEnum.PROMOTION + operations.append(link_op) + else: + raise ValueError(f"Unknown scope: {scope}") + + response = googleads_service.mutate( + customer_id=cid, mutate_operations=operations ) + new_asset_resource = "" + new_link_resource = "" + for resp in response.mutate_operation_responses: + if resp.asset_result.resource_name and not new_asset_resource: + new_asset_resource = resp.asset_result.resource_name + elif scope == "campaign" and resp.campaign_asset_result.resource_name: + new_link_resource = resp.campaign_asset_result.resource_name + elif scope == "customer" and resp.customer_asset_result.resource_name: + new_link_resource = resp.customer_asset_result.resource_name + + # 3. Find the old link and remove it + old_link_resource = _find_promotion_link( + client, cid, old_asset_id, scope, campaign_id + ) + old_link_removed = "" + if old_link_resource: + if scope == "campaign": + ca_service = client.get_service("CampaignAssetService") + rm_op = client.get_type("CampaignAssetOperation") + rm_op.remove = old_link_resource + ca_service.mutate_campaign_assets(customer_id=cid, operations=[rm_op]) + else: + cust_service = client.get_service("CustomerAssetService") + rm_op = client.get_type("CustomerAssetOperation") + rm_op.remove = old_link_resource + cust_service.mutate_customer_assets(customer_id=cid, operations=[rm_op]) + old_link_removed = old_link_resource -def _apply_create_image_assets(client: object, cid: str, changes: dict) -> dict: - """Create image assets from local files and link them to a campaign.""" + return { + "new_asset": new_asset_resource, + "new_link": new_link_resource, + "old_link_removed": old_link_removed, + } - def populate(asset: object, payload: dict) -> None: - image_path = Path(str(payload["path"])) - image_bytes = image_path.read_bytes() - mime_type_name = _VALID_IMAGE_MIME_TYPES[str(payload["mime_type"])] - asset.name = str(payload.get("name") or _build_image_asset_name(image_path, image_bytes)) - asset.type_ = client.enums.AssetTypeEnum.IMAGE - asset.image_asset.data = image_bytes - asset.image_asset.mime_type = getattr(client.enums.MimeTypeEnum, mime_type_name) - asset.image_asset.full_size.width_pixels = int(payload["width"]) - asset.image_asset.full_size.height_pixels = int(payload["height"]) - return _apply_campaign_assets( - client, - cid, - changes["campaign_id"], - changes["images"], - client.enums.AssetFieldTypeEnum.AD_IMAGE, - populate, +def _find_promotion_link( + client: object, + cid: str, + asset_id: str, + scope: str, + campaign_id: str, +) -> str: + """Look up the CampaignAsset or CustomerAsset link for a given asset_id.""" + googleads_service = client.get_service("GoogleAdsService") + asset_service = client.get_service("AssetService") + asset_resource = asset_service.asset_path(cid, asset_id) + + if scope == "campaign": + if not campaign_id: + return "" + query = ( + "SELECT campaign_asset.resource_name " + "FROM campaign_asset " + f"WHERE campaign_asset.asset = '{asset_resource}' " + f" AND campaign_asset.campaign = " + f" '{client.get_service('CampaignService').campaign_path(cid, campaign_id)}' " + " AND campaign_asset.field_type = 'PROMOTION'" + ) + else: + query = ( + "SELECT customer_asset.resource_name " + "FROM customer_asset " + f"WHERE customer_asset.asset = '{asset_resource}' " + " AND customer_asset.field_type = 'PROMOTION'" + ) + + response = googleads_service.search(customer_id=cid, query=query) + for row in response: + if scope == "campaign": + return row.campaign_asset.resource_name + return row.customer_asset.resource_name + return "" + + +def _apply_link_asset_to_customer( + client: object, cid: str, changes: dict +) -> dict: + """Create CustomerAsset link rows pointing to existing Asset rows. + + Does NOT create new Asset rows — only the link. Use this to promote + existing assets (e.g. images, logos that were uploaded to a legacy + campaign) so they apply at the account level. + """ + asset_service = client.get_service("AssetService") + cust_service = client.get_service("CustomerAssetService") + + operations = [] + for link in changes["links"]: + op = client.get_type("CustomerAssetOperation") + ca = op.create + ca.asset = asset_service.asset_path(cid, link["asset_id"]) + ca.field_type = getattr( + client.enums.AssetFieldTypeEnum, link["field_type"] + ) + operations.append(op) + + response = cust_service.mutate_customer_assets( + customer_id=cid, operations=operations ) + return { + "customer_assets": [r.resource_name for r in response.results], + "linked_count": len(response.results), + } def _apply_create_sitelinks(client: object, cid: str, changes: dict) -> dict: - """Create sitelink assets and link them to a campaign.""" + """Create sitelink assets at customer or campaign scope.""" def populate(asset: object, payload: dict) -> None: asset.sitelink_asset.link_text = payload["link_text"] @@ -2962,13 +5198,14 @@ def populate(asset: object, payload: dict) -> None: if payload.get("description2"): asset.sitelink_asset.description2 = payload["description2"] - return _apply_campaign_assets( + return _apply_assets( client, cid, - changes["campaign_id"], changes["sitelinks"], client.enums.AssetFieldTypeEnum.SITELINK, populate, + scope=changes.get("scope", "campaign"), + campaign_id=changes.get("campaign_id", ""), ) diff --git a/src/adloop/server.py b/src/adloop/server.py index 3d8bf03..93a0169 100644 --- a/src/adloop/server.py +++ b/src/adloop/server.py @@ -811,601 +811,2254 @@ def attribution_check( ) +@mcp.tool(annotations=_READONLY) +@_safe +def audit_event_coverage( + expected_events: list[str], + gtm_account_id: str, + gtm_container_id: str, + property_id: str = "", + date_range_start: str = "", + date_range_end: str = "", +) -> dict: + """Three-way audit: codebase events ↔ GTM tags ↔ GA4 actual fires. + + First, search the user's codebase for gtag('event', ...) and + dataLayer.push({event: ...}) calls and extract every distinct event name. + Pass that list as `expected_events`. The tool fetches the LIVE GTM + container, joins it against GA4 event counts for the date range, and + returns a per-event matrix with one of these statuses: + ok — tag active and event firing + ok_auto_collected — GA4 Enhanced Measurement event, no tag needed + no_tag_no_fire — codebase event, no GTM tag, never fires + tag_paused — GTM tag exists but is paused + tag_active_but_not_firing — tag is active but no GA4 hits + gtm_only_firing — GA4 event from a tag, not in codebase + gtm_only_not_firing — tag exists, not in codebase, no fires + ga4_only — fires in GA4, no tag, no codebase ref + ga4_fires_no_tag — codebase event firing without a GTM tag + auto_event_only — Enhanced Measurement event with no codebase ref + + Also surfaces dynamic-event tags ({{Event}} variables) and Custom HTML + tags that the audit cannot interpret automatically. + + GTM IDs come from Tag Manager UI → Admin → Container Settings. + Date format: "YYYY-MM-DD". Empty = last 30 days. + """ + from adloop.crossref import audit_event_coverage as _impl + + return _impl( + _config, + expected_events=expected_events, + gtm_account_id=gtm_account_id, + gtm_container_id=gtm_container_id, + property_id=property_id or _config.ga4.property_id, + date_range_start=date_range_start, + date_range_end=date_range_end, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def list_gtm_accounts() -> dict: + """List all GTM accounts the AdLoop service account / OAuth user can read. + + Use this for first-time discovery before calling audit_event_coverage — + you need the account_id from here. If this returns an empty list, the + service account hasn't been added to any GTM container with at least + Read permission. + """ + from adloop.gtm.read import list_accounts as _impl + + return _impl(_config) + + +@mcp.tool(annotations=_READONLY) +@_safe +def list_gtm_containers(gtm_account_id: str) -> dict: + """List all containers under a GTM account. + + Returns container_id (the numeric ID needed by audit_event_coverage), + public_id (the GTM-XXXXXXX string shown in the UI), name, and usage + context (web / iOS / Android / amp / server). + """ + from adloop.gtm.read import list_containers as _impl + + return _impl(_config, account_id=gtm_account_id) + + +@mcp.tool(annotations=_READONLY) +@_safe +def list_gtm_tags(gtm_account_id: str, gtm_container_id: str) -> dict: + """List every tag in the LIVE GTM container. + + Each tag includes type, status, parsed parameters, the GA4 event name + (for GA4 event tags), and resolved firing/blocking trigger names. + Use after audit_event_coverage to inspect specific tags. + """ + from adloop.gtm.read import list_tags as _impl + + return _impl( + _config, account_id=gtm_account_id, container_id=gtm_container_id + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def get_gtm_tag( + gtm_account_id: str, gtm_container_id: str, tag_id: str +) -> dict: + """Get the full RAW configuration for a single GTM tag. + + Includes every parameter, firing/blocking triggers (with their filter + conditions resolved to text), priority, pause status, sampling, and + monitoring metadata. Use to inspect a tag flagged by audit_event_coverage. + """ + from adloop.gtm.read import get_tag as _impl + + return _impl( + _config, + account_id=gtm_account_id, + container_id=gtm_container_id, + tag_id=tag_id, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def list_gtm_triggers(gtm_account_id: str, gtm_container_id: str) -> dict: + """List every trigger in the LIVE GTM container. + + Each trigger has its filter conditions parsed to readable text + (e.g. "{{Page Path}} matches RegExp ^/service-promotions/"). Use to + diagnose why a tag fires or doesn't fire on specific pages. + """ + from adloop.gtm.read import list_triggers as _impl + + return _impl( + _config, account_id=gtm_account_id, container_id=gtm_container_id + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def get_gtm_trigger( + gtm_account_id: str, gtm_container_id: str, trigger_id: str +) -> dict: + """Get the full RAW configuration for a single GTM trigger. + + Includes filters, auto-event filters, custom-event filters, validation + settings, and a list of every tag that uses this trigger. Use to + diagnose why a tag with a specific trigger ID does or doesn't fire. + """ + from adloop.gtm.read import get_trigger as _impl + + return _impl( + _config, + account_id=gtm_account_id, + container_id=gtm_container_id, + trigger_id=trigger_id, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def list_gtm_variables(gtm_account_id: str, gtm_container_id: str) -> dict: + """List GTM variables — both custom and enabled built-in. + + Custom variables come from the live container. Built-in variables + (Page URL, Click Element, Form ID, etc.) come from the workspace's + enabled-built-ins list. Variables matter because triggers reference + them — if a trigger uses {{Form ID}} but Form ID isn't enabled, the + trigger never matches. + """ + from adloop.gtm.read import list_variables as _impl + + return _impl( + _config, account_id=gtm_account_id, container_id=gtm_container_id + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def list_gtm_workspaces(gtm_account_id: str, gtm_container_id: str) -> dict: + """List workspaces (drafts) under a GTM container. + + Workspace IDs are needed for `get_gtm_workspace_diff`. Most containers + have a single Default Workspace; multiple workspaces appear when the + team uses parallel drafts. + """ + from adloop.gtm.read import list_workspaces as _impl + + return _impl( + _config, account_id=gtm_account_id, container_id=gtm_container_id + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def get_gtm_workspace_diff( + gtm_account_id: str, gtm_container_id: str, workspace_id: str +) -> dict: + """Show drafted-but-not-published changes in a GTM workspace. + + Returns the list of entities (tags, triggers, variables) added, + modified, or deleted relative to the live published version, plus + any merge conflicts. Common cause of "I edited a tag but nothing + happened" — the workspace was never published. is_clean=true means + no pending changes and no conflicts. + """ + from adloop.gtm.read import get_workspace_diff as _impl + + return _impl( + _config, + account_id=gtm_account_id, + container_id=gtm_container_id, + workspace_id=workspace_id, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def list_gtm_versions( + gtm_account_id: str, gtm_container_id: str, page_size: int = 50 +) -> dict: + """List published GTM version history (newest first). + + Version headers include version_id, name, and entity counts. Use to + correlate a metric drop with a recent publish: fetch versions, find + one with timestamps near the drop date, then call get_gtm_version + for full content + author info. + """ + from adloop.gtm.read import list_versions as _impl + + return _impl( + _config, + account_id=gtm_account_id, + container_id=gtm_container_id, + page_size=page_size, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def get_gtm_version( + gtm_account_id: str, gtm_container_id: str, container_version_id: str +) -> dict: + """Get full metadata + entity counts for a single GTM container version. + + Returns name, description, fingerprint, and lists of tag/trigger/ + variable names at that point in time. Use after list_gtm_versions + when correlating a metric drop with a specific publish. + """ + from adloop.gtm.read import get_version as _impl + + return _impl( + _config, + account_id=gtm_account_id, + container_id=gtm_container_id, + container_version_id=container_version_id, + ) + + +# --------------------------------------------------------------------------- +# GTM Write Tools +# --------------------------------------------------------------------------- + @mcp.tool(annotations=_READONLY) @_safe def run_gaql( query: str, customer_id: str = "", - format: str = "table", + format: str = "table", +) -> dict: + """Execute an arbitrary GAQL (Google Ads Query Language) query. + + Use this for advanced queries not covered by the other tools. + See the GAQL reference in the AdLoop cursor rules for syntax help. + + format: "table" (default, readable), "json" (structured), "csv" (exportable) + """ + from adloop.ads.gaql import run_gaql as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + query=query, + format=format, + ) + + +# --------------------------------------------------------------------------- +# ServiceTitan Read Tools +# --------------------------------------------------------------------------- + + +@mcp.tool(annotations=_READONLY) +@_safe +def health_check_servicetitan() -> dict: + """Verify ServiceTitan auth + tenant access. + + Tests OAuth client_credentials flow against auth.servicetitan.io and a + tenant-scoped read against the configured tenant_id. Returns auth_ok, + tenant_ok, and a count of business units the app can see. + + Run this first if any ServiceTitan tool is failing. + """ + from adloop.servicetitan.read import health_check as _impl + + return _impl(_config) + + +@mcp.tool(annotations=_READONLY) +@_safe +def list_st_business_units() -> dict: + """List business units in the ServiceTitan tenant. + + Most ST endpoints accept a businessUnitId filter — use this to discover + the IDs you'll need (e.g. separate residential vs commercial BUs). + """ + from adloop.servicetitan.read import list_business_units as _impl + + return _impl(_config) + + +@mcp.tool(annotations=_READONLY) +@_safe +def list_st_campaigns(active_only: bool = False) -> dict: + """List ServiceTitan marketing campaigns (channel-level). + + These are the channels ST uses to attribute leads/jobs (e.g. "Google PPC", + "Direct Mail", "Yelp"). Campaign IDs returned here are the values to pass + to get_st_calls / get_st_leads / get_st_jobs as `campaign_id`. + + Set active_only=True to filter to currently-active campaigns. + """ + from adloop.servicetitan.read import list_campaigns as _impl + + return _impl(_config, active_only=active_only) + + +@mcp.tool(annotations=_READONLY) +@_safe +def list_st_campaign_categories() -> dict: + """List ServiceTitan marketing campaign categories (parent groupings).""" + from adloop.servicetitan.read import list_campaign_categories as _impl + + return _impl(_config) + + +@mcp.tool(annotations=_READONLY) +@_safe +def get_st_calls( + date_range_start: str = "", + date_range_end: str = "", + campaign_id: int | None = None, + business_unit_id: int | None = None, + direction: str = "", + has_recording: bool | None = None, + max_results: int = 500, +) -> dict: + """Pull ServiceTitan calls — duration, customer, campaign, recording flag. + + date_range_start / date_range_end accept ISO-8601 (e.g. "2026-04-01T00:00:00Z"). + Defaults to last 30 days when omitted. + + Filters: campaign_id (from list_st_campaigns), business_unit_id, direction + ("Inbound" or "Outbound"), has_recording (True/False/None). + + Returns calls with `recording_id` populated when audio is available — feed + that ID to get_st_call_recording_url to fetch the audio for transcription. + """ + from adloop.servicetitan.read import get_calls as _impl + + return _impl( + _config, + date_range_start=date_range_start or None, + date_range_end=date_range_end or None, + campaign_id=campaign_id, + business_unit_id=business_unit_id, + direction=direction or None, + has_recording=has_recording, + max_results=max_results, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def get_st_call_recording_url(call_id: int) -> dict: + """Get a downloadable URL for a ServiceTitan call recording. + + Returns the recording payload (URL or signed redirect) for the given call. + Pass call_id from get_st_calls (where has_recording=true). + + Note: requires the Call Recording API add-on on your ST tenant. + """ + from adloop.servicetitan.read import get_call_recording_url as _impl + + return _impl(_config, call_id=call_id) + + +@mcp.tool(annotations=_READONLY) +@_safe +def get_st_leads( + date_range_start: str = "", + date_range_end: str = "", + status: str = "", + campaign_id: int | None = None, + max_results: int = 500, +) -> dict: + """Pull ServiceTitan leads — status, campaign, and GCLID extraction from notes. + + Each lead is scanned for GCLID-shaped strings in the `summary` field + (since ST has no native GCLID field, web form integrations sometimes + push it into Notes). The response includes `leads_with_gclid_in_notes` + and per-lead `gclids_in_notes` arrays. + + Defaults to last 30 days. Filter by status or campaign_id as needed. + """ + from adloop.servicetitan.read import get_leads as _impl + + return _impl( + _config, + date_range_start=date_range_start or None, + date_range_end=date_range_end or None, + status=status or None, + campaign_id=campaign_id, + max_results=max_results, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def get_st_jobs( + date_range_start: str = "", + date_range_end: str = "", + job_status: str = "", + campaign_id: int | None = None, + business_unit_id: int | None = None, + max_results: int = 500, +) -> dict: + """Pull ServiceTitan jobs — status, campaign, BU, originating lead, total revenue. + + Defaults to last 30 days. Use `total` to compute average job value for + static conversion-value calibration in Google Ads. + """ + from adloop.servicetitan.read import get_jobs as _impl + + return _impl( + _config, + date_range_start=date_range_start or None, + date_range_end=date_range_end or None, + job_status=job_status or None, + campaign_id=campaign_id, + business_unit_id=business_unit_id, + max_results=max_results, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def find_gclid_in_st( + date_range_start: str = "", + date_range_end: str = "", + max_results: int = 500, +) -> dict: + """Scan recent ServiceTitan leads + jobs for GCLID-shaped strings in notes. + + ServiceTitan has no native GCLID field. This tool checks if your form + integration pushes the GCLID into Notes/Summary so it can be uploaded + to Google Ads as an offline conversion. + + Returns counts and per-entity matches plus an actionable insight if + nothing was found (i.e. the form integration needs to be updated). + """ + from adloop.servicetitan.read import find_gclid_in_st as _impl + + return _impl( + _config, + date_range_start=date_range_start or None, + date_range_end=date_range_end or None, + max_results=max_results, + ) + + +# --------------------------------------------------------------------------- +# ServiceTitan Analytics — value calibration + funnel + cross-system +# --------------------------------------------------------------------------- + + +@mcp.tool(annotations=_READONLY) +@_safe +def st_compute_avg_job_value_by_campaign( + date_range_start: str = "", + date_range_end: str = "", + business_unit_id: int | None = None, + min_jobs: int = 5, +) -> dict: + """Average job revenue per ST campaign over a window (default last 90d). + + Returns per-campaign avg + overall avg. Use the per-campaign averages to + set differentiated Google Ads conversion values — PPC may have a different + avg ticket than Direct Mail or Yelp. Combine with st_compute_close_rate + to compute: conversion_value = avg_job_value × close_rate. + + Insights flag campaigns whose avg deviates ≥40% from overall — those + deserve their own conversion value rather than the global default. + """ + from adloop.servicetitan.analytics import compute_avg_job_value_by_campaign as _impl + + return _impl( + _config, + date_range_start=date_range_start or None, + date_range_end=date_range_end or None, + business_unit_id=business_unit_id, + min_jobs=min_jobs, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def st_compute_close_rate_by_campaign( + date_range_start: str = "", + date_range_end: str = "", + min_leads: int = 10, +) -> dict: + """Lead → paying-job close rate per ST campaign (default last 90d). + + Highlights best/worst close-rate campaigns. Low close rate at high lead + volume usually indicates a dispatch/sales process problem rather than a + Google Ads keyword problem — investigate before scaling spend. + """ + from adloop.servicetitan.analytics import compute_close_rate_by_campaign as _impl + + return _impl( + _config, + date_range_start=date_range_start or None, + date_range_end=date_range_end or None, + min_leads=min_leads, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def st_compute_lead_to_revenue_funnel( + date_range_start: str = "", + date_range_end: str = "", + campaign_id: int | None = None, +) -> dict: + """Funnel: leads → bookings → jobs → completed → paying, per campaign. + + Reveals at which stage leads die. If a Google Ads campaign produces leads + that never book, the campaign is fine — the dispatch process is broken. + """ + from adloop.servicetitan.analytics import compute_lead_to_revenue_funnel as _impl + + return _impl( + _config, + date_range_start=date_range_start or None, + date_range_end=date_range_end or None, + campaign_id=campaign_id, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def st_correlate_ads_to_revenue( + date_range_start: str = "", + date_range_end: str = "", + name_overrides: dict | None = None, +) -> dict: + """Join Google Ads campaigns to ServiceTitan revenue by name match. + + Computes true_cpa (ads_cost / st_jobs_paid) and true_roas (st_revenue / + ads_cost) — the actual numbers that should drive bidding decisions. Pass + `name_overrides={ads_name: st_name}` for campaigns whose names don't + fuzzy-match. + """ + from adloop.servicetitan.analytics import correlate_ads_to_st_revenue as _impl + + return _impl( + _config, + date_range_start=date_range_start or None, + date_range_end=date_range_end or None, + name_overrides=name_overrides, + ) + + +# --------------------------------------------------------------------------- +# ServiceTitan Exports — Google Ads-ready CSVs +# --------------------------------------------------------------------------- + + +@mcp.tool(annotations=_READONLY) +@_safe +def st_export_offline_conversions( + conversion_action_name: str, + date_range_start: str = "", + date_range_end: str = "", + use_avg_value: float | None = None, + currency: str = "USD", +) -> dict: + """GCLID-based offline conversion CSV for upload to Google Ads. + + Scans completed jobs whose originating lead has a GCLID in its notes. + Writes a Google Ads-ready CSV under ~/.adloop/exports/. Returns the + rows-written count and absolute path. + + `conversion_action_name` MUST exactly match an existing conversion + action in Google Ads. Pass `use_avg_value` to override per-job revenue + with a static value (e.g. for early-stage value calibration). + """ + from adloop.servicetitan.exports import export_offline_conversions_for_ads_upload as _impl + + return _impl( + _config, + conversion_action_name=conversion_action_name, + date_range_start=date_range_start or None, + date_range_end=date_range_end or None, + use_avg_value=use_avg_value, + currency=currency, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def st_export_phone_conversions( + conversion_action_name: str, + date_range_start: str = "", + date_range_end: str = "", + use_avg_value: float | None = None, + currency: str = "USD", + business_unit_id: int | None = None, +) -> dict: + """Phone-based call-conversion CSV for upload to Google Ads. + + Works WITHOUT GCLID — this is the right tool for accounts that don't + yet capture GCLID through the web form. Matches inbound calls (lead-call) + to Google Ads call extensions via Caller ID + Call Start Time. + + `conversion_action_name` MUST exactly match a call-conversion action + in Google Ads. + """ + from adloop.servicetitan.exports import export_phone_conversions_for_ads_upload as _impl + + return _impl( + _config, + conversion_action_name=conversion_action_name, + date_range_start=date_range_start or None, + date_range_end=date_range_end or None, + use_avg_value=use_avg_value, + currency=currency, + business_unit_id=business_unit_id, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def st_export_enhanced_conversions_for_leads( + conversion_action_name: str, + date_range_start: str = "", + date_range_end: str = "", + use_avg_value: float | None = None, + currency: str = "USD", +) -> dict: + """Enhanced Conversions for Leads — hashed PII upload CSV. + + Recovers attribution for users who blocked GCLID (consent rejection, + iOS, etc) by matching SHA-256 hashed email/phone/name to logged-in + Google users. Pairs with the EC for Leads tag (awud) you set up in GTM. + """ + from adloop.servicetitan.exports import export_enhanced_conversions_for_leads as _impl + + return _impl( + _config, + conversion_action_name=conversion_action_name, + date_range_start=date_range_start or None, + date_range_end=date_range_end or None, + use_avg_value=use_avg_value, + currency=currency, + ) + + +# --------------------------------------------------------------------------- +# ServiceTitan Transcription + Classification +# --------------------------------------------------------------------------- + + +@mcp.tool(annotations=_READONLY) +@_safe +def st_transcribe_call(call_id: int, force_refresh: bool = False) -> dict: + """Transcribe a ServiceTitan call recording with speaker diarization. + + Uses Google Cloud Speech-to-Text (reuses the GA4/Ads service account). + Cached at ~/.adloop/st_transcripts/{call_id}.json — pass force_refresh=True + to re-transcribe. + + Requires Speech-to-Text API enabled on the GCP project and the service + account to have `roles/speech.editor` (or "Cloud Speech-to-Text User"). + """ + from adloop.servicetitan.transcribe import transcribe_st_call as _impl + + return _impl(_config, call_id=call_id, force_refresh=force_refresh) + + +@mcp.tool(annotations=_READONLY) +@_safe +def st_classify_call_outcome(call_id: int) -> dict: + """Classify a call as booked/quoted/no_show/wrong_number/sales_call/spam/info_only. + + Auto-transcribes if needed. Rule-based — predictable + free. Returns the + matched rule + a snippet for verification. + """ + from adloop.servicetitan.classify import classify_call_outcome as _impl + + return _impl(_config, call_id=call_id) + + +@mcp.tool(annotations=_READONLY) +@_safe +def st_extract_call_intent(call_id: int) -> dict: + """Extract service intents from a call (drain, water heater, leak, emergency...). + + Multiple intents per call are possible. Use to map call patterns to ad + groups: if 70% of "Plumbing PPC" calls ask for water heaters but you bid + on drain cleaning, the budget allocation is wrong. + """ + from adloop.servicetitan.classify import extract_call_intent as _impl + + return _impl(_config, call_id=call_id) + + +@mcp.tool(annotations=_READONLY) +@_safe +def st_extract_negative_keywords_from_calls( + date_range_start: str = "", + date_range_end: str = "", + campaign_id: int | None = None, + min_calls_for_review: int = 5, + max_calls: int = 100, + only_with_recording: bool = True, +) -> dict: + """Mine call transcripts for negative-keyword candidates. + + Walks recent calls, transcribes each (cached), and aggregates "do you + also do X" / "I'm looking for X" phrases. Returns ranked candidates with + occurrence counts and example snippets. + + NOTE: transcribing 100 calls can take several minutes and cost ~$1 in + Google STT charges. Lower max_calls for a faster sample. + """ + from adloop.servicetitan.classify import extract_negative_keywords_from_calls as _impl + + return _impl( + _config, + date_range_start=date_range_start or None, + date_range_end=date_range_end or None, + campaign_id=campaign_id, + min_calls_for_review=min_calls_for_review, + max_calls=max_calls, + only_with_recording=only_with_recording, + ) + + +# --------------------------------------------------------------------------- +# ServiceTitan Customer Match exports +# --------------------------------------------------------------------------- + + +@mcp.tool(annotations=_READONLY) +@_safe +def st_export_customer_match_list(max_results: int = 5000) -> dict: + """Full ST customer base as a hashed Google Ads Customer Match CSV. + + Output written to ~/.adloop/exports/. Email/Phone/First/Last hashed + SHA-256 lowercase per Google spec. Upload in Google Ads → Audience + manager → Your data segments. + """ + from adloop.servicetitan.audiences import export_customer_match_list as _impl + + return _impl(_config, max_results=max_results) + + +@mcp.tool(annotations=_READONLY) +@_safe +def st_export_lapsed_customer_audience( + months_inactive: int = 12, max_results: int = 5000 +) -> dict: + """Customers with no completed job in the last N months — reactivation audience. + + Use for low-CPM Display reactivation campaigns. Repeat-customer revenue + is much cheaper than net-new acquisition. + """ + from adloop.servicetitan.audiences import export_lapsed_customer_audience as _impl + + return _impl(_config, months_inactive=months_inactive, max_results=max_results) + + +@mcp.tool(annotations=_READONLY) +@_safe +def st_export_high_value_seed_audience( + top_pct: float = 0.05, lookback_months: int = 24, max_results: int = 5000 +) -> dict: + """Top X% of customers by lifetime revenue — Customer Match + PMax seed. + + Exports a hashed Customer Match CSV. Use as the seed for Similar-Audience + targeting and as a PMax audience signal (PMax signals are hints — these + are your strongest hints). + """ + from adloop.servicetitan.audiences import export_high_value_seed_audience as _impl + + return _impl( + _config, + top_pct=top_pct, + lookback_months=lookback_months, + max_results=max_results, + ) + + +# --------------------------------------------------------------------------- +# ServiceTitan Demand + Attribution Decay +# --------------------------------------------------------------------------- + + +@mcp.tool(annotations=_READONLY) +@_safe +def st_geo_demand_analysis( + date_range_start: str = "", + date_range_end: str = "", + business_unit_id: int | None = None, + group_by: str = "zip", + min_jobs: int = 5, +) -> dict: + """Job revenue + count grouped by ZIP / city / state (default last 365d). + + Drives geo bid adjustments. Areas with above-average ticket size deserve + positive bid adjustments; areas below average deserve negative or + exclusion. Insights flag both ends with concrete adjustment recommendations. + """ + from adloop.servicetitan.demand import geo_demand_analysis as _impl + + return _impl( + _config, + date_range_start=date_range_start or None, + date_range_end=date_range_end or None, + business_unit_id=business_unit_id, + group_by=group_by, + min_jobs=min_jobs, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def st_seasonal_demand_curve( + date_range_start: str = "", + date_range_end: str = "", + business_unit_id: int | None = None, + group_by: str = "week", +) -> dict: + """Jobs by week-of-year (or month) — drives ad scheduling + budget pacing. + + Identifies peak and trough periods. Ramp Google Ads budget the period + BEFORE peak demand, not when leads are already pouring in. + """ + from adloop.servicetitan.demand import seasonal_demand_curve as _impl + + return _impl( + _config, + date_range_start=date_range_start or None, + date_range_end=date_range_end or None, + business_unit_id=business_unit_id, + group_by=group_by, + ) + + +@mcp.tool(annotations=_READONLY) +@_safe +def st_attribution_decay_report( + date_range_start: str = "", + date_range_end: str = "", + campaign_id: int | None = None, +) -> dict: + """P50/P90/P95 days from job creation to completion + histogram. + + Use to set the conversion window in Google Ads. If P90 is 45 days, the + default 30-day window will MISS 10%+ of true conversions and break Smart + Bidding's optimization signal. + """ + from adloop.servicetitan.demand import attribution_decay_report as _impl + + return _impl( + _config, + date_range_start=date_range_start or None, + date_range_end=date_range_end or None, + campaign_id=campaign_id, + ) + + +# --------------------------------------------------------------------------- +# Google Ads Write Tools (Safety Layer) +# --------------------------------------------------------------------------- + + +@mcp.tool(annotations=_WRITE) +@_safe +def draft_campaign( + campaign_name: str, + daily_budget: float, + bidding_strategy: str, + geo_target_ids: _StrList, + language_ids: _StrList, + customer_id: str = "", + target_cpa: float = 0, + target_roas: float = 0, + channel_type: str = "SEARCH", + ad_group_name: str = "", + keywords: _DictListOpt = None, + search_partners_enabled: bool = False, + display_network_enabled: bool | None = None, + display_expansion_enabled: bool | None = None, + max_cpc: float = 0, + geo_exclude_ids: list[str] | None = None, + ad_schedule: list[dict] | None = None, +) -> dict: + """Draft a full campaign structure — returns a PREVIEW, does NOT create anything. + + Creates: CampaignBudget + Campaign (PAUSED) + AdGroup + optional Keywords + + geo targeting + language targeting. + Ads are NOT included — use draft_responsive_search_ad after the campaign exists. + + bidding_strategy: MAXIMIZE_CONVERSIONS | TARGET_CPA | TARGET_ROAS | + MAXIMIZE_CONVERSION_VALUE | TARGET_SPEND | MANUAL_CPC + target_cpa: required if bidding_strategy is TARGET_CPA (in account currency) + target_roas: required if bidding_strategy is TARGET_ROAS + keywords: list of {"text": "keyword", "match_type": "EXACT|PHRASE|BROAD"} + search_partners_enabled: include ads on Search partners + display_network_enabled: enable Search campaign display expansion + display_expansion_enabled: alias for display_network_enabled + max_cpc: manual CPC bid for the initial ad group when bidding_strategy is + MANUAL_CPC, or the Maximize Clicks CPC cap when bidding_strategy is + TARGET_SPEND + geo_target_ids: REQUIRED list of geo target constant IDs + Common: "2276" Germany, "2040" Austria, "2756" Switzerland, "2840" USA, + "2826" UK, "2250" France. Full list: Google Ads API geo target constants. + geo_exclude_ids: optional list of geo target constant IDs to EXCLUDE + (negative location criteria). Useful when targeting a broad + region while suppressing specific sub-geos. + language_ids: REQUIRED list of language constant IDs + Common: "1001" German, "1000" English, "1002" French, "1004" Spanish, + "1014" Portuguese. Full list: Google Ads API language constants. + ad_schedule: optional list of {day_of_week, start_hour, end_hour, + start_minute, end_minute} entries restricting when the campaign + serves. day_of_week: MONDAY..SUNDAY. minutes: 0/15/30/45. + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.write import draft_campaign as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + campaign_name=campaign_name, + daily_budget=daily_budget, + bidding_strategy=bidding_strategy, + target_cpa=target_cpa, + target_roas=target_roas, + channel_type=channel_type, + ad_group_name=ad_group_name, + keywords=keywords, + geo_target_ids=geo_target_ids, + geo_exclude_ids=geo_exclude_ids, + language_ids=language_ids, + search_partners_enabled=search_partners_enabled, + display_network_enabled=display_network_enabled, + display_expansion_enabled=display_expansion_enabled, + max_cpc=max_cpc, + ad_schedule=ad_schedule, + ) + + +@mcp.tool(annotations=_WRITE) +@_safe +def draft_ad_group( + campaign_id: str, + ad_group_name: str, + keywords: _DictListOpt = None, + customer_id: str = "", + cpc_bid_micros: int = 0, +) -> dict: + """Draft a new ad group within an existing campaign — returns a PREVIEW, does NOT create. + + Creates an ad group (ENABLED, type SEARCH_STANDARD) in the specified campaign. + Optionally includes keywords in the same atomic operation. + + campaign_id: The campaign to add the ad group to (get from get_campaign_performance). + ad_group_name: Name for the new ad group. + keywords: Optional list of {"text": "keyword", "match_type": "EXACT|PHRASE|BROAD"}. + cpc_bid_micros: Optional ad group CPC bid in micros (only for MANUAL_CPC campaigns). + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.write import draft_ad_group as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + campaign_id=campaign_id, + ad_group_name=ad_group_name, + keywords=keywords, + cpc_bid_micros=cpc_bid_micros, + ) + + +@mcp.tool(annotations=_WRITE) +@_safe +def update_campaign( + campaign_id: str, + customer_id: str = "", + bidding_strategy: str = "", + target_cpa: float = 0, + target_roas: float = 0, + daily_budget: float = 0, + geo_target_ids: _StrListOpt = None, + geo_exclude_ids: _StrListOpt = None, + language_ids: _StrListOpt = None, + search_partners_enabled: bool | None = None, + display_network_enabled: bool | None = None, + display_expansion_enabled: bool | None = None, + max_cpc: float = 0, + ad_schedule: list[dict] | None = None, +) -> dict: + """Draft an update to an existing campaign — returns a PREVIEW, does NOT apply. + + Only include the parameters you want to change. Omit the rest. List-typed + fields (geo_target_ids, geo_exclude_ids, language_ids, ad_schedule) follow + REPLACE semantics: when provided, all existing entries of that type are + removed and the new list is added. Pass an empty list (e.g. + ``geo_exclude_ids=[]``) to clear that field. + + campaign_id: the numeric ID of the campaign to update (required) + bidding_strategy: MAXIMIZE_CONVERSIONS | TARGET_CPA | TARGET_ROAS | + MAXIMIZE_CONVERSION_VALUE | TARGET_SPEND | MANUAL_CPC + target_cpa: required if bidding_strategy is TARGET_CPA (in account currency) + target_roas: required if bidding_strategy is TARGET_ROAS + daily_budget: new daily budget in account currency + geo_target_ids: REPLACES all geo targets. + geo_exclude_ids: REPLACES all negative-location geo criteria. + language_ids: REPLACES all language targets. + search_partners_enabled: include ads on Search partners + display_network_enabled: enable Search campaign display expansion + display_expansion_enabled: alias for display_network_enabled + max_cpc: Maximize Clicks CPC cap when bidding_strategy is TARGET_SPEND + ad_schedule: REPLACES all schedule criteria. Each entry: {day_of_week, + start_hour, end_hour, start_minute, end_minute}. + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.write import update_campaign as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + campaign_id=campaign_id, + bidding_strategy=bidding_strategy, + target_cpa=target_cpa, + target_roas=target_roas, + daily_budget=daily_budget, + geo_target_ids=geo_target_ids, + geo_exclude_ids=geo_exclude_ids, + language_ids=language_ids, + search_partners_enabled=search_partners_enabled, + display_network_enabled=display_network_enabled, + display_expansion_enabled=display_expansion_enabled, + max_cpc=max_cpc, + ad_schedule=ad_schedule, + ) + + +@mcp.tool(annotations=_WRITE) +@_safe +def draft_responsive_search_ad( + ad_group_id: str, + headlines: list[str | dict], + descriptions: list[str | dict], + final_url: str, + customer_id: str = "", + path1: str = "", + path2: str = "", +) -> dict: + """Draft a Responsive Search Ad — returns a PREVIEW, does NOT create the ad. + + Provide 3-15 headlines (max 30 chars each) and 2-4 descriptions (max 90 chars each). + The preview shows exactly what will be created. Call confirm_and_apply to execute. + + Each headline/description entry may be either: + + - a plain string (unpinned), or + - a dict ``{"text": "...", "pinned_field": "HEADLINE_1"}`` (pinned). + + Valid pin values: + headlines: HEADLINE_1, HEADLINE_2, HEADLINE_3 + descriptions: DESCRIPTION_1, DESCRIPTION_2 + + Google caps: at most 2 headlines per pin slot, at most 1 description per pin + slot. Mixed plain-string and dict entries are allowed within a single call + (e.g. brand pinned to HEADLINE_1, the rest unpinned). + """ + from adloop.ads.write import draft_responsive_search_ad as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + ad_group_id=ad_group_id, + headlines=headlines, + descriptions=descriptions, + final_url=final_url, + path1=path1, + path2=path2, + ) + + +@mcp.tool(annotations=_WRITE) +@_safe +def update_responsive_search_ad( + ad_id: str, + customer_id: str = "", + headlines: list[str | dict] | None = None, + descriptions: list[str | dict] | None = None, + final_url: str = "", + path1: str = "", + path2: str = "", + clear_path1: bool = False, + clear_path2: bool = False, +) -> dict: + """Update mutable fields on an existing RSA — returns a PREVIEW. + + In-place edit of an RSA without creating a new ad (preserves serving + history and avoids the learning-period reset that pause-old + create-new + triggers). Google Ads API v23 via ``AdService.MutateAds`` permits in-place + mutation of ``final_urls``, ``path1``, ``path2``, ``headlines``, and + ``descriptions`` on existing RSAs. + + Headlines/descriptions are LIST-REPLACE — when provided, the supplied + list fully swaps in for the existing one, and Google's RSA constraints + apply (3-15 headlines, 2-4 descriptions, 30/90 char limits, pin-slot + rules). Each entry may be a plain string (unpinned) or + ``{"text": "...", "pinned_field": "HEADLINE_1"}``. + + Argument semantics: + - ``headlines`` / ``descriptions``: None or [] -> no change; + non-empty list -> replaces the existing list in full + - ``final_url``: empty -> no change; non-empty -> replaces final URL + - ``path1`` / ``path2``: empty -> no change; non-empty -> sets value + - ``clear_path1`` / ``clear_path2``: True -> set to empty string + + At least one mutation must be requested. Call confirm_and_apply with the + returned plan_id to execute. + """ + from adloop.ads.write import update_responsive_search_ad as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + ad_id=ad_id, + headlines=headlines, + descriptions=descriptions, + final_url=final_url, + path1=path1, + path2=path2, + clear_path1=clear_path1, + clear_path2=clear_path2, + ) + + +@mcp.tool(annotations=_WRITE) +@_safe +def draft_keywords( + ad_group_id: str, + keywords: _DictList, + customer_id: str = "", +) -> dict: + """Draft keyword additions — returns a PREVIEW, does NOT add keywords. + + keywords: list of {"text": "keyword phrase", "match_type": "EXACT|PHRASE|BROAD"} + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.write import draft_keywords as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + ad_group_id=ad_group_id, + keywords=keywords, + ) + + +@mcp.tool(annotations=_WRITE) +@_safe +def add_negative_keywords( + campaign_id: str, + keywords: _StrList, + customer_id: str = "", + match_type: str = "EXACT", +) -> dict: + """Draft negative keyword additions — returns a PREVIEW. + + Negative keywords prevent your ads from showing for irrelevant searches. + match_type: "EXACT", "PHRASE", or "BROAD" + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.write import add_negative_keywords as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + campaign_id=campaign_id, + keywords=keywords, + match_type=match_type, + ) + + +@mcp.tool(annotations=_WRITE) +@_safe +def propose_negative_keyword_list( + campaign_id: str, + list_name: str, + keywords: _StrList, + customer_id: str = "", + match_type: str = "EXACT", +) -> dict: + """Draft a shared negative keyword list and attach it to a campaign — returns a PREVIEW. + + Creates a reusable negative keyword list that can later be applied to multiple + campaigns, unlike add_negative_keywords which adds directly to one campaign. + match_type: "EXACT", "PHRASE", or "BROAD" + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.write import propose_negative_keyword_list as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + campaign_id=campaign_id, + list_name=list_name, + keywords=keywords, + match_type=match_type, + ) + + +@mcp.tool(annotations=_WRITE) +@_safe +def add_to_negative_keyword_list( + shared_set_id: str, + keywords: _StrList, + customer_id: str = "", + match_type: str = "EXACT", +) -> dict: + """Append keywords to an EXISTING shared negative keyword list — returns a PREVIEW. + + Use this when a suitable list already exists and only needs more keywords + (instead of propose_negative_keyword_list, which creates a new list). + Always call get_negative_keyword_lists first to find the right shared_set_id + and get_negative_keyword_list_keywords to avoid duplicating existing terms. + + shared_set_id: numeric ID from get_negative_keyword_lists (shared_set.id). + keywords: list of keyword strings to append (duplicates in the input list + are collapsed). + match_type: "EXACT", "PHRASE", or "BROAD" + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.write import add_to_negative_keyword_list as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + shared_set_id=shared_set_id, + keywords=keywords, + match_type=match_type, + ) + + +@mcp.tool(annotations=_WRITE) +@_safe +def attach_shared_set_to_campaigns( + shared_set_id: str, + campaign_ids: _StrList, + customer_id: str = "", +) -> dict: + """Attach an existing shared set to one or more campaigns — returns a PREVIEW. + + Creates CampaignSharedSet linkages so the campaigns inherit the shared + set's criteria (e.g. negative keywords). Most commonly used to attach a + shared negative keyword list to newly-built campaigns. + + Use ``get_negative_keyword_lists`` to find the shared_set_id, and + ``get_negative_keyword_list_campaigns`` to inspect existing attachments. + + shared_set_id: numeric ID from get_negative_keyword_lists. + campaign_ids: list of numeric campaign IDs to attach the set to. + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.write import attach_shared_set_to_campaigns as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + shared_set_id=shared_set_id, + campaign_ids=campaign_ids, + ) + + +@mcp.tool(annotations=_WRITE) +@_safe +def detach_shared_set_from_campaigns( + shared_set_id: str, + campaign_ids: _StrList, + customer_id: str = "", +) -> dict: + """Detach a shared set from one or more campaigns — returns a PREVIEW. + + Removes CampaignSharedSet linkages so the campaigns no longer inherit the + shared set's criteria. The shared set itself is unchanged; only the + per-campaign attachment is removed. + + Use ``get_negative_keyword_list_campaigns`` to inspect existing attachments + before detaching. + + shared_set_id: numeric ID from get_negative_keyword_lists. + campaign_ids: list of numeric campaign IDs to detach the set from. + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.write import detach_shared_set_from_campaigns as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + shared_set_id=shared_set_id, + campaign_ids=campaign_ids, + ) + + +@mcp.tool(annotations=_WRITE) +@_safe +def update_ad_group( + ad_group_id: str, + customer_id: str = "", + ad_group_name: str = "", + max_cpc: float = 0, ) -> dict: - """Execute an arbitrary GAQL (Google Ads Query Language) query. + """Draft an ad group update for name and/or manual CPC bid.""" + from adloop.ads.write import update_ad_group as _impl - Use this for advanced queries not covered by the other tools. - See the GAQL reference in the AdLoop cursor rules for syntax help. + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + ad_group_id=ad_group_id, + ad_group_name=ad_group_name, + max_cpc=max_cpc, + ) - format: "table" (default, readable), "json" (structured), "csv" (exportable) + +@mcp.tool(annotations=_WRITE) +@_safe +def draft_callouts( + callouts: _StrList, + campaign_id: str = "", + customer_id: str = "", +) -> dict: + """Draft callout assets — returns a PREVIEW. + + If ``campaign_id`` is empty, callouts attach at the customer/account + level (CustomerAsset) and apply to all eligible campaigns. Pass a + campaign_id to scope them to one campaign instead. """ - from adloop.ads.gaql import run_gaql as _impl + from adloop.ads.write import draft_callouts as _impl return _impl( _config, customer_id=customer_id or _config.ads.customer_id, - query=query, - format=format, + campaign_id=campaign_id, + callouts=callouts, ) -# --------------------------------------------------------------------------- -# Google Ads Write Tools (Safety Layer) -# --------------------------------------------------------------------------- - - @mcp.tool(annotations=_WRITE) @_safe -def draft_campaign( - campaign_name: str, - daily_budget: float, - bidding_strategy: str, - geo_target_ids: _StrList, - language_ids: _StrList, +def draft_structured_snippets( + snippets: _DictList, + campaign_id: str = "", customer_id: str = "", - target_cpa: float = 0, - target_roas: float = 0, - channel_type: str = "SEARCH", - ad_group_name: str = "", - keywords: _DictListOpt = None, - search_partners_enabled: bool = False, - display_network_enabled: bool | None = None, - display_expansion_enabled: bool | None = None, - max_cpc: float = 0, ) -> dict: - """Draft a full campaign structure — returns a PREVIEW, does NOT create anything. + """Draft structured snippet assets — returns a PREVIEW. - Creates: CampaignBudget + Campaign (PAUSED) + AdGroup + optional Keywords - + geo targeting + language targeting. - Ads are NOT included — use draft_responsive_search_ad after the campaign exists. + If ``campaign_id`` is empty, snippets attach at the customer/account + level. Pass a campaign_id to scope to one campaign. + """ + from adloop.ads.write import draft_structured_snippets as _impl - bidding_strategy: MAXIMIZE_CONVERSIONS | TARGET_CPA | TARGET_ROAS | - MAXIMIZE_CONVERSION_VALUE | TARGET_SPEND | MANUAL_CPC - target_cpa: required if bidding_strategy is TARGET_CPA (in account currency) - target_roas: required if bidding_strategy is TARGET_ROAS - keywords: list of {"text": "keyword", "match_type": "EXACT|PHRASE|BROAD"} - search_partners_enabled: include ads on Search partners - display_network_enabled: enable Search campaign display expansion - display_expansion_enabled: alias for display_network_enabled - max_cpc: manual CPC bid for the initial ad group when bidding_strategy is - MANUAL_CPC, or the Maximize Clicks CPC cap when bidding_strategy is - TARGET_SPEND - geo_target_ids: REQUIRED list of geo target constant IDs - Common: "2276" Germany, "2040" Austria, "2756" Switzerland, "2840" USA, - "2826" UK, "2250" France. Full list: Google Ads API geo target constants. - language_ids: REQUIRED list of language constant IDs - Common: "1001" German, "1000" English, "1002" French, "1004" Spanish, - "1014" Portuguese. Full list: Google Ads API language constants. + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + campaign_id=campaign_id, + snippets=snippets, + ) + + +@mcp.tool(annotations=_WRITE) +@_safe +def draft_image_assets( + image_paths: _StrList, + campaign_id: str = "", + customer_id: str = "", + field_types: _StrListOpt = None, +) -> dict: + """Draft image assets from local PNG, JPEG, or GIF files — returns a PREVIEW. + + Scope: + - If ``campaign_id`` is empty (default), images attach at the + customer/account level (CustomerAsset). + - If ``campaign_id`` is provided, images attach at that campaign + (CampaignAsset). + + Field type: + Each image's AssetFieldType is auto-detected from its aspect + ratio (with a 'logo' filename hint): + 1:1 → SQUARE_MARKETING_IMAGE (or BUSINESS_LOGO if 'logo' in name) + 1.91:1 → MARKETING_IMAGE + 4:1 → LANDSCAPE_LOGO (logo hint required) + 4:5 → PORTRAIT_MARKETING_IMAGE + Pass ``field_types`` (one entry per image, same order as + image_paths) to override. Valid override values: + MARKETING_IMAGE, SQUARE_MARKETING_IMAGE, + PORTRAIT_MARKETING_IMAGE, TALL_PORTRAIT_MARKETING_IMAGE, + LOGO, LANDSCAPE_LOGO, BUSINESS_LOGO. Call confirm_and_apply with the returned plan_id to execute. """ - from adloop.ads.write import draft_campaign as _impl + from adloop.ads.write import draft_image_assets as _impl return _impl( _config, customer_id=customer_id or _config.ads.customer_id, - campaign_name=campaign_name, - daily_budget=daily_budget, - bidding_strategy=bidding_strategy, - target_cpa=target_cpa, - target_roas=target_roas, - channel_type=channel_type, - ad_group_name=ad_group_name, - keywords=keywords, - geo_target_ids=geo_target_ids, - language_ids=language_ids, - search_partners_enabled=search_partners_enabled, - display_network_enabled=display_network_enabled, - display_expansion_enabled=display_expansion_enabled, - max_cpc=max_cpc, + campaign_id=campaign_id, + image_paths=image_paths, + field_types=field_types, ) @mcp.tool(annotations=_WRITE) @_safe -def draft_ad_group( - campaign_id: str, - ad_group_name: str, - keywords: _DictListOpt = None, +def draft_business_name_asset( + business_name: str, + campaign_id: str = "", customer_id: str = "", - cpc_bid_micros: int = 0, ) -> dict: - """Draft a new ad group within an existing campaign — returns a PREVIEW, does NOT create. + """Draft a BUSINESS_NAME text asset — returns a PREVIEW. - Creates an ad group (ENABLED, type SEARCH_STANDARD) in the specified campaign. - Optionally includes keywords in the same atomic operation. + Creates a TEXT asset with the business name and links it as + ``BUSINESS_NAME`` so Google can show the brand name alongside ads. - campaign_id: The campaign to add the ad group to (get from get_campaign_performance). - ad_group_name: Name for the new ad group. - keywords: Optional list of {"text": "keyword", "match_type": "EXACT|PHRASE|BROAD"}. - cpc_bid_micros: Optional ad group CPC bid in micros (only for MANUAL_CPC campaigns). + Scope: + - If ``campaign_id`` is empty (default), the asset attaches at the + customer/account level (CustomerAsset) and applies to all + eligible campaigns. + - If ``campaign_id`` is provided, it scopes to that one campaign. + + business_name: max 25 characters per Google Ads policy. Call confirm_and_apply with the returned plan_id to execute. """ - from adloop.ads.write import draft_ad_group as _impl + from adloop.ads.write import draft_business_name_asset as _impl return _impl( _config, customer_id=customer_id or _config.ads.customer_id, campaign_id=campaign_id, - ad_group_name=ad_group_name, - keywords=keywords, - cpc_bid_micros=cpc_bid_micros, + business_name=business_name, ) @mcp.tool(annotations=_WRITE) @_safe -def update_campaign( - campaign_id: str, +def pause_entity( + entity_type: str, + entity_id: str, customer_id: str = "", - bidding_strategy: str = "", - target_cpa: float = 0, - target_roas: float = 0, - daily_budget: float = 0, - geo_target_ids: _StrListOpt = None, - language_ids: _StrListOpt = None, - search_partners_enabled: bool | None = None, - display_network_enabled: bool | None = None, - display_expansion_enabled: bool | None = None, - max_cpc: float = 0, ) -> dict: - """Draft an update to an existing campaign — returns a PREVIEW, does NOT apply. + """Draft pausing a campaign, ad group, ad, or keyword — returns a PREVIEW. - Only include the parameters you want to change. Omit the rest. + entity_type: "campaign", "ad_group", "ad", or "keyword" + entity_id format by type: + - campaign: campaign ID (e.g. "12345678") + - ad_group: ad group ID (e.g. "12345678") + - ad: "adGroupId~adId" (e.g. "12345678~987654") + - keyword: "adGroupId~criterionId" (e.g. "12345678~987654") - campaign_id: the numeric ID of the campaign to update (required) - bidding_strategy: MAXIMIZE_CONVERSIONS | TARGET_CPA | TARGET_ROAS | - MAXIMIZE_CONVERSION_VALUE | TARGET_SPEND | MANUAL_CPC - target_cpa: required if bidding_strategy is TARGET_CPA (in account currency) - target_roas: required if bidding_strategy is TARGET_ROAS - daily_budget: new daily budget in account currency - geo_target_ids: REPLACES all geo targets. Common IDs: "2276" Germany, - "2040" Austria, "2756" Switzerland, "2840" USA, "2826" UK - language_ids: REPLACES all language targets. Common IDs: "1001" German, - "1000" English, "1002" French, "1004" Spanish - search_partners_enabled: include ads on Search partners - display_network_enabled: enable Search campaign display expansion - display_expansion_enabled: alias for display_network_enabled - max_cpc: Maximize Clicks CPC cap when bidding_strategy is TARGET_SPEND, or - when the existing campaign already uses TARGET_SPEND + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.write import pause_entity as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + entity_type=entity_type, + entity_id=entity_id, + ) + + +@mcp.tool(annotations=_WRITE) +@_safe +def enable_entity( + entity_type: str, + entity_id: str, + customer_id: str = "", +) -> dict: + """Draft enabling a paused campaign, ad group, ad, or keyword — returns a PREVIEW. + + entity_type: "campaign", "ad_group", "ad", or "keyword" + entity_id format by type: + - campaign: campaign ID (e.g. "12345678") + - ad_group: ad group ID (e.g. "12345678") + - ad: "adGroupId~adId" (e.g. "12345678~987654") + - keyword: "adGroupId~criterionId" (e.g. "12345678~987654") Call confirm_and_apply with the returned plan_id to execute. """ - from adloop.ads.write import update_campaign as _impl + from adloop.ads.write import enable_entity as _impl return _impl( _config, customer_id=customer_id or _config.ads.customer_id, - campaign_id=campaign_id, - bidding_strategy=bidding_strategy, - target_cpa=target_cpa, - target_roas=target_roas, - daily_budget=daily_budget, - geo_target_ids=geo_target_ids, - language_ids=language_ids, - search_partners_enabled=search_partners_enabled, - display_network_enabled=display_network_enabled, - display_expansion_enabled=display_expansion_enabled, - max_cpc=max_cpc, + entity_type=entity_type, + entity_id=entity_id, + ) + + +@mcp.tool(annotations=_DESTRUCTIVE) +@_safe +def remove_entity( + entity_type: str, + entity_id: str, + customer_id: str = "", +) -> dict: + """Draft REMOVING an entity — returns a PREVIEW. This is IRREVERSIBLE. + + entity_type: "campaign", "ad_group", "ad", "keyword", "negative_keyword", + "shared_criterion", "campaign_asset", "asset", or "customer_asset" + entity_id: The resource ID. + For keywords: "adGroupId~criterionId" + For negative_keywords: "campaignId~criterionId" + (use the resource_id field from get_negative_keywords) + For shared_criterion: "sharedSetId~criterionId" + (use the resource_id field from get_negative_keyword_list_keywords) + For campaign_asset: "campaignId~assetId~fieldType" + For asset: simple asset ID + For customer_asset: "assetId~fieldType" + + WARNING: Removed entities cannot be re-enabled. Use pause_entity instead + if you just want to temporarily disable something. + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.write import remove_entity as _impl + + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + entity_type=entity_type, + entity_id=entity_id, ) @mcp.tool(annotations=_WRITE) @_safe -def draft_responsive_search_ad( - ad_group_id: str, - headlines: list[str | dict], - descriptions: list[str | dict], - final_url: str, +def draft_sitelinks( + sitelinks: _DictList, + campaign_id: str = "", customer_id: str = "", - path1: str = "", - path2: str = "", ) -> dict: - """Draft a Responsive Search Ad — returns a PREVIEW, does NOT create the ad. + """Draft sitelink extensions — returns a PREVIEW. - Provide 3-15 headlines (max 30 chars each) and 2-4 descriptions (max 90 chars each). - The preview shows exactly what will be created. Call confirm_and_apply to execute. + Sitelinks appear as additional links below your ad, increasing click area + and directing users to specific pages. - Each headline/description entry may be either: + Scope: + - If ``campaign_id`` is empty, sitelinks attach at the customer/account + level (CustomerAsset) and apply to all eligible campaigns. + - If ``campaign_id`` is provided, sitelinks attach at the campaign level + (CampaignAsset). - - a plain string (unpinned), or - - a dict ``{"text": "...", "pinned_field": "HEADLINE_1"}`` (pinned). + sitelinks: list of dicts, each with: + - link_text (str, required, max 25 chars) — the clickable text shown + - final_url (str, required) — destination URL for this sitelink + - description1 (str, optional, max 35 chars) — first description line + - description2 (str, optional, max 35 chars) — second description line - Valid pin values: - headlines: HEADLINE_1, HEADLINE_2, HEADLINE_3 - descriptions: DESCRIPTION_1, DESCRIPTION_2 + Google recommends at least 4 sitelinks. Fewer than 2 may not show. - Google caps: at most 2 headlines per pin slot, at most 1 description per pin - slot. Mixed plain-string and dict entries are allowed within a single call - (e.g. brand pinned to HEADLINE_1, the rest unpinned). + Call confirm_and_apply with the returned plan_id to execute. """ - from adloop.ads.write import draft_responsive_search_ad as _impl + from adloop.ads.write import draft_sitelinks as _impl return _impl( _config, customer_id=customer_id or _config.ads.customer_id, - ad_group_id=ad_group_id, - headlines=headlines, - descriptions=descriptions, - final_url=final_url, - path1=path1, - path2=path2, + campaign_id=campaign_id, + sitelinks=sitelinks, ) @mcp.tool(annotations=_WRITE) @_safe -def draft_keywords( - ad_group_id: str, - keywords: _DictList, +def draft_call_asset( + phone_number: str, + country_code: str = "US", + campaign_id: str = "", customer_id: str = "", + call_conversion_action_id: str = "", + ad_schedule: list[dict] | None = None, ) -> dict: - """Draft keyword additions — returns a PREVIEW, does NOT add keywords. + """Draft a call asset (phone extension) — returns a PREVIEW. + + Scope: + - If ``campaign_id`` is empty, the call asset is added at the + customer/account level via CustomerAsset. + - If ``campaign_id`` is provided, the call asset is scoped to that + single campaign via CampaignAsset. + + phone_number: human-formatted or E.164 (e.g. "(916) 339-3676" or + "+19163393676"). Auto-normalized to E.164 using country_code when + no leading '+' is present. + country_code: ISO-3166 alpha-2 (default "US"). Used only for E.164 + normalization when phone_number lacks a leading '+'. + call_conversion_action_id: optional Google Ads conversion action ID to + use for call-conversion counting (e.g. count calls ≥60 sec). + ad_schedule: optional list limiting hours when the call asset shows. + Each entry: {day_of_week: MONDAY..SUNDAY, start_hour: 0-23, + end_hour: 0-24, start_minute: 0/15/30/45, end_minute: 0/15/30/45}. + + NOTE: Google Ads requires manual phone-number verification before the + call asset can serve. The asset is created via API but won't show in + ads until verification is completed in Tools → Assets → Calls. - keywords: list of {"text": "keyword phrase", "match_type": "EXACT|PHRASE|BROAD"} Call confirm_and_apply with the returned plan_id to execute. """ - from adloop.ads.write import draft_keywords as _impl + from adloop.ads.write import draft_call_asset as _impl return _impl( _config, customer_id=customer_id or _config.ads.customer_id, - ad_group_id=ad_group_id, - keywords=keywords, + phone_number=phone_number, + country_code=country_code, + campaign_id=campaign_id, + call_conversion_action_id=call_conversion_action_id, + ad_schedule=ad_schedule, ) @mcp.tool(annotations=_WRITE) @_safe -def add_negative_keywords( - campaign_id: str, - keywords: _StrList, +def draft_location_asset( + business_profile_account_id: str, + asset_set_name: str = "", + campaign_id: str = "", customer_id: str = "", - match_type: str = "EXACT", + label_filters: list[str] | None = None, + listing_id_filters: list[str] | None = None, ) -> dict: - """Draft negative keyword additions — returns a PREVIEW. + """Draft a Google Business Profile-backed location AssetSet — PREVIEW. + + Creates a LOCATION_SYNC AssetSet that pulls business locations from a + linked Google Business Profile (GBP) and exposes them as location assets + in ads (used by location extensions and the local map pin). + + business_profile_account_id: numeric GBP/LBC account ID. Find it in + the Google Business Profile admin URL or settings. + asset_set_name: optional human-readable name. Defaults to + "GBP Locations - ". + campaign_id: empty (default) for customer/account-level scope; pass a + campaign ID to limit the location assets to one campaign. + label_filters: optional list of GBP location labels to limit sync. + listing_id_filters: optional list of GBP listing IDs to limit sync. + + REQUIRED PRECONDITION: the Google Business Profile must already be + linked at Tools → Linked accounts → Business Profile in Google Ads. + If it isn't, this tool will fail at apply time with a clear error. - Negative keywords prevent your ads from showing for irrelevant searches. - match_type: "EXACT", "PHRASE", or "BROAD" Call confirm_and_apply with the returned plan_id to execute. """ - from adloop.ads.write import add_negative_keywords as _impl + from adloop.ads.write import draft_location_asset as _impl return _impl( _config, customer_id=customer_id or _config.ads.customer_id, + business_profile_account_id=business_profile_account_id, + asset_set_name=asset_set_name, campaign_id=campaign_id, - keywords=keywords, - match_type=match_type, + label_filters=label_filters, + listing_id_filters=listing_id_filters, ) @mcp.tool(annotations=_WRITE) @_safe -def propose_negative_keyword_list( - campaign_id: str, - list_name: str, - keywords: _StrList, +def draft_promotion( + promotion_target: str, + final_url: str, + money_off: float = 0, + percent_off: float = 0, + currency_code: str = "USD", + promotion_code: str = "", + orders_over_amount: float = 0, + occasion: str = "", + discount_modifier: str = "", + language_code: str = "en", + start_date: str = "", + end_date: str = "", + redemption_start_date: str = "", + redemption_end_date: str = "", + campaign_id: str = "", customer_id: str = "", - match_type: str = "EXACT", + ad_schedule: list[dict] | None = None, ) -> dict: - """Draft a shared negative keyword list and attach it to a campaign — returns a PREVIEW. + """Draft a promotion extension asset — returns a PREVIEW. + + Creates a PromotionAsset and links it at campaign or customer scope. + Exactly one of money_off / percent_off must be provided. + + Scope: + - campaign_id provided → CampaignAsset link. + - campaign_id empty → CustomerAsset link (account-level, applies + to every eligible campaign automatically). + + Required: + promotion_target: what the promotion is for, e.g. "Window Tint" + (max 20 chars; this is the label Google shows in the ad). + final_url: landing page (must return 2xx/3xx — validated). + money_off OR percent_off: the discount amount. + + Optional: + currency_code: ISO 4217 (default USD). + promotion_code: optional coupon code (max 15 chars). + orders_over_amount: minimum order amount that unlocks the promo. + occasion: optional event tag — BLACK_FRIDAY, CYBER_MONDAY, + CHRISTMAS, NEW_YEARS, MOTHERS_DAY, FATHERS_DAY, BACK_TO_SCHOOL, + HALLOWEEN, SUMMER_SALE, WINTER_SALE, etc. Leave blank for + always-on. + discount_modifier: "UP_TO" surfaces "Up to $X off" instead of + "$X off". Leave blank for plain. + language_code: BCP-47 (default "en"). + start_date / end_date: YYYY-MM-DD. + redemption_start_date / redemption_end_date: YYYY-MM-DD. + ad_schedule: optional list — see add_ad_schedule for shape. - Creates a reusable negative keyword list that can later be applied to multiple - campaigns, unlike add_negative_keywords which adds directly to one campaign. - match_type: "EXACT", "PHRASE", or "BROAD" Call confirm_and_apply with the returned plan_id to execute. """ - from adloop.ads.write import propose_negative_keyword_list as _impl + from adloop.ads.write import draft_promotion as _impl return _impl( _config, customer_id=customer_id or _config.ads.customer_id, + promotion_target=promotion_target, + final_url=final_url, + money_off=money_off, + percent_off=percent_off, + currency_code=currency_code, + promotion_code=promotion_code, + orders_over_amount=orders_over_amount, + occasion=occasion, + discount_modifier=discount_modifier, + language_code=language_code, + start_date=start_date, + end_date=end_date, + redemption_start_date=redemption_start_date, + redemption_end_date=redemption_end_date, campaign_id=campaign_id, - list_name=list_name, - keywords=keywords, - match_type=match_type, + ad_schedule=ad_schedule, ) @mcp.tool(annotations=_WRITE) @_safe -def add_to_negative_keyword_list( - shared_set_id: str, - keywords: _StrList, +def update_promotion( + asset_id: str, + promotion_target: str, + final_url: str, + money_off: float = 0, + percent_off: float = 0, + currency_code: str = "USD", + promotion_code: str = "", + orders_over_amount: float = 0, + occasion: str = "", + discount_modifier: str = "", + language_code: str = "en", + start_date: str = "", + end_date: str = "", + redemption_start_date: str = "", + redemption_end_date: str = "", + campaign_id: str = "", customer_id: str = "", - match_type: str = "EXACT", + ad_schedule: list[dict] | None = None, ) -> dict: - """Append keywords to an EXISTING shared negative keyword list — returns a PREVIEW. + """Update a promotion via swap — returns a PREVIEW. - Use this when a suitable list already exists and only needs more keywords - (instead of propose_negative_keyword_list, which creates a new list). - Always call get_negative_keyword_lists first to find the right shared_set_id - and get_negative_keyword_list_keywords to avoid duplicating existing terms. + PromotionAsset fields are immutable once created, so "update" is a SWAP: + 1. Create a new PromotionAsset with the new values. + 2. Link it at the same scope. + 3. Unlink the old asset. - shared_set_id: numeric ID from get_negative_keyword_lists (shared_set.id). - keywords: list of keyword strings to append (duplicates in the input list - are collapsed). - match_type: "EXACT", "PHRASE", or "BROAD" + The old Asset row stays in the account (orphaned) — Google Ads API + does not support hard-deleting Asset rows. + + asset_id: numeric ID of the existing PromotionAsset (find via + SELECT asset.id, asset.promotion_asset.promotion_target FROM asset + WHERE asset.type = 'PROMOTION'). + campaign_id: pass to scope BOTH the new and old links to that + campaign. Empty for customer/account-level scope. + + All other parameters: see draft_promotion. Call confirm_and_apply with the returned plan_id to execute. """ - from adloop.ads.write import add_to_negative_keyword_list as _impl + from adloop.ads.write import update_promotion as _impl return _impl( _config, customer_id=customer_id or _config.ads.customer_id, - shared_set_id=shared_set_id, - keywords=keywords, - match_type=match_type, + asset_id=asset_id, + campaign_id=campaign_id, + promotion_target=promotion_target, + final_url=final_url, + money_off=money_off, + percent_off=percent_off, + currency_code=currency_code, + promotion_code=promotion_code, + orders_over_amount=orders_over_amount, + occasion=occasion, + discount_modifier=discount_modifier, + language_code=language_code, + start_date=start_date, + end_date=end_date, + redemption_start_date=redemption_start_date, + redemption_end_date=redemption_end_date, + ad_schedule=ad_schedule, ) @mcp.tool(annotations=_WRITE) @_safe -def attach_shared_set_to_campaigns( - shared_set_id: str, - campaign_ids: _StrList, +def link_asset_to_customer( + links: list[dict], customer_id: str = "", ) -> dict: - """Attach an existing shared set to one or more campaigns — returns a PREVIEW. + """Link EXISTING assets to the customer (account) — returns a PREVIEW. - Creates CampaignSharedSet linkages so the campaigns inherit the shared - set's criteria (e.g. negative keywords). Most commonly used to attach a - shared negative keyword list to newly-built campaigns. + Use this to "promote" assets that already exist in the account + (typically attached to legacy campaigns) so they apply at the account + level and inherit to every eligible campaign automatically. - Use ``get_negative_keyword_lists`` to find the shared_set_id, and - ``get_negative_keyword_list_campaigns`` to inspect existing attachments. + Unlike draft_image_assets / draft_callouts / etc., this does NOT + create new Asset rows — it only adds CustomerAsset link rows + pointing to assets you already have. - shared_set_id: numeric ID from get_negative_keyword_lists. - campaign_ids: list of numeric campaign IDs to attach the set to. + Find candidate asset_ids via run_gaql: + SELECT asset.id, asset.type, asset.name FROM asset + + links: list of dicts, each with: + - asset_id (str, required) — numeric asset ID + - field_type (str, required) — AssetFieldType enum value, e.g. + BUSINESS_LOGO, AD_IMAGE, MARKETING_IMAGE, SQUARE_MARKETING_IMAGE, + BUSINESS_NAME, SITELINK, CALLOUT, CALL, PROMOTION Call confirm_and_apply with the returned plan_id to execute. """ - from adloop.ads.write import attach_shared_set_to_campaigns as _impl + from adloop.ads.write import link_asset_to_customer as _impl return _impl( _config, customer_id=customer_id or _config.ads.customer_id, - shared_set_id=shared_set_id, - campaign_ids=campaign_ids, + links=links, ) +# --------------------------------------------------------------------------- +# Conversion Actions (create / update / remove) +# --------------------------------------------------------------------------- + + @mcp.tool(annotations=_WRITE) @_safe -def detach_shared_set_from_campaigns( - shared_set_id: str, - campaign_ids: _StrList, +def draft_create_conversion_action( + name: str, + type_: str, + category: str = "DEFAULT", + default_value: float = 0, + currency_code: str = "USD", + always_use_default_value: bool = False, + counting_type: str = "ONE_PER_CLICK", + phone_call_duration_seconds: int = 0, + primary_for_goal: bool = True, + include_in_conversions_metric: bool = True, + click_through_window_days: int = 0, + view_through_window_days: int = 0, + attribution_model: str = "", customer_id: str = "", ) -> dict: - """Detach a shared set from one or more campaigns — returns a PREVIEW. + """Draft a new ConversionAction — returns a PREVIEW. - Removes CampaignSharedSet linkages so the campaigns no longer inherit the - shared set's criteria. The shared set itself is unchanged; only the - per-campaign attachment is removed. + type_ values: AD_CALL, WEBSITE_CALL, WEBPAGE, WEBPAGE_CODELESS, + GOOGLE_ANALYTICS_4_CUSTOM, GOOGLE_ANALYTICS_4_PURCHASE, + UPLOAD_CALLS, UPLOAD_CLICKS, FLOODLIGHT_ACTION, STORE_VISITS, + STORE_SALES_DIRECT_UPLOAD. - Use ``get_negative_keyword_list_campaigns`` to inspect existing attachments - before detaching. + category: DEFAULT, PHONE_CALL_LEAD, SUBMIT_LEAD_FORM, PURCHASE, + SIGNUP, LEAD, CONTACT, GET_DIRECTIONS, ENGAGEMENT, etc. - shared_set_id: numeric ID from get_negative_keyword_lists. - campaign_ids: list of numeric campaign IDs to detach the set from. + For WEBSITE_CALL with GFN, set: + type_="WEBSITE_CALL", category="PHONE_CALL_LEAD", + phone_call_duration_seconds=90, default_value=250 + + For AD_CALL (calls from Call assets in ads), set: + type_="AD_CALL", category="PHONE_CALL_LEAD", + default_value=250, counting_type="ONE_PER_CLICK" + (the duration threshold for AD_CALL lives on the Call ASSET, + not on the conversion action — see update_call_asset) Call confirm_and_apply with the returned plan_id to execute. """ - from adloop.ads.write import detach_shared_set_from_campaigns as _impl + from adloop.ads.conversion_actions import draft_create_conversion_action as _impl return _impl( _config, customer_id=customer_id or _config.ads.customer_id, - shared_set_id=shared_set_id, - campaign_ids=campaign_ids, + name=name, + type_=type_, + category=category, + default_value=default_value, + currency_code=currency_code, + always_use_default_value=always_use_default_value, + counting_type=counting_type, + phone_call_duration_seconds=phone_call_duration_seconds, + primary_for_goal=primary_for_goal, + include_in_conversions_metric=include_in_conversions_metric, + click_through_window_days=click_through_window_days, + view_through_window_days=view_through_window_days, + attribution_model=attribution_model, ) @mcp.tool(annotations=_WRITE) @_safe -def update_ad_group( - ad_group_id: str, +def draft_update_conversion_action( + conversion_action_id: str, + name: str = "", + primary_for_goal: bool | None = None, + default_value: float = 0, + currency_code: str = "", + always_use_default_value: bool | None = None, + counting_type: str = "", + phone_call_duration_seconds: int = 0, + include_in_conversions_metric: bool | None = None, + click_through_window_days: int = 0, + view_through_window_days: int = 0, + attribution_model: str = "", customer_id: str = "", - ad_group_name: str = "", - max_cpc: float = 0, ) -> dict: - """Draft an ad group update for name and/or manual CPC bid.""" - from adloop.ads.write import update_ad_group as _impl + """Draft a partial UPDATE of an existing ConversionAction — PREVIEW. + + Pass only the fields you want to change. Empty strings/0/None mean + "do not change this field". + + Common workflows: + - Rename: name="Calls from Ads (>=90s)" + - Demote to Secondary: primary_for_goal=False + - Set value: default_value=250, currency_code="USD", + always_use_default_value=True + - Set call duration threshold: phone_call_duration_seconds=90 + - Switch counting: counting_type="ONE_PER_CLICK" + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.conversion_actions import draft_update_conversion_action as _impl return _impl( _config, customer_id=customer_id or _config.ads.customer_id, - ad_group_id=ad_group_id, - ad_group_name=ad_group_name, - max_cpc=max_cpc, + conversion_action_id=conversion_action_id, + name=name, + primary_for_goal=primary_for_goal, + default_value=default_value, + currency_code=currency_code, + always_use_default_value=always_use_default_value, + counting_type=counting_type, + phone_call_duration_seconds=phone_call_duration_seconds, + include_in_conversions_metric=include_in_conversions_metric, + click_through_window_days=click_through_window_days, + view_through_window_days=view_through_window_days, + attribution_model=attribution_model, ) -@mcp.tool(annotations=_WRITE) +@mcp.tool(annotations=_DESTRUCTIVE) @_safe -def draft_callouts( - campaign_id: str, - callouts: _StrList, +def draft_remove_conversion_action( + conversion_action_id: str, customer_id: str = "", ) -> dict: - """Draft campaign callout assets — returns a PREVIEW.""" - from adloop.ads.write import draft_callouts as _impl + """Draft a REMOVAL of a ConversionAction — returns PREVIEW (irreversible). + + Removed conversion actions stop counting. Historical data is preserved. + + Note: SMART_CAMPAIGN_* and GOOGLE_HOSTED types reject removal with + MUTATE_NOT_ALLOWED — those are auto-managed by Google. + + Call confirm_and_apply with the returned plan_id to execute. + """ + from adloop.ads.conversion_actions import draft_remove_conversion_action as _impl return _impl( _config, customer_id=customer_id or _config.ads.customer_id, - campaign_id=campaign_id, - callouts=callouts, + conversion_action_id=conversion_action_id, ) @mcp.tool(annotations=_WRITE) @_safe -def draft_structured_snippets( - campaign_id: str, - snippets: _DictList, +def draft_upload_call_conversions( + csv_path: str, + partial_failure: bool = True, customer_id: str = "", ) -> dict: - """Draft campaign structured snippet assets — returns a PREVIEW.""" - from adloop.ads.write import draft_structured_snippets as _impl + """Draft a CSV upload of call conversions to Google Ads — returns PREVIEW. + + Reads any CSV matching Google Ads' call-upload schema and previews what + will be pushed via ConversionUploadService.UploadCallConversions. + + The CSV must contain these columns (Parameters/comment rows ignored): + - Caller's Phone Number (E.164 format, e.g. +19162264625) + - Call Start Time (ISO 8601, e.g. 2026-02-26T16:49:44Z) + - Conversion Name (must EXACTLY match an UPLOAD_CALLS-typed + conversion action that already exists) + - Conversion Time (ISO 8601) + - Conversion Value (numeric) + - Conversion Currency (ISO 3-letter, e.g. USD) + + partial_failure (default True): Google accepts the parseable rows and + reports only the bad ones — recommended for batch uploads. + + Call confirm_and_apply with the returned plan_id to execute. + + Use this when GCLID isn't flowing through your CRM and the Web UI's + generic Uploads page rejects call CSVs (which it does — that page + only accepts click conversions). + """ + from adloop.ads.conversion_actions import draft_upload_call_conversions as _impl return _impl( _config, customer_id=customer_id or _config.ads.customer_id, - campaign_id=campaign_id, - snippets=snippets, + csv_path=csv_path, + partial_failure=partial_failure, ) @mcp.tool(annotations=_WRITE) @_safe -def draft_image_assets( - campaign_id: str, - image_paths: _StrList, +def draft_upload_enhanced_conversions_for_leads( + csv_path: str, + partial_failure: bool = True, customer_id: str = "", ) -> dict: - """Draft campaign image assets from local PNG, JPEG, or GIF files.""" - from adloop.ads.write import draft_image_assets as _impl + """Draft an Enhanced Conversions for Leads upload — returns PREVIEW. + + Reads a SHA-256-hashed PII CSV (Email, Phone Number, First Name, Last + Name + conversion details) and previews what will be pushed via + ConversionUploadService.UploadClickConversions with user_identifiers + populated. + + The target conversion action (named in the CSV's "Conversion Name" + column) must be of type UPLOAD_CLICKS. Works RETROACTIVELY — Google + matches hashed identifiers to past ad clicks, no "action must exist + before call" constraint. + + Use this path when GCLID isn't flowing and you need to upload + historical lead/job conversion data. + """ + from adloop.ads.conversion_actions import ( + draft_upload_enhanced_conversions_for_leads as _impl, + ) return _impl( _config, customer_id=customer_id or _config.ads.customer_id, - campaign_id=campaign_id, - image_paths=image_paths, + csv_path=csv_path, + partial_failure=partial_failure, ) +# --------------------------------------------------------------------------- +# Asset in-place updates (call asset, sitelink, callout) +# --------------------------------------------------------------------------- + + @mcp.tool(annotations=_WRITE) @_safe -def pause_entity( - entity_type: str, - entity_id: str, +def update_call_asset( + asset_id: str, + phone_number: str = "", + country_code: str = "", + call_conversion_action_id: str = "", + call_conversion_reporting_state: str = "", + ad_schedule: list[dict] | None = None, customer_id: str = "", ) -> dict: - """Draft pausing a campaign, ad group, ad, or keyword — returns a PREVIEW. + """Update an existing CallAsset in place — returns a PREVIEW. - entity_type: "campaign", "ad_group", "ad", or "keyword" - entity_id format by type: - - campaign: campaign ID (e.g. "12345678") - - ad_group: ad group ID (e.g. "12345678") - - ad: "adGroupId~adId" (e.g. "12345678~987654") - - keyword: "adGroupId~criterionId" (e.g. "12345678~987654") + Common use case: re-point a Call asset at a specific conversion action + (e.g. 'Calls from Ads (>=90s)') with USE_RESOURCE_LEVEL. + + Fields: + phone_number: human or E.164 (auto-normalized) + country_code: ISO-3166 alpha-2 (default US when normalizing) + call_conversion_action_id: numeric conversion action ID + call_conversion_reporting_state: DISABLED | + USE_ACCOUNT_LEVEL_CALL_CONVERSION_ACTION | + USE_RESOURCE_LEVEL_CALL_CONVERSION_ACTION + ad_schedule: optional schedule list (replaces existing) Call confirm_and_apply with the returned plan_id to execute. """ - from adloop.ads.write import pause_entity as _impl + from adloop.ads.write import update_call_asset as _impl return _impl( _config, customer_id=customer_id or _config.ads.customer_id, - entity_type=entity_type, - entity_id=entity_id, + asset_id=asset_id, + phone_number=phone_number, + country_code=country_code, + call_conversion_action_id=call_conversion_action_id, + call_conversion_reporting_state=call_conversion_reporting_state, + ad_schedule=ad_schedule, ) @mcp.tool(annotations=_WRITE) @_safe -def enable_entity( - entity_type: str, - entity_id: str, +def update_sitelink( + asset_id: str, + link_text: str = "", + final_url: str = "", + description1: str = "", + description2: str = "", customer_id: str = "", ) -> dict: - """Draft enabling a paused campaign, ad group, ad, or keyword — returns a PREVIEW. + """Update an existing SitelinkAsset in place — returns a PREVIEW. - entity_type: "campaign", "ad_group", "ad", or "keyword" - entity_id format by type: - - campaign: campaign ID (e.g. "12345678") - - ad_group: ad group ID (e.g. "12345678") - - ad: "adGroupId~adId" (e.g. "12345678~987654") - - keyword: "adGroupId~criterionId" (e.g. "12345678~987654") + Pass only the fields you want to change. Empty string = "do not change". + URL is validated for reachability when provided. + """ + from adloop.ads.write import update_sitelink as _impl - Call confirm_and_apply with the returned plan_id to execute. + return _impl( + _config, + customer_id=customer_id or _config.ads.customer_id, + asset_id=asset_id, + link_text=link_text, + final_url=final_url, + description1=description1, + description2=description2, + ) + + +@mcp.tool(annotations=_WRITE) +@_safe +def update_callout( + asset_id: str, + callout_text: str, + customer_id: str = "", +) -> dict: + """Update an existing CalloutAsset's text in place — returns a PREVIEW. + + callout_text: new callout text (max 25 chars). """ - from adloop.ads.write import enable_entity as _impl + from adloop.ads.write import update_callout as _impl return _impl( _config, customer_id=customer_id or _config.ads.customer_id, - entity_type=entity_type, - entity_id=entity_id, + asset_id=asset_id, + callout_text=callout_text, ) -@mcp.tool(annotations=_DESTRUCTIVE) +@mcp.tool(annotations=_WRITE) @_safe -def remove_entity( - entity_type: str, - entity_id: str, +def add_ad_schedule( + campaign_id: str, + schedule: list[dict], customer_id: str = "", ) -> dict: - """Draft REMOVING an entity — returns a PREVIEW. This is IRREVERSIBLE. + """Draft ad schedule criteria for a campaign — returns a PREVIEW. - entity_type: "campaign", "ad_group", "ad", "keyword", "negative_keyword", - "shared_criterion", "campaign_asset", "asset", or "customer_asset" - entity_id: The resource ID. - For keywords: "adGroupId~criterionId" - For negative_keywords: "campaignId~criterionId" - (use the resource_id field from get_negative_keywords) - For shared_criterion: "sharedSetId~criterionId" - (use the resource_id field from get_negative_keyword_list_keywords) - For campaign_asset: "campaignId~assetId~fieldType" - For asset: simple asset ID - For customer_asset: "assetId~fieldType" + Adds AdScheduleInfo CampaignCriterion records so the campaign only + serves during the specified hours/days. Hours follow the account's + configured time zone. - WARNING: Removed entities cannot be re-enabled. Use pause_entity instead - if you just want to temporarily disable something. + schedule: list of dicts: + - day_of_week: MONDAY..SUNDAY + - start_hour: 0..23 + - end_hour: 0..24 (must be > start) + - start_minute / end_minute: 0, 15, 30, or 45 (default 0) + + Note: this tool is additive. Existing schedule criteria are not + removed; if you need a clean slate, use remove_entity on the existing + schedule criteria first. Call confirm_and_apply with the returned plan_id to execute. """ - from adloop.ads.write import remove_entity as _impl + from adloop.ads.write import add_ad_schedule as _impl return _impl( _config, customer_id=customer_id or _config.ads.customer_id, - entity_type=entity_type, - entity_id=entity_id, + campaign_id=campaign_id, + schedule=schedule, ) @mcp.tool(annotations=_WRITE) @_safe -def draft_sitelinks( +def add_geo_exclusions( campaign_id: str, - sitelinks: _DictList, + geo_target_ids: list[str], customer_id: str = "", ) -> dict: - """Draft sitelink extensions for a campaign — returns a PREVIEW. - - Sitelinks appear as additional links below your ad, increasing click area - and directing users to specific pages. + """Draft negative geo CampaignCriterion records — returns a PREVIEW. - campaign_id: the campaign to attach sitelinks to - sitelinks: list of dicts, each with: - - link_text (str, required, max 25 chars) — the clickable text shown - - final_url (str, required) — destination URL for this sitelink - - description1 (str, optional, max 35 chars) — first description line - - description2 (str, optional, max 35 chars) — second description line + Adds excluded locations so the campaign does not serve to users in + those geos. Use this when you have a broad include list but specific + sub-geos to suppress (e.g. include "California" but exclude "Los + Angeles"). - Google recommends at least 4 sitelinks per campaign. Fewer than 2 may not show. + geo_target_ids: list of geoTargetConstant IDs. Look them up via: + SELECT geo_target_constant.id, geo_target_constant.name + FROM geo_target_constant + WHERE geo_target_constant.country_code = 'US' + AND geo_target_constant.name = 'Los Angeles' Call confirm_and_apply with the returned plan_id to execute. """ - from adloop.ads.write import draft_sitelinks as _impl + from adloop.ads.write import add_geo_exclusions as _impl return _impl( _config, customer_id=customer_id or _config.ads.customer_id, campaign_id=campaign_id, - sitelinks=sitelinks, + geo_target_ids=geo_target_ids, ) diff --git a/tests/test_ads_extensions.py b/tests/test_ads_extensions.py new file mode 100644 index 0000000..8c7eacb --- /dev/null +++ b/tests/test_ads_extensions.py @@ -0,0 +1,2975 @@ +"""Tests for AdLoop write-tool extensions: + +- Customer-level scope for sitelinks/callouts/structured_snippets +- draft_call_asset +- draft_location_asset +- add_ad_schedule (+ ad_schedule integration in draft_campaign / update_campaign) +- add_geo_exclusions (+ geo_exclude_ids integration in draft_campaign / update_campaign) +- _validate_ad_schedule + _normalize_phone_e164 +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from google.ads.googleads.client import GoogleAdsClient + +from adloop.ads.client import GOOGLE_ADS_API_VERSION +from adloop.ads import write +from adloop.config import AdLoopConfig, AdsConfig, SafetyConfig +from adloop.safety import preview as preview_store + + +# --------------------------------------------------------------------------- +# Shared fakes (mirror test_ads_write.py to stay consistent with existing +# patterns) +# --------------------------------------------------------------------------- + + +class _FakeResult: + def __init__(self, resource_name: str = ""): + self.resource_name = resource_name + + +class _FakeMutateOperationResponse: + def __init__(self, response_type: str | None = None, resource_name: str = ""): + self.campaign_budget_result = _FakeResult() + self.campaign_result = _FakeResult() + self.ad_group_result = _FakeResult() + self.campaign_criterion_result = _FakeResult() + self.asset_result = _FakeResult() + self.campaign_asset_result = _FakeResult() + self.customer_asset_result = _FakeResult() + self._response_type = response_type + if response_type: + getattr(self, response_type).resource_name = resource_name + + def WhichOneof(self, _: str) -> str | None: + return self._response_type + + +class _FakePathService: + def __init__(self, prefix: str): + self.prefix = prefix + + def campaign_path(self, customer_id: str, entity_id: str) -> str: + return f"customers/{customer_id}/{self.prefix}/{entity_id}" + + def campaign_budget_path(self, customer_id: str, entity_id: str) -> str: + return f"customers/{customer_id}/{self.prefix}/{entity_id}" + + def ad_group_path(self, customer_id: str, entity_id: str) -> str: + return f"customers/{customer_id}/{self.prefix}/{entity_id}" + + def asset_path(self, customer_id: str, entity_id: str) -> str: + return f"customers/{customer_id}/{self.prefix}/{entity_id}" + + def conversion_action_path(self, customer_id: str, entity_id: str) -> str: + return f"customers/{customer_id}/{self.prefix}/{entity_id}" + + +class _FakeGoogleAdsService(_FakePathService): + def __init__( + self, + responses: list[_FakeMutateOperationResponse] | None = None, + search_rows: list[object] | None = None, + ): + super().__init__("campaigns") + self.operations = None + self._responses = responses or [] + self._search_rows = search_rows or [] + self.search_calls: list[str] = [] + + def mutate(self, customer_id: str, mutate_operations: list[object]) -> object: + self.operations = mutate_operations + return SimpleNamespace(mutate_operation_responses=self._responses) + + def search(self, customer_id: str, query: str) -> list[object]: + self.search_calls.append(query) + return list(self._search_rows) + + +class _FakeCampaignCriterionService(_FakePathService): + def __init__(self, responses: list[_FakeResult] | None = None): + super().__init__("campaignCriteria") + self.operations = None + self._responses = responses or [] + + def mutate_campaign_criteria( + self, customer_id: str, operations: list[object] + ) -> object: + self.operations = operations + return SimpleNamespace(results=self._responses) + + +class _FakeAssetSetService(_FakePathService): + def __init__(self, responses: list[_FakeResult] | None = None): + super().__init__("assetSets") + self.operations = None + self._responses = responses or [] + + def mutate_asset_sets( + self, customer_id: str, operations: list[object] + ) -> object: + self.operations = operations + return SimpleNamespace(results=self._responses) + + +class _FakeCustomerAssetSetService(_FakePathService): + def __init__(self, responses: list[_FakeResult] | None = None): + super().__init__("customerAssetSets") + self.operations = None + self._responses = responses or [] + + def mutate_customer_asset_sets( + self, customer_id: str, operations: list[object] + ) -> object: + self.operations = operations + return SimpleNamespace(results=self._responses) + + +class _FakeCampaignAssetSetService(_FakePathService): + def __init__(self, responses: list[_FakeResult] | None = None): + super().__init__("campaignAssetSets") + self.operations = None + self._responses = responses or [] + + def mutate_campaign_asset_sets( + self, customer_id: str, operations: list[object] + ) -> object: + self.operations = operations + return SimpleNamespace(results=self._responses) + + +class _FakeClient: + def __init__(self, services: dict[str, object]): + self._base = GoogleAdsClient( + credentials=None, + developer_token="test-token", + use_proto_plus=True, + version=GOOGLE_ADS_API_VERSION, + ) + self.enums = self._base.enums + self.get_type = self._base.get_type + self._services = services + + def get_service(self, name: str) -> object: + return self._services[name] + + +@pytest.fixture(autouse=True) +def clear_pending_plans(): + preview_store._pending_plans.clear() + yield + preview_store._pending_plans.clear() + + +@pytest.fixture +def config() -> AdLoopConfig: + return AdLoopConfig( + ads=AdsConfig(customer_id="123-456-7890"), + safety=SafetyConfig(require_dry_run=True), + ) + + +# --------------------------------------------------------------------------- +# _validate_ad_schedule +# --------------------------------------------------------------------------- + + +class TestValidateAdSchedule: + def test_valid_entry_normalizes_day_to_uppercase(self): + validated, errors = write._validate_ad_schedule( + [{"day_of_week": "monday", "start_hour": 7, "end_hour": 18}] + ) + assert errors == [] + assert validated == [{ + "day_of_week": "MONDAY", + "start_hour": 7, + "start_minute": 0, + "end_hour": 18, + "end_minute": 0, + }] + + def test_invalid_day_of_week_raises_error(self): + _, errors = write._validate_ad_schedule( + [{"day_of_week": "TOMORROW", "start_hour": 7, "end_hour": 18}] + ) + assert any("day_of_week" in e for e in errors) + + def test_invalid_minutes_value_rejected(self): + _, errors = write._validate_ad_schedule( + [{ + "day_of_week": "MONDAY", + "start_hour": 7, + "start_minute": 7, + "end_hour": 18, + }] + ) + assert any("start_minute" in e for e in errors) + + def test_end_must_be_after_start(self): + _, errors = write._validate_ad_schedule( + [{"day_of_week": "MONDAY", "start_hour": 18, "end_hour": 7}] + ) + assert any("end" in e and "after" in e for e in errors) + + def test_non_dict_entry_rejected(self): + _, errors = write._validate_ad_schedule(["MONDAY 7-18"]) + assert any("must be a dict" in e for e in errors) + + def test_hour_out_of_range_rejected(self): + _, errors = write._validate_ad_schedule( + [{"day_of_week": "MONDAY", "start_hour": 25, "end_hour": 26}] + ) + assert any("start_hour" in e for e in errors) + assert any("end_hour" in e for e in errors) + + def test_minute_increments_accepted(self): + for minute in (0, 15, 30, 45): + validated, errors = write._validate_ad_schedule( + [{ + "day_of_week": "WEDNESDAY", + "start_hour": 9, + "start_minute": minute, + "end_hour": 17, + "end_minute": minute, + }] + ) + assert errors == [] + assert validated[0]["start_minute"] == minute + assert validated[0]["end_minute"] == minute + + +# --------------------------------------------------------------------------- +# _normalize_phone_e164 +# --------------------------------------------------------------------------- + + +class TestNormalizePhoneE164: + @pytest.mark.parametrize( + "phone,country,expected", + [ + ("(916) 339-3676", "US", "+19163393676"), + ("9163393676", "US", "+19163393676"), + ("19163393676", "US", "+19163393676"), + ("+19163393676", "US", "+19163393676"), + ("020 7946 0958", "GB", "+442079460958"), + ], + ) + def test_normalizes_to_e164(self, phone, country, expected): + normalized, err = write._normalize_phone_e164(phone, country) + assert err is None + assert normalized == expected + + def test_empty_phone_errors(self): + normalized, err = write._normalize_phone_e164("", "US") + assert "empty" in err + assert normalized == "" + + def test_unknown_country_without_plus_prefix_errors(self): + normalized, err = write._normalize_phone_e164("123456789", "ZZ") + assert "country_code" in err + assert normalized == "" + + def test_already_e164_with_unknown_country_passes(self): + normalized, err = write._normalize_phone_e164("+99000111222", "ZZ") + assert err is None + assert normalized == "+99000111222" + + +# --------------------------------------------------------------------------- +# Customer-level scope on sitelinks / callouts / structured_snippets +# --------------------------------------------------------------------------- + + +class TestCustomerScopeAssets: + def test_draft_callouts_without_campaign_id_uses_customer_scope(self, config): + result = write.draft_callouts( + config, + customer_id="1234567890", + callouts=["Free Pickup", "R2 Certified"], + ) + assert "error" not in result + assert result["entity_type"] == "customer_asset" + plan_id = result["plan_id"] + plan = preview_store._pending_plans[plan_id] + assert plan.changes["scope"] == "customer" + assert plan.changes["campaign_id"] == "" + + def test_draft_callouts_with_campaign_id_uses_campaign_scope(self, config): + result = write.draft_callouts( + config, + customer_id="1234567890", + campaign_id="1001", + callouts=["Free Pickup"], + ) + assert result["entity_type"] == "campaign_asset" + plan_id = result["plan_id"] + plan = preview_store._pending_plans[plan_id] + assert plan.changes["scope"] == "campaign" + assert plan.changes["campaign_id"] == "1001" + + def test_draft_structured_snippets_customer_scope(self, config): + result = write.draft_structured_snippets( + config, + customer_id="1234567890", + snippets=[{"header": "Services", "values": ["A", "B", "C"]}], + ) + assert result["entity_type"] == "customer_asset" + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["scope"] == "customer" + + def test_draft_sitelinks_customer_scope(self, config, monkeypatch): + # _validate_urls performs HTTP — stub it out for unit tests + monkeypatch.setattr( + write, + "_validate_urls", + lambda urls, timeout=10: {u: None for u in urls}, + ) + result = write.draft_sitelinks( + config, + customer_id="1234567890", + sitelinks=[ + { + "link_text": "Get a Quote", + "final_url": "https://example.com/quote", + }, + { + "link_text": "Contact Us", + "final_url": "https://example.com/contact", + }, + ], + ) + assert result["entity_type"] == "customer_asset" + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["scope"] == "customer" + + def test_apply_create_callouts_customer_scope_emits_customer_asset_op(self): + responses = [ + _FakeMutateOperationResponse("asset_result", "customers/1/assets/-1"), + _FakeMutateOperationResponse( + "customer_asset_result", "customers/1/customerAssets/1~CALLOUT" + ), + ] + google_ads = _FakeGoogleAdsService(responses) + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "AssetService": _FakePathService("assets"), + } + ) + + write._apply_create_callouts( + client, + "1", + { + "scope": "customer", + "campaign_id": "", + "callouts": ["Free Shipping"], + }, + ) + + assert len(google_ads.operations) == 2 + # Op 0: asset create + asset_op = google_ads.operations[0].asset_operation.create + assert asset_op.callout_asset.callout_text == "Free Shipping" + # Op 1: customer asset link (NOT campaign asset) + link_op = google_ads.operations[1].customer_asset_operation.create + assert link_op.field_type == client.enums.AssetFieldTypeEnum.CALLOUT + # Must NOT have populated campaign_asset_operation + assert ( + google_ads.operations[1].campaign_asset_operation.create.field_type + == client.enums.AssetFieldTypeEnum.UNSPECIFIED + ) + + def test_apply_create_sitelinks_customer_scope_emits_customer_asset_op(self): + responses = [ + _FakeMutateOperationResponse("asset_result", "customers/1/assets/-1"), + _FakeMutateOperationResponse( + "customer_asset_result", "customers/1/customerAssets/1~SITELINK" + ), + ] + google_ads = _FakeGoogleAdsService(responses) + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "AssetService": _FakePathService("assets"), + } + ) + + write._apply_create_sitelinks( + client, + "1", + { + "scope": "customer", + "campaign_id": "", + "sitelinks": [ + { + "link_text": "Quote", + "final_url": "https://example.com/quote", + "description1": "Quick quote", + "description2": "Sacramento-based", + } + ], + }, + ) + link_op = google_ads.operations[1].customer_asset_operation.create + assert link_op.field_type == client.enums.AssetFieldTypeEnum.SITELINK + + def test_apply_create_structured_snippets_customer_scope(self): + responses = [ + _FakeMutateOperationResponse("asset_result", "customers/1/assets/-1"), + _FakeMutateOperationResponse( + "customer_asset_result", + "customers/1/customerAssets/1~STRUCTURED_SNIPPET", + ), + ] + google_ads = _FakeGoogleAdsService(responses) + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "AssetService": _FakePathService("assets"), + } + ) + + write._apply_create_structured_snippets( + client, + "1", + { + "scope": "customer", + "campaign_id": "", + "snippets": [ + {"header": "Services", "values": ["A", "B", "C"]} + ], + }, + ) + link_op = google_ads.operations[1].customer_asset_operation.create + assert link_op.field_type == client.enums.AssetFieldTypeEnum.STRUCTURED_SNIPPET + + def test_apply_assets_rejects_unknown_scope(self): + client = _FakeClient( + { + "GoogleAdsService": _FakeGoogleAdsService(), + "AssetService": _FakePathService("assets"), + } + ) + with pytest.raises(ValueError, match="Unknown asset scope"): + write._apply_assets( + client, + "1", + [{"callout_text": "X"}], + client.enums.AssetFieldTypeEnum.CALLOUT, + lambda asset, p: None, + scope="bogus", + campaign_id="", + ) + + def test_apply_assets_campaign_scope_requires_campaign_id(self): + client = _FakeClient( + { + "GoogleAdsService": _FakeGoogleAdsService(), + "AssetService": _FakePathService("assets"), + } + ) + with pytest.raises(ValueError, match="campaign_id is required"): + write._apply_assets( + client, + "1", + [{"callout_text": "X"}], + client.enums.AssetFieldTypeEnum.CALLOUT, + lambda asset, p: None, + scope="campaign", + campaign_id="", + ) + + +# --------------------------------------------------------------------------- +# draft_call_asset +# --------------------------------------------------------------------------- + + +class TestDraftCallAsset: + def test_requires_phone_number(self, config): + result = write.draft_call_asset(config, customer_id="1234567890") + assert "phone_number is required" in result["error"] + + def test_normalizes_us_phone_and_picks_customer_scope(self, config): + result = write.draft_call_asset( + config, + customer_id="1234567890", + phone_number="(916) 339-3676", + ) + assert "error" not in result + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["phone_number"] == "+19163393676" + assert plan.changes["scope"] == "customer" + assert plan.changes["country_code"] == "US" + assert "warnings" in result + assert any("verification" in w.lower() for w in result["warnings"]) + + def test_campaign_scope_when_campaign_id_provided(self, config): + result = write.draft_call_asset( + config, + customer_id="1234567890", + campaign_id="42", + phone_number="+19163393676", + ) + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["scope"] == "campaign" + assert plan.changes["campaign_id"] == "42" + + def test_invalid_ad_schedule_short_circuits(self, config): + result = write.draft_call_asset( + config, + customer_id="1234567890", + phone_number="+19163393676", + ad_schedule=[{"day_of_week": "BAD", "start_hour": 7, "end_hour": 18}], + ) + assert result["error"] == "Ad schedule validation failed" + + +class TestApplyCreateCallAsset: + def test_customer_scope_creates_customer_asset_link(self): + responses = [ + _FakeMutateOperationResponse("asset_result", "customers/1/assets/-1"), + _FakeMutateOperationResponse( + "customer_asset_result", "customers/1/customerAssets/1~CALL" + ), + ] + google_ads = _FakeGoogleAdsService(responses) + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + "ConversionActionService": _FakePathService("conversionActions"), + } + ) + + write._apply_create_call_asset( + client, + "1", + { + "scope": "customer", + "campaign_id": "", + "phone_number": "+19163393676", + "country_code": "US", + "call_conversion_action_id": "", + "ad_schedule": [ + { + "day_of_week": "MONDAY", + "start_hour": 7, + "start_minute": 0, + "end_hour": 18, + "end_minute": 0, + } + ], + }, + ) + + assert len(google_ads.operations) == 2 + asset_op = google_ads.operations[0].asset_operation.create + assert asset_op.call_asset.country_code == "US" + assert asset_op.call_asset.phone_number == "+19163393676" + # Schedule embedded on call asset + assert len(asset_op.call_asset.ad_schedule_targets) == 1 + sched = asset_op.call_asset.ad_schedule_targets[0] + assert sched.day_of_week == client.enums.DayOfWeekEnum.MONDAY + assert sched.start_hour == 7 + assert sched.end_hour == 18 + # Customer-scope link + link = google_ads.operations[1].customer_asset_operation.create + assert link.field_type == client.enums.AssetFieldTypeEnum.CALL + + def test_campaign_scope_creates_campaign_asset_link(self): + responses = [ + _FakeMutateOperationResponse("asset_result", "customers/1/assets/-1"), + _FakeMutateOperationResponse( + "campaign_asset_result", "customers/1/campaignAssets/42~CALL" + ), + ] + google_ads = _FakeGoogleAdsService(responses) + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + "ConversionActionService": _FakePathService("conversionActions"), + } + ) + + write._apply_create_call_asset( + client, + "1", + { + "scope": "campaign", + "campaign_id": "42", + "phone_number": "+442079460958", + "country_code": "GB", + "call_conversion_action_id": "", + "ad_schedule": [], + }, + ) + link = google_ads.operations[1].campaign_asset_operation.create + assert link.field_type == client.enums.AssetFieldTypeEnum.CALL + assert link.campaign == "customers/1/campaigns/42" + + def test_with_call_conversion_action(self): + responses = [ + _FakeMutateOperationResponse("asset_result", "customers/1/assets/-1"), + _FakeMutateOperationResponse( + "customer_asset_result", "customers/1/customerAssets/1~CALL" + ), + ] + google_ads = _FakeGoogleAdsService(responses) + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + "ConversionActionService": _FakePathService("conversionActions"), + } + ) + + write._apply_create_call_asset( + client, + "1", + { + "scope": "customer", + "campaign_id": "", + "phone_number": "+19163393676", + "country_code": "US", + "call_conversion_action_id": "777", + "ad_schedule": [], + }, + ) + asset_op = google_ads.operations[0].asset_operation.create + assert asset_op.call_asset.call_conversion_action == ( + "customers/1/conversionActions/777" + ) + assert ( + asset_op.call_asset.call_conversion_reporting_state + == client.enums.CallConversionReportingStateEnum.USE_RESOURCE_LEVEL_CALL_CONVERSION_ACTION + ) + + def test_unknown_scope_raises(self): + google_ads = _FakeGoogleAdsService([]) + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + "ConversionActionService": _FakePathService("conversionActions"), + } + ) + with pytest.raises(ValueError, match="Unknown scope"): + write._apply_create_call_asset( + client, + "1", + { + "scope": "bogus", + "campaign_id": "", + "phone_number": "+19163393676", + "country_code": "US", + "call_conversion_action_id": "", + "ad_schedule": [], + }, + ) + + +# --------------------------------------------------------------------------- +# draft_location_asset +# --------------------------------------------------------------------------- + + +class TestDraftLocationAsset: + def test_requires_business_profile_account_id(self, config): + result = write.draft_location_asset(config, customer_id="1234567890") + assert "business_profile_account_id is required" in result["error"] + + def test_default_asset_set_name_uses_id(self, config): + result = write.draft_location_asset( + config, + customer_id="1234567890", + business_profile_account_id="987654321", + ) + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["asset_set_name"] == "GBP Locations - 987654321" + assert plan.changes["scope"] == "customer" + assert plan.changes["business_profile_account_id"] == "987654321" + + def test_campaign_scope_when_campaign_id_provided(self, config): + result = write.draft_location_asset( + config, + customer_id="1234567890", + business_profile_account_id="987654321", + campaign_id="42", + ) + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["scope"] == "campaign" + + def test_warnings_mention_gbp_link_requirement(self, config): + result = write.draft_location_asset( + config, + customer_id="1234567890", + business_profile_account_id="987654321", + ) + assert "warnings" in result + assert any("Business Profile" in w for w in result["warnings"]) + + +class TestApplyCreateLocationAsset: + def test_customer_scope_creates_asset_set_and_customer_link(self): + asset_set_service = _FakeAssetSetService( + [_FakeResult("customers/1/assetSets/9001")] + ) + customer_link_service = _FakeCustomerAssetSetService( + [_FakeResult("customers/1/customerAssetSets/9001")] + ) + client = _FakeClient( + { + "GoogleAdsService": _FakeGoogleAdsService(), + "AssetSetService": asset_set_service, + "CustomerAssetSetService": customer_link_service, + } + ) + + result = write._apply_create_location_asset( + client, + "1", + { + "scope": "customer", + "campaign_id": "", + "business_profile_account_id": "987", + "asset_set_name": "GBP Locations - 987", + "label_filters": [], + "listing_id_filters": [], + }, + ) + assert result["asset_set"] == "customers/1/assetSets/9001" + assert result["customer_asset_set"] == "customers/1/customerAssetSets/9001" + + op = asset_set_service.operations[0].create + assert op.name == "GBP Locations - 987" + assert op.type_ == client.enums.AssetSetTypeEnum.LOCATION_SYNC + assert ( + op.location_set.business_profile_location_set.business_account_id + == "987" + ) + + def test_campaign_scope_creates_campaign_asset_set(self): + asset_set_service = _FakeAssetSetService( + [_FakeResult("customers/1/assetSets/9001")] + ) + campaign_link_service = _FakeCampaignAssetSetService( + [_FakeResult("customers/1/campaignAssetSets/42~9001")] + ) + client = _FakeClient( + { + "GoogleAdsService": _FakeGoogleAdsService(), + "AssetSetService": asset_set_service, + "CampaignService": _FakePathService("campaigns"), + "CampaignAssetSetService": campaign_link_service, + } + ) + + result = write._apply_create_location_asset( + client, + "1", + { + "scope": "campaign", + "campaign_id": "42", + "business_profile_account_id": "987", + "asset_set_name": "GBP", + "label_filters": [], + "listing_id_filters": [], + }, + ) + assert ( + result["campaign_asset_set"] == "customers/1/campaignAssetSets/42~9001" + ) + link_op = campaign_link_service.operations[0].create + assert link_op.campaign == "customers/1/campaigns/42" + assert link_op.asset_set == "customers/1/assetSets/9001" + + def test_label_filters_propagate(self): + asset_set_service = _FakeAssetSetService( + [_FakeResult("customers/1/assetSets/9001")] + ) + customer_link_service = _FakeCustomerAssetSetService( + [_FakeResult("customers/1/customerAssetSets/9001")] + ) + client = _FakeClient( + { + "GoogleAdsService": _FakeGoogleAdsService(), + "AssetSetService": asset_set_service, + "CustomerAssetSetService": customer_link_service, + } + ) + + write._apply_create_location_asset( + client, + "1", + { + "scope": "customer", + "campaign_id": "", + "business_profile_account_id": "987", + "asset_set_name": "GBP", + "label_filters": ["Storefront", "Warehouse"], + "listing_id_filters": [], + }, + ) + op = asset_set_service.operations[0].create + assert list(op.location_set.business_profile_location_set.label_filters) == [ + "Storefront", + "Warehouse", + ] + + def test_campaign_scope_requires_campaign_id(self): + asset_set_service = _FakeAssetSetService( + [_FakeResult("customers/1/assetSets/9001")] + ) + client = _FakeClient( + { + "GoogleAdsService": _FakeGoogleAdsService(), + "AssetSetService": asset_set_service, + "CampaignService": _FakePathService("campaigns"), + "CampaignAssetSetService": _FakeCampaignAssetSetService(), + } + ) + with pytest.raises(ValueError, match="campaign_id required"): + write._apply_create_location_asset( + client, + "1", + { + "scope": "campaign", + "campaign_id": "", + "business_profile_account_id": "987", + "asset_set_name": "GBP", + "label_filters": [], + "listing_id_filters": [], + }, + ) + + +# --------------------------------------------------------------------------- +# add_ad_schedule +# --------------------------------------------------------------------------- + + +class TestAddAdSchedule: + def test_requires_campaign_id(self, config): + result = write.add_ad_schedule( + config, + customer_id="1234567890", + schedule=[ + {"day_of_week": "MONDAY", "start_hour": 7, "end_hour": 18} + ], + ) + assert "campaign_id is required" in result["error"] + + def test_requires_at_least_one_entry(self, config): + result = write.add_ad_schedule( + config, + customer_id="1234567890", + campaign_id="42", + schedule=[], + ) + assert "At least one schedule entry" in result["error"] + + def test_invalid_schedule_returns_validation_failure(self, config): + result = write.add_ad_schedule( + config, + customer_id="1234567890", + campaign_id="42", + schedule=[{"day_of_week": "BAD", "start_hour": 7, "end_hour": 18}], + ) + assert result["error"] == "Validation failed" + + def test_valid_schedule_stores_plan(self, config): + result = write.add_ad_schedule( + config, + customer_id="1234567890", + campaign_id="42", + schedule=[ + {"day_of_week": "Monday", "start_hour": 7, "end_hour": 18}, + {"day_of_week": "TUESDAY", "start_hour": 7, "end_hour": 18}, + ], + ) + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.operation == "add_ad_schedule" + assert plan.changes["campaign_id"] == "42" + assert len(plan.changes["schedule"]) == 2 + assert plan.changes["schedule"][0]["day_of_week"] == "MONDAY" + + +class TestApplyAddAdSchedule: + def test_creates_one_criterion_per_day(self): + crit_service = _FakeCampaignCriterionService( + [ + _FakeResult("customers/1/campaignCriteria/42~1001"), + _FakeResult("customers/1/campaignCriteria/42~1002"), + ] + ) + client = _FakeClient( + { + "CampaignService": _FakePathService("campaigns"), + "CampaignCriterionService": crit_service, + } + ) + + result = write._apply_add_ad_schedule( + client, + "1", + { + "campaign_id": "42", + "schedule": [ + { + "day_of_week": "MONDAY", + "start_hour": 7, + "start_minute": 0, + "end_hour": 18, + "end_minute": 30, + }, + { + "day_of_week": "TUESDAY", + "start_hour": 9, + "start_minute": 15, + "end_hour": 17, + "end_minute": 0, + }, + ], + }, + ) + + assert len(crit_service.operations) == 2 + first = crit_service.operations[0].create + assert first.campaign == "customers/1/campaigns/42" + assert first.ad_schedule.day_of_week == client.enums.DayOfWeekEnum.MONDAY + assert first.ad_schedule.start_hour == 7 + assert first.ad_schedule.end_hour == 18 + assert first.ad_schedule.end_minute == client.enums.MinuteOfHourEnum.THIRTY + + second = crit_service.operations[1].create + assert second.ad_schedule.day_of_week == client.enums.DayOfWeekEnum.TUESDAY + assert second.ad_schedule.start_minute == client.enums.MinuteOfHourEnum.FIFTEEN + + assert len(result["campaign_criteria"]) == 2 + + +# --------------------------------------------------------------------------- +# add_geo_exclusions +# --------------------------------------------------------------------------- + + +class TestAddGeoExclusions: + def test_requires_campaign_id(self, config): + result = write.add_geo_exclusions( + config, + customer_id="1234567890", + geo_target_ids=["1014962"], + ) + assert "campaign_id is required" in result["error"] + + def test_requires_at_least_one_geo(self, config): + result = write.add_geo_exclusions( + config, + customer_id="1234567890", + campaign_id="42", + geo_target_ids=[], + ) + assert "At least one geo_target_id" in result["error"] + + def test_strips_blank_entries_and_stores_plan(self, config): + result = write.add_geo_exclusions( + config, + customer_id="1234567890", + campaign_id="42", + geo_target_ids=[" 1014962 ", "", "1013570"], + ) + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.operation == "add_geo_exclusions" + assert plan.changes["geo_target_ids"] == ["1014962", "1013570"] + + +class TestApplyAddGeoExclusions: + def test_creates_negative_location_criteria(self): + crit_service = _FakeCampaignCriterionService( + [ + _FakeResult("customers/1/campaignCriteria/42~1014962"), + _FakeResult("customers/1/campaignCriteria/42~1013570"), + ] + ) + client = _FakeClient( + { + "CampaignService": _FakePathService("campaigns"), + "CampaignCriterionService": crit_service, + } + ) + + write._apply_add_geo_exclusions( + client, + "1", + {"campaign_id": "42", "geo_target_ids": ["1014962", "1013570"]}, + ) + + assert len(crit_service.operations) == 2 + first = crit_service.operations[0].create + assert first.campaign == "customers/1/campaigns/42" + assert first.location.geo_target_constant == "geoTargetConstants/1014962" + assert first.negative is True + + second = crit_service.operations[1].create + assert second.location.geo_target_constant == "geoTargetConstants/1013570" + assert second.negative is True + + +# --------------------------------------------------------------------------- +# draft_campaign integration with new params +# --------------------------------------------------------------------------- + + +class TestDraftCampaignNewParams: + def test_geo_exclude_overlap_returns_error(self, config): + result = write.draft_campaign( + config, + customer_id="1234567890", + campaign_name="X", + daily_budget=10, + bidding_strategy="MAXIMIZE_CONVERSIONS", + geo_target_ids=["2840"], + geo_exclude_ids=["2840"], + language_ids=["1000"], + ) + assert result["error"] == "geo_exclude_ids overlap with geo_target_ids" + + def test_invalid_ad_schedule_returns_error(self, config): + result = write.draft_campaign( + config, + customer_id="1234567890", + campaign_name="X", + daily_budget=10, + bidding_strategy="MAXIMIZE_CONVERSIONS", + geo_target_ids=["2840"], + language_ids=["1000"], + ad_schedule=[ + {"day_of_week": "BOGUS", "start_hour": 7, "end_hour": 18}, + ], + ) + assert result["error"] == "Ad schedule validation failed" + + def test_valid_new_params_stored_in_plan(self, config): + result = write.draft_campaign( + config, + customer_id="1234567890", + campaign_name="X", + daily_budget=10, + bidding_strategy="MAXIMIZE_CONVERSIONS", + geo_target_ids=["2840"], + geo_exclude_ids=["1014962", "1013570"], + language_ids=["1000"], + ad_schedule=[ + {"day_of_week": "monday", "start_hour": 7, "end_hour": 18}, + ], + ) + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["geo_exclude_ids"] == ["1014962", "1013570"] + assert plan.changes["ad_schedule"][0]["day_of_week"] == "MONDAY" + + +class TestApplyCreateCampaignNewParams: + def _build_client(self, num_extra_responses: int): + responses = [ + _FakeMutateOperationResponse( + "campaign_budget_result", "customers/1/campaignBudgets/1" + ), + _FakeMutateOperationResponse( + "campaign_result", "customers/1/campaigns/2" + ), + _FakeMutateOperationResponse( + "ad_group_result", "customers/1/adGroups/3" + ), + ] + [ + _FakeMutateOperationResponse( + "campaign_criterion_result", + f"customers/1/campaignCriteria/2~{i}", + ) + for i in range(num_extra_responses) + ] + google_ads = _FakeGoogleAdsService(responses) + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "CampaignService": _FakePathService("campaigns"), + "CampaignBudgetService": _FakePathService("campaignBudgets"), + "AdGroupService": _FakePathService("adGroups"), + } + ) + return client, google_ads + + def test_geo_exclusions_emit_negative_criteria(self): + client, google_ads = self._build_client(num_extra_responses=4) + write._apply_create_campaign( + client, + "1", + { + "campaign_name": "X", + "daily_budget": 10, + "bidding_strategy": "MAXIMIZE_CONVERSIONS", + "channel_type": "SEARCH", + "ad_group_name": "Default", + "geo_target_ids": ["2840"], + "language_ids": ["1000"], + "geo_exclude_ids": ["1014962", "1013570"], + }, + ) + # Operations: budget, campaign, ad_group, geo (1), lang (1), excl (2) = 7 + assert len(google_ads.operations) == 7 + excl_ops = google_ads.operations[5:7] + for op, geo_id in zip(excl_ops, ["1014962", "1013570"]): + crit = op.campaign_criterion_operation.create + assert crit.location.geo_target_constant == f"geoTargetConstants/{geo_id}" + assert crit.negative is True + + def test_ad_schedule_entries_emit_schedule_criteria(self): + client, google_ads = self._build_client(num_extra_responses=4) + write._apply_create_campaign( + client, + "1", + { + "campaign_name": "X", + "daily_budget": 10, + "bidding_strategy": "MAXIMIZE_CONVERSIONS", + "channel_type": "SEARCH", + "ad_group_name": "Default", + "geo_target_ids": ["2840"], + "language_ids": ["1000"], + "ad_schedule": [ + { + "day_of_week": "MONDAY", + "start_hour": 7, + "start_minute": 0, + "end_hour": 18, + "end_minute": 0, + }, + { + "day_of_week": "FRIDAY", + "start_hour": 7, + "start_minute": 0, + "end_hour": 18, + "end_minute": 0, + }, + ], + }, + ) + # budget, campaign, ad_group, geo (1), lang (1), schedule (2) = 7 + assert len(google_ads.operations) == 7 + for op, day in zip( + google_ads.operations[5:7], + [client.enums.DayOfWeekEnum.MONDAY, client.enums.DayOfWeekEnum.FRIDAY], + ): + crit = op.campaign_criterion_operation.create + assert crit.ad_schedule.day_of_week == day + + +# --------------------------------------------------------------------------- +# update_campaign integration with new params +# --------------------------------------------------------------------------- + + +class TestUpdateCampaignNewParams: + def test_geo_exclude_overlap_returns_error(self, config): + result = write.update_campaign( + config, + customer_id="1234567890", + campaign_id="42", + geo_target_ids=["2840"], + geo_exclude_ids=["2840"], + ) + assert result["error"] == "Validation failed" + assert any("overlap" in d for d in result["details"]) + + def test_invalid_ad_schedule_returns_validation_error(self, config): + result = write.update_campaign( + config, + customer_id="1234567890", + campaign_id="42", + ad_schedule=[ + {"day_of_week": "BOGUS", "start_hour": 7, "end_hour": 18} + ], + ) + assert result["error"] == "Validation failed" + + def test_setting_only_geo_exclusions_passes_validation(self, config): + result = write.update_campaign( + config, + customer_id="1234567890", + campaign_id="42", + geo_exclude_ids=["1014962"], + ) + assert "error" not in result + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["geo_exclude_ids"] == ["1014962"] + + def test_setting_only_ad_schedule_passes_validation(self, config): + result = write.update_campaign( + config, + customer_id="1234567890", + campaign_id="42", + ad_schedule=[ + {"day_of_week": "monday", "start_hour": 7, "end_hour": 18}, + ], + ) + assert "error" not in result + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["ad_schedule"][0]["day_of_week"] == "MONDAY" + + def test_empty_list_clears_field(self, config): + result = write.update_campaign( + config, + customer_id="1234567890", + campaign_id="42", + geo_exclude_ids=[], + ) + # An empty list is a valid "clear" instruction — should not error. + assert "error" not in result + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["geo_exclude_ids"] == [] + + +class TestApplyUpdateCampaignNewParams: + def _build_client(self, search_results: list[object] | None = None): + google_ads = _FakeGoogleAdsService( + responses=[ + _FakeMutateOperationResponse( + "campaign_criterion_result", + "customers/1/campaignCriteria/42~rep", + ) + ] + * 8, + search_rows=search_results or [], + ) + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "CampaignService": _FakePathService("campaigns"), + } + ) + return client, google_ads + + def test_geo_exclusions_replace_semantics(self): + existing_excl = SimpleNamespace( + campaign_criterion=SimpleNamespace( + resource_name="customers/1/campaignCriteria/42~old" + ) + ) + client, google_ads = self._build_client([existing_excl]) + + write._apply_update_campaign( + client, + "1", + { + "campaign_id": "42", + "geo_exclude_ids": ["1014962", "1013570"], + }, + ) + + # Should have queried for existing negative-location criteria + assert any( + "campaign_criterion.negative = TRUE" in q + for q in google_ads.search_calls + ) + # First op = remove old, then 2 adds + assert google_ads.operations[0].campaign_criterion_operation.remove == ( + "customers/1/campaignCriteria/42~old" + ) + for op, geo_id in zip( + google_ads.operations[1:3], + ["1014962", "1013570"], + ): + crit = op.campaign_criterion_operation.create + assert crit.location.geo_target_constant == ( + f"geoTargetConstants/{geo_id}" + ) + assert crit.negative is True + + def test_ad_schedule_replace_semantics(self): + existing_sched = SimpleNamespace( + campaign_criterion=SimpleNamespace( + resource_name="customers/1/campaignCriteria/42~old" + ) + ) + client, google_ads = self._build_client([existing_sched]) + + write._apply_update_campaign( + client, + "1", + { + "campaign_id": "42", + "ad_schedule": [ + { + "day_of_week": "MONDAY", + "start_hour": 9, + "start_minute": 0, + "end_hour": 17, + "end_minute": 0, + }, + ], + }, + ) + + assert any( + "AD_SCHEDULE" in q for q in google_ads.search_calls + ) + assert google_ads.operations[0].campaign_criterion_operation.remove == ( + "customers/1/campaignCriteria/42~old" + ) + new = google_ads.operations[1].campaign_criterion_operation.create + assert new.ad_schedule.day_of_week == client.enums.DayOfWeekEnum.MONDAY + assert new.ad_schedule.start_hour == 9 + + def test_empty_geo_exclude_ids_only_removes(self): + existing = SimpleNamespace( + campaign_criterion=SimpleNamespace( + resource_name="customers/1/campaignCriteria/42~old" + ) + ) + client, google_ads = self._build_client([existing]) + + write._apply_update_campaign( + client, + "1", + {"campaign_id": "42", "geo_exclude_ids": []}, + ) + # Only the remove op should be present + assert len(google_ads.operations) == 1 + assert google_ads.operations[0].campaign_criterion_operation.remove == ( + "customers/1/campaignCriteria/42~old" + ) + + +# --------------------------------------------------------------------------- +# _populate_ad_schedule_info direct test +# --------------------------------------------------------------------------- + + +class TestPopulateAdScheduleInfo: + def test_populates_all_fields_on_proto(self): + client = _FakeClient( + {"GoogleAdsService": _FakeGoogleAdsService()} + ) + info = client.get_type("AdScheduleInfo") + write._populate_ad_schedule_info( + client, + info, + { + "day_of_week": "FRIDAY", + "start_hour": 9, + "start_minute": 30, + "end_hour": 17, + "end_minute": 45, + }, + ) + assert info.day_of_week == client.enums.DayOfWeekEnum.FRIDAY + assert info.start_hour == 9 + assert info.end_hour == 17 + assert info.start_minute == client.enums.MinuteOfHourEnum.THIRTY + assert info.end_minute == client.enums.MinuteOfHourEnum.FORTY_FIVE + + +# --------------------------------------------------------------------------- +# _execute_plan dispatch coverage for new operations +# --------------------------------------------------------------------------- + + +class TestExecutePlanDispatch: + def test_new_operations_present_in_dispatch(self): + # Static read of source code rather than monkey-patching internals + import inspect + + source = inspect.getsource(write._execute_plan) + for op in ( + "create_call_asset", + "create_location_asset", + "add_ad_schedule", + "add_geo_exclusions", + ): + assert f'"{op}"' in source, f"{op} missing from dispatch" + + +# --------------------------------------------------------------------------- +# Server-tool registration smoke check +# --------------------------------------------------------------------------- + + +class TestServerToolRegistration: + @pytest.fixture + def tools_by_name(self): + import asyncio + + from adloop.server import mcp + + async def _list(): + return await mcp.list_tools() + + tools = asyncio.run(_list()) + return {t.name: t for t in tools} + + def test_new_tools_are_registered(self, tools_by_name): + for name in ( + "draft_call_asset", + "draft_location_asset", + "add_ad_schedule", + "add_geo_exclusions", + ): + assert name in tools_by_name, f"{name} not registered" + + def test_sitelinks_callouts_snippets_make_campaign_id_optional( + self, tools_by_name + ): + for name in ("draft_sitelinks", "draft_callouts", "draft_structured_snippets"): + tool = tools_by_name[name] + required = tool.parameters.get("required", []) + assert "campaign_id" not in required, ( + f"{name} should not require campaign_id" + ) + + def test_draft_campaign_exposes_new_optional_params(self, tools_by_name): + params = tools_by_name["draft_campaign"].parameters["properties"] + assert "geo_exclude_ids" in params + assert "ad_schedule" in params + + def test_update_campaign_exposes_new_optional_params(self, tools_by_name): + params = tools_by_name["update_campaign"].parameters["properties"] + assert "geo_exclude_ids" in params + assert "ad_schedule" in params + + def test_promotion_tools_are_registered(self, tools_by_name): + for name in ("draft_promotion", "update_promotion"): + assert name in tools_by_name, f"{name} not registered" + + def test_link_asset_to_customer_registered(self, tools_by_name): + assert "link_asset_to_customer" in tools_by_name + required = ( + tools_by_name["link_asset_to_customer"].parameters.get("required", []) + ) + assert "links" in required + + def test_draft_promotion_required_params(self, tools_by_name): + required = tools_by_name["draft_promotion"].parameters.get("required", []) + assert "promotion_target" in required + assert "final_url" in required + # money_off / percent_off are mutually exclusive at validation time, + # but neither is required at the schema level (default 0) + assert "money_off" not in required + assert "percent_off" not in required + + def test_update_promotion_requires_asset_id(self, tools_by_name): + required = tools_by_name["update_promotion"].parameters.get("required", []) + assert "asset_id" in required + + +# --------------------------------------------------------------------------- +# _validate_promotion_inputs +# --------------------------------------------------------------------------- + + +class TestValidatePromotionInputs: + def _ok_kwargs(self, **overrides): + defaults = dict( + promotion_target="Window Tint", + final_url="https://example.com/tint", + money_off=100.0, + percent_off=0, + currency_code="USD", + promotion_code="", + orders_over_amount=0, + occasion="", + discount_modifier="", + language_code="en", + start_date="", + end_date="", + redemption_start_date="", + redemption_end_date="", + ad_schedule=None, + ) + defaults.update(overrides) + return defaults + + def _patched(self, monkeypatch): + # Stub URL validation so unit tests don't hit the network + monkeypatch.setattr( + write, + "_validate_urls", + lambda urls, timeout=10: {u: None for u in urls}, + ) + + def test_happy_path_money_off(self, monkeypatch): + self._patched(monkeypatch) + normalized, errors = write._validate_promotion_inputs(**self._ok_kwargs()) + assert errors == [] + assert normalized["money_off"] == 100.0 + assert normalized["percent_off"] == 0.0 + assert normalized["currency_code"] == "USD" + assert normalized["language_code"] == "en" + + def test_happy_path_percent_off(self, monkeypatch): + self._patched(monkeypatch) + normalized, errors = write._validate_promotion_inputs( + **self._ok_kwargs(money_off=0, percent_off=15.0) + ) + assert errors == [] + assert normalized["percent_off"] == 15.0 + assert normalized["money_off"] == 0.0 + + def test_promotion_target_required(self, monkeypatch): + self._patched(monkeypatch) + _, errors = write._validate_promotion_inputs( + **self._ok_kwargs(promotion_target="") + ) + assert any("promotion_target is required" in e for e in errors) + + def test_promotion_target_max_20_chars(self, monkeypatch): + self._patched(monkeypatch) + long_target = "A" * 21 + _, errors = write._validate_promotion_inputs( + **self._ok_kwargs(promotion_target=long_target) + ) + assert any("max 20" in e for e in errors) + + def test_final_url_required(self, monkeypatch): + self._patched(monkeypatch) + _, errors = write._validate_promotion_inputs( + **self._ok_kwargs(final_url="") + ) + assert any("final_url is required" in e for e in errors) + + def test_money_and_percent_both_set_rejected(self, monkeypatch): + self._patched(monkeypatch) + _, errors = write._validate_promotion_inputs( + **self._ok_kwargs(money_off=10, percent_off=5) + ) + assert any("exactly one of money_off or percent_off" in e for e in errors) + + def test_neither_money_nor_percent_rejected(self, monkeypatch): + self._patched(monkeypatch) + _, errors = write._validate_promotion_inputs( + **self._ok_kwargs(money_off=0, percent_off=0) + ) + assert any("One of money_off or percent_off" in e for e in errors) + + def test_percent_off_out_of_range_rejected(self, monkeypatch): + self._patched(monkeypatch) + _, errors = write._validate_promotion_inputs( + **self._ok_kwargs(money_off=0, percent_off=150) + ) + assert any("must be in (0, 100]" in e for e in errors) + + def test_promotion_code_max_15_chars(self, monkeypatch): + self._patched(monkeypatch) + _, errors = write._validate_promotion_inputs( + **self._ok_kwargs(promotion_code="A" * 16) + ) + assert any("max 15" in e for e in errors) + + def test_invalid_occasion_rejected(self, monkeypatch): + self._patched(monkeypatch) + _, errors = write._validate_promotion_inputs( + **self._ok_kwargs(occasion="MARDI_GRAS") + ) + assert any("occasion 'MARDI_GRAS' invalid" in e for e in errors) + + def test_valid_occasion_normalized_uppercase(self, monkeypatch): + self._patched(monkeypatch) + normalized, errors = write._validate_promotion_inputs( + **self._ok_kwargs(occasion="black_friday") + ) + assert errors == [] + assert normalized["occasion"] == "BLACK_FRIDAY" + + def test_invalid_discount_modifier_rejected(self, monkeypatch): + self._patched(monkeypatch) + _, errors = write._validate_promotion_inputs( + **self._ok_kwargs(discount_modifier="MORE_THAN") + ) + assert any("discount_modifier 'MORE_THAN' invalid" in e for e in errors) + + def test_valid_discount_modifier_up_to(self, monkeypatch): + self._patched(monkeypatch) + normalized, errors = write._validate_promotion_inputs( + **self._ok_kwargs(discount_modifier="up_to") + ) + assert errors == [] + assert normalized["discount_modifier"] == "UP_TO" + + def test_bad_date_format_rejected(self, monkeypatch): + self._patched(monkeypatch) + _, errors = write._validate_promotion_inputs( + **self._ok_kwargs(start_date="01/01/2026") + ) + assert any("start_date '01/01/2026' must be YYYY-MM-DD" in e for e in errors) + + def test_iso_date_accepted(self, monkeypatch): + self._patched(monkeypatch) + normalized, errors = write._validate_promotion_inputs( + **self._ok_kwargs(start_date="2026-01-01", end_date="2026-12-31") + ) + assert errors == [] + assert normalized["start_date"] == "2026-01-01" + assert normalized["end_date"] == "2026-12-31" + + def test_unreachable_url_rejected(self, monkeypatch): + # Force URL check to fail + monkeypatch.setattr( + write, + "_validate_urls", + lambda urls, timeout=10: {u: "Connection refused" for u in urls}, + ) + _, errors = write._validate_promotion_inputs(**self._ok_kwargs()) + assert any("not reachable" in e for e in errors) + + def test_promotion_code_and_orders_over_amount_mutually_exclusive(self, monkeypatch): + self._patched(monkeypatch) + _, errors = write._validate_promotion_inputs( + **self._ok_kwargs(promotion_code="SAVE10", orders_over_amount=500) + ) + assert any("mutually exclusive" in e for e in errors) + + def test_orders_over_amount_alone_is_fine(self, monkeypatch): + self._patched(monkeypatch) + normalized, errors = write._validate_promotion_inputs( + **self._ok_kwargs(orders_over_amount=500) + ) + assert errors == [] + assert normalized["orders_over_amount"] == 500.0 + + def test_ad_schedule_validation_propagates(self, monkeypatch): + self._patched(monkeypatch) + _, errors = write._validate_promotion_inputs( + **self._ok_kwargs( + ad_schedule=[{"day_of_week": "MARTES", "start_hour": 8, "end_hour": 17}] + ) + ) + assert any("day_of_week" in e for e in errors) + + +# --------------------------------------------------------------------------- +# draft_promotion +# --------------------------------------------------------------------------- + + +class TestDraftPromotion: + @pytest.fixture(autouse=True) + def _stub_urls(self, monkeypatch): + monkeypatch.setattr( + write, + "_validate_urls", + lambda urls, timeout=10: {u: None for u in urls}, + ) + + def test_customer_scope_when_no_campaign_id(self, config): + result = write.draft_promotion( + config, + customer_id="1234567890", + promotion_target="Window Tint", + final_url="https://example.com/tint", + money_off=100, + ) + assert "error" not in result + assert result["entity_type"] == "customer_asset" + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["scope"] == "customer" + assert plan.changes["campaign_id"] == "" + assert plan.changes["promotion"]["money_off"] == 100.0 + assert plan.changes["promotion"]["promotion_target"] == "Window Tint" + + def test_campaign_scope_when_campaign_id(self, config): + result = write.draft_promotion( + config, + customer_id="1234567890", + campaign_id="42", + promotion_target="Full Front PPF", + final_url="https://example.com/ppf", + money_off=301, + ) + assert result["entity_type"] == "campaign_asset" + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["scope"] == "campaign" + assert plan.changes["campaign_id"] == "42" + + def test_validation_failure_returns_error_dict(self, config): + result = write.draft_promotion( + config, + customer_id="1234567890", + promotion_target="", # required + final_url="https://example.com/tint", + money_off=100, + ) + assert result.get("error") == "Validation failed" + assert any("promotion_target" in d for d in result["details"]) + + def test_percent_off_path(self, config): + result = write.draft_promotion( + config, + customer_id="1234567890", + promotion_target="Spring Sale", + final_url="https://example.com/sale", + percent_off=20, + ) + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["promotion"]["percent_off"] == 20.0 + assert plan.changes["promotion"]["money_off"] == 0.0 + + +# --------------------------------------------------------------------------- +# update_promotion +# --------------------------------------------------------------------------- + + +class TestUpdatePromotion: + @pytest.fixture(autouse=True) + def _stub_urls(self, monkeypatch): + monkeypatch.setattr( + write, + "_validate_urls", + lambda urls, timeout=10: {u: None for u in urls}, + ) + + def test_requires_asset_id(self, config): + result = write.update_promotion( + config, + customer_id="1234567890", + asset_id="", + promotion_target="Window Tint", + final_url="https://example.com/tint", + money_off=100, + ) + assert "asset_id is required" in result["error"] + + def test_emits_swap_plan_with_old_asset_id(self, config): + result = write.update_promotion( + config, + customer_id="1234567890", + campaign_id="42", + asset_id="55555", + promotion_target="Full Front PPF", + final_url="https://example.com/ppf", + money_off=399, + ) + assert "error" not in result + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.operation == "update_promotion" + assert plan.changes["old_asset_id"] == "55555" + assert plan.changes["scope"] == "campaign" + + def test_swap_warning_explains_orphaned_asset(self, config): + result = write.update_promotion( + config, + customer_id="1234567890", + asset_id="55555", + promotion_target="Tint", + final_url="https://example.com/x", + money_off=10, + ) + warnings = result.get("warnings", []) + assert any("orphaned" in w.lower() for w in warnings) + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["old_asset_id"] == "55555" + # remove_old_asset is no longer a supported field + assert "remove_old_asset" not in plan.changes + + +# --------------------------------------------------------------------------- +# _apply_create_promotion / _populate_promotion_asset +# --------------------------------------------------------------------------- + + +class TestApplyCreatePromotion: + def test_customer_scope_emits_customer_asset_with_promotion_field_type(self): + responses = [ + _FakeMutateOperationResponse("asset_result", "customers/1/assets/-1"), + _FakeMutateOperationResponse( + "customer_asset_result", "customers/1/customerAssets/1~PROMOTION" + ), + ] + google_ads = _FakeGoogleAdsService(responses) + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + } + ) + + write._apply_create_promotion( + client, + "1", + { + "scope": "customer", + "campaign_id": "", + "promotion": { + "promotion_target": "Tint", + "final_url": "https://example.com/tint", + "money_off": 100.0, + "percent_off": 0.0, + "currency_code": "USD", + "promotion_code": "", + "orders_over_amount": 0, + "occasion": "", + "discount_modifier": "", + "language_code": "en", + "start_date": "", + "end_date": "", + "redemption_start_date": "", + "redemption_end_date": "", + "ad_schedule": [], + }, + }, + ) + + assert len(google_ads.operations) == 2 + asset_op = google_ads.operations[0].asset_operation.create + assert asset_op.promotion_asset.promotion_target == "Tint" + assert asset_op.promotion_asset.money_amount_off.amount_micros == 100_000_000 + assert asset_op.promotion_asset.money_amount_off.currency_code == "USD" + assert "https://example.com/tint" in list(asset_op.final_urls) + + link_op = google_ads.operations[1].customer_asset_operation.create + assert link_op.field_type == client.enums.AssetFieldTypeEnum.PROMOTION + + def test_campaign_scope_emits_campaign_asset(self): + responses = [ + _FakeMutateOperationResponse("asset_result", "customers/1/assets/-1"), + _FakeMutateOperationResponse( + "campaign_asset_result", "customers/1/campaignAssets/42~PROMOTION" + ), + ] + google_ads = _FakeGoogleAdsService(responses) + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + } + ) + + write._apply_create_promotion( + client, + "1", + { + "scope": "campaign", + "campaign_id": "42", + "promotion": { + "promotion_target": "PPF", + "final_url": "https://example.com/ppf", + "money_off": 0, + "percent_off": 25.0, + "currency_code": "USD", + "promotion_code": "BGI25", + "orders_over_amount": 0, + "occasion": "BLACK_FRIDAY", + "discount_modifier": "UP_TO", + "language_code": "en", + "start_date": "2026-11-25", + "end_date": "2026-11-30", + "redemption_start_date": "", + "redemption_end_date": "", + "ad_schedule": [], + }, + }, + ) + + asset_op = google_ads.operations[0].asset_operation.create + # percent_off path — micros encoded + assert asset_op.promotion_asset.percent_off == 25_000_000 + assert asset_op.promotion_asset.promotion_code == "BGI25" + # orders_over_amount and promotion_code are a oneof — only code set here + assert asset_op.promotion_asset.start_date == "2026-11-25" + assert asset_op.promotion_asset.end_date == "2026-11-30" + assert ( + asset_op.promotion_asset.occasion + == client.enums.PromotionExtensionOccasionEnum.BLACK_FRIDAY + ) + assert ( + asset_op.promotion_asset.discount_modifier + == client.enums.PromotionExtensionDiscountModifierEnum.UP_TO + ) + + link_op = google_ads.operations[1].campaign_asset_operation.create + assert link_op.field_type == client.enums.AssetFieldTypeEnum.PROMOTION + + +# --------------------------------------------------------------------------- +# _apply_update_promotion (swap) +# --------------------------------------------------------------------------- + + +class _FakeCampaignAssetService(_FakePathService): + def __init__(self, responses: list[_FakeResult] | None = None): + super().__init__("campaignAssets") + self.operations = None + self._responses = responses or [] + + def mutate_campaign_assets( + self, customer_id: str, operations: list[object] + ) -> object: + self.operations = operations + return SimpleNamespace(results=self._responses) + + +class _FakeCustomerAssetService(_FakePathService): + def __init__(self, responses: list[_FakeResult] | None = None): + super().__init__("customerAssets") + self.operations = None + self._responses = responses or [] + + def mutate_customer_assets( + self, customer_id: str, operations: list[object] + ) -> object: + self.operations = operations + return SimpleNamespace(results=self._responses) + + +class TestApplyUpdatePromotion: + def _promo(self, **overrides): + base = { + "promotion_target": "PPF", + "final_url": "https://example.com/ppf", + "money_off": 200.0, + "percent_off": 0, + "currency_code": "USD", + "promotion_code": "", + "orders_over_amount": 0, + "occasion": "", + "discount_modifier": "", + "language_code": "en", + "start_date": "", + "end_date": "", + "redemption_start_date": "", + "redemption_end_date": "", + "ad_schedule": [], + } + base.update(overrides) + return base + + def test_campaign_swap_creates_new_links_unlinks_old(self): + # Old link found via search + search_row = SimpleNamespace( + campaign_asset=SimpleNamespace( + resource_name="customers/1/campaignAssets/42~99~PROMOTION" + ) + ) + responses = [ + _FakeMutateOperationResponse("asset_result", "customers/1/assets/-1"), + _FakeMutateOperationResponse( + "campaign_asset_result", "customers/1/campaignAssets/42~999~PROMOTION" + ), + ] + google_ads = _FakeGoogleAdsService(responses, search_rows=[search_row]) + ca_service = _FakeCampaignAssetService([_FakeResult("removed")]) + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + "CampaignAssetService": ca_service, + } + ) + + result = write._apply_update_promotion( + client, + "1", + { + "scope": "campaign", + "campaign_id": "42", + "old_asset_id": "99", + "promotion": self._promo(), + }, + ) + + # Step 1+2: create + link in one mutate call + assert len(google_ads.operations) == 2 + # Step 3: unlink old + assert ca_service.operations is not None + assert len(ca_service.operations) == 1 + assert ( + ca_service.operations[0].remove + == "customers/1/campaignAssets/42~99~PROMOTION" + ) + assert result["new_asset"] == "customers/1/assets/-1" + assert result["old_link_removed"] == "customers/1/campaignAssets/42~99~PROMOTION" + + def test_customer_swap_uses_customer_asset_service(self): + search_row = SimpleNamespace( + customer_asset=SimpleNamespace( + resource_name="customers/1/customerAssets/99~PROMOTION" + ) + ) + responses = [ + _FakeMutateOperationResponse("asset_result", "customers/1/assets/-1"), + _FakeMutateOperationResponse( + "customer_asset_result", "customers/1/customerAssets/-1~PROMOTION" + ), + ] + google_ads = _FakeGoogleAdsService(responses, search_rows=[search_row]) + cust_service = _FakeCustomerAssetService([_FakeResult("removed")]) + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + "CustomerAssetService": cust_service, + } + ) + + write._apply_update_promotion( + client, + "1", + { + "scope": "customer", + "campaign_id": "", + "old_asset_id": "99", + "promotion": self._promo(), + }, + ) + + # Customer-level link in step 2 + link_op = google_ads.operations[1].customer_asset_operation.create + assert link_op.field_type == client.enums.AssetFieldTypeEnum.PROMOTION + # Old customer-level link removed + assert cust_service.operations is not None + assert ( + cust_service.operations[0].remove + == "customers/1/customerAssets/99~PROMOTION" + ) + + def test_swap_when_old_link_not_found_skips_unlink(self): + # Empty search rows = no old link found + responses = [ + _FakeMutateOperationResponse("asset_result", "customers/1/assets/-1"), + _FakeMutateOperationResponse( + "campaign_asset_result", "customers/1/campaignAssets/42~999~PROMOTION" + ), + ] + google_ads = _FakeGoogleAdsService(responses, search_rows=[]) + ca_service = _FakeCampaignAssetService() + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + "CampaignAssetService": ca_service, + } + ) + + result = write._apply_update_promotion( + client, + "1", + { + "scope": "campaign", + "campaign_id": "42", + "old_asset_id": "99", + "promotion": self._promo(), + }, + ) + + # Step 3 is no-op when old link not found + assert ca_service.operations is None + assert result["old_link_removed"] == "" + assert result["new_asset"] == "customers/1/assets/-1" + + def test_unknown_scope_raises(self): + client = _FakeClient( + { + "GoogleAdsService": _FakeGoogleAdsService(), + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + } + ) + with pytest.raises(ValueError, match="Unknown scope"): + write._apply_update_promotion( + client, + "1", + { + "scope": "bogus", + "campaign_id": "", + "old_asset_id": "99", + "promotion": self._promo(), + }, + ) + + +# --------------------------------------------------------------------------- +# Dispatch wiring +# --------------------------------------------------------------------------- + + +# --------------------------------------------------------------------------- +# link_asset_to_customer +# --------------------------------------------------------------------------- + + +class TestLinkAssetToCustomer: + def test_validation_rejects_unknown_field_type(self, config): + result = write.link_asset_to_customer( + config, + customer_id="1234567890", + links=[{"asset_id": "12345", "field_type": "NONSENSE"}], + ) + assert result.get("error") == "Validation failed" + assert any("NONSENSE" in d for d in result["details"]) + + def test_validation_rejects_non_numeric_asset_id(self, config): + result = write.link_asset_to_customer( + config, + customer_id="1234567890", + links=[{"asset_id": "abc", "field_type": "BUSINESS_LOGO"}], + ) + assert result.get("error") == "Validation failed" + assert any("must be numeric" in d for d in result["details"]) + + def test_validation_requires_asset_id(self, config): + result = write.link_asset_to_customer( + config, + customer_id="1234567890", + links=[{"asset_id": "", "field_type": "AD_IMAGE"}], + ) + assert result.get("error") == "Validation failed" + assert any("asset_id is required" in d for d in result["details"]) + + def test_validation_requires_field_type(self, config): + result = write.link_asset_to_customer( + config, + customer_id="1234567890", + links=[{"asset_id": "12345", "field_type": ""}], + ) + assert result.get("error") == "Validation failed" + assert any("field_type is required" in d for d in result["details"]) + + def test_empty_links_rejected(self, config): + result = write.link_asset_to_customer( + config, customer_id="1234567890", links=[] + ) + assert "At least one link is required" in result["error"] + + def test_happy_path_emits_customer_asset_plan(self, config): + result = write.link_asset_to_customer( + config, + customer_id="1234567890", + links=[ + {"asset_id": "120726490775", "field_type": "BUSINESS_LOGO"}, + {"asset_id": "200848497279", "field_type": "AD_IMAGE"}, + ], + ) + assert "error" not in result + assert result["entity_type"] == "customer_asset" + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.operation == "link_asset_to_customer" + assert len(plan.changes["links"]) == 2 + assert plan.changes["links"][0]["field_type"] == "BUSINESS_LOGO" + + def test_apply_creates_customer_asset_operations(self): + cust_service = _FakeCustomerAssetService( + [_FakeResult("customers/1/customerAssets/120726490775~BUSINESS_LOGO"), + _FakeResult("customers/1/customerAssets/200848497279~AD_IMAGE")] + ) + client = _FakeClient( + { + "GoogleAdsService": _FakeGoogleAdsService(), + "AssetService": _FakePathService("assets"), + "CustomerAssetService": cust_service, + } + ) + + result = write._apply_link_asset_to_customer( + client, + "1", + { + "links": [ + {"asset_id": "120726490775", "field_type": "BUSINESS_LOGO"}, + {"asset_id": "200848497279", "field_type": "AD_IMAGE"}, + ] + }, + ) + + assert cust_service.operations is not None + assert len(cust_service.operations) == 2 + # First op: BUSINESS_LOGO link + op0 = cust_service.operations[0].create + assert op0.asset == "customers/1/assets/120726490775" + assert op0.field_type == client.enums.AssetFieldTypeEnum.BUSINESS_LOGO + # Second op: AD_IMAGE link + op1 = cust_service.operations[1].create + assert op1.asset == "customers/1/assets/200848497279" + assert op1.field_type == client.enums.AssetFieldTypeEnum.AD_IMAGE + + assert result["linked_count"] == 2 + assert len(result["customer_assets"]) == 2 + + +class TestPromotionDispatchWired: + def test_create_and_update_promotion_in_dispatch(self): + # confirm_and_apply uses an internal dispatch dict — exercise it via + # a dry-run roundtrip on each operation. The handlers themselves + # are tested above; here we verify the names are wired. + from adloop.safety import preview as ps + + # Build a fake plan and put it in the store, then ensure + # _execute_plan finds the correct dispatch entry. We can't easily + # invoke confirm_and_apply (needs a real Ads client), but we can + # introspect the dispatch mapping by reaching into _execute_plan's + # source to confirm the keys exist. + import inspect + + src = inspect.getsource(write._execute_plan) + assert '"create_promotion": _apply_create_promotion' in src + assert '"update_promotion": _apply_update_promotion' in src + + +# --------------------------------------------------------------------------- +# Asset in-place updates: update_call_asset, update_sitelink, update_callout +# --------------------------------------------------------------------------- + + +class TestUpdateCallAsset: + def test_asset_id_required(self, config): + result = write.update_call_asset(config, customer_id="1", asset_id="") + assert "asset_id is required" in result["error"] + + def test_no_fields_to_update_rejected(self, config): + result = write.update_call_asset( + config, customer_id="1", asset_id="357825439813" + ) + assert "No fields to update" in result["error"] + + def test_invalid_reporting_state(self, config): + result = write.update_call_asset( + config, + customer_id="1", + asset_id="357825439813", + call_conversion_reporting_state="WRONG", + ) + assert result["error"] == "Validation failed" + + def test_phone_normalized(self, config): + result = write.update_call_asset( + config, + customer_id="1", + asset_id="357825439813", + phone_number="(916) 460-9257", + country_code="US", + ) + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["phone_number"] == "+19164609257" + assert plan.changes["country_code"] == "US" + + def test_repoint_to_conversion_action(self, config): + result = write.update_call_asset( + config, + customer_id="1", + asset_id="357825439813", + call_conversion_action_id="6797442210", + call_conversion_reporting_state="USE_RESOURCE_LEVEL_CALL_CONVERSION_ACTION", + ) + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["call_conversion_action_id"] == "6797442210" + assert ( + plan.changes["call_conversion_reporting_state"] + == "USE_RESOURCE_LEVEL_CALL_CONVERSION_ACTION" + ) + + +class _FakeAssetService(_FakePathService): + def __init__(self, responses=None): + super().__init__("assets") + self.operations = None + self._responses = responses or [] + + def mutate_assets(self, customer_id: str, operations: list) -> object: + self.operations = operations + return SimpleNamespace(results=self._responses) + + +class _FakeConversionActionService(_FakePathService): + def __init__(self): + super().__init__("conversionActions") + + +class TestApplyUpdateCallAsset: + def test_emits_field_mask_for_specified_fields_only(self): + a_svc = _FakeAssetService( + [_FakeResult("customers/1/assets/357825439813")] + ) + client = _FakeClient( + { + "AssetService": a_svc, + "ConversionActionService": _FakeConversionActionService(), + } + ) + write._apply_update_call_asset( + client, + "1", + { + "asset_id": "357825439813", + "call_conversion_action_id": "6797442210", + "call_conversion_reporting_state": "USE_RESOURCE_LEVEL_CALL_CONVERSION_ACTION", + }, + ) + + op = a_svc.operations[0] + asset = op.update + assert asset.resource_name == "customers/1/assets/357825439813" + assert asset.call_asset.call_conversion_action == "customers/1/conversionActions/6797442210" + assert ( + asset.call_asset.call_conversion_reporting_state + == client.enums.CallConversionReportingStateEnum.USE_RESOURCE_LEVEL_CALL_CONVERSION_ACTION + ) + mask = list(op.update_mask.paths) + assert "call_asset.call_conversion_action" in mask + assert "call_asset.call_conversion_reporting_state" in mask + assert "call_asset.phone_number" not in mask + + +class TestUpdateSitelink: + @pytest.fixture(autouse=True) + def _stub_urls(self, monkeypatch): + monkeypatch.setattr( + write, "_validate_urls", lambda urls, timeout=10: {u: None for u in urls} + ) + + def test_asset_id_required(self, config): + result = write.update_sitelink(config, customer_id="1", asset_id="") + assert "asset_id is required" in result["error"] + + def test_link_text_max_25_chars(self, config): + result = write.update_sitelink( + config, + customer_id="1", + asset_id="357825455476", + link_text="A" * 26, + ) + assert result["error"] == "Validation failed" + assert any("max 25" in d for d in result["details"]) + + def test_description1_max_35(self, config): + result = write.update_sitelink( + config, + customer_id="1", + asset_id="357825455476", + description1="X" * 36, + ) + assert result["error"] == "Validation failed" + assert any("description1" in d for d in result["details"]) + + def test_no_fields_to_update_rejected(self, config): + result = write.update_sitelink( + config, customer_id="1", asset_id="357825455476" + ) + assert "No fields to update" in result["error"] + + def test_partial_update_persists(self, config): + result = write.update_sitelink( + config, + customer_id="1", + asset_id="357825455476", + description1="Premium ceramic from $299", + ) + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["description1"] == "Premium ceramic from $299" + assert "link_text" not in plan.changes + + +class TestApplyUpdateSitelink: + def test_emits_field_mask(self): + a_svc = _FakeAssetService( + [_FakeResult("customers/1/assets/357825455476")] + ) + client = _FakeClient({"AssetService": a_svc}) + write._apply_update_sitelink( + client, + "1", + { + "asset_id": "357825455476", + "link_text": "Auto Window Tint", + "description1": "Premium ceramic from $299", + }, + ) + op = a_svc.operations[0] + asset = op.update + assert asset.sitelink_asset.link_text == "Auto Window Tint" + assert asset.sitelink_asset.description1 == "Premium ceramic from $299" + mask = list(op.update_mask.paths) + assert "sitelink_asset.link_text" in mask + assert "sitelink_asset.description1" in mask + assert "sitelink_asset.description2" not in mask + + +class TestUpdateCallout: + def test_asset_id_required(self, config): + result = write.update_callout( + config, customer_id="1", asset_id="", callout_text="Free Snacks" + ) + assert "asset_id is required" in result["error"] + + def test_callout_text_required(self, config): + result = write.update_callout( + config, customer_id="1", asset_id="123", callout_text=" " + ) + assert "callout_text is required" in result["error"] + + def test_max_25_chars(self, config): + result = write.update_callout( + config, + customer_id="1", + asset_id="123", + callout_text="A" * 26, + ) + assert result["error"] == "Validation failed" + + def test_happy_path(self, config): + result = write.update_callout( + config, + customer_id="1", + asset_id="357825439780", + callout_text="Free Snacks & Lounge", + ) + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["callout_text"] == "Free Snacks & Lounge" + + +class TestApplyUpdateCallout: + def test_emits_minimal_field_mask(self): + a_svc = _FakeAssetService( + [_FakeResult("customers/1/assets/357825439780")] + ) + client = _FakeClient({"AssetService": a_svc}) + write._apply_update_callout( + client, + "1", + {"asset_id": "357825439780", "callout_text": "Free Snacks & Lounge"}, + ) + op = a_svc.operations[0] + asset = op.update + assert asset.callout_asset.callout_text == "Free Snacks & Lounge" + assert list(op.update_mask.paths) == ["callout_asset.callout_text"] + + +# --------------------------------------------------------------------------- +# MCP registration + dispatch wiring (asset updates + conversion actions) +# --------------------------------------------------------------------------- + + +class TestNewToolsRegistered: + @pytest.fixture(scope="class") + def tools_by_name(self): + import asyncio + from adloop.server import mcp + + async def _list(): + return await mcp.list_tools() + + tools = asyncio.run(_list()) + return {t.name: t for t in tools} + + def test_asset_update_and_conversion_tools_registered(self, tools_by_name): + for name in ( + # conversion actions + "draft_create_conversion_action", + "draft_update_conversion_action", + "draft_remove_conversion_action", + # asset updates + "update_call_asset", + "update_sitelink", + "update_callout", + ): + assert name in tools_by_name, f"{name} not registered" + + def test_dispatch_routes_asset_and_conversion_ops(self): + import inspect + + src = inspect.getsource(write._execute_plan) + # asset update ops + assert '"update_call_asset": _apply_update_call_asset' in src + assert '"update_sitelink": _apply_update_sitelink' in src + assert '"update_callout": _apply_update_callout' in src + # conversion-action ops + assert '"create_conversion_action"' in src + assert '"update_conversion_action"' in src + assert '"remove_conversion_action"' in src + + +# --------------------------------------------------------------------------- +# Image asset field-type detection + customer-scope refactor +# --------------------------------------------------------------------------- + + +class TestDetectImageFieldType: + @pytest.mark.parametrize( + "width,height,name,expected", + [ + (1200, 1200, "team-square", "SQUARE_MARKETING_IMAGE"), + (1088, 1088, "team-square", "SQUARE_MARKETING_IMAGE"), + (1024, 1024, "logo-square", "BUSINESS_LOGO"), + (1200, 1200, "company-logo", "BUSINESS_LOGO"), + (1200, 628, "marketing-hero", "MARKETING_IMAGE"), + (1200, 627, "marketing-hero", "MARKETING_IMAGE"), + (1200, 300, "wordmark-logo", "LANDSCAPE_LOGO"), + (480, 600, "vertical-photo", "PORTRAIT_MARKETING_IMAGE"), + (480, 800, "tall-portrait", "TALL_PORTRAIT_MARKETING_IMAGE"), + (1500, 800, "wide-photo", "MARKETING_IMAGE"), + (1300, 1000, "near-square", "MARKETING_IMAGE"), + ], + ) + def test_aspect_ratio_picks_field_type(self, width, height, name, expected): + result = write._detect_image_field_type({ + "width": width, "height": height, "name": name, "path": f"{name}.jpg", + }) + assert result == expected + + def test_explicit_field_type_overrides_detection(self): + result = write._detect_image_field_type({ + "width": 1200, "height": 628, "name": "x", + "field_type": "SQUARE_MARKETING_IMAGE", + }) + assert result == "SQUARE_MARKETING_IMAGE" + + def test_explicit_invalid_field_type_raises(self): + with pytest.raises(ValueError, match="not a supported"): + write._detect_image_field_type({ + "width": 1200, "height": 628, "field_type": "AD_IMAGE", + }) + + def test_zero_dims_returns_marketing_image(self): + assert ( + write._detect_image_field_type({"width": 0, "height": 0}) + == "MARKETING_IMAGE" + ) + + def test_filename_logo_hint_only_applies_to_logo_friendly_ratios(self): + # Wide non-4:1 image with 'logo' in name should NOT become LANDSCAPE_LOGO + result = write._detect_image_field_type({ + "width": 1200, "height": 628, "name": "company-logo-hero", + }) + assert result == "MARKETING_IMAGE" + + +class TestApplyCreateImageAssets: + def _png_path(self, tmp_path): + # Tiny 1x1 PNG so the apply layer can read bytes + import base64 + + p = tmp_path / "tiny.png" + p.write_bytes( + base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO2ZfZ0AAAAASUVORK5CYII=" + ) + ) + return str(p) + + def test_campaign_scope_uses_marketing_image_for_landscape(self, tmp_path): + responses = [ + _FakeMutateOperationResponse("asset_result", "customers/1/assets/-1"), + _FakeMutateOperationResponse( + "campaign_asset_result", + "customers/1/campaignAssets/42~MARKETING_IMAGE", + ), + ] + google_ads = _FakeGoogleAdsService(responses) + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + } + ) + + write._apply_create_image_assets( + client, + "1", + { + "scope": "campaign", + "campaign_id": "42", + "images": [ + { + "path": self._png_path(tmp_path), + "name": "marketing-hero", + "mime_type": "image/png", + "width": 1200, + "height": 628, + } + ], + }, + ) + link = google_ads.operations[1].campaign_asset_operation.create + assert link.field_type == client.enums.AssetFieldTypeEnum.MARKETING_IMAGE + assert link.campaign == "customers/1/campaigns/42" + + def test_customer_scope_uses_business_logo_for_logo_named_square(self, tmp_path): + responses = [ + _FakeMutateOperationResponse("asset_result", "customers/1/assets/-1"), + _FakeMutateOperationResponse( + "customer_asset_result", "customers/1/customerAssets/-1~BUSINESS_LOGO" + ), + ] + google_ads = _FakeGoogleAdsService(responses) + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + } + ) + + write._apply_create_image_assets( + client, + "1", + { + "scope": "customer", + "campaign_id": "", + "images": [ + { + "path": self._png_path(tmp_path), + "name": "logo-square", + "mime_type": "image/png", + "width": 1024, + "height": 1024, + } + ], + }, + ) + link = google_ads.operations[1].customer_asset_operation.create + assert link.field_type == client.enums.AssetFieldTypeEnum.BUSINESS_LOGO + # Should not have populated campaign_asset_operation + assert ( + google_ads.operations[1].campaign_asset_operation.create.field_type + == client.enums.AssetFieldTypeEnum.UNSPECIFIED + ) + + def test_explicit_field_type_override(self, tmp_path): + responses = [ + _FakeMutateOperationResponse("asset_result", "customers/1/assets/-1"), + _FakeMutateOperationResponse( + "customer_asset_result", "customers/1/customerAssets/-1~LOGO" + ), + ] + google_ads = _FakeGoogleAdsService(responses) + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + } + ) + + write._apply_create_image_assets( + client, + "1", + { + "scope": "customer", + "campaign_id": "", + "images": [ + { + "path": self._png_path(tmp_path), + "name": "weird-asset", + "mime_type": "image/png", + "width": 1200, + "height": 628, + "field_type": "LOGO", + } + ], + }, + ) + link = google_ads.operations[1].customer_asset_operation.create + assert link.field_type == client.enums.AssetFieldTypeEnum.LOGO + + def test_apply_rejects_unknown_scope(self, tmp_path): + client = _FakeClient( + { + "GoogleAdsService": _FakeGoogleAdsService(), + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + } + ) + with pytest.raises(ValueError, match="Unknown asset scope"): + write._apply_create_image_assets( + client, + "1", + { + "scope": "bogus", + "campaign_id": "", + "images": [ + { + "path": self._png_path(tmp_path), + "name": "x", + "mime_type": "image/png", + "width": 1200, + "height": 628, + } + ], + }, + ) + + def test_apply_campaign_scope_requires_campaign_id(self, tmp_path): + client = _FakeClient( + { + "GoogleAdsService": _FakeGoogleAdsService(), + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + } + ) + with pytest.raises(ValueError, match="campaign_id is required"): + write._apply_create_image_assets( + client, + "1", + { + "scope": "campaign", + "campaign_id": "", + "images": [ + { + "path": self._png_path(tmp_path), + "name": "x", + "mime_type": "image/png", + "width": 1200, + "height": 628, + } + ], + }, + ) + + +class TestDraftImageAssets: + def _png_path(self, tmp_path): + import base64 + + p = tmp_path / "tiny.png" + p.write_bytes( + base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO2ZfZ0AAAAASUVORK5CYII=" + ) + ) + return str(p) + + def test_no_campaign_id_uses_customer_scope(self, config, tmp_path): + result = write.draft_image_assets( + config, + customer_id="1234567890", + image_paths=[self._png_path(tmp_path)], + ) + assert "error" not in result + assert result["entity_type"] == "customer_asset" + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["scope"] == "customer" + # Detection ran and stored the resolved field type in the preview + assert plan.changes["images"][0]["resolved_field_type"] in { + "BUSINESS_LOGO", + "SQUARE_MARKETING_IMAGE", + "MARKETING_IMAGE", + "PORTRAIT_MARKETING_IMAGE", + "TALL_PORTRAIT_MARKETING_IMAGE", + "LOGO", + "LANDSCAPE_LOGO", + } + + def test_field_types_length_mismatch(self, config, tmp_path): + result = write.draft_image_assets( + config, + customer_id="1234567890", + image_paths=[self._png_path(tmp_path)], + field_types=["MARKETING_IMAGE", "BUSINESS_LOGO"], + ) + assert result["error"] == "Validation failed" + assert any("field_types has 2" in d for d in result["details"]) + + def test_invalid_override_field_type_rejected(self, config, tmp_path): + result = write.draft_image_assets( + config, + customer_id="1234567890", + image_paths=[self._png_path(tmp_path)], + field_types=["AD_IMAGE"], # AD_IMAGE not allowed for direct linking + ) + assert result["error"] == "Validation failed" + assert any("not a supported" in d for d in result["details"]) + + +# --------------------------------------------------------------------------- +# draft_business_name_asset +# --------------------------------------------------------------------------- + + +class TestDraftBusinessNameAsset: + def test_requires_business_name(self, config): + result = write.draft_business_name_asset(config, customer_id="1") + assert "business_name is required" in result["error"] + + def test_max_25_chars(self, config): + result = write.draft_business_name_asset( + config, + customer_id="1", + business_name="X" * 26, + ) + assert result["error"] == "Validation failed" + assert any("25" in d for d in result["details"]) + + def test_customer_scope_default(self, config): + result = write.draft_business_name_asset( + config, + customer_id="1", + business_name="Modern Waste Solutions", # 22 chars + ) + assert "error" not in result + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["scope"] == "customer" + assert plan.changes["business_name"] == "Modern Waste Solutions" + + def test_campaign_scope_when_campaign_id_provided(self, config): + result = write.draft_business_name_asset( + config, + customer_id="1", + campaign_id="42", + business_name="MWS", + ) + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["scope"] == "campaign" + assert plan.changes["campaign_id"] == "42" + + +class TestApplyCreateBusinessNameAsset: + def test_customer_scope_creates_text_asset_and_customer_link(self): + responses = [ + _FakeMutateOperationResponse("asset_result", "customers/1/assets/-1"), + _FakeMutateOperationResponse( + "customer_asset_result", "customers/1/customerAssets/-1~BUSINESS_NAME" + ), + ] + google_ads = _FakeGoogleAdsService(responses) + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + } + ) + + result = write._apply_create_business_name_asset( + client, + "1", + { + "scope": "customer", + "campaign_id": "", + "business_name": "Modern Waste Solutions", + }, + ) + + # Asset op: TEXT type with the business name + asset_op = google_ads.operations[0].asset_operation.create + assert asset_op.type_ == client.enums.AssetTypeEnum.TEXT + assert asset_op.text_asset.text == "Modern Waste Solutions" + # Link op: BUSINESS_NAME at customer scope + link = google_ads.operations[1].customer_asset_operation.create + assert link.field_type == client.enums.AssetFieldTypeEnum.BUSINESS_NAME + assert result["asset"] == "customers/1/assets/-1" + + def test_campaign_scope_creates_campaign_asset_link(self): + responses = [ + _FakeMutateOperationResponse("asset_result", "customers/1/assets/-1"), + _FakeMutateOperationResponse( + "campaign_asset_result", "customers/1/campaignAssets/42~BUSINESS_NAME" + ), + ] + google_ads = _FakeGoogleAdsService(responses) + client = _FakeClient( + { + "GoogleAdsService": google_ads, + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + } + ) + + write._apply_create_business_name_asset( + client, + "1", + { + "scope": "campaign", + "campaign_id": "42", + "business_name": "MWS", + }, + ) + link = google_ads.operations[1].campaign_asset_operation.create + assert link.field_type == client.enums.AssetFieldTypeEnum.BUSINESS_NAME + assert link.campaign == "customers/1/campaigns/42" + + def test_campaign_scope_requires_campaign_id(self): + client = _FakeClient( + { + "GoogleAdsService": _FakeGoogleAdsService(), + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + } + ) + with pytest.raises(ValueError, match="campaign_id required"): + write._apply_create_business_name_asset( + client, + "1", + { + "scope": "campaign", + "campaign_id": "", + "business_name": "MWS", + }, + ) + + def test_unknown_scope_raises(self): + client = _FakeClient( + { + "GoogleAdsService": _FakeGoogleAdsService(), + "AssetService": _FakePathService("assets"), + "CampaignService": _FakePathService("campaigns"), + } + ) + with pytest.raises(ValueError, match="Unknown scope"): + write._apply_create_business_name_asset( + client, + "1", + { + "scope": "bogus", + "campaign_id": "", + "business_name": "MWS", + }, + ) + + +class TestServerToolRegistrationsImageAndBusinessName: + @pytest.fixture + def tools_by_name(self): + import asyncio + + from adloop.server import mcp + + async def _list(): + return await mcp.list_tools() + + tools = asyncio.run(_list()) + return {t.name: t for t in tools} + + def test_draft_image_assets_now_optional_campaign_id(self, tools_by_name): + tool = tools_by_name["draft_image_assets"] + required = tool.parameters.get("required", []) + assert "campaign_id" not in required + assert "image_paths" in required + assert "field_types" in tool.parameters["properties"] + + def test_draft_business_name_asset_registered(self, tools_by_name): + assert "draft_business_name_asset" in tools_by_name + tool = tools_by_name["draft_business_name_asset"] + required = tool.parameters.get("required", []) + assert "business_name" in required + assert "campaign_id" not in required + + def test_dispatch_includes_business_name_op(self): + import inspect + + src = inspect.getsource(write._execute_plan) + assert ( + '"create_business_name_asset": _apply_create_business_name_asset' in src + ) diff --git a/tests/test_ads_write.py b/tests/test_ads_write.py index 20833a1..11f59f2 100644 --- a/tests/test_ads_write.py +++ b/tests/test_ads_write.py @@ -27,6 +27,7 @@ def __init__(self, response_type: str | None = None, resource_name: str = ""): self.campaign_criterion_result = _FakeResult() self.asset_result = _FakeResult() self.campaign_asset_result = _FakeResult() + self.customer_asset_result = _FakeResult() self._response_type = response_type if response_type: getattr(self, response_type).resource_name = resource_name @@ -704,6 +705,7 @@ def test_apply_campaign_asset_variants_create_asset_and_link_operations(tmp_path client, "1234567890", { + "scope": "campaign", "campaign_id": "1001", "images": [ { @@ -721,13 +723,16 @@ def test_apply_campaign_asset_variants_create_asset_and_link_operations(tmp_path assert image_asset.name == "AdLoop image square deadbeefcafe" assert image_asset.type_ == client.enums.AssetTypeEnum.IMAGE assert image_asset.image_asset.mime_type == client.enums.MimeTypeEnum.IMAGE_PNG - assert image_link.field_type == client.enums.AssetFieldTypeEnum.AD_IMAGE + # Field type is now auto-detected from aspect ratio (1:1 → SQUARE_MARKETING_IMAGE). + # AD_IMAGE was rejected by Google's API for direct asset linking. + assert image_link.field_type == client.enums.AssetFieldTypeEnum.SQUARE_MARKETING_IMAGE google_ads_service._responses = responses write._apply_create_image_assets( client, "1234567890", { + "scope": "campaign", "campaign_id": "1001", "images": [ { diff --git a/tests/test_conversion_actions.py b/tests/test_conversion_actions.py new file mode 100644 index 0000000..cd72059 --- /dev/null +++ b/tests/test_conversion_actions.py @@ -0,0 +1,956 @@ +"""Tests for conversion-action write tools (create / update / remove).""" +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from google.ads.googleads.client import GoogleAdsClient +from google.protobuf import field_mask_pb2 + +from adloop.ads import conversion_actions, write +from adloop.ads.client import GOOGLE_ADS_API_VERSION +from adloop.config import AdLoopConfig, AdsConfig, SafetyConfig +from adloop.safety import preview as preview_store + + +# --------------------------------------------------------------------------- +# Fakes +# --------------------------------------------------------------------------- + + +class _FakeResult: + def __init__(self, resource_name: str = ""): + self.resource_name = resource_name + + +class _FakeConversionActionService: + def __init__(self, results: list[_FakeResult] | None = None): + self.operations: list = [] + self.results_to_return = results or [] + + def conversion_action_path(self, customer_id: str, ca_id: str) -> str: + return f"customers/{customer_id}/conversionActions/{ca_id}" + + def mutate_conversion_actions( + self, customer_id: str, operations: list + ) -> object: + self.operations = operations + return SimpleNamespace(results=self.results_to_return) + + +class _FakeClient: + def __init__(self, services: dict[str, object] | None = None): + self._base = GoogleAdsClient( + credentials=None, + developer_token="test-token", + use_proto_plus=True, + version=GOOGLE_ADS_API_VERSION, + ) + self.enums = self._base.enums + self.get_type = self._base.get_type + self._services = services or {} + + def get_service(self, name: str) -> object: + return self._services[name] + + +@pytest.fixture(autouse=True) +def clear_pending_plans(): + preview_store._pending_plans.clear() + yield + preview_store._pending_plans.clear() + + +@pytest.fixture +def config() -> AdLoopConfig: + return AdLoopConfig( + ads=AdsConfig(customer_id="123-456-7890"), + safety=SafetyConfig(require_dry_run=True), + ) + + +# --------------------------------------------------------------------------- +# Validation tests for draft_create_conversion_action +# --------------------------------------------------------------------------- + + +class TestDraftCreateConversionActionValidation: + def _ok_args(self, **overrides): + defaults = dict( + customer_id="1234567890", + name="Calls from Ads", + type_="AD_CALL", + category="PHONE_CALL_LEAD", + default_value=250, + currency_code="USD", + ) + defaults.update(overrides) + return defaults + + def test_happy_path(self, config): + result = conversion_actions.draft_create_conversion_action( + config, **self._ok_args() + ) + assert "error" not in result + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["name"] == "Calls from Ads" + assert plan.changes["type"] == "AD_CALL" + assert plan.changes["default_value"] == 250.0 + assert plan.changes["currency_code"] == "USD" + assert plan.changes["counting_type"] == "ONE_PER_CLICK" + assert plan.changes["primary_for_goal"] is True + + def test_name_required(self, config): + result = conversion_actions.draft_create_conversion_action( + config, **self._ok_args(name="") + ) + assert result["error"] == "Validation failed" + assert any("name is required" in d for d in result["details"]) + + def test_invalid_type(self, config): + result = conversion_actions.draft_create_conversion_action( + config, **self._ok_args(type_="MADE_UP_TYPE") + ) + assert result["error"] == "Validation failed" + assert any("MADE_UP_TYPE" in d for d in result["details"]) + + def test_invalid_category(self, config): + result = conversion_actions.draft_create_conversion_action( + config, **self._ok_args(category="WRONG_CATEGORY") + ) + assert result["error"] == "Validation failed" + assert any("WRONG_CATEGORY" in d for d in result["details"]) + + def test_invalid_counting_type(self, config): + result = conversion_actions.draft_create_conversion_action( + config, **self._ok_args(counting_type="WRONG") + ) + assert result["error"] == "Validation failed" + assert any("counting_type" in d for d in result["details"]) + + def test_negative_default_value_rejected(self, config): + result = conversion_actions.draft_create_conversion_action( + config, **self._ok_args(default_value=-1) + ) + assert result["error"] == "Validation failed" + assert any("default_value" in d for d in result["details"]) + + def test_invalid_currency_length(self, config): + result = conversion_actions.draft_create_conversion_action( + config, **self._ok_args(currency_code="USDX") + ) + assert result["error"] == "Validation failed" + assert any("currency_code" in d for d in result["details"]) + + def test_invalid_click_through_window(self, config): + result = conversion_actions.draft_create_conversion_action( + config, **self._ok_args(click_through_window_days=120) + ) + assert result["error"] == "Validation failed" + assert any("click_through_window_days" in d for d in result["details"]) + + def test_invalid_view_through_window(self, config): + result = conversion_actions.draft_create_conversion_action( + config, **self._ok_args(view_through_window_days=60) + ) + assert result["error"] == "Validation failed" + assert any("view_through_window_days" in d for d in result["details"]) + + def test_invalid_attribution_model(self, config): + result = conversion_actions.draft_create_conversion_action( + config, **self._ok_args(attribution_model="MAGIC") + ) + assert result["error"] == "Validation failed" + assert any("attribution_model" in d for d in result["details"]) + + def test_phone_call_duration_threshold_persisted(self, config): + result = conversion_actions.draft_create_conversion_action( + config, + **self._ok_args( + type_="WEBSITE_CALL", + phone_call_duration_seconds=90, + ), + ) + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["phone_call_duration_seconds"] == 90 + + +# --------------------------------------------------------------------------- +# draft_update_conversion_action +# --------------------------------------------------------------------------- + + +class TestDraftUpdateConversionAction: + def test_id_required(self, config): + result = conversion_actions.draft_update_conversion_action( + config, customer_id="1", conversion_action_id="" + ) + assert "conversion_action_id is required" in result["error"] + + def test_no_fields_to_update_rejected(self, config): + result = conversion_actions.draft_update_conversion_action( + config, + customer_id="1", + conversion_action_id="6797442210", + ) + assert "No fields to update" in result["error"] + + def test_partial_update_only_includes_specified(self, config): + result = conversion_actions.draft_update_conversion_action( + config, + customer_id="1", + conversion_action_id="6797442210", + name="Calls from Ads (>=90s)", + primary_for_goal=False, + default_value=250, + currency_code="USD", + ) + plan = preview_store._pending_plans[result["plan_id"]] + # specified fields present + assert plan.changes["name"] == "Calls from Ads (>=90s)" + assert plan.changes["primary_for_goal"] is False + assert plan.changes["default_value"] == 250.0 + assert plan.changes["currency_code"] == "USD" + # unspecified fields absent + assert "counting_type" not in plan.changes + assert "click_through_window_days" not in plan.changes + + def test_promote_to_primary(self, config): + result = conversion_actions.draft_update_conversion_action( + config, + customer_id="1", + conversion_action_id="6797442210", + primary_for_goal=True, + ) + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["primary_for_goal"] is True + + def test_demote_to_secondary(self, config): + result = conversion_actions.draft_update_conversion_action( + config, + customer_id="1", + conversion_action_id="6797442210", + primary_for_goal=False, + ) + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["primary_for_goal"] is False + + def test_invalid_counting_type_rejected(self, config): + result = conversion_actions.draft_update_conversion_action( + config, + customer_id="1", + conversion_action_id="6797442210", + counting_type="BAD", + ) + assert result["error"] == "Validation failed" + + def test_phone_duration_persisted(self, config): + result = conversion_actions.draft_update_conversion_action( + config, + customer_id="1", + conversion_action_id="6797442210", + phone_call_duration_seconds=90, + ) + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.changes["phone_call_duration_seconds"] == 90 + + +# --------------------------------------------------------------------------- +# draft_remove_conversion_action +# --------------------------------------------------------------------------- + + +class TestDraftRemoveConversionAction: + def test_id_required(self, config): + result = conversion_actions.draft_remove_conversion_action( + config, customer_id="1", conversion_action_id="" + ) + assert "conversion_action_id is required" in result["error"] + + def test_emits_irreversible_warning(self, config): + result = conversion_actions.draft_remove_conversion_action( + config, customer_id="1", conversion_action_id="6797442210" + ) + assert "warnings" in result + assert any("irreversible" in w.lower() for w in result["warnings"]) + plan = preview_store._pending_plans[result["plan_id"]] + assert plan.operation == "remove_conversion_action" + assert plan.entity_id == "6797442210" + + +# --------------------------------------------------------------------------- +# Apply handlers — exercised against fake services +# --------------------------------------------------------------------------- + + +class TestApplyCreateConversionAction: + def test_websitecall_with_duration_threshold(self): + ca_svc = _FakeConversionActionService( + [_FakeResult("customers/1/conversionActions/100")] + ) + client = _FakeClient({"ConversionActionService": ca_svc}) + + conversion_actions._apply_create_conversion_action( + client, + "1", + { + "name": "Website Call (GFN >=90s)", + "type": "WEBSITE_CALL", + "category": "PHONE_CALL_LEAD", + "default_value": 250.0, + "currency_code": "USD", + "always_use_default_value": True, + "counting_type": "ONE_PER_CLICK", + "phone_call_duration_seconds": 90, + "primary_for_goal": True, + "include_in_conversions_metric": True, + "click_through_window_days": 30, + "view_through_window_days": 1, + "attribution_model": "GOOGLE_SEARCH_ATTRIBUTION_DATA_DRIVEN", + }, + ) + + assert len(ca_svc.operations) == 1 + ca = ca_svc.operations[0].create + assert ca.name == "Website Call (GFN >=90s)" + assert ca.type_ == client.enums.ConversionActionTypeEnum.WEBSITE_CALL + assert ca.category == client.enums.ConversionActionCategoryEnum.PHONE_CALL_LEAD + assert ca.value_settings.default_value == 250.0 + assert ca.value_settings.default_currency_code == "USD" + assert ca.value_settings.always_use_default_value is True + assert ca.counting_type == client.enums.ConversionActionCountingTypeEnum.ONE_PER_CLICK + assert ca.primary_for_goal is True + assert ca.phone_call_duration_seconds == 90 + assert ca.click_through_lookback_window_days == 30 + assert ca.view_through_lookback_window_days == 1 + + +class TestApplyUpdateConversionAction: + def test_partial_update_fieldmask(self): + ca_svc = _FakeConversionActionService( + [_FakeResult("customers/1/conversionActions/6797442210")] + ) + client = _FakeClient({"ConversionActionService": ca_svc}) + + conversion_actions._apply_update_conversion_action( + client, + "1", + { + "conversion_action_id": "6797442210", + "name": "Calls from Ads (>=90s)", + "default_value": 250.0, + "currency_code": "USD", + "always_use_default_value": True, + "counting_type": "ONE_PER_CLICK", + "primary_for_goal": True, + }, + ) + + op = ca_svc.operations[0] + ca = op.update + assert ca.resource_name == "customers/1/conversionActions/6797442210" + assert ca.name == "Calls from Ads (>=90s)" + assert ca.value_settings.default_value == 250.0 + assert ca.counting_type == client.enums.ConversionActionCountingTypeEnum.ONE_PER_CLICK + assert ca.primary_for_goal is True + # Field mask reflects exactly the keys we set + mask_paths = list(op.update_mask.paths) + assert "name" in mask_paths + assert "value_settings.default_value" in mask_paths + assert "value_settings.default_currency_code" in mask_paths + assert "value_settings.always_use_default_value" in mask_paths + assert "counting_type" in mask_paths + assert "primary_for_goal" in mask_paths + # Fields we didn't pass shouldn't be in the mask + assert "phone_call_duration_seconds" not in mask_paths + + def test_update_only_phone_duration(self): + ca_svc = _FakeConversionActionService( + [_FakeResult("customers/1/conversionActions/6797442210")] + ) + client = _FakeClient({"ConversionActionService": ca_svc}) + + conversion_actions._apply_update_conversion_action( + client, + "1", + { + "conversion_action_id": "6797442210", + "phone_call_duration_seconds": 90, + }, + ) + + op = ca_svc.operations[0] + ca = op.update + assert ca.phone_call_duration_seconds == 90 + mask_paths = list(op.update_mask.paths) + assert mask_paths == ["phone_call_duration_seconds"] + + +class TestApplyRemoveConversionAction: + def test_remove_sets_resource_name(self): + ca_svc = _FakeConversionActionService( + [_FakeResult("customers/1/conversionActions/6797442210")] + ) + client = _FakeClient({"ConversionActionService": ca_svc}) + + conversion_actions._apply_remove_conversion_action( + client, + "1", + {"conversion_action_id": "6797442210"}, + ) + + op = ca_svc.operations[0] + assert op.remove == "customers/1/conversionActions/6797442210" + + +# --------------------------------------------------------------------------- +# MCP tool registration + dispatch wiring +# --------------------------------------------------------------------------- + + +class TestMCPRegistration: + @pytest.fixture(scope="class") + def tools_by_name(self): + import asyncio + from adloop.server import mcp + + async def _list(): + return await mcp.list_tools() + + tools = asyncio.run(_list()) + return {t.name: t for t in tools} + + def test_three_conversion_action_tools_registered(self, tools_by_name): + for name in ( + "draft_create_conversion_action", + "draft_update_conversion_action", + "draft_remove_conversion_action", + ): + assert name in tools_by_name, f"{name} not registered" + + def test_create_required_params(self, tools_by_name): + required = ( + tools_by_name["draft_create_conversion_action"] + .parameters.get("required", []) + ) + assert "name" in required + assert "type_" in required + + def test_update_requires_id(self, tools_by_name): + required = ( + tools_by_name["draft_update_conversion_action"] + .parameters.get("required", []) + ) + assert "conversion_action_id" in required + + def test_remove_requires_id(self, tools_by_name): + required = ( + tools_by_name["draft_remove_conversion_action"] + .parameters.get("required", []) + ) + assert "conversion_action_id" in required + + def test_dispatch_routes(self): + import inspect + src = inspect.getsource(write._execute_plan) + assert '"create_conversion_action": _apply_create_conversion_action_route' in src + assert '"update_conversion_action": _apply_update_conversion_action_route' in src + assert '"remove_conversion_action": _apply_remove_conversion_action_route' in src + assert '"upload_call_conversions": _apply_upload_call_conversions_route' in src + + def test_upload_call_conversions_tool_registered(self, tools_by_name): + assert "draft_upload_call_conversions" in tools_by_name + + def test_upload_call_conversions_requires_csv_path(self, tools_by_name): + required = ( + tools_by_name["draft_upload_call_conversions"] + .parameters.get("required", []) + ) + assert "csv_path" in required + + +# --------------------------------------------------------------------------- +# Call-conversion CSV parsing helpers +# --------------------------------------------------------------------------- + + +class TestNormalizeCallTimestamp: + def test_strips_fractional_seconds_and_z(self): + out = conversion_actions._normalize_call_timestamp( + "2026-02-26T16:49:44.5679977Z" + ) + assert out == "2026-02-26 16:49:44+00:00" + + def test_replaces_t_only(self): + out = conversion_actions._normalize_call_timestamp( + "2026-02-26T16:49:44Z" + ) + assert out == "2026-02-26 16:49:44+00:00" + + def test_preserves_offset(self): + out = conversion_actions._normalize_call_timestamp( + "2026-02-26T16:49:44.123-08:00" + ) + assert out == "2026-02-26 16:49:44-08:00" + + def test_empty(self): + assert conversion_actions._normalize_call_timestamp("") == "" + + +class TestParseCallConversionCsv: + def _write_csv(self, tmp_path, content): + p = tmp_path / "phone.csv" + p.write_text(content) + return str(p) + + def test_missing_file(self, tmp_path): + rows, errors = conversion_actions._parse_call_conversion_csv( + str(tmp_path / "does-not-exist.csv") + ) + assert rows == [] + assert any("not found" in e for e in errors) + + def test_skips_parameters_and_comments(self, tmp_path): + csv_text = ( + "Parameters:TimeZone=America/Los_Angeles,,,,,,\n" + "Caller's Phone Number,Call Start Time,Conversion Name," + "Conversion Time,Conversion Value,Conversion Currency," + "Ad User Data,Ad Personalization\n" + "+19165550100,2026-03-01T12:00:00Z,Test Action," + "2026-03-01T13:00:00Z,250.00,USD,,\n" + ) + path = self._write_csv(tmp_path, csv_text) + rows, errors = conversion_actions._parse_call_conversion_csv(path) + assert errors == [] + assert len(rows) == 1 + r = rows[0] + assert r["caller_id"] == "+19165550100" + assert r["call_start_time"] == "2026-03-01 12:00:00+00:00" + assert r["conversion_name"] == "Test Action" + assert r["conversion_value"] == 250.0 + assert r["currency_code"] == "USD" + + def test_missing_required_column(self, tmp_path): + csv_text = ( + "Caller's Phone Number,Call Start Time,Conversion Name," + "Conversion Time,Conversion Value\n" # missing Currency + "+19165550100,2026-03-01T12:00:00Z,X,2026-03-01T13:00:00Z,1\n" + ) + path = self._write_csv(tmp_path, csv_text) + rows, errors = conversion_actions._parse_call_conversion_csv(path) + assert rows == [] + assert any("Conversion Currency" in e for e in errors) + + def test_invalid_value_row_skipped(self, tmp_path): + csv_text = ( + "Caller's Phone Number,Call Start Time,Conversion Name," + "Conversion Time,Conversion Value,Conversion Currency\n" + "+19165550100,2026-03-01T12:00:00Z,X,2026-03-01T13:00:00Z,not-a-num,USD\n" + "+19165550101,2026-03-01T12:01:00Z,X,2026-03-01T13:01:00Z,99.0,USD\n" + ) + path = self._write_csv(tmp_path, csv_text) + rows, errors = conversion_actions._parse_call_conversion_csv(path) + assert len(rows) == 1 + assert rows[0]["caller_id"] == "+19165550101" + assert any("Conversion Value" in e for e in errors) + + +# --------------------------------------------------------------------------- +# Draft validation for upload_call_conversions +# --------------------------------------------------------------------------- + + +class TestDraftUploadCallConversions: + def _write_valid_csv(self, tmp_path): + p = tmp_path / "phone.csv" + p.write_text( + "Parameters:TimeZone=America/Los_Angeles,,,,,,\n" + "Caller's Phone Number,Call Start Time,Conversion Name," + "Conversion Time,Conversion Value,Conversion Currency," + "Ad User Data,Ad Personalization\n" + "+19165550100,2026-03-01T12:00:00Z,A,2026-03-01T13:00:00Z,250.00,USD,,\n" + "+19165550101,2026-03-02T12:00:00Z,A,2026-03-02T13:00:00Z,500.00,USD,,\n" + "+19165550102,2026-03-03T12:00:00Z,B,2026-03-03T13:00:00Z,75.00,USD,,\n" + ) + return str(p) + + def test_missing_csv_returns_error(self, config, tmp_path): + result = conversion_actions.draft_upload_call_conversions( + config, + customer_id="1234567890", + csv_path=str(tmp_path / "missing.csv"), + ) + assert "error" in result + + def test_happy_path_preview(self, config, tmp_path): + path = self._write_valid_csv(tmp_path) + result = conversion_actions.draft_upload_call_conversions( + config, + customer_id="1234567890", + csv_path=path, + ) + assert "plan_id" in result + assert result["operation"] == "upload_call_conversions" + assert result["entity_type"] == "call_conversion_batch" + c = result["changes"] + assert c["row_count"] == 3 + assert c["total_value"] == 825.00 + assert c["distinct_conversion_actions"] == ["A", "B"] + assert c["partial_failure"] is True + assert len(c["sample_rows"]) == 3 + assert c["sample_rows"][0]["caller_id"] == "+19165550100" + + def test_plan_stored(self, config, tmp_path): + path = self._write_valid_csv(tmp_path) + result = conversion_actions.draft_upload_call_conversions( + config, customer_id="1234567890", csv_path=path, + ) + plan = preview_store.get_plan(result["plan_id"]) + assert plan is not None + assert plan.operation == "upload_call_conversions" + assert plan.changes["csv_path"] == path + + def test_safety_blocked_operation(self, tmp_path): + from adloop.config import AdLoopConfig, AdsConfig, SafetyConfig + cfg = AdLoopConfig( + ads=AdsConfig(customer_id="123-456-7890"), + safety=SafetyConfig(blocked_operations=["upload_call_conversions"]), + ) + path = self._write_valid_csv(tmp_path) + result = conversion_actions.draft_upload_call_conversions( + cfg, customer_id="1234567890", csv_path=path, + ) + assert "error" in result + + +# --------------------------------------------------------------------------- +# Apply (mock upload + GAQL action-name lookup) +# --------------------------------------------------------------------------- + + +class _FakeUploadService: + def __init__(self, results_count: int = 0, error_message: str = ""): + self.called_with: dict | None = None + self._results_count = results_count + self._error_message = error_message + + def upload_call_conversions(self, *, customer_id, conversions, partial_failure): + self.called_with = { + "customer_id": customer_id, + "conversions": list(conversions), + "partial_failure": partial_failure, + } + results = [ + SimpleNamespace(caller_id=c.caller_id) for c in conversions[:self._results_count] + ] + [SimpleNamespace(caller_id="") for _ in conversions[self._results_count:]] + partial = SimpleNamespace( + message=self._error_message, code=0 + ) if self._error_message else SimpleNamespace(message="", code=0) + return SimpleNamespace(results=results, partial_failure_error=partial) + + +class _FakeSearchRow: + def __init__(self, name: str, resource_name: str, type_name: str = "UPLOAD_CALLS"): + self.conversion_action = SimpleNamespace( + name=name, + resource_name=resource_name, + type_=SimpleNamespace(name=type_name), + status=SimpleNamespace(name="ENABLED"), + id=int(resource_name.split("/")[-1]), + ) + + +class _FakeGoogleAdsService: + def __init__(self, rows: list): + self._rows = rows + self.last_query = "" + + def search(self, *, customer_id, query): + self.last_query = query + return iter(self._rows) + + +def _client_with(*, upload_service, ads_service): + fake = _FakeClient({ + "ConversionUploadService": upload_service, + "GoogleAdsService": ads_service, + }) + return fake + + +class TestApplyUploadCallConversions: + def _make_changes(self, tmp_path): + p = tmp_path / "phone.csv" + p.write_text( + "Parameters:TimeZone=America/Los_Angeles,,,,,,\n" + "Caller's Phone Number,Call Start Time,Conversion Name," + "Conversion Time,Conversion Value,Conversion Currency," + "Ad User Data,Ad Personalization\n" + "+19165550100,2026-03-01T12:00:00Z,My Action," + "2026-03-01T13:00:00Z,250.00,USD,,\n" + "+19165550101,2026-03-02T12:00:00Z,My Action," + "2026-03-02T13:00:00Z,500.00,USD,,\n" + ) + return { + "csv_path": str(p), + "row_count": 2, + "partial_failure": True, + } + + def test_full_success(self, tmp_path): + upload = _FakeUploadService(results_count=2) + ads = _FakeGoogleAdsService([ + _FakeSearchRow("My Action", "customers/1/conversionActions/777") + ]) + client = _client_with(upload_service=upload, ads_service=ads) + + result = conversion_actions._apply_upload_call_conversions( + client, "1", self._make_changes(tmp_path) + ) + + assert result["uploaded_total"] == 2 + assert result["success_count"] == 2 + assert result["failure_count"] == 0 + assert result["conversion_actions_used"] == { + "My Action": "customers/1/conversionActions/777" + } + # Verify the API got the right shape + assert upload.called_with["customer_id"] == "1" + assert upload.called_with["partial_failure"] is True + sent = upload.called_with["conversions"] + assert len(sent) == 2 + assert sent[0].caller_id == "+19165550100" + assert sent[0].conversion_action == "customers/1/conversionActions/777" + assert sent[0].conversion_value == 250.0 + assert sent[0].currency_code == "USD" + # Timestamp normalized + assert sent[0].call_start_date_time == "2026-03-01 12:00:00+00:00" + + def test_partial_failure_message_surfaced(self, tmp_path): + upload = _FakeUploadService( + results_count=1, error_message="one row was bad" + ) + ads = _FakeGoogleAdsService([ + _FakeSearchRow("My Action", "customers/1/conversionActions/777") + ]) + client = _client_with(upload_service=upload, ads_service=ads) + + result = conversion_actions._apply_upload_call_conversions( + client, "1", self._make_changes(tmp_path) + ) + + assert result["success_count"] == 1 + assert result["failure_count"] == 1 + assert any( + e["type"] == "partial_failure" and e["message"] == "one row was bad" + for e in result["row_errors"] + ) + + def test_action_not_found_raises(self, tmp_path): + upload = _FakeUploadService(results_count=0) + ads = _FakeGoogleAdsService([]) # No matching action + client = _client_with(upload_service=upload, ads_service=ads) + + with pytest.raises(ValueError) as exc: + conversion_actions._apply_upload_call_conversions( + client, "1", self._make_changes(tmp_path) + ) + assert "not found" in str(exc.value) + + def test_wrong_type_raises(self, tmp_path): + upload = _FakeUploadService(results_count=0) + ads = _FakeGoogleAdsService([ + _FakeSearchRow( + "My Action", + "customers/1/conversionActions/777", + type_name="UPLOAD_CLICKS", # Wrong — should be UPLOAD_CALLS + ) + ]) + client = _client_with(upload_service=upload, ads_service=ads) + + with pytest.raises(ValueError) as exc: + conversion_actions._apply_upload_call_conversions( + client, "1", self._make_changes(tmp_path) + ) + assert "UPLOAD_CALLS" in str(exc.value) + + def test_empty_csv_returns_error(self, tmp_path): + p = tmp_path / "empty.csv" + p.write_text("") + upload = _FakeUploadService() + ads = _FakeGoogleAdsService([]) + client = _client_with(upload_service=upload, ads_service=ads) + + result = conversion_actions._apply_upload_call_conversions( + client, "1", {"csv_path": str(p), "partial_failure": True} + ) + assert "error" in result + assert upload.called_with is None # Never called + + +# --------------------------------------------------------------------------- +# Enhanced Conversions for Leads — parsing + draft + apply +# --------------------------------------------------------------------------- + + +class TestParseEcForLeadsCsv: + def _write(self, tmp_path, content): + p = tmp_path / "ec.csv" + p.write_text(content) + return str(p) + + def test_missing_file(self, tmp_path): + rows, errors = conversion_actions._parse_ec_for_leads_csv( + str(tmp_path / "missing.csv") + ) + assert rows == [] + assert any("not found" in e for e in errors) + + def test_happy_path(self, tmp_path): + path = self._write( + tmp_path, + "Email,Phone Number,First Name,Last Name,Conversion Name," + "Conversion Time,Conversion Value,Conversion Currency\n" + "aaaa,bbbb,cccc,dddd,My Action,2026-03-01T12:00:00Z,250.00,USD\n", + ) + rows, errors = conversion_actions._parse_ec_for_leads_csv(path) + assert errors == [] + assert len(rows) == 1 + r = rows[0] + assert r["email_sha256"] == "aaaa" + assert r["phone_sha256"] == "bbbb" + assert r["conversion_name"] == "My Action" + assert r["conversion_value"] == 250.0 + assert r["conversion_time"] == "2026-03-01 12:00:00+00:00" + + def test_missing_required_column(self, tmp_path): + path = self._write( + tmp_path, + "Email,Phone Number,Conversion Name,Conversion Time," + "Conversion Value,Conversion Currency\n" # missing First/Last + "aaaa,bbbb,X,2026-03-01T12:00:00Z,100,USD\n", + ) + rows, errors = conversion_actions._parse_ec_for_leads_csv(path) + assert rows == [] + assert any("First Name" in e or "Last Name" in e for e in errors) + + +class TestDraftUploadEnhancedConversionsForLeads: + def _write(self, tmp_path): + p = tmp_path / "ec.csv" + p.write_text( + "Email,Phone Number,First Name,Last Name,Conversion Name," + "Conversion Time,Conversion Value,Conversion Currency\n" + "aaa,bbb,ccc,ddd,Job Close,2026-03-01T12:00:00Z,500.00,USD\n" + "eee,fff,ggg,hhh,Job Close,2026-03-02T12:00:00Z,1500.00,USD\n" + ",zzz,,,Job Close,2026-03-03T12:00:00Z,200.00,USD\n" + ) + return str(p) + + def test_happy_path_preview(self, config, tmp_path): + path = self._write(tmp_path) + result = conversion_actions.draft_upload_enhanced_conversions_for_leads( + config, customer_id="1234567890", csv_path=path, + ) + assert "plan_id" in result + assert result["operation"] == "upload_enhanced_conversions_for_leads" + c = result["changes"] + assert c["row_count"] == 3 + assert c["total_value"] == 2200.00 + assert c["rows_with_email"] == 2 + assert c["rows_with_phone"] == 3 + assert c["distinct_conversion_actions"] == ["Job Close"] + # PII in sample is truncated + assert "..." in c["sample_rows"][0]["email_sha256"] + + +class _FakeClickUploadService: + def __init__(self, results_count: int = 0, error_message: str = ""): + self.called_with: dict | None = None + self._results_count = results_count + self._error_message = error_message + + def upload_click_conversions( + self, *, customer_id, conversions, partial_failure + ): + self.called_with = { + "customer_id": customer_id, + "conversions": list(conversions), + "partial_failure": partial_failure, + } + # Mark first N results as having conversion_action set (= success) + results = [] + for i, c in enumerate(conversions): + r = SimpleNamespace( + conversion_action=c.conversion_action if i < self._results_count else "", + gclid="", + user_identifiers=[], + ) + results.append(r) + partial = SimpleNamespace( + message=self._error_message, code=0 + ) if self._error_message else SimpleNamespace(message="", code=0) + return SimpleNamespace(results=results, partial_failure_error=partial) + + +def _ec_client_with(*, upload, ads): + return _FakeClient({ + "ConversionUploadService": upload, + "GoogleAdsService": ads, + }) + + +class TestApplyUploadEcForLeads: + def _make_changes(self, tmp_path): + p = tmp_path / "ec.csv" + p.write_text( + "Email,Phone Number,First Name,Last Name,Conversion Name," + "Conversion Time,Conversion Value,Conversion Currency\n" + "aaa,bbb,ccc,ddd,My Job,2026-03-01T12:00:00Z,500.00,USD\n" + "eee,fff,ggg,hhh,My Job,2026-03-02T12:00:00Z,1500.00,USD\n" + ) + return {"csv_path": str(p), "partial_failure": True} + + def test_full_success_with_user_identifiers(self, tmp_path): + upload = _FakeClickUploadService(results_count=2) + ads = _FakeGoogleAdsService([ + _FakeSearchRow( + "My Job", "customers/1/conversionActions/999", + type_name="UPLOAD_CLICKS", + ) + ]) + client = _ec_client_with(upload=upload, ads=ads) + + result = conversion_actions._apply_upload_enhanced_conversions_for_leads( + client, "1", self._make_changes(tmp_path) + ) + + assert result["uploaded_total"] == 2 + assert result["success_count"] == 2 + assert result["failure_count"] == 0 + sent = upload.called_with["conversions"] + assert len(sent) == 2 + # Each row should have multiple user_identifiers (email, phone, name) + assert len(sent[0].user_identifiers) == 3 + assert sent[0].user_identifiers[0].hashed_email == "aaa" + assert sent[0].user_identifiers[1].hashed_phone_number == "bbb" + assert sent[0].user_identifiers[2].address_info.hashed_first_name == "ccc" + assert sent[0].conversion_action == "customers/1/conversionActions/999" + assert sent[0].conversion_date_time == "2026-03-01 12:00:00+00:00" + assert sent[0].conversion_value == 500.0 + + def test_wrong_type_rejected(self, tmp_path): + upload = _FakeClickUploadService() + ads = _FakeGoogleAdsService([ + _FakeSearchRow( + "My Job", "customers/1/conversionActions/999", + type_name="UPLOAD_CALLS", # Wrong — needs UPLOAD_CLICKS + ) + ]) + client = _ec_client_with(upload=upload, ads=ads) + + with pytest.raises(ValueError) as exc: + conversion_actions._apply_upload_enhanced_conversions_for_leads( + client, "1", self._make_changes(tmp_path) + ) + assert "UPLOAD_CLICKS" in str(exc.value) diff --git a/tests/test_update_rsa.py b/tests/test_update_rsa.py new file mode 100644 index 0000000..72aba81 --- /dev/null +++ b/tests/test_update_rsa.py @@ -0,0 +1,1071 @@ +"""Tests for ``update_responsive_search_ad`` — both the draft (preview) layer +and the ``_apply_update_rsa`` mutation layer. + +The Google Ads client is faked: we never hit the network, but we do verify +that the AdOperation we build carries the correct resource_name, the correct +update_mask paths, and the right field values. URL reachability is also +faked so the tests don't depend on the public internet. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from google.ads.googleads.client import GoogleAdsClient + +from adloop.ads import write +from adloop.ads.client import GOOGLE_ADS_API_VERSION +from adloop.config import AdLoopConfig, AdsConfig, SafetyConfig +from adloop.safety import preview as preview_store + + +# --------------------------------------------------------------------------- +# Fake Google Ads client + AdService +# --------------------------------------------------------------------------- + + +class _FakeAdService: + """Captures the operations passed to ``mutate_ads`` for assertion. + + Returns a fake response shaped like the real one so callers that read + ``response.results[0].resource_name`` continue to work. + """ + + def __init__(self) -> None: + self.captured_operations: list[object] | None = None + self.captured_customer_id: str | None = None + + def ad_path(self, customer_id: str, ad_id: str) -> str: + return f"customers/{customer_id}/ads/{ad_id}" + + def mutate_ads( + self, + customer_id: str, + operations: list[object], + ) -> object: + self.captured_operations = operations + self.captured_customer_id = customer_id + first_op = operations[0] + return SimpleNamespace( + results=[SimpleNamespace(resource_name=first_op.update.resource_name)] + ) + + +class _FakeClient: + """Shim around the real client to swap in our fake AdService. + + Reuses the real client's ``enums`` and ``get_type`` for proto wiring; + only ``get_service`` is intercepted. + """ + + def __init__(self, ad_service: _FakeAdService): + self._base = GoogleAdsClient( + credentials=None, + developer_token="test-token", + use_proto_plus=True, + version=GOOGLE_ADS_API_VERSION, + ) + self.enums = self._base.enums + self.get_type = self._base.get_type + self._services = {"AdService": ad_service} + + def get_service(self, name: str) -> object: + return self._services[name] + + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def clear_pending_plans(): + preview_store._pending_plans.clear() + yield + preview_store._pending_plans.clear() + + +@pytest.fixture(autouse=True) +def stub_url_validation(monkeypatch): + """Default: every URL passes. Tests can override to inject failures.""" + monkeypatch.setattr( + write, + "_validate_urls", + lambda urls, timeout=10: {u: None for u in urls}, + ) + + +@pytest.fixture +def config(tmp_path) -> AdLoopConfig: + return AdLoopConfig( + ads=AdsConfig(customer_id="123-456-7890"), + safety=SafetyConfig( + require_dry_run=False, + log_file=str(tmp_path / "audit.log"), + ), + ) + + +@pytest.fixture +def dry_run_config(tmp_path) -> AdLoopConfig: + return AdLoopConfig( + ads=AdsConfig(customer_id="123-456-7890"), + safety=SafetyConfig( + require_dry_run=True, + log_file=str(tmp_path / "audit.log"), + ), + ) + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + + +class TestValidation: + def test_rejects_missing_ad_id(self, config): + result = write.update_responsive_search_ad( + config, customer_id="1234567890", final_url="https://example.com" + ) + assert result["error"] == "Validation failed" + assert any("ad_id is required" in d for d in result["details"]) + + def test_rejects_non_numeric_ad_id(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="abc123", + path1="Pricing", + ) + assert result["error"] == "Validation failed" + assert any("numeric" in d for d in result["details"]) + + def test_rejects_when_no_change_provided(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + ) + assert result["error"] == "Validation failed" + assert any("No changes specified" in d for d in result["details"]) + + def test_no_change_error_mentions_headlines_and_descriptions(self, config): + # The error message must hint at the headlines/descriptions options too — + # otherwise users won't know they exist. + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + ) + joined = " ".join(result["details"]) + assert "headlines" in joined + assert "descriptions" in joined + + def test_rejects_path1_too_long(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + path1="this-is-way-too-long-for-a-path", + ) + assert result["error"] == "Validation failed" + assert any("path1 must be 15 chars" in d for d in result["details"]) + + def test_rejects_path2_too_long(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + path2="X" * 16, + ) + assert result["error"] == "Validation failed" + assert any("path2 must be 15 chars" in d for d in result["details"]) + + def test_accepts_path_at_max_length_15(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + path1="X" * 15, + ) + assert result.get("error") is None + assert result["operation"] == "update_responsive_search_ad" + + def test_rejects_unreachable_url(self, config, monkeypatch): + monkeypatch.setattr( + write, + "_validate_urls", + lambda urls, timeout=10: {u: "HTTP 404" for u in urls}, + ) + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + final_url="https://example.com/missing", + ) + assert result["error"] == "URL validation failed" + assert any("not reachable" in d for d in result["details"]) + + def test_blocked_operation_rejected_before_validation( + self, config, monkeypatch + ): + config.safety.blocked_operations = ["update_responsive_search_ad"] + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + path1="OK", + ) + assert "blocked" in result["error"] + + def test_url_validation_skipped_when_only_paths_changed( + self, config, monkeypatch + ): + called = {"count": 0} + + def spy_validate(urls, timeout=10): + called["count"] += 1 + return {u: None for u in urls} + + monkeypatch.setattr(write, "_validate_urls", spy_validate) + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + path1="Pricing", + ) + assert result.get("error") is None + # No URL was supplied — we shouldn't have hit the validator. + assert called["count"] == 0 + + def test_multiple_validation_errors_returned_together(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="abc", # non-numeric + path1="X" * 16, # too long + ) + assert result["error"] == "Validation failed" + assert len(result["details"]) >= 2 + + +# --------------------------------------------------------------------------- +# Plan construction +# --------------------------------------------------------------------------- + + +class TestPlanConstruction: + def test_plan_metadata(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + path1="Sale", + ) + assert result["operation"] == "update_responsive_search_ad" + assert result["entity_type"] == "ad" + assert result["entity_id"] == "999" + assert result["customer_id"] == "1234567890" + assert result["status"] == "PENDING_CONFIRMATION" + assert "plan_id" in result + + def test_plan_stored_for_retrieval(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + path1="Sale", + ) + plan = preview_store.get_plan(result["plan_id"]) + assert plan is not None + assert plan.operation == "update_responsive_search_ad" + + def test_url_only_change_does_not_include_paths(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + final_url="https://example.com/x", + ) + changes = result["changes"] + assert changes["final_url"] == "https://example.com/x" + assert "path1" not in changes + assert "path2" not in changes + + def test_path1_only_change_does_not_include_url_or_path2(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + path1="Pricing", + ) + changes = result["changes"] + assert changes["path1"] == "Pricing" + assert "final_url" not in changes + assert "path2" not in changes + + def test_path2_only_change_does_not_include_url_or_path1(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + path2="Sacramento", + ) + changes = result["changes"] + assert changes["path2"] == "Sacramento" + assert "final_url" not in changes + assert "path1" not in changes + + def test_all_three_fields_set(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + final_url="https://example.com/x", + path1="A", + path2="B", + ) + changes = result["changes"] + assert changes["final_url"] == "https://example.com/x" + assert changes["path1"] == "A" + assert changes["path2"] == "B" + + def test_clear_path1_writes_empty_string_into_changes(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + clear_path1=True, + ) + changes = result["changes"] + # ``"path1" in changes`` is critical — apply uses presence to decide + # whether to mutate. Empty string is the *value*, not "no change". + assert "path1" in changes + assert changes["path1"] == "" + assert "path2" not in changes + + def test_clear_path2_writes_empty_string_into_changes(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + clear_path2=True, + ) + changes = result["changes"] + assert "path2" in changes + assert changes["path2"] == "" + assert "path1" not in changes + + def test_clear_path1_overrides_path1_argument(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + path1="Pricing", + clear_path1=True, + ) + # When clear_path1 is True we ignore the path1 string and clear it. + assert result["changes"]["path1"] == "" + + def test_paths_are_stripped_of_whitespace(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + path1=" Sale ", + path2=" \t Pricing\n", + ) + assert result["changes"]["path1"] == "Sale" + assert result["changes"]["path2"] == "Pricing" + + def test_ad_id_coerced_to_string(self, config): + # FastMCP types ad_id as str, but defensive coercion is cheap. + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + path1="X", + ) + assert isinstance(result["changes"]["ad_id"], str) + assert result["changes"]["ad_id"] == "999" + + +# --------------------------------------------------------------------------- +# Apply / mutate +# --------------------------------------------------------------------------- + + +class TestApply: + def test_calls_mutate_ads_with_correct_resource_name(self, config): + ad_service = _FakeAdService() + client = _FakeClient(ad_service) + + write._apply_update_rsa( + client, + "1234567890", + {"ad_id": "999", "final_url": "https://example.com"}, + ) + + op = ad_service.captured_operations[0] + assert op.update.resource_name == "customers/1234567890/ads/999" + + def test_url_only_field_mask_has_only_final_urls(self, config): + ad_service = _FakeAdService() + client = _FakeClient(ad_service) + + write._apply_update_rsa( + client, + "1234567890", + {"ad_id": "999", "final_url": "https://example.com"}, + ) + + op = ad_service.captured_operations[0] + assert list(op.update_mask.paths) == ["final_urls"] + assert list(op.update.final_urls) == ["https://example.com"] + + def test_path1_only_field_mask(self, config): + ad_service = _FakeAdService() + client = _FakeClient(ad_service) + + write._apply_update_rsa( + client, + "1234567890", + {"ad_id": "999", "path1": "Sale"}, + ) + + op = ad_service.captured_operations[0] + assert list(op.update_mask.paths) == ["responsive_search_ad.path1"] + assert op.update.responsive_search_ad.path1 == "Sale" + + def test_path2_only_field_mask(self, config): + ad_service = _FakeAdService() + client = _FakeClient(ad_service) + + write._apply_update_rsa( + client, + "1234567890", + {"ad_id": "999", "path2": "Sacramento"}, + ) + + op = ad_service.captured_operations[0] + assert list(op.update_mask.paths) == ["responsive_search_ad.path2"] + assert op.update.responsive_search_ad.path2 == "Sacramento" + + def test_all_three_fields_in_mask(self, config): + ad_service = _FakeAdService() + client = _FakeClient(ad_service) + + write._apply_update_rsa( + client, + "1234567890", + { + "ad_id": "999", + "final_url": "https://example.com", + "path1": "Sale", + "path2": "NE", + }, + ) + + op = ad_service.captured_operations[0] + assert set(op.update_mask.paths) == { + "final_urls", + "responsive_search_ad.path1", + "responsive_search_ad.path2", + } + + def test_clear_path_writes_empty_string_to_proto(self, config): + ad_service = _FakeAdService() + client = _FakeClient(ad_service) + + write._apply_update_rsa( + client, + "1234567890", + {"ad_id": "999", "path1": ""}, # the "clear" semantic + ) + + op = ad_service.captured_operations[0] + assert "responsive_search_ad.path1" in list(op.update_mask.paths) + assert op.update.responsive_search_ad.path1 == "" + + def test_returns_resource_name(self, config): + ad_service = _FakeAdService() + client = _FakeClient(ad_service) + + result = write._apply_update_rsa( + client, + "1234567890", + {"ad_id": "999", "path1": "X"}, + ) + + assert result == {"resource_name": "customers/1234567890/ads/999"} + + def test_passes_customer_id_to_mutate_call(self, config): + ad_service = _FakeAdService() + client = _FakeClient(ad_service) + + write._apply_update_rsa( + client, + "1234567890", + {"ad_id": "999", "path1": "X"}, + ) + + assert ad_service.captured_customer_id == "1234567890" + + +# --------------------------------------------------------------------------- +# Confirm-and-apply integration +# --------------------------------------------------------------------------- + + +class TestConfirmAndApplyIntegration: + def test_dry_run_returns_dry_run_success(self, config): + draft = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + path1="Pricing", + ) + result = write.confirm_and_apply( + config, plan_id=draft["plan_id"], dry_run=True + ) + assert result["status"] == "DRY_RUN_SUCCESS" + assert result["operation"] == "update_responsive_search_ad" + + def test_require_dry_run_overrides_dry_run_false(self, dry_run_config): + draft = write.update_responsive_search_ad( + dry_run_config, + customer_id="1234567890", + ad_id="999", + path1="Pricing", + ) + result = write.confirm_and_apply( + dry_run_config, plan_id=draft["plan_id"], dry_run=False + ) + assert result["status"] == "DRY_RUN_SUCCESS" + assert result.get("dry_run_forced_by") == "config.safety.require_dry_run" + + def test_unknown_plan_id_returns_error(self, config): + result = write.confirm_and_apply( + config, plan_id="does-not-exist", dry_run=True + ) + assert "error" in result + + def test_apply_routes_to_update_rsa(self, config, monkeypatch): + ad_service = _FakeAdService() + client = _FakeClient(ad_service) + + monkeypatch.setattr( + "adloop.ads.client.get_ads_client", lambda _cfg: client + ) + + draft = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + final_url="https://example.com", + path1="Sale", + ) + result = write.confirm_and_apply( + config, plan_id=draft["plan_id"], dry_run=False + ) + + assert result["status"] == "APPLIED" + assert result["operation"] == "update_responsive_search_ad" + assert ad_service.captured_operations is not None + assert len(ad_service.captured_operations) == 1 + + def test_apply_writes_audit_log(self, config, monkeypatch, tmp_path): + ad_service = _FakeAdService() + client = _FakeClient(ad_service) + monkeypatch.setattr( + "adloop.ads.client.get_ads_client", lambda _cfg: client + ) + + draft = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + path1="Sale", + ) + write.confirm_and_apply( + config, plan_id=draft["plan_id"], dry_run=False + ) + + log_path = config.safety.log_file + from pathlib import Path + contents = Path(log_path).read_text() + assert "update_responsive_search_ad" in contents + assert "success" in contents + + def test_apply_writes_dry_run_audit_log(self, config): + draft = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + path1="Sale", + ) + write.confirm_and_apply( + config, plan_id=draft["plan_id"], dry_run=True + ) + + from pathlib import Path + contents = Path(config.safety.log_file).read_text() + assert "dry_run_success" in contents + assert '"dry_run": true' in contents + + def test_plan_removed_after_successful_apply(self, config, monkeypatch): + ad_service = _FakeAdService() + client = _FakeClient(ad_service) + monkeypatch.setattr( + "adloop.ads.client.get_ads_client", lambda _cfg: client + ) + + draft = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + path1="Sale", + ) + plan_id = draft["plan_id"] + assert preview_store.get_plan(plan_id) is not None + + write.confirm_and_apply(config, plan_id=plan_id, dry_run=False) + assert preview_store.get_plan(plan_id) is None + + +# --------------------------------------------------------------------------- +# Headline / description mutation (in-place text replacement via AdService) +# --------------------------------------------------------------------------- + + +def _valid_headlines() -> list[str]: + """Minimum-viable headline set: 3 entries, each under 30 chars.""" + return ["Beaver Brothers Plumbing", "Same-Day Service", "Call Today"] + + +def _valid_descriptions() -> list[str]: + """Minimum-viable description set: 2 entries, each under 90 chars.""" + return [ + "Sacramento plumbing — same-day service. Call now.", + "Licensed pros, free estimates. Beaver Brothers Plumbing.", + ] + + +class TestHeadlineDescriptionValidation: + """Covers the count / char-limit / pin-slot validation paths added when + update_responsive_search_ad gained headlines & descriptions support. + """ + + def test_rejects_too_few_headlines(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + headlines=["Only one headline"], + descriptions=_valid_descriptions(), + ) + assert result["error"] == "Validation failed" + assert any("at least 3 headlines" in d for d in result["details"]) + + def test_rejects_too_many_headlines(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + headlines=[f"H{i}" for i in range(16)], + descriptions=_valid_descriptions(), + ) + assert result["error"] == "Validation failed" + assert any("Maximum 15 headlines" in d for d in result["details"]) + + def test_rejects_headline_over_30_chars(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + headlines=[ + "OK headline one", + "OK headline two", + "X" * 31, # over the cap by 1 + ], + descriptions=_valid_descriptions(), + ) + assert result["error"] == "Validation failed" + assert any("exceeds 30 chars" in d for d in result["details"]) + + def test_accepts_headline_at_max_30_chars(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + headlines=["X" * 30, "OK", "Also OK"], + descriptions=_valid_descriptions(), + ) + assert result.get("error") is None + + def test_rejects_invalid_headline_pin(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + headlines=[ + {"text": "Brand", "pinned_field": "HEADLINE_99"}, + "OK two", + "OK three", + ], + descriptions=_valid_descriptions(), + ) + assert result["error"] == "Validation failed" + assert any( + "HEADLINE_99" in d and "invalid" in d for d in result["details"] + ) + + def test_rejects_too_many_headlines_per_pin_slot(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + headlines=[ + {"text": "Brand A", "pinned_field": "HEADLINE_1"}, + {"text": "Brand B", "pinned_field": "HEADLINE_1"}, + {"text": "Brand C", "pinned_field": "HEADLINE_1"}, # 3rd in same slot + ], + descriptions=_valid_descriptions(), + ) + assert result["error"] == "Validation failed" + assert any( + "At most 2 headlines may pin to HEADLINE_1" in d + for d in result["details"] + ) + + def test_rejects_too_few_descriptions(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + headlines=_valid_headlines(), + descriptions=["Only one description here."], + ) + assert result["error"] == "Validation failed" + assert any("at least 2 descriptions" in d for d in result["details"]) + + def test_rejects_too_many_descriptions(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + headlines=_valid_headlines(), + descriptions=[f"Description {i} for the ad." for i in range(5)], + ) + assert result["error"] == "Validation failed" + assert any("Maximum 4 descriptions" in d for d in result["details"]) + + def test_rejects_description_over_90_chars(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + headlines=_valid_headlines(), + descriptions=[ + "Short OK description.", + "Y" * 91, # over the cap by 1 + ], + ) + assert result["error"] == "Validation failed" + assert any("exceeds 90 chars" in d for d in result["details"]) + + def test_rejects_invalid_description_pin(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + headlines=_valid_headlines(), + descriptions=[ + {"text": "Legal disclaimer.", "pinned_field": "DESCRIPTION_9"}, + "Second description.", + ], + ) + assert result["error"] == "Validation failed" + assert any( + "DESCRIPTION_9" in d and "invalid" in d for d in result["details"] + ) + + def test_rejects_too_many_descriptions_per_pin_slot(self, config): + # Descriptions: cap is 1 per slot (vs 2 for headlines) + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + headlines=_valid_headlines(), + descriptions=[ + {"text": "First desc.", "pinned_field": "DESCRIPTION_1"}, + {"text": "Second desc.", "pinned_field": "DESCRIPTION_1"}, + ], + ) + assert result["error"] == "Validation failed" + assert any( + "At most 1 description may pin to DESCRIPTION_1" in d + for d in result["details"] + ) + + def test_headlines_only_does_not_require_descriptions(self, config): + # Common use case: rewriting just headlines without touching descriptions. + # Wait — list-replace semantics mean if you provide only headlines, + # Google still enforces the 3-15 cap on the new headlines but doesn't + # touch descriptions at all. So validation should pass here. + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + headlines=_valid_headlines(), + ) + assert result.get("error") is None + assert "headlines" in result["changes"] + assert "descriptions" not in result["changes"] + + def test_descriptions_only_does_not_require_headlines(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + descriptions=_valid_descriptions(), + ) + assert result.get("error") is None + assert "descriptions" in result["changes"] + assert "headlines" not in result["changes"] + + +class TestHeadlineDescriptionPlanConstruction: + def test_normalizes_string_headlines_to_dicts(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + headlines=_valid_headlines(), + ) + for h in result["changes"]["headlines"]: + assert isinstance(h, dict) + assert h["text"] # non-empty + assert h["pinned_field"] is None # unpinned + + def test_preserves_pinned_field_dict_input(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + headlines=[ + {"text": "Beaver Brothers Plumbing", "pinned_field": "HEADLINE_1"}, + "Same-Day Service", + "Call Today", + ], + ) + pinned = result["changes"]["headlines"][0] + assert pinned == { + "text": "Beaver Brothers Plumbing", + "pinned_field": "HEADLINE_1", + } + + def test_mixed_string_and_dict_entries_normalize(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + headlines=[ + {"text": "Pinned brand", "pinned_field": "HEADLINE_1"}, + "Unpinned plain", + "Third headline", + ], + ) + hs = result["changes"]["headlines"] + assert hs[0]["pinned_field"] == "HEADLINE_1" + assert hs[1]["pinned_field"] is None + assert hs[2]["pinned_field"] is None + + def test_combined_headlines_paths_url(self, config): + result = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + headlines=_valid_headlines(), + descriptions=_valid_descriptions(), + final_url="https://example.com/x", + path1="Pricing", + path2="Sale", + ) + changes = result["changes"] + assert changes["final_url"] == "https://example.com/x" + assert changes["path1"] == "Pricing" + assert changes["path2"] == "Sale" + assert len(changes["headlines"]) == 3 + assert len(changes["descriptions"]) == 2 + + +class TestHeadlineDescriptionApply: + def test_headlines_field_mask_and_assets_populated(self, config): + ad_service = _FakeAdService() + client = _FakeClient(ad_service) + + write._apply_update_rsa( + client, + "1234567890", + { + "ad_id": "999", + "headlines": [ + {"text": "Beaver Brothers Plumbing", "pinned_field": None}, + {"text": "Same-Day Service", "pinned_field": None}, + {"text": "Call Today", "pinned_field": None}, + ], + }, + ) + + op = ad_service.captured_operations[0] + assert "responsive_search_ad.headlines" in list(op.update_mask.paths) + # Each AdTextAsset should carry the text through to the proto + headlines = list(op.update.responsive_search_ad.headlines) + assert len(headlines) == 3 + assert headlines[0].text == "Beaver Brothers Plumbing" + assert headlines[1].text == "Same-Day Service" + assert headlines[2].text == "Call Today" + + def test_descriptions_field_mask_and_assets_populated(self, config): + ad_service = _FakeAdService() + client = _FakeClient(ad_service) + + write._apply_update_rsa( + client, + "1234567890", + { + "ad_id": "999", + "descriptions": [ + {"text": "Desc one.", "pinned_field": None}, + {"text": "Desc two.", "pinned_field": None}, + ], + }, + ) + + op = ad_service.captured_operations[0] + assert "responsive_search_ad.descriptions" in list(op.update_mask.paths) + descs = list(op.update.responsive_search_ad.descriptions) + assert len(descs) == 2 + assert descs[0].text == "Desc one." + + def test_pinned_field_propagates_to_proto_enum(self, config): + ad_service = _FakeAdService() + client = _FakeClient(ad_service) + + write._apply_update_rsa( + client, + "1234567890", + { + "ad_id": "999", + "headlines": [ + {"text": "Brand", "pinned_field": "HEADLINE_1"}, + {"text": "Plain A", "pinned_field": None}, + {"text": "Plain B", "pinned_field": None}, + ], + }, + ) + + op = ad_service.captured_operations[0] + headlines = list(op.update.responsive_search_ad.headlines) + # The pinned headline carries the HEADLINE_1 enum value; unpinned + # carries UNSPECIFIED (the proto default). + expected_pin = client.enums.ServedAssetFieldTypeEnum.HEADLINE_1 + assert headlines[0].pinned_field == expected_pin + # Unpinned entries should not set pinned_field (proto default). + unspecified = client.enums.ServedAssetFieldTypeEnum.UNSPECIFIED + assert headlines[1].pinned_field == unspecified + assert headlines[2].pinned_field == unspecified + + def test_combined_paths_headlines_descriptions_in_mask(self, config): + ad_service = _FakeAdService() + client = _FakeClient(ad_service) + + write._apply_update_rsa( + client, + "1234567890", + { + "ad_id": "999", + "path1": "Sale", + "headlines": [ + {"text": "H1", "pinned_field": None}, + {"text": "H2", "pinned_field": None}, + {"text": "H3", "pinned_field": None}, + ], + "descriptions": [ + {"text": "D1.", "pinned_field": None}, + {"text": "D2.", "pinned_field": None}, + ], + }, + ) + + op = ad_service.captured_operations[0] + paths = set(op.update_mask.paths) + assert paths == { + "responsive_search_ad.path1", + "responsive_search_ad.headlines", + "responsive_search_ad.descriptions", + } + + def test_headlines_only_does_not_include_url_or_path_in_mask(self, config): + ad_service = _FakeAdService() + client = _FakeClient(ad_service) + + write._apply_update_rsa( + client, + "1234567890", + { + "ad_id": "999", + "headlines": [ + {"text": "H1", "pinned_field": None}, + {"text": "H2", "pinned_field": None}, + {"text": "H3", "pinned_field": None}, + ], + }, + ) + + op = ad_service.captured_operations[0] + paths = list(op.update_mask.paths) + assert paths == ["responsive_search_ad.headlines"] + # final_urls must remain empty when no URL change is requested + assert list(op.update.final_urls) == [] + + +class TestHeadlineDescriptionIntegration: + """End-to-end via confirm_and_apply — the same path the MCP tool takes.""" + + def test_headline_rewrite_routes_through_dispatch_to_apply( + self, config, monkeypatch + ): + ad_service = _FakeAdService() + client = _FakeClient(ad_service) + monkeypatch.setattr( + "adloop.ads.client.get_ads_client", lambda _cfg: client + ) + + draft = write.update_responsive_search_ad( + config, + customer_id="1234567890", + ad_id="999", + headlines=[ + "Replacement Headline One", + "Replacement Headline Two", + "Replacement Headline Three", + ], + ) + assert draft.get("error") is None + + result = write.confirm_and_apply( + config, plan_id=draft["plan_id"], dry_run=False + ) + assert result["status"] == "APPLIED" + assert ad_service.captured_operations is not None + op = ad_service.captured_operations[0] + # Verify the round trip carried headlines all the way to the proto + texts = [h.text for h in op.update.responsive_search_ad.headlines] + assert texts == [ + "Replacement Headline One", + "Replacement Headline Two", + "Replacement Headline Three", + ]