diff --git a/README.md b/README.md index 8e2d753..440a7cf 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,8 @@ except FopostError as err: | `sequences` | `list`, `get`, `create`, `update`, `delete`, `enroll`, `unenroll`, `enrollments` | | `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` | +| `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` | +| `google_business` | `get_location`, `update_location`, `get_attributes`, `update_attributes`, `get_menus`, `replace_menus`, `get_services`, `replace_services`, `list_media`, `add_media`, `delete_media`, `list_place_actions`, `create_place_action`, `update_place_action`, `delete_place_action`, `get_verification_options`, `start_verification`, `complete_verification`, `get_performance`, `get_search_keywords`, `assign` | | `validate` | `post`, `length`, `media` | | `activity` | `list` | diff --git a/src/fopost/client.py b/src/fopost/client.py index 7842fe4..68f529f 100644 --- a/src/fopost/client.py +++ b/src/fopost/client.py @@ -17,6 +17,7 @@ AiResource, BroadcastsResource, ContactsResource, + GoogleBusinessResource, InboxResource, KnowledgeResource, LabelsResource, @@ -83,6 +84,7 @@ def __init__( self.validate = ValidateResource(self._http) self.knowledge = KnowledgeResource(self._http) self.activity = ActivityResource(self._http) + self.google_business = GoogleBusinessResource(self._http) @property def base_url(self) -> str: diff --git a/src/fopost/resources/__init__.py b/src/fopost/resources/__init__.py index 6ff5b46..4e5095f 100644 --- a/src/fopost/resources/__init__.py +++ b/src/fopost/resources/__init__.py @@ -5,6 +5,7 @@ from .ai import AiResource from .broadcasts import BroadcastsResource, SequencesResource from .contacts import ContactsResource +from .google_business import GoogleBusinessResource from .inbox import InboxResource from .knowledge import KnowledgeResource from .labels import LabelsResource @@ -22,6 +23,7 @@ "BroadcastsResource", "ContactsResource", "SequencesResource", + "GoogleBusinessResource", "InboxResource", "KnowledgeResource", "LabelsResource", diff --git a/src/fopost/resources/google_business.py b/src/fopost/resources/google_business.py new file mode 100644 index 0000000..0a55d38 --- /dev/null +++ b/src/fopost/resources/google_business.py @@ -0,0 +1,313 @@ +"""``client.google_business`` — manage a connected Google Business Profile location. + +Google grants Business Profile API access per project. Until that grant lands +on a deployment every call here raises a 503 ``configuration_error``. + +Responses relay Google's own shape, field for field, so a field you know from +the Business Profile APIs is the field you get back; they come through as +plain dicts rather than models we would have to keep chasing. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any + +from .._http import unwrap +from ..models import AccountMove +from ._base import UNSET, Resource, drop_unset + +__all__ = ["GoogleBusinessResource"] + +Payload = dict[str, Any] + +#: The set fetched when a caller names no metrics. +DEFAULT_DAILY_METRICS = ( + "BUSINESS_IMPRESSIONS_DESKTOP_MAPS", + "BUSINESS_IMPRESSIONS_DESKTOP_SEARCH", + "BUSINESS_IMPRESSIONS_MOBILE_MAPS", + "BUSINESS_IMPRESSIONS_MOBILE_SEARCH", + "CALL_CLICKS", + "WEBSITE_CLICKS", + "BUSINESS_DIRECTION_REQUESTS", +) + + +def _payload(data: Any) -> Payload: + return data if isinstance(data, dict) else {"data": data} + + +class GoogleBusinessResource(Resource): + # ── Location ────────────────────────────────────────────────── + + def get_location(self, account_id: str) -> Payload: + return _payload(unwrap(self._http.get(f"/accounts/{account_id}/gbp/location"))) + + def update_location( + self, + account_id: str, + *, + title: str | Any = UNSET, + description: str | None | Any = UNSET, + website_uri: str | None | Any = UNSET, + primary_phone: str | None | Any = UNSET, + additional_phones: Sequence[str] | Any = UNSET, + store_code: str | None | Any = UNSET, + regular_hours: Sequence[Mapping[str, Any]] | Any = UNSET, + ) -> Payload: + """Patch the profile; an argument left out keeps its value.""" + body = drop_unset( + { + "title": title, + "description": description, + "website_uri": website_uri, + "primary_phone": primary_phone, + "additional_phones": additional_phones, + "store_code": store_code, + "regular_hours": regular_hours, + } + ) + return _payload( + unwrap(self._http.request("PATCH", f"/accounts/{account_id}/gbp/location", json=body)) + ) + + # ── Attributes ──────────────────────────────────────────────── + + def get_attributes( + self, + account_id: str, + *, + available: bool | None = None, + category_name: str | None = None, + region_code: str | None = None, + language_code: str | None = None, + ) -> Payload: + """The values set on the location, or what Google offers it.""" + return _payload( + unwrap( + self._http.get( + f"/accounts/{account_id}/gbp/attributes", + { + "available": available, + "category_name": category_name, + "region_code": region_code, + "language_code": language_code, + }, + ) + ) + ) + + def update_attributes( + self, account_id: str, attributes: Sequence[Mapping[str, Any]] + ) -> Payload: + """Only the named attributes change; every other one is left alone.""" + return _payload( + unwrap( + self._http.request( + "PATCH", + f"/accounts/{account_id}/gbp/attributes", + json={"attributes": list(attributes)}, + ) + ) + ) + + # ── Food menus and services ─────────────────────────────────── + + def get_menus(self, account_id: str) -> Payload: + return _payload(unwrap(self._http.get(f"/accounts/{account_id}/gbp/menus"))) + + def replace_menus(self, account_id: str, menus: Sequence[Mapping[str, Any]]) -> Payload: + """Google has no per-section patch, so the whole menu set is replaced.""" + return _payload( + unwrap(self._http.put(f"/accounts/{account_id}/gbp/menus", {"menus": list(menus)})) + ) + + def get_services(self, account_id: str) -> Payload: + return _payload(unwrap(self._http.get(f"/accounts/{account_id}/gbp/services"))) + + def replace_services( + self, account_id: str, service_items: Sequence[Mapping[str, Any]] + ) -> Payload: + return _payload( + unwrap( + self._http.put( + f"/accounts/{account_id}/gbp/services", + {"service_items": list(service_items)}, + ) + ) + ) + + # ── Photos ──────────────────────────────────────────────────── + + def list_media( + self, account_id: str, *, page_size: int | None = None, page_token: str | None = None + ) -> Payload: + return _payload( + unwrap( + self._http.get( + f"/accounts/{account_id}/gbp/media", + {"page_size": page_size, "page_token": page_token}, + ) + ) + ) + + def add_media( + self, + account_id: str, + *, + media_id: str, + category: str = "ADDITIONAL", + description: str | None = None, + ) -> Payload: + """The photo is a media-library asset in the same workspace, JPEG or PNG.""" + body: Payload = {"media_id": media_id, "category": category} + if description is not None: + body["description"] = description + return _payload(unwrap(self._http.post(f"/accounts/{account_id}/gbp/media", body))) + + def delete_media(self, account_id: str, media_key: str) -> Payload: + return _payload(unwrap(self._http.delete(f"/accounts/{account_id}/gbp/media/{media_key}"))) + + # ── Place action links ──────────────────────────────────────── + + def list_place_actions(self, account_id: str) -> Payload: + return _payload(unwrap(self._http.get(f"/accounts/{account_id}/gbp/place-actions"))) + + def create_place_action( + self, + account_id: str, + *, + uri: str, + place_action_type: str, + is_preferred: bool | None = None, + ) -> Payload: + body: Payload = {"uri": uri, "place_action_type": place_action_type} + if is_preferred is not None: + body["is_preferred"] = is_preferred + return _payload(unwrap(self._http.post(f"/accounts/{account_id}/gbp/place-actions", body))) + + def update_place_action( + self, + account_id: str, + link_id: str, + *, + uri: str | Any = UNSET, + is_preferred: bool | Any = UNSET, + ) -> Payload: + body = drop_unset({"uri": uri, "is_preferred": is_preferred}) + return _payload( + unwrap( + self._http.request( + "PATCH", f"/accounts/{account_id}/gbp/place-actions/{link_id}", json=body + ) + ) + ) + + def delete_place_action(self, account_id: str, link_id: str) -> Payload: + return _payload( + unwrap(self._http.delete(f"/accounts/{account_id}/gbp/place-actions/{link_id}")) + ) + + # ── Verification ────────────────────────────────────────────── + + def get_verification_options( + self, account_id: str, *, language_code: str | None = None + ) -> Payload: + """The ways Google will let this location be verified.""" + return _payload( + unwrap( + self._http.get( + f"/accounts/{account_id}/gbp/verification", {"language_code": language_code} + ) + ) + ) + + def start_verification( + self, + account_id: str, + *, + method: str, + language_code: str | None = None, + phone_number: str | None = None, + email_address: str | None = None, + mailer_contact_name: str | None = None, + ) -> Payload: + """The response names the pending verification to complete with the PIN.""" + body: Payload = {"method": method} + for key, value in ( + ("language_code", language_code), + ("phone_number", phone_number), + ("email_address", email_address), + ("mailer_contact_name", mailer_contact_name), + ): + if value is not None: + body[key] = value + return _payload( + unwrap(self._http.post(f"/accounts/{account_id}/gbp/verification/start", body)) + ) + + def complete_verification( + self, account_id: str, *, verification_name: str, pin: str + ) -> Payload: + return _payload( + unwrap( + self._http.post( + f"/accounts/{account_id}/gbp/verification/complete", + {"verification_name": verification_name, "pin": pin}, + ) + ) + ) + + # ── Performance ─────────────────────────────────────────────── + + def get_performance( + self, + account_id: str, + *, + start_date: str, + end_date: str, + daily_metrics: Sequence[str] | None = None, + ) -> Payload: + """Daily impressions, calls, direction requests and clicks for the range.""" + return _payload( + unwrap( + self._http.get( + f"/accounts/{account_id}/gbp/performance", + { + "start_date": start_date, + "end_date": end_date, + "daily_metrics": list(daily_metrics) if daily_metrics else None, + }, + ) + ) + ) + + def get_search_keywords( + self, account_id: str, *, start_date: str, end_date: str, page_token: str | None = None + ) -> Payload: + """The search terms people used to find the listing, by month.""" + return _payload( + unwrap( + self._http.get( + f"/accounts/{account_id}/gbp/performance", + { + "keywords": True, + "start_date": start_date, + "end_date": end_date, + "page_token": page_token, + }, + ) + ) + ) + + # ── Workspace assignment ────────────────────────────────────── + + def assign(self, account_id: str, *, workspace_id: str) -> AccountMove: + """Hand the location to another workspace; the caller must own both.""" + return AccountMove.model_validate( + unwrap( + self._http.post( + f"/accounts/{account_id}/gbp/assign", {"workspace_id": workspace_id} + ) + ) + ) diff --git a/tests/test_google_business.py b/tests/test_google_business.py new file mode 100644 index 0000000..f98b7eb --- /dev/null +++ b/tests/test_google_business.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import json + +import httpx +import pytest +import respx + +from fopost import Fopost, FopostError +from tests.conftest import BASE_URL + + +@respx.mock +def test_every_method_maps_onto_its_route_and_body(client: Fopost) -> None: + routes = [ + respx.get(f"{BASE_URL}/accounts/acc_1/gbp/location"), + respx.patch(f"{BASE_URL}/accounts/acc_1/gbp/location"), + respx.get(f"{BASE_URL}/accounts/acc_1/gbp/attributes"), + respx.patch(f"{BASE_URL}/accounts/acc_1/gbp/attributes"), + respx.get(f"{BASE_URL}/accounts/acc_1/gbp/menus"), + respx.put(f"{BASE_URL}/accounts/acc_1/gbp/menus"), + respx.get(f"{BASE_URL}/accounts/acc_1/gbp/services"), + respx.put(f"{BASE_URL}/accounts/acc_1/gbp/services"), + respx.get(f"{BASE_URL}/accounts/acc_1/gbp/media"), + respx.post(f"{BASE_URL}/accounts/acc_1/gbp/media"), + respx.delete(f"{BASE_URL}/accounts/acc_1/gbp/media/CAoSL"), + respx.get(f"{BASE_URL}/accounts/acc_1/gbp/place-actions"), + respx.post(f"{BASE_URL}/accounts/acc_1/gbp/place-actions"), + respx.patch(f"{BASE_URL}/accounts/acc_1/gbp/place-actions/links-1"), + respx.delete(f"{BASE_URL}/accounts/acc_1/gbp/place-actions/links-1"), + respx.get(f"{BASE_URL}/accounts/acc_1/gbp/verification"), + respx.post(f"{BASE_URL}/accounts/acc_1/gbp/verification/start"), + respx.post(f"{BASE_URL}/accounts/acc_1/gbp/verification/complete"), + respx.get(f"{BASE_URL}/accounts/acc_1/gbp/performance"), + ] + for route in routes: + route.mock(return_value=httpx.Response(200, json={"data": {"ok": True}})) + + gb = client.google_business + assert gb.get_location("acc_1") == {"ok": True} + gb.update_location("acc_1", title="Corner Bakery", primary_phone="+15550100") + gb.get_attributes("acc_1", available=True, region_code="US") + gb.update_attributes("acc_1", [{"name": "attributes/has_wifi", "values": [True]}]) + gb.get_menus("acc_1") + gb.replace_menus("acc_1", []) + gb.get_services("acc_1") + gb.replace_services("acc_1", []) + gb.list_media("acc_1") + gb.add_media("acc_1", media_id="m1", category="INTERIOR") + gb.delete_media("acc_1", "CAoSL") + gb.list_place_actions("acc_1") + gb.create_place_action("acc_1", uri="https://example.com/book", place_action_type="APPOINTMENT") + gb.update_place_action("acc_1", "links-1", is_preferred=True) + gb.delete_place_action("acc_1", "links-1") + gb.get_verification_options("acc_1") + gb.start_verification("acc_1", method="SMS", phone_number="+15550100") + gb.complete_verification("acc_1", verification_name="v1", pin="123456") + gb.get_performance( + "acc_1", + start_date="2026-09-01", + end_date="2026-09-07", + daily_metrics=["CALL_CLICKS", "WEBSITE_CLICKS"], + ) + + for route in routes: + assert route.called + + # A patch carries only what the caller set. + patch_location = json.loads(routes[1].calls.last.request.content) + assert patch_location == {"title": "Corner Bakery", "primary_phone": "+15550100"} + assert json.loads(routes[13].calls.last.request.content) == {"is_preferred": True} + + # The photo comes from the library, by id. + assert json.loads(routes[9].calls.last.request.content) == { + "media_id": "m1", + "category": "INTERIOR", + } + + +@respx.mock +def test_performance_repeats_the_metric_parameter(client: Fopost) -> None: + route = respx.get(f"{BASE_URL}/accounts/acc_1/gbp/performance").mock( + return_value=httpx.Response(200, json={"data": {}}) + ) + + client.google_business.get_performance( + "acc_1", + start_date="2026-09-01", + end_date="2026-09-07", + daily_metrics=["CALL_CLICKS", "WEBSITE_CLICKS"], + ) + + params = route.calls.last.request.url.params + assert params.get_list("daily_metrics") == ["CALL_CLICKS", "WEBSITE_CLICKS"] + assert params["start_date"] == "2026-09-01" + + +@respx.mock +def test_search_keywords_asks_the_same_route_for_the_monthly_terms(client: Fopost) -> None: + route = respx.get(f"{BASE_URL}/accounts/acc_1/gbp/performance").mock( + return_value=httpx.Response(200, json={"data": {}}) + ) + + client.google_business.get_search_keywords( + "acc_1", start_date="2026-08-01", end_date="2026-09-01" + ) + + assert route.calls.last.request.url.params["keywords"] == "true" + + +@respx.mock +def test_a_pending_api_grant_raises_a_503(client: Fopost) -> None: + respx.get(f"{BASE_URL}/accounts/acc_1/gbp/location").mock( + return_value=httpx.Response( + 503, json={"error": "configuration_error", "message": "Not available yet"} + ) + ) + + with pytest.raises(FopostError) as excinfo: + client.google_business.get_location("acc_1") + + assert excinfo.value.status == 503 + assert excinfo.value.code == "configuration_error"