Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/fopost/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,14 @@
ActivityPage,
Ad,
AdAccountTree,
AdBusinessCenter,
AdCampaign,
AdCampaignNode,
AdComment,
AdCommentsPage,
AdConnection,
AdCreative,
AdIdentity,
AdInsights,
AdInsightsReport,
AdSet,
Expand Down Expand Up @@ -157,6 +161,7 @@
SlackIdentity,
SlackMember,
SocialAccount,
SparkPost,
TargetingOption,
TelegramBotCommand,
TelegramBotCommands,
Expand Down Expand Up @@ -320,6 +325,11 @@
"WebhookSubscription",
"SlackMember",
"SocialAccount",
"AdBusinessCenter",
"AdComment",
"AdCommentsPage",
"AdIdentity",
"SparkPost",
"TargetingOption",
"TelegramBotCommand",
"TelegramBotCommands",
Expand Down
50 changes: 50 additions & 0 deletions src/fopost/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1135,6 +1135,56 @@ class TargetingOption(FopostModel):
detail: str | None = None


class AdBusinessCenter(FopostModel):
"""A Business Center, or the network's equivalent grouping of ad accounts."""

id: str
name: str
role: str | None = None


class AdIdentity(FopostModel):
"""The account an ad runs as. Meta calls it a Page, TikTok an identity."""

id: str
type: str
name: str
avatar_url: str | None = None


class SparkPost(FopostModel):
"""A post already live on the network, offered as the source of a Spark ad."""

id: str
identity_id: str
caption: str | None = None
thumbnail_url: str | None = None
created_at: str | None = None
views: int | None = None


class AdComment(FopostModel):
"""A comment on an ad, read live from the network and never stored."""

id: str
ad_id: str | None = None
text: str = ""
author_name: str | None = None
author_avatar_url: str | None = None
created_at: str | None = None
likes: int = 0
reply_count: int = 0
hidden: bool = False
parent_id: str | None = None


class AdCommentsPage(FopostModel):
"""One page of an ad's comments; pass ``next_cursor`` back as ``after``."""

comments: list[AdComment] = []
next_cursor: str | None = None


class LeadForm(FopostModel):
id: str
name: str
Expand Down
168 changes: 165 additions & 3 deletions src/fopost/resources/ads.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""``client.ads`` — ads, catalogs, audiences, the ad archive and lead forms.
"""``client.ads`` — ads, catalogs, audiences, the ad archive, lead forms and ad comments.

Meta is what this module covers; the Google-only surface is ``client.ads.google``.
The connection decides which network a call reaches, so the same methods run
Meta and TikTok; the Google-only surface is ``client.ads.google``.

Every method needs the ``ads`` scope; ``boost``, ``create``, ``set_status``,
``delete``, ``bulk_set_status`` and every create, update, delete or duplicate on
Expand All @@ -18,9 +19,12 @@
Ad,
AdAccountTree,
AdActivityResult,
AdBusinessCenter,
AdCampaign,
AdCommentsPage,
AdConnection,
AdCreative,
AdIdentity,
AdInsightsReport,
AdLabel,
AdLibraryPage,
Expand Down Expand Up @@ -52,6 +56,7 @@
ReachEstimate,
ReachFrequencyPrediction,
ReachFrequencyResult,
SparkPost,
TargetingOption,
ValueRuleSet,
)
Expand Down Expand Up @@ -169,6 +174,7 @@ def create(
destination_url: str | None = None,
media_url: str | None = None,
url_tags: str | None = None,
spark_post_id: str | None = None,
paused: bool | None = None,
) -> Ad:
"""Create a standalone ad from a creative. Starts paused unless ``paused=False``.
Expand All @@ -191,6 +197,7 @@ def create(
"destinationUrl": destination_url,
"mediaUrl": media_url,
"urlTags": url_tags,
"sparkPostId": spark_post_id,
"paused": paused,
}
body.update({k: v for k, v in optional.items() if v is not None})
Expand Down Expand Up @@ -368,8 +375,13 @@ def create_campaign(
name: str,
goal: str,
paused: bool | None = None,
smart_plus: bool | None = None,
) -> AdCampaign:
"""Starts paused unless ``paused=False``."""
"""Starts paused unless ``paused=False``.

``smart_plus`` hands targeting and creative rotation to the network and
needs its ``smartPlus`` capability.
"""
body: dict[str, Any] = {
"workspaceId": workspace_id,
"connectionId": connection_id,
Expand All @@ -379,6 +391,8 @@ def create_campaign(
}
if paused is not None:
body["paused"] = paused
if smart_plus is not None:
body["smartPlus"] = smart_plus
return AdCampaign.model_validate(unwrap(self._http.post("/ads/campaigns", body)))

def get_campaign(
Expand Down Expand Up @@ -1600,6 +1614,154 @@ def _reach_frequency_action(
unwrap(self._http.post(f"/ads/reach-frequency/{prediction_id}/{action}", body))
)

def tiktok_business_centers(
self, *, connection_id: str, workspace_id: str | None = None
) -> builtins.list[AdBusinessCenter]:
"""TikTok's Business Centers, the one network-named read in this resource."""
return parse_list(
AdBusinessCenter,
unwrap(
self._http.get(
"/ads/tiktok/business-centers",
{"workspace_id": workspace_id, "connection_id": connection_id},
)
),
)

def tiktok_identities(
self, *, connection_id: str, ad_account_id: str, workspace_id: str | None = None
) -> builtins.list[AdIdentity]:
"""The accounts an ad can run as; an identity id is a ``page_id``."""
return parse_list(
AdIdentity,
unwrap(
self._http.get(
"/ads/tiktok/identities",
{
"workspace_id": workspace_id,
"connection_id": connection_id,
"ad_account_id": ad_account_id,
},
)
),
)

def spark_posts(
self,
*,
connection_id: str,
ad_account_id: str,
identity_id: str,
workspace_id: str | None = None,
) -> builtins.list[SparkPost]:
"""Posts already live under an identity, each a candidate Spark ad."""
return parse_list(
SparkPost,
unwrap(
self._http.get(
"/ads/spark-posts",
{
"workspace_id": workspace_id,
"connection_id": connection_id,
"ad_account_id": ad_account_id,
"identity_id": identity_id,
},
)
),
)

def upload_conversions(
self,
*,
workspace_id: str,
connection_id: str,
ad_account_id: str,
pixel_id: str,
events: Sequence[Mapping[str, Any]],
) -> dict[str, Any]:
"""Offline conversions. Identifiers are hashed before they leave FoPost."""
result = unwrap(
self._http.post(
"/ads/conversions",
{
"workspaceId": workspace_id,
"connectionId": connection_id,
"adAccountId": ad_account_id,
"pixelId": pixel_id,
"events": [dict(e) for e in events],
},
)
)
return result if isinstance(result, dict) else {"data": result}

def comments(
self,
*,
connection_id: str,
ad_id: str,
after: str | None = None,
workspace_id: str | None = None,
) -> AdCommentsPage:
"""One page of an ad's comments; pass ``next_cursor`` back as ``after``."""
return AdCommentsPage.model_validate(
unwrap(
self._http.get(
"/ads/comments",
{
"workspace_id": workspace_id,
"connection_id": connection_id,
"ad_id": ad_id,
"after": after,
},
)
)
)

def reply_to_comment(
self, comment_id: str, *, workspace_id: str, connection_id: str, ad_id: str, text: str
) -> dict[str, Any]:
"""Needs the ``publish`` scope as well as ``ads``."""
result = unwrap(
self._http.post(
f"/ads/comments/{comment_id}/reply",
{
"workspaceId": workspace_id,
"connectionId": connection_id,
"adId": ad_id,
"text": text,
},
)
)
return result if isinstance(result, dict) else {"data": result}

def set_comment_hidden(
self, comment_id: str, *, workspace_id: str, connection_id: str, ad_id: str, hidden: bool
) -> None:
"""Needs the ``publish`` scope as well as ``ads``."""
self._http.post(
f"/ads/comments/{comment_id}/hide",
{
"workspaceId": workspace_id,
"connectionId": connection_id,
"adId": ad_id,
"hidden": hidden,
},
)

def delete_comment(
self, comment_id: str, *, workspace_id: str, connection_id: str, ad_id: str
) -> None:
"""One already gone on the network succeeds. Needs ``publish`` as well as ``ads``."""
self._http.request(
"DELETE",
f"/ads/comments/{comment_id}",
json={
"workspaceId": workspace_id,
"connectionId": connection_id,
"adId": ad_id,
},
)

def _object(
self,
method: str,
Expand Down
Loading
Loading