Skip to content
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
hierarchy path (e.g. `"Marketing/Q1 Reports"`). The walk is performed level by
level using the REST API name filter, so a path with *n* components issues *n*
requests. Returns the matching `ProjectItem` or `None` if no project is found.
* Added `Webhooks.update()` to modify an existing webhook's name, url, event, or
enabled state. Exposed new `WebhookItem` fields: `is_enabled`,
`status_change_reason` (read-only, server-set), and `event_tag` (raw wire form
of the event tag).

## 0.18.0 (6 April 2022)
* Switched to using defused_xml for xml attack protection
Expand Down
73 changes: 66 additions & 7 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,11 +72,20 @@ 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:
return self._id

@property
def status_change_reason(self) -> str | None:
"""Server-set; assignment has no effect on the wire."""
return self._status_change_reason

@property
def event(self) -> str | None:
if self._event:
Expand All @@ -84,6 +101,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,8 +123,33 @@ 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:
(
_,
name,
url,
event,
_,
is_enabled,
status_change_reason,
) = self._parse_element(webhook_xml, ns)
self._set_values(None, name, url, event, None, is_enabled, status_change_reason)
return self

@staticmethod
def _parse_element(webhook_xml: ET.Element, ns) -> tuple:
def _parse_element(
webhook_xml: ET.Element, ns
) -> tuple[str | None, str | None, str | None, str | None, str | None, bool | None, str | None]:
id = webhook_xml.get("id", None)
name = webhook_xml.get("name", None)

Expand All @@ -107,16 +158,24 @@ def _parse_element(webhook_xml: ET.Element, ns) -> tuple:
if url_tag is not None:
url = url_tag.get("url", None)

event = webhook_xml.findall(".//t:webhook-source/*", namespaces=ns)
if event is not None and len(event) > 0:
event = _parse_event(event)
event: str | None = None
event_elements = webhook_xml.findall(".//t:webhook-source/*", namespaces=ns)
if event_elements:
event = _parse_event(event_elements)

owner_id = None
owner_tag = webhook_xml.find(".//t:owner", namespaces=ns)
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}>"
36 changes: 36 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,40 @@ 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.

Fields serialized to the wire: name, url, event, is_enabled. Other
fields on the passed `WebhookItem` (e.g. `status_change_reason`,
`owner_id`) are not sent.

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
47 changes: 44 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,47 @@ 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:
if not isinstance(webhook_item.name, str):
raise ValueError(f"Name must be a string on {webhook_item}")
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:
if not isinstance(webhook_item.event_tag, str):
raise ValueError(f"event for Webhook must be a string on {webhook_item}")
source = ET.SubElement(webhook, "webhook-source")
ET.SubElement(source, webhook_item.event_tag)

if webhook_item.url is not None:
if not isinstance(webhook_item.url, str):
raise ValueError(f"URL must be a string on {webhook_item}")
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