From 17a38572c3296b66a31be2444d52dba5689e40a9 Mon Sep 17 00:00:00 2001 From: Sam Petherbridge Date: Tue, 9 Jun 2026 14:07:11 +1000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20ad=20write=20operations=20(cr?= =?UTF-8?q?eate,=20update,=20delete)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the ads group from read-only to full management: - asa ads create — create an ad (CUSTOM_PRODUCT_PAGE or DEFAULT_PRODUCT_PAGE) - asa ads update — change an ad's name and/or status - asa ads delete — delete an ad (confirmation, --yes to skip) Creative-type/product-page coupling is validated client-side before any API call: CUSTOM_PRODUCT_PAGE requires --product-page, DEFAULT_PRODUCT_PAGE rejects it, and CREATIVE_SET is rejected (asset selection unsupported by the AdCreate model). create supports --dry-run to preview the payload. Closes #35. --- asa_api_cli/ads.py | 229 +++++++++++++++++++++++++++++++++++++++++++- asa_api_cli/main.py | 2 +- tests/test_cli.py | 45 +++++++++ 3 files changed, 273 insertions(+), 3 deletions(-) diff --git a/asa_api_cli/ads.py b/asa_api_cli/ads.py index b49bbe1..877690c 100644 --- a/asa_api_cli/ads.py +++ b/asa_api_cli/ads.py @@ -1,27 +1,59 @@ -"""Ad (creative) read-only CLI commands.""" +"""Ad (creative) CLI commands.""" from typing import Annotated, Any import typer from asa_api_client.exceptions import AppleSearchAdsError +from asa_api_client.models import AdCreate, AdStatus, AdUpdate, CreativeType from asa_api_cli.utils import ( EXIT_ERROR, + EXIT_USAGE, OutputFormat, + confirm_action, enum_value, get_client, handle_api_error, output_data, + print_error, + print_info, print_result_panel, + print_success, print_warning, spinner, ) -app = typer.Typer(help="View ads (creatives) within ad groups") +app = typer.Typer(help="View and manage ads (creatives) within ad groups") AD_COLUMNS = ["id", "name", "creative_type", "status", "serving_status", "creative_id"] +def _validate_create(creative_type: CreativeType, product_page_id: str | None) -> None: + """Validate the creative-type / product-page combination before any API call. + + Raises typer.Exit(EXIT_USAGE) with a friendly message on an invalid combination. + """ + if creative_type == CreativeType.CREATIVE_SET: + print_error( + "Unsupported creative type", + "CREATIVE_SET ads require selecting creative set assets, which this command " + "does not support yet. Use CUSTOM_PRODUCT_PAGE or DEFAULT_PRODUCT_PAGE.", + ) + raise typer.Exit(EXIT_USAGE) + if creative_type == CreativeType.CUSTOM_PRODUCT_PAGE and not product_page_id: + print_error( + "Missing product page", + "CUSTOM_PRODUCT_PAGE ads require --product-page . Find ids with 'asa product-pages list'.", + ) + raise typer.Exit(EXIT_USAGE) + if creative_type == CreativeType.DEFAULT_PRODUCT_PAGE and product_page_id: + print_error( + "Unexpected product page", + "DEFAULT_PRODUCT_PAGE ads must not specify --product-page.", + ) + raise typer.Exit(EXIT_USAGE) + + @app.command("list") def list_ads( campaign_id: Annotated[ @@ -118,3 +150,196 @@ def get_ad( except AppleSearchAdsError as e: handle_api_error(e) raise typer.Exit(EXIT_ERROR) from None + + +@app.command("create") +def create_ad( + name: Annotated[ + str, + typer.Option("--name", "-n", help="Ad name"), + ], + campaign_id: Annotated[ + int, + typer.Option("--campaign", "-c", help="Campaign ID"), + ], + ad_group_id: Annotated[ + int, + typer.Option("--ad-group", "-a", help="Ad group ID"), + ], + creative_type: Annotated[ + CreativeType, + typer.Option("--creative-type", "-t", help="Creative type"), + ] = CreativeType.CUSTOM_PRODUCT_PAGE, + product_page_id: Annotated[ + str | None, + typer.Option("--product-page", "-p", help="Custom product page ID (required for CUSTOM_PRODUCT_PAGE)"), + ] = None, + status: Annotated[ + AdStatus, + typer.Option("--status", "-s", help="Initial status"), + ] = AdStatus.ENABLED, + dry_run: Annotated[ + bool, + typer.Option("--dry-run", help="Preview the ad payload without creating it"), + ] = False, + yes: Annotated[ + bool, + typer.Option("--yes", "-y", help="Skip the confirmation prompt"), + ] = False, +) -> None: + """Create an ad in an ad group. + + CUSTOM_PRODUCT_PAGE ads need a --product-page id (see 'asa product-pages list'). + DEFAULT_PRODUCT_PAGE ads use the app's default page and take no product page. + + Examples: + asa ads create -c 123 -a 456 -n "Holiday CPP" -p + asa ads create -c 123 -a 456 -n "Default ad" -t DEFAULT_PRODUCT_PAGE + asa ads create -c 123 -a 456 -n "Preview" -p --dry-run + """ + _validate_create(creative_type, product_page_id) + + payload = AdCreate( + name=name, + creative_type=creative_type, + status=status, + product_page_id=product_page_id, + ) + + preview = { + "Name": name, + "Creative type": enum_value(creative_type), + "Status": enum_value(status), + "Product page": product_page_id or "-", + "Campaign ID": str(campaign_id), + "Ad group ID": str(ad_group_id), + } + + if dry_run: + print_info("Dry run - no ad created") + print_result_panel("Ad payload", preview) + return + + if not yes and not confirm_action(f"Create ad '{name}' in ad group {ad_group_id}?", default=True): + print_info("Cancelled") + return + + client = get_client() + + try: + with client: + with spinner("Creating ad..."): + ad = client.campaigns(campaign_id).ad_groups(ad_group_id).ads.create(payload) + + print_success(f"Created ad {ad.id}: {ad.name}") + print_result_panel( + f"Ad {ad.id}", + { + "Name": ad.name, + "Creative type": enum_value(ad.creative_type), + "Status": enum_value(ad.status), + "Serving status": enum_value(ad.serving_status), + }, + ) + except AppleSearchAdsError as e: + handle_api_error(e) + raise typer.Exit(EXIT_ERROR) from None + + +@app.command("update") +def update_ad( + ad_id: Annotated[ + int, + typer.Argument(help="Ad ID"), + ], + campaign_id: Annotated[ + int, + typer.Option("--campaign", "-c", help="Campaign ID"), + ], + ad_group_id: Annotated[ + int, + typer.Option("--ad-group", "-a", help="Ad group ID"), + ], + name: Annotated[ + str | None, + typer.Option("--name", "-n", help="New ad name"), + ] = None, + status: Annotated[ + AdStatus | None, + typer.Option("--status", "-s", help="New status (ENABLED or PAUSED)"), + ] = None, +) -> None: + """Update an ad's name and/or status. + + At least one of --name or --status must be provided. + + Examples: + asa ads update 789 -c 123 -a 456 --status PAUSED + asa ads update 789 -c 123 -a 456 --name "Renamed ad" + """ + if name is None and status is None: + print_error("Nothing to update", "Provide at least one of --name or --status.") + raise typer.Exit(EXIT_USAGE) + + payload = AdUpdate(name=name, status=status) + + client = get_client() + + try: + with client: + with spinner("Updating ad..."): + ad = client.campaigns(campaign_id).ad_groups(ad_group_id).ads.update(ad_id, payload) + + print_success(f"Updated ad {ad.id}") + print_result_panel( + f"Ad {ad.id}", + { + "Name": ad.name, + "Status": enum_value(ad.status), + "Serving status": enum_value(ad.serving_status), + }, + ) + except AppleSearchAdsError as e: + handle_api_error(e) + raise typer.Exit(EXIT_ERROR) from None + + +@app.command("delete") +def delete_ad( + ad_id: Annotated[ + int, + typer.Argument(help="Ad ID"), + ], + campaign_id: Annotated[ + int, + typer.Option("--campaign", "-c", help="Campaign ID"), + ], + ad_group_id: Annotated[ + int, + typer.Option("--ad-group", "-a", help="Ad group ID"), + ], + yes: Annotated[ + bool, + typer.Option("--yes", "-y", help="Skip the confirmation prompt"), + ] = False, +) -> None: + """Delete an ad. + + Example: + asa ads delete 789 -c 123 -a 456 + """ + if not yes and not confirm_action(f"Delete ad {ad_id}? This cannot be undone.", default=False): + print_info("Cancelled") + return + + client = get_client() + + try: + with client: + with spinner("Deleting ad..."): + client.campaigns(campaign_id).ad_groups(ad_group_id).ads.delete(ad_id) + + print_success(f"Deleted ad {ad_id}") + except AppleSearchAdsError as e: + handle_api_error(e) + raise typer.Exit(EXIT_ERROR) from None diff --git a/asa_api_cli/main.py b/asa_api_cli/main.py index d002424..e9676d2 100644 --- a/asa_api_cli/main.py +++ b/asa_api_cli/main.py @@ -34,7 +34,7 @@ app.add_typer(brand.app, name="brand", help="Create brand protection campaigns") app.add_typer(campaigns.app, name="campaigns", help="Manage campaigns") app.add_typer(ad_groups.app, name="ad-groups", help="Manage ad groups") -app.add_typer(ads.app, name="ads", help="View ads (creatives) within ad groups") +app.add_typer(ads.app, name="ads", help="View and manage ads (creatives) within ad groups") app.add_typer(keywords.app, name="keywords", help="Manage keywords") app.add_typer(product_pages.app, name="product-pages", help="View custom product pages (CPP)") app.add_typer(apps.app, name="apps", help="Search the App Store for advertisable apps") diff --git a/tests/test_cli.py b/tests/test_cli.py index f088f6d..89b1ccd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -76,6 +76,51 @@ def test_ads_help() -> None: assert result.exit_code == 0 assert "list" in result.stdout assert "get" in result.stdout + assert "create" in result.stdout + assert "update" in result.stdout + assert "delete" in result.stdout + + +def test_ads_create_creative_set_rejected() -> None: + """CREATIVE_SET is not supported by the create command (no asset selection).""" + result = runner.invoke( + app, + ["ads", "create", "-c", "1", "-a", "2", "-n", "x", "-t", "CREATIVE_SET"], + ) + assert result.exit_code != 0 + + +def test_ads_create_custom_page_requires_product_page() -> None: + """CUSTOM_PRODUCT_PAGE without --product-page should fail before any API call.""" + result = runner.invoke( + app, + ["ads", "create", "-c", "1", "-a", "2", "-n", "x", "-t", "CUSTOM_PRODUCT_PAGE"], + ) + assert result.exit_code != 0 + + +def test_ads_create_default_page_rejects_product_page() -> None: + """DEFAULT_PRODUCT_PAGE must not be given a --product-page.""" + result = runner.invoke( + app, + ["ads", "create", "-c", "1", "-a", "2", "-n", "x", "-t", "DEFAULT_PRODUCT_PAGE", "-p", "pp1"], + ) + assert result.exit_code != 0 + + +def test_ads_create_dry_run_succeeds_offline() -> None: + """A valid --dry-run create previews the payload and exits 0 without an API call.""" + result = runner.invoke( + app, + ["ads", "create", "-c", "1", "-a", "2", "-n", "Test ad", "-t", "DEFAULT_PRODUCT_PAGE", "--dry-run"], + ) + assert result.exit_code == 0 + + +def test_ads_update_requires_a_field() -> None: + """update with neither --name nor --status should fail before any API call.""" + result = runner.invoke(app, ["ads", "update", "789", "-c", "1", "-a", "2"]) + assert result.exit_code != 0 def test_product_pages_help() -> None: