Skip to content
49 changes: 46 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 All @@ -84,6 +96,15 @@ def event(self, value: str | None) -> None:
else:
self._event = f"webhook-source-event-{value}"

@property
def event_tag(self) -> str | None:
"""Internal event tag as stored (e.g. 'webhook-source-event-datasource-created'
or 'webhook-event-user-promoted-admin'). Unlike `event`, this returns the
raw tag without stripping the `webhook-source-event-` prefix. Used by the
request-factory serializers so they don't reach into the private `_event`.
"""
return self._event

@classmethod
def from_response(cls: type["WebhookItem"], resp: bytes, ns) -> list["WebhookItem"]:
all_webhooks_items = list()
Expand All @@ -97,6 +118,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 +152,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}>"
32 changes: 32 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,36 @@ 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. Set webhook_item.id directly, or fetch the "
"webhook via webhooks.get_by_id() / webhooks.get() before updating."
)
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
41 changes: 38 additions & 3 deletions tableauserverclient/server/request_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -1397,10 +1397,10 @@ def create_req(self, xml_request: ET.Element, webhook_item: "WebhookItem") -> by
raise ValueError(f"Name must be provided for {webhook_item}")

source = ET.SubElement(webhook, "webhook-source")
if isinstance(webhook_item._event, str):
ET.SubElement(source, webhook_item._event)
if isinstance(webhook_item.event_tag, str):
ET.SubElement(source, webhook_item.event_tag)
else:
raise ValueError(f"_event for Webhook must be provided. {webhook_item}")
raise ValueError(f"event for Webhook must be provided. {webhook_item}")

destination = ET.SubElement(webhook, "webhook-destination")
post = ET.SubElement(destination, "webhook-destination-http")
Expand All @@ -1412,6 +1412,41 @@ 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:
# Reject a no-op update up front. Without at least one updatable
# attribute set the payload is <tsRequest><webhook/></tsRequest>,
# which the server rejects with a generic 400 that gives the caller
# no idea what happened. Raise here with an actionable message.
if (
webhook_item.name is None
and webhook_item.is_enabled is None
and webhook_item.event_tag is None
and webhook_item.url is None
):
raise ValueError(
"WebhookItem has no updatable fields set; "
"at least one of name, is_enabled, event, or url must be provided."
)

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_tag is not None:
source = ET.SubElement(webhook, "webhook-source")
ET.SubElement(source, webhook_item.event_tag)

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>
Loading
Loading