Task 4 review: Approved with no Critical findings, but three Important and two hardening items. All five addressed: - objects.build_campaign_payload: normalise a bare string special_ad_categories value to a single-element list instead of exploding it into characters via list() - the field controls regulated-advertising compliance and advanced is unvalidated input. - objects.build_ad_set_payload: validate optimization_goal and billing_event against new allowlists before forwarding them, since billing_event determines how the ad account is charged and both come from client-controlled advanced. - validation.validate_budget: reject a lifetime-only budget (Meta's create_ad_set only accepts daily_budget) so it fails at validation time instead of passing approval and only failing at sync. - client.py: create_campaign/create_ad_set/create_ad now send the CREATE_STATUS constant rather than the caller's status object, so a str subclass with a lying __ne__ can no longer slip "ACTIVE" past the guard. - client._post: refuse any POST to a campaign/adset/ad creation path with a non-PAUSED status before touching the transport, as defence in depth if a future caller bypasses the typed create_* guards. 10 new tests (8 in test_meta_writes.py, 2 in test_campaign_validation.py).
160 lines
6.6 KiB
Python
160 lines
6.6 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Pure mappers from a CampaignSpec's stored JSON blobs to Graph API params.
|
|
|
|
These functions do no I/O and make no Meta calls; they only reshape data so
|
|
`MetaClient`'s write methods stay transport-only and this mapping logic is
|
|
unit-testable without a fake transport at all. Callers spread the returned
|
|
dict as keyword arguments into the matching `MetaClient` method, e.g.:
|
|
|
|
await client.create_campaign(ad_account_id, **build_campaign_payload(spec))
|
|
|
|
Field mapping decisions
|
|
------------------------
|
|
Phase 2 does not yet have separate AdSet/Ad spec models — `CampaignSpec` is
|
|
the only spec in play — so ad-set- and ad-level fields that don't fit
|
|
`CampaignSpec`'s campaign-level columns are read out of `spec.advanced`.
|
|
This is a deliberate, documented choice rather than an oversight:
|
|
|
|
- `spec.budget["daily_budget"]` -> ad set `daily_budget`, passed through
|
|
unchanged (already minor currency units / cents, matching
|
|
`adclaw.campaigns.validation` and Graph's own expectation). Lifetime
|
|
budgets are out of scope for this mapping: `daily_budget` is required and
|
|
`build_ad_set_payload` raises `ValueError` if it is missing.
|
|
- `spec.targeting["age_min"]` / `["age_max"]` -> Graph
|
|
`targeting.age_min` / `targeting.age_max`.
|
|
- `spec.targeting["countries"]` -> Graph
|
|
`targeting.geo_locations.countries`.
|
|
- `spec.targeting["genders"]` -> Graph `targeting.genders`, if present.
|
|
- `spec.advanced["special_ad_categories"]` -> campaign
|
|
`special_ad_categories` (defaults to an empty list). A bare string is
|
|
normalised to a single-element list rather than exploded into
|
|
characters, since `advanced` is unvalidated free-form JSON and this is
|
|
the compliance field for regulated advertising (housing, credit,
|
|
employment, ...).
|
|
- `spec.advanced["optimization_goal"]` / `["billing_event"]` -> ad set
|
|
fields of the same name, defaulting to `LEAD_GENERATION` /
|
|
`IMPRESSIONS` (MaskanX's default lead-gen objective). Both are checked
|
|
against an allowlist (`ALLOWED_OPTIMIZATION_GOALS` /
|
|
`ALLOWED_BILLING_EVENTS`) before being forwarded, since `advanced` is
|
|
client-controlled and `billing_event` in particular determines how the
|
|
ad account is charged; an unrecognised value raises `ValueError` naming
|
|
the offending value and the allowed set rather than reaching Meta.
|
|
- `spec.advanced["message"]` / `["headline"]` / `["description"]` /
|
|
`["link"]` -> ad creative `object_story_spec.link_data` fields of the
|
|
same purpose (headline defaults to the campaign name if unset).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from ..campaigns.models import CampaignSpec
|
|
|
|
DEFAULT_OPTIMIZATION_GOAL = "LEAD_GENERATION"
|
|
DEFAULT_BILLING_EVENT = "IMPRESSIONS"
|
|
|
|
# advanced.optimization_goal / advanced.billing_event are client-controlled
|
|
# and billing_event determines how the ad account is charged, so both are
|
|
# checked against an allowlist before being forwarded to a spending API.
|
|
ALLOWED_OPTIMIZATION_GOALS = frozenset({
|
|
"LEAD_GENERATION", "LINK_CLICKS", "IMPRESSIONS", "REACH",
|
|
"OFFSITE_CONVERSIONS", "LANDING_PAGE_VIEWS", "THRUPLAY",
|
|
"POST_ENGAGEMENT", "QUALITY_CALL",
|
|
})
|
|
ALLOWED_BILLING_EVENTS = frozenset({
|
|
"IMPRESSIONS", "LINK_CLICKS", "THRUPLAY", "POST_ENGAGEMENT",
|
|
})
|
|
|
|
|
|
def build_campaign_payload(spec: CampaignSpec) -> dict[str, Any]:
|
|
"""Map a CampaignSpec onto `MetaClient.create_campaign` kwargs.
|
|
|
|
Raises `ValueError` if `spec.objective` is unset — Graph requires an
|
|
objective on every campaign and there is no sensible default to guess.
|
|
"""
|
|
if not spec.objective:
|
|
raise ValueError(
|
|
"CampaignSpec.objective is required to create a Meta campaign.",
|
|
)
|
|
# A bare string ("HOUSING") must become a single-element list, not be
|
|
# exploded into characters by list(); advanced is unvalidated JSON from
|
|
# the API and special_ad_categories is the compliance field for
|
|
# regulated advertising, so this cannot be left to Meta's opaque error.
|
|
raw_categories = spec.advanced.get("special_ad_categories") or []
|
|
categories = (
|
|
[raw_categories] if isinstance(raw_categories, str) else list(raw_categories)
|
|
)
|
|
return {
|
|
"name": spec.name,
|
|
"objective": spec.objective,
|
|
"special_ad_categories": categories,
|
|
}
|
|
|
|
|
|
def build_ad_set_payload(spec: CampaignSpec, campaign_id: str) -> dict[str, Any]:
|
|
"""Map a CampaignSpec onto `MetaClient.create_ad_set` kwargs.
|
|
|
|
Raises `ValueError` if `spec.budget["daily_budget"]` is unset — this
|
|
mapping does not yet support lifetime-budget campaigns.
|
|
"""
|
|
daily_budget = spec.budget.get("daily_budget")
|
|
if daily_budget is None:
|
|
raise ValueError(
|
|
"CampaignSpec.budget.daily_budget is required to create an ad "
|
|
"set (lifetime budgets are not yet supported).",
|
|
)
|
|
|
|
targeting: dict[str, Any] = {}
|
|
age_min = spec.targeting.get("age_min")
|
|
age_max = spec.targeting.get("age_max")
|
|
if age_min is not None:
|
|
targeting["age_min"] = age_min
|
|
if age_max is not None:
|
|
targeting["age_max"] = age_max
|
|
countries = spec.targeting.get("countries")
|
|
if countries:
|
|
targeting["geo_locations"] = {"countries": list(countries)}
|
|
genders = spec.targeting.get("genders")
|
|
if genders:
|
|
targeting["genders"] = list(genders)
|
|
|
|
optimization_goal = spec.advanced.get(
|
|
"optimization_goal", DEFAULT_OPTIMIZATION_GOAL,
|
|
)
|
|
if optimization_goal not in ALLOWED_OPTIMIZATION_GOALS:
|
|
raise ValueError(
|
|
f"Unsupported optimization_goal {optimization_goal!r}. Allowed: "
|
|
f"{sorted(ALLOWED_OPTIMIZATION_GOALS)}",
|
|
)
|
|
billing_event = spec.advanced.get("billing_event", DEFAULT_BILLING_EVENT)
|
|
if billing_event not in ALLOWED_BILLING_EVENTS:
|
|
raise ValueError(
|
|
f"Unsupported billing_event {billing_event!r}. Allowed: "
|
|
f"{sorted(ALLOWED_BILLING_EVENTS)}",
|
|
)
|
|
|
|
return {
|
|
"campaign_id": campaign_id,
|
|
"name": f"{spec.name} - Ad Set",
|
|
"daily_budget": daily_budget,
|
|
"targeting": targeting,
|
|
"optimization_goal": optimization_goal,
|
|
"billing_event": billing_event,
|
|
}
|
|
|
|
|
|
def build_creative_payload(
|
|
spec: CampaignSpec,
|
|
page_id: str,
|
|
image_hash: str,
|
|
) -> dict[str, Any]:
|
|
"""Map a CampaignSpec onto `MetaClient.create_ad_creative` kwargs."""
|
|
return {
|
|
"name": f"{spec.name} - Creative",
|
|
"page_id": page_id,
|
|
"message": spec.advanced.get("message", ""),
|
|
"headline": spec.advanced.get("headline") or spec.name,
|
|
"description": spec.advanced.get("description", ""),
|
|
"link": spec.advanced.get("link", ""),
|
|
"image_hash": image_hash,
|
|
}
|