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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ except FopostError as err:
| `ads` | `list`, `external`, `boostable`, `connections`, `sources`, `authorize_meta`, `delete_connection`, `boost`, `create`, `refresh`, `set_status`, `delete`, `audiences`, `create_audience`, `search_targeting`, `lead_forms`, `create_lead_form`, `leads`, `account_tree`, `create_campaign`, `get_campaign`, `update_campaign`, `delete_campaign`, `duplicate_campaign`, `create_ad_set`, `get_ad_set`, `update_ad_set`, `delete_ad_set`, `duplicate_ad_set`, `create_network_ad`, `get_network_ad`, `update_network_ad`, `delete_network_ad`, `duplicate_network_ad`, `bulk_set_status`, `creatives`, `create_creative`, `get_creative`, `delete_creative`, `get_audience`, `update_audience`, `delete_audience`, `add_audience_users`, `estimate_reach`, `insights`, `ad_insights`, `get_lead_form`, `archive_lead_form`, `leads_feed`, `lead_pages`, `subscribe_lead_page`, `unsubscribe_lead_page`, `goals`, `catalogs`, `create_catalog`, `get_catalog`, `update_catalog`, `delete_catalog`, `catalog_products`, `write_catalog_products`, `product_feeds`, `create_product_feed`, `delete_product_feed`, `feed_uploads`, `start_feed_upload`, `product_sets`, `create_product_set`, `update_product_set`, `delete_product_set`, `reach_frequency`, `create_reach_frequency`, `get_reach_frequency`, `reserve_reach_frequency`, `cancel_reach_frequency`, `library`, `partnership_creators`, `request_partnership`, `revoke_partnership`, `account_activity`, `labels`, `create_label`, `update_label`, `delete_label`, `apply_label`, `studies`, `create_study`, `get_study`, `delete_study`, `ios_campaign_limits`, `high_demand_periods`, `create_high_demand_period`, `delete_high_demand_period`, `value_rule_sets`, `create_value_rule_set`, `delete_value_rule_set` |
| `knowledge` | `list`, `create`, `update`, `delete`, `sync`, `search` |
| `validate` | `post`, `length`, `media` |
| `activity` | `list` |

For an endpoint the SDK does not wrap yet, `client.request` sends an
authenticated call and hands back the decoded body:
Expand Down
6 changes: 6 additions & 0 deletions src/fopost/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@
AccountGroup,
AccountMove,
AccountRename,
ActivityActor,
ActivityEvent,
ActivityPage,
Ad,
AdAccountTree,
AdCampaign,
Expand Down Expand Up @@ -161,6 +164,9 @@
"AccountGroup",
"AccountMove",
"AccountRename",
"ActivityActor",
"ActivityEvent",
"ActivityPage",
"Ad",
"AdConnection",
"AdInsights",
Expand Down
2 changes: 2 additions & 0 deletions src/fopost/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from .resources import (
AccountGroupsResource,
AccountsResource,
ActivityResource,
AdsResource,
AiResource,
BroadcastsResource,
Expand Down Expand Up @@ -81,6 +82,7 @@ def __init__(
self.ads = AdsResource(self._http)
self.validate = ValidateResource(self._http)
self.knowledge = KnowledgeResource(self._http)
self.activity = ActivityResource(self._http)

@property
def base_url(self) -> str:
Expand Down
53 changes: 53 additions & 0 deletions src/fopost/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@
"PostStatus",
"AccountGroup",
"AccountMove",
"ACTIVITY_KINDS",
"ActivityKind",
"ActivityActor",
"ActivityEvent",
"ActivityPage",
"AccountRename",
"Ad",
"AdConnection",
Expand Down Expand Up @@ -1604,3 +1609,51 @@ class Enrollment(FopostModel):
last_sent_at: datetime | None = None
#: On a skipped step, the reason it was skipped.
error: str | None = None


ACTIVITY_KINDS = (
"publish",
"connection",
"webhook",
"inbox",
"automation",
"billing",
"security",
)
ActivityKind = Literal[
"publish", "connection", "webhook", "inbox", "automation", "billing", "security"
]


class ActivityActor(FopostModel):
"""Who did it. ``name`` is absent for a system event."""

type: Literal["user", "api_key", "agent", "system"]
name: str | None = None


class ActivityEvent(FopostModel):
id: str
workspace_id: str | None = None
kind: str
ref_type: str | None = None
ref_id: str | None = None
summary: str
actor: ActivityActor
time: datetime


class ActivityPage(FopostModel):
"""One page of activity, newest first, plus the cursor for the next."""

items: list[ActivityEvent] = []
next_cursor: str | None = None

def __iter__(self) -> Any:
return iter(self.items)

def __len__(self) -> int:
return len(self.items)

def __getitem__(self, index: int) -> ActivityEvent:
return self.items[index]
2 changes: 2 additions & 0 deletions src/fopost/resources/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from .account_groups import AccountGroupsResource
from .accounts import AccountsResource
from .activity import ActivityResource
from .ads import AdsResource
from .ai import AiResource
from .broadcasts import BroadcastsResource, SequencesResource
Expand All @@ -15,6 +16,7 @@
__all__ = [
"AccountGroupsResource",
"AccountsResource",
"ActivityResource",
"AdsResource",
"AiResource",
"BroadcastsResource",
Expand Down
49 changes: 49 additions & 0 deletions src/fopost/resources/activity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""``client.activity`` — what happened in a workspace, and the audit log."""

from __future__ import annotations

from datetime import datetime

from ..models import ActivityEvent, ActivityKind, ActivityPage
from ._base import Resource

__all__ = ["ActivityResource"]


def _when(value: datetime | str | None) -> str | None:
return value.isoformat() if isinstance(value, datetime) else value


class ActivityResource(Resource):
def list(
self,
*,
workspace_id: str | None = None,
kind: ActivityKind | None = None,
from_: datetime | str | None = None,
to: datetime | str | None = None,
cursor: str | None = None,
limit: int | None = None,
) -> ActivityPage:
"""Newest first. Omit ``workspace_id`` to read every workspace the key can reach.

``kind="security"`` is the audit log: members joining, leaving or changing
role and access, and changes to two-step verification, passkeys, single
sign-on and signed-in devices. Those rows are append-only and never expire.
"""
body = self._http.get(
"/activity",
{
"workspace_id": workspace_id,
"kind": kind,
"from": _when(from_),
"to": _when(to),
"cursor": cursor,
"limit": limit,
},
)
data = body.get("data") if isinstance(body, dict) else None
meta = body.get("meta") if isinstance(body, dict) else None
items = [ActivityEvent.model_validate(row) for row in data or []]
next_cursor = meta.get("next_cursor") if isinstance(meta, dict) else None
return ActivityPage(items=items, next_cursor=next_cursor)
57 changes: 57 additions & 0 deletions tests/test_activity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from __future__ import annotations

from datetime import datetime, timezone

import httpx
import respx

from fopost import Fopost
from tests.conftest import BASE_URL

SECURITY_EVENT = {
"id": "evt_1",
"workspace_id": "ws_1",
"kind": "security",
"ref_type": "member_removed",
"ref_id": "usr_2",
"summary": "Removed sam@example.com",
"actor": {"type": "user", "name": "Ada"},
"time": "2026-09-20T10:00:00Z",
}


@respx.mock
def test_reads_the_audit_log_and_keeps_the_cursor(client: Fopost) -> None:
route = respx.get(f"{BASE_URL}/activity").mock(
return_value=httpx.Response(
200, json={"data": [SECURITY_EVENT], "meta": {"next_cursor": "42"}}
)
)

page = client.activity.list(
workspace_id="ws_1",
kind="security",
from_=datetime(2026, 9, 1, tzinfo=timezone.utc),
limit=1,
)

params = route.calls[0].request.url.params
assert params["kind"] == "security"
assert params["workspace_id"] == "ws_1"
assert params["from"].startswith("2026-09-01")
assert len(page) == 1
assert page[0].ref_type == "member_removed"
assert page[0].actor.name == "Ada"
assert page.next_cursor == "42"


@respx.mock
def test_the_end_of_the_list_is_a_null_cursor(client: Fopost) -> None:
respx.get(f"{BASE_URL}/activity").mock(
return_value=httpx.Response(200, json={"data": [], "meta": {"next_cursor": None}})
)

page = client.activity.list()

assert list(page) == []
assert page.next_cursor is None
Loading