diff --git a/README.md b/README.md index 1b251a9..8e2d753 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/src/fopost/__init__.py b/src/fopost/__init__.py index 005cb98..dfceb24 100644 --- a/src/fopost/__init__.py +++ b/src/fopost/__init__.py @@ -34,6 +34,9 @@ AccountGroup, AccountMove, AccountRename, + ActivityActor, + ActivityEvent, + ActivityPage, Ad, AdAccountTree, AdCampaign, @@ -161,6 +164,9 @@ "AccountGroup", "AccountMove", "AccountRename", + "ActivityActor", + "ActivityEvent", + "ActivityPage", "Ad", "AdConnection", "AdInsights", diff --git a/src/fopost/client.py b/src/fopost/client.py index aaa9ea5..7842fe4 100644 --- a/src/fopost/client.py +++ b/src/fopost/client.py @@ -12,6 +12,7 @@ from .resources import ( AccountGroupsResource, AccountsResource, + ActivityResource, AdsResource, AiResource, BroadcastsResource, @@ -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: diff --git a/src/fopost/models.py b/src/fopost/models.py index 89a3c23..af58f98 100644 --- a/src/fopost/models.py +++ b/src/fopost/models.py @@ -20,6 +20,11 @@ "PostStatus", "AccountGroup", "AccountMove", + "ACTIVITY_KINDS", + "ActivityKind", + "ActivityActor", + "ActivityEvent", + "ActivityPage", "AccountRename", "Ad", "AdConnection", @@ -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] diff --git a/src/fopost/resources/__init__.py b/src/fopost/resources/__init__.py index 5b1dcdd..6ff5b46 100644 --- a/src/fopost/resources/__init__.py +++ b/src/fopost/resources/__init__.py @@ -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 @@ -15,6 +16,7 @@ __all__ = [ "AccountGroupsResource", "AccountsResource", + "ActivityResource", "AdsResource", "AiResource", "BroadcastsResource", diff --git a/src/fopost/resources/activity.py b/src/fopost/resources/activity.py new file mode 100644 index 0000000..a8b875d --- /dev/null +++ b/src/fopost/resources/activity.py @@ -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) diff --git a/tests/test_activity.py b/tests/test_activity.py new file mode 100644 index 0000000..82c47d1 --- /dev/null +++ b/tests/test_activity.py @@ -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