From a172cbbbdf6e2e717a238437965bed9a1f2d639d Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 20 Sep 2026 02:51:04 +0200 Subject: [PATCH] feat: add the activity resource and the security audit log client.activity.list() reads GET /v1/activity; kind 'security' is the append-only audit trail of membership, role, access and sign-in changes. --- README.md | 1 + src/fopost/__init__.py | 6 ++++ src/fopost/client.py | 2 ++ src/fopost/models.py | 53 +++++++++++++++++++++++++++++ src/fopost/resources/__init__.py | 2 ++ src/fopost/resources/activity.py | 49 +++++++++++++++++++++++++++ tests/test_activity.py | 57 ++++++++++++++++++++++++++++++++ 7 files changed, 170 insertions(+) create mode 100644 src/fopost/resources/activity.py create mode 100644 tests/test_activity.py diff --git a/README.md b/README.md index 828b424..aea129c 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,7 @@ except FopostError as err: | `inbox` | `list`, `threads`, `conversations`, `unread_count`, `accounts`, `platforms`, `mark_thread_read`, `refresh`, `update`, `edit_comment`, `reply`, `hide`, `unhide`, `delete`, `like`, `unlike`, `pin`, `unpin`, `react`, `start_conversation`, `set_typing`, `list_approvals`, `approve_reply`, `reject_reply` | | `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` | | `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 6ec2553..f25e506 100644 --- a/src/fopost/__init__.py +++ b/src/fopost/__init__.py @@ -34,6 +34,9 @@ AccountGroup, AccountMove, AccountRename, + ActivityActor, + ActivityEvent, + ActivityPage, Ad, AdAccountTree, AdCampaign, @@ -125,6 +128,9 @@ "AccountGroup", "AccountMove", "AccountRename", + "ActivityActor", + "ActivityEvent", + "ActivityPage", "Ad", "AdConnection", "AdInsights", diff --git a/src/fopost/client.py b/src/fopost/client.py index 348a888..1e74a12 100644 --- a/src/fopost/client.py +++ b/src/fopost/client.py @@ -12,6 +12,7 @@ from .resources import ( AccountGroupsResource, AccountsResource, + ActivityResource, AdsResource, AiResource, InboxResource, @@ -73,6 +74,7 @@ def __init__( self.inbox = InboxResource(self._http) self.ads = AdsResource(self._http) self.validate = ValidateResource(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 7a0b4ec..391fb15 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", @@ -999,3 +1004,51 @@ class ValidateMediaResult(FopostModel): size: int | None = None mime_type: str | None = None type: 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 8a0ad04..f8c989d 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 .inbox import InboxResource @@ -12,6 +13,7 @@ __all__ = [ "AccountGroupsResource", "AccountsResource", + "ActivityResource", "AdsResource", "AiResource", "InboxResource", 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