Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 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 @@ -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"<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}>"
28 changes: 28 additions & 0 deletions tableauserverclient/server/endpoint/webhooks_endpoint.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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):
"""
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()

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
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>
116 changes: 112 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."""
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,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"<?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