diff --git a/tableauserverclient/models/webhook_item.py b/tableauserverclient/models/webhook_item.py index a8dfe3ef0..b39ca7e8a 100644 --- a/tableauserverclient/models/webhook_item.py +++ b/tableauserverclient/models/webhook_item.py @@ -44,6 +44,12 @@ class WebhookItem: owner_id : str | None The identifier (luid) of the user who owns the webhook. + + is_enabled : bool | None + Whether the webhook is enabled. Disabled webhooks do not fire. + + status_change_reason : str | None + The reason the webhook status last changed (e.g. why it was disabled). """ def __init__(self): @@ -52,8 +58,10 @@ def __init__(self): self.url: str | None = None self._event: str | None = None self.owner_id: str | None = None + self.is_enabled: bool | None = None + self.status_change_reason: str | None = None - def _set_values(self, id, name, url, event, owner_id): + def _set_values(self, id, name, url, event, owner_id, is_enabled=None, status_change_reason=None): if id is not None: self._id = id if name: @@ -64,6 +72,10 @@ def _set_values(self, id, name, url, event, owner_id): self.event = event if owner_id: self.owner_id = owner_id + if is_enabled is not None: + self.is_enabled = is_enabled + if status_change_reason is not None: + self.status_change_reason = status_change_reason @property def id(self) -> str | None: @@ -116,7 +128,14 @@ def _parse_element(webhook_xml: ET.Element, ns) -> tuple: if owner_tag is not None: owner_id = owner_tag.get("id", None) - return id, name, url, event, owner_id + is_enabled = None + is_enabled_str = webhook_xml.get("isEnabled", None) + if is_enabled_str is not None: + is_enabled = is_enabled_str.lower() == "true" + + status_change_reason = webhook_xml.get("statusChangeReason", None) + + return id, name, url, event, owner_id, is_enabled, status_change_reason def __repr__(self) -> str: - return f"" + return f"" diff --git a/tableauserverclient/server/endpoint/webhooks_endpoint.py b/tableauserverclient/server/endpoint/webhooks_endpoint.py index 449fdbdf5..00729544f 100644 --- a/tableauserverclient/server/endpoint/webhooks_endpoint.py +++ b/tableauserverclient/server/endpoint/webhooks_endpoint.py @@ -1,6 +1,7 @@ import logging from .endpoint import Endpoint, api +from .exceptions import MissingRequiredFieldError from tableauserverclient.server import RequestFactory from tableauserverclient.models import WebhookItem, PaginationItem @@ -118,6 +119,33 @@ def create(self, webhook_item: WebhookItem) -> WebhookItem: logger.info(f"Created new webhook (ID: {new_webhook.id})") return new_webhook + @api(version="3.6") + def update(self, webhook_item: WebhookItem) -> WebhookItem: + """ + Modifies an existing webhook. + + REST API: https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref.htm#update_webhook + + Parameters + ---------- + webhook_item : WebhookItem + The webhook item to update. Must have a valid id. + + Returns + ------- + WebhookItem + An object containing information about the updated webhook. + """ + if not webhook_item.id: + error = "Webhook item missing ID. Webhook must be retrieved from server first." + raise MissingRequiredFieldError(error) + url = f"{self.baseurl}/{webhook_item.id}" + update_req = RequestFactory.Webhook.update_req(webhook_item) + server_response = self.put_request(url, update_req) + updated_webhook = WebhookItem.from_response(server_response.content, self.parent_srv.namespace)[0] + logger.info(f"Updated webhook (ID: {webhook_item.id})") + return updated_webhook + @api(version="3.6") def test(self, webhook_id: str): """ diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index fc4694c01..502aed06d 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -1412,6 +1412,26 @@ def create_req(self, xml_request: ET.Element, webhook_item: "WebhookItem") -> by return ET.tostring(xml_request) + @_tsrequest_wrapped + def update_req(self, xml_request: ET.Element, webhook_item: "WebhookItem") -> bytes: + webhook = ET.SubElement(xml_request, "webhook") + if webhook_item.name is not None: + webhook.attrib["name"] = webhook_item.name + if webhook_item.is_enabled is not None: + webhook.attrib["isEnabled"] = str(webhook_item.is_enabled).lower() + + if webhook_item._event is not None: + source = ET.SubElement(webhook, "webhook-source") + ET.SubElement(source, webhook_item._event) + + if webhook_item.url is not None: + destination = ET.SubElement(webhook, "webhook-destination") + post = ET.SubElement(destination, "webhook-destination-http") + post.attrib["method"] = "POST" + post.attrib["url"] = webhook_item.url + + return ET.tostring(xml_request) + class MetricRequest: @_tsrequest_wrapped diff --git a/test/assets/webhook_update.xml b/test/assets/webhook_update.xml new file mode 100644 index 000000000..c001710e9 --- /dev/null +++ b/test/assets/webhook_update.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/test/test_webhook.py b/test/test_webhook.py index 4fa011da0..4afd0c389 100644 --- a/test/test_webhook.py +++ b/test/test_webhook.py @@ -13,6 +13,7 @@ GET_NEW_EVENT_XML = TEST_ASSET_DIR / "webhook_get_new_event.xml" CREATE_XML = TEST_ASSET_DIR / "webhook_create.xml" CREATE_REQUEST_XML = TEST_ASSET_DIR / "webhook_create_request.xml" +UPDATE_XML = TEST_ASSET_DIR / "webhook_update.xml" @pytest.fixture(scope="function") @@ -90,7 +91,7 @@ def test_request_factory(): assert webhook_request_expected.replace("\r", "") == webhook_request_actual -def test_event_setter_none(): +def test_event_setter_none() -> None: """Setting event to None should store None without crashing.""" item = WebhookItem() item.event = "datasource-updated" @@ -100,7 +101,7 @@ def test_event_setter_none(): assert item.event is None -def test_event_setter_short_name(): +def test_event_setter_short_name() -> None: """Short event names should be stored with the webhook-source-event- prefix.""" item = WebhookItem() item.event = "datasource-updated" @@ -108,7 +109,7 @@ def test_event_setter_short_name(): assert item.event == "datasource-updated" -def test_event_setter_full_source_name(): +def test_event_setter_full_source_name() -> None: """Full webhook-source-event- names should be accepted and stored as-is.""" item = WebhookItem() item.event = "webhook-source-event-datasource-updated" @@ -116,7 +117,7 @@ def test_event_setter_full_source_name(): assert item.event == "datasource-updated" -def test_event_setter_new_style_event_name(): +def test_event_setter_new_style_event_name() -> None: """New-style event names (webhook-event-*) should be stored as-is and not mangled.""" item = WebhookItem() item.event = "webhook-event-user-promoted-admin" @@ -167,3 +168,110 @@ def test_create_with_source_event_name(server: TSC.Server) -> None: new_webhook = server.webhooks.create(webhook_model) assert new_webhook.id is not None + + +def test_get_parses_is_enabled_and_status_change_reason(server: TSC.Server) -> None: + response_xml = UPDATE_XML.read_text() + with requests_mock.mock() as m: + m.get(server.webhooks.baseurl + "/webhook-id", text=response_xml) + webhook = server.webhooks.get_by_id("webhook-id") + + assert webhook.is_enabled is True + assert webhook.status_change_reason == "" + assert webhook.name == "webhook-name-updated" + assert webhook.url == "https://updated-url.example.com/hook" + + +def test_update(server: TSC.Server) -> None: + response_xml = UPDATE_XML.read_text() + with requests_mock.mock() as m: + m.put(server.webhooks.baseurl + "/webhook-id", text=response_xml) + webhook_item = WebhookItem() + webhook_item._set_values( + "webhook-id", "webhook-name-updated", "https://updated-url.example.com/hook", "datasource-created", None + ) + webhook_item.is_enabled = True + + updated_webhook = server.webhooks.update(webhook_item) + + assert updated_webhook.id == "webhook-id" + assert updated_webhook.name == "webhook-name-updated" + assert updated_webhook.url == "https://updated-url.example.com/hook" + assert updated_webhook.is_enabled is True + + +def test_update_missing_id(server: TSC.Server) -> None: + webhook_item = WebhookItem() + webhook_item.name = "some-webhook" + with pytest.raises(TSC.MissingRequiredFieldError): + server.webhooks.update(webhook_item) + + +def test_update_request_factory_is_enabled() -> None: + webhook_item = WebhookItem() + webhook_item._set_values( + "webhook-id", "webhook-name", "https://example.com/hook", "datasource-created", None, is_enabled=False + ) + + request_bytes = RequestFactory.Webhook.update_req(webhook_item) + request_str = request_bytes.decode("utf-8") + + assert 'isEnabled="false"' in request_str + assert "webhook-name" in request_str + + +def test_update_request_factory_url_and_event() -> None: + """update_req should serialize url and event into the request body.""" + webhook_item = WebhookItem() + webhook_item._set_values("webhook-id", "webhook-name", "https://example.com/hook", "datasource-created", None) + + request_bytes = RequestFactory.Webhook.update_req(webhook_item) + request_str = request_bytes.decode("utf-8") + + assert "https://example.com/hook" in request_str + assert "webhook-source-event-datasource-created" in request_str + assert 'method="POST"' in request_str + + +def test_update_request_factory_partial_update_name_only() -> None: + """update_req with only name set should omit url, event, and isEnabled.""" + webhook_item = WebhookItem() + webhook_item.name = "new-name" + + request_bytes = RequestFactory.Webhook.update_req(webhook_item) + request_str = request_bytes.decode("utf-8") + + assert "new-name" in request_str + assert "isEnabled" not in request_str + assert "webhook-source" not in request_str + assert "webhook-destination" not in request_str + + +def test_update_request_factory_omits_is_enabled_when_none() -> None: + """update_req should not emit isEnabled when is_enabled is None.""" + webhook_item = WebhookItem() + webhook_item._set_values("webhook-id", "webhook-name", "https://example.com/hook", "datasource-created", None) + # is_enabled is None by default + + request_bytes = RequestFactory.Webhook.update_req(webhook_item) + request_str = request_bytes.decode("utf-8") + + assert "isEnabled" not in request_str + + +def test_parse_is_enabled_false() -> None: + """isEnabled='false' in XML should parse to boolean False.""" + xml = ( + b"" + b'' + b' ' + b" " + b' ' + b" " + b" " + b"" + ) + ns = {"t": "http://tableau.com/api"} + webhooks = WebhookItem.from_response(xml, ns) + assert len(webhooks) == 1 + assert webhooks[0].is_enabled is False