Skip to content
40 changes: 37 additions & 3 deletions tableauserverclient/models/webhook_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -97,6 +109,21 @@ def from_response(cls: type["WebhookItem"], resp: bytes, ns) -> list["WebhookIte
all_webhooks_items.append(webhook_item)
return all_webhooks_items

def _parse_common_tags(self, webhook_xml, ns) -> "WebhookItem":
"""Merge fields from a server response into this item.

Used by the update endpoint (matching the convention in users_endpoint
and datasources_endpoint) so that locally-set fields are preserved
when the server's response omits them.
"""
if not isinstance(webhook_xml, ET.Element):
parsed = fromstring(webhook_xml)
webhook_xml = parsed.find(".//t:webhook", namespaces=ns)
if webhook_xml is not None:
values = self._parse_element(webhook_xml, ns)
self._set_values(*values)
return self

@staticmethod
def _parse_element(webhook_xml: ET.Element, ns) -> tuple:
id = webhook_xml.get("id", None)
Expand All @@ -116,7 +143,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"<Webhook id={self.id} name={self.name} url={self.url} event={self.event}>"
return f"<Webhook id={self.id} name={self.name} url={self.url} event={self.event} is_enabled={self.is_enabled}>"
29 changes: 29 additions & 0 deletions tableauserverclient/server/endpoint/webhooks_endpoint.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import copy
import logging

from .endpoint import Endpoint, api
from .exceptions import MissingRequiredFieldError
from tableauserverclient.server import RequestFactory
from tableauserverclient.models import WebhookItem, PaginationItem

Expand Down Expand Up @@ -118,6 +120,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)
Comment thread
jacalata marked this conversation as resolved.
url = f"{self.baseurl}/{webhook_item.id}"
update_req = RequestFactory.Webhook.update_req(webhook_item)
server_response = self.put_request(url, update_req)
logger.info(f"Updated webhook (ID: {webhook_item.id})")
updated_webhook = copy.copy(webhook_item)
return updated_webhook._parse_common_tags(server_response.content, self.parent_srv.namespace)

@api(version="3.6")
def test(self, webhook_id: str):
"""
Expand Down
20 changes: 20 additions & 0 deletions tableauserverclient/server/request_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Comment thread
jacalata marked this conversation as resolved.

if webhook_item._event is not None:
source = ET.SubElement(webhook, "webhook-source")
ET.SubElement(source, webhook_item._event)
Comment thread
jacalata marked this conversation as resolved.
Outdated

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
Expand Down
12 changes: 12 additions & 0 deletions test/assets/webhook_update.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?xml version='1.0' encoding='UTF-8'?>
<tsResponse xmlns="http://tableau.com/api" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://tableau.com/api http://tableau.com/api/ts-api-2.3.xsd">
<webhook id="webhook-id" name="webhook-name-updated" isEnabled="true" statusChangeReason="">
<webhook-source>
<webhook-source-event-datasource-created />
</webhook-source>
<webhook-destination>
<webhook-destination-http method="POST" url="https://updated-url.example.com/hook"/>
</webhook-destination>
<owner id="webhook_owner_luid" name="webhook_owner_name"/>
</webhook>
</tsResponse>
156 changes: 152 additions & 4 deletions test/test_webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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"
Expand All @@ -100,23 +101,23 @@ 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"
assert item._event == "webhook-source-event-datasource-updated"
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."""
Comment thread
jacalata marked this conversation as resolved.
Outdated
item = WebhookItem()
item.event = "webhook-source-event-datasource-updated"
assert item._event == "webhook-source-event-datasource-updated"
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"
Expand Down Expand Up @@ -167,3 +168,150 @@ 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_preserves_locally_set_fields_omitted_by_server(server: TSC.Server) -> None:
"""update should preserve locally-set fields that the server response omits.

Matches the pattern used by users_endpoint.update and datasources_endpoint.update:
fields set on the input item should survive when the server returns a partial
response.
"""
# Server response omits is_enabled, owner, and url; only id and name are returned.
partial_response = (
"<?xml version='1.0' encoding='UTF-8'?>"
'<tsResponse xmlns="http://tableau.com/api">'
' <webhook id="webhook-id" name="webhook-name-updated">'
" <webhook-source>"
" <webhook-source-event-datasource-created />"
" </webhook-source>"
" </webhook>"
"</tsResponse>"
)
with requests_mock.mock() as m:
m.put(server.webhooks.baseurl + "/webhook-id", text=partial_response)
webhook_item = WebhookItem()
webhook_item._set_values(
"webhook-id",
"webhook-name-original",
"https://local-url.example.com/hook",
"datasource-created",
"local-owner-luid",
)
webhook_item.is_enabled = False

updated_webhook = server.webhooks.update(webhook_item)

# Fields returned by the server should be updated.
assert updated_webhook.name == "webhook-name-updated"
# Fields omitted by the server should retain their locally-set values.
assert updated_webhook.url == "https://local-url.example.com/hook"
assert updated_webhook.owner_id == "local-owner-luid"
assert updated_webhook.is_enabled is False


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"<?xml version='1.0' encoding='UTF-8'?>"
b'<tsResponse xmlns="http://tableau.com/api">'
b' <webhook id="wh-1" name="wh" isEnabled="false">'
b" <webhook-source><webhook-source-event-datasource-created /></webhook-source>"
b' <webhook-destination><webhook-destination-http method="POST" url="https://x.example.com/h"/>'
b" </webhook-destination>"
b" </webhook>"
b"</tsResponse>"
)
ns = {"t": "http://tableau.com/api"}
webhooks = WebhookItem.from_response(xml, ns)
assert len(webhooks) == 1
assert webhooks[0].is_enabled is False
Loading