Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add webhooks #523

Merged
merged 17 commits into from Nov 12, 2019
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 0 additions & 1 deletion .travis.yml
@@ -1,7 +1,6 @@
dist: xenial
language: python
python:
- "2.7"
- "3.5"
- "3.6"
- "3.7"
Expand Down
22 changes: 12 additions & 10 deletions samples/list.py
Expand Up @@ -7,35 +7,36 @@
import argparse
import getpass
import logging
import os
import sys

import tableauserverclient as TSC


def main():
parser = argparse.ArgumentParser(description='List out the names and LUIDs for different resource types')
parser.add_argument('--server', '-s', required=True, help='server address')
parser.add_argument('--site', '-S', default=None, help='site to log into, do not specify for default site')
parser.add_argument('--username', '-u', required=True, help='username to sign into server')
parser.add_argument('--password', '-p', default=None, help='password for the user')
parser.add_argument('--site', '-S', default="", help='site to log into, do not specify for default site')
parser.add_argument('--token-name', '-n', required=True, help='username to signin under')
parser.add_argument('--token', '-t', help='personal access token for logging in')

parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error',
help='desired logging level (set to error by default)')

parser.add_argument('resource_type', choices=['workbook', 'datasource', 'project', 'view', 'job'])
parser.add_argument('resource_type', choices=['workbook', 'datasource', 'project', 'view', 'job', 'webhooks'])

args = parser.parse_args()

if args.password is None:
password = getpass.getpass("Password: ")
else:
password = args.password
token = os.environ.get('TOKEN', args.token)
if not token:
print("--token or TOKEN environment variable needs to be set")
sys.exit(1)

# Set logging level based on user input, or error by default
logging_level = getattr(logging, args.logging_level.upper())
logging.basicConfig(level=logging_level)

# SIGN IN
tableau_auth = TSC.TableauAuth(args.username, password, args.site)
tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, token, site_id=args.site)
server = TSC.Server(args.server, use_server_version=True)
with server.auth.sign_in(tableau_auth):
endpoint = {
Expand All @@ -44,6 +45,7 @@ def main():
'view': server.views,
'job': server.jobs,
'project': server.projects,
'webhooks': server.webhooks,
}.get(args.resource_type)

for resource in TSC.Pager(endpoint.get):
Expand Down
3 changes: 2 additions & 1 deletion tableauserverclient/__init__.py
Expand Up @@ -3,7 +3,8 @@
GroupItem, JobItem, BackgroundJobItem, PaginationItem, ProjectItem, ScheduleItem,\
SiteItem, TableauAuth, PersonalAccessTokenAuth, UserItem, ViewItem, WorkbookItem, UnpopulatedPropertyError,\
HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval, IntervalItem, TaskItem,\
SubscriptionItem, Target, PermissionsRule, Permission, DatabaseItem, TableItem, ColumnItem, FlowItem
SubscriptionItem, Target, PermissionsRule, Permission, DatabaseItem, TableItem, ColumnItem, FlowItem, \
WebhookItem, PersonalAccessTokenAuth
from .server import RequestOptions, CSVRequestOptions, ImageRequestOptions, PDFRequestOptions, Filter, Sort, \
Server, ServerResponseError, MissingRequiredFieldError, NotSignedInError, Pager
from ._version import get_versions
Expand Down
2 changes: 2 additions & 0 deletions tableauserverclient/models/__init__.py
Expand Up @@ -23,3 +23,5 @@
from .workbook_item import WorkbookItem
from .subscription_item import SubscriptionItem
from .permissions_item import PermissionsRule, Permission
from .webhook_item import WebhookItem
from .personal_access_token_auth import PersonalAccessTokenAuth
9 changes: 9 additions & 0 deletions tableauserverclient/models/pagination_item.py
Expand Up @@ -29,3 +29,12 @@ def from_response(cls, resp, ns):
pagination_item._page_size = int(pagination_xml.get('pageSize', '-1'))
pagination_item._total_available = int(pagination_xml.get('totalAvailable', '-1'))
return pagination_item

@classmethod
def from_single_page_list(cls, l):
item = cls()
item._page_number = 1
item._page_size = len(l)
item._total_available = len(l)

return item
3 changes: 3 additions & 0 deletions tableauserverclient/models/personal_access_token_auth.py
Expand Up @@ -9,3 +9,6 @@ def __init__(self, token_name, personal_access_token, site_id=''):
@property
def credentials(self):
return {'personalAccessTokenName': self.token_name, 'personalAccessTokenSecret': self.personal_access_token}

def __repr__(self):
return "<PersonalAccessToken name={} token={}>".format(self.token_name, self.personal_access_token)
88 changes: 88 additions & 0 deletions tableauserverclient/models/webhook_item.py
@@ -0,0 +1,88 @@
import xml.etree.ElementTree as ET
from .exceptions import UnpopulatedPropertyError
from .property_decorators import property_not_nullable, property_is_boolean, property_is_materialized_views_config
from .tag_item import TagItem
from .view_item import ViewItem
from .permissions_item import PermissionsRule
from ..datetime_helpers import parse_datetime
import re


NAMESPACE_RE = re.compile(r'^{.*}')


def _parse_event(events):
event = events[0]
# Strip out the namespace from the tag name
return NAMESPACE_RE.sub('', event.tag)


class WebhookItem(object):
def __init__(self):
self._id = None
self.name = None
self.url = None
self._event = None
self.owner_id = None

def _set_values(self, id, name, url, event, owner_id):
if id is not None:
self._id = id
if name:
self.name = name
if url:
self.url = url
if event:
self.event = event
if owner_id:
self.owner_id = owner_id

@property
def id(self): return self._id
graysonarts marked this conversation as resolved.
Show resolved Hide resolved

@property
def event(self):
if self._event:
return self._event.replace("webhook-source-event-", "")
return None

@event.setter
def event(self, value):
self._event = "webhook-source-event-{}".format(value)

@classmethod
def from_response(cls, resp, ns):
all_webhooks_items = list()
parsed_response = ET.fromstring(resp)
all_webhooks_xml = parsed_response.findall('.//t:webhook', namespaces=ns)
for webhook_xml in all_webhooks_xml:
values = cls._parse_element(webhook_xml, ns)

webhook_item = cls()
webhook_item._set_values(*values)
all_webhooks_items.append(webhook_item)
return all_webhooks_items

@staticmethod
def _parse_element(webhook_xml, ns):
id = webhook_xml.get('id', None)
name = webhook_xml.get('name', None)

url = None
url_tag = webhook_xml.find('.//t:webhook-destination-http', namespaces=ns)
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)

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

def __repr__(self):
return "<Webhook id={} name={} url={} event={}>".format(self.id, self.name, self.url, self.event)
2 changes: 1 addition & 1 deletion tableauserverclient/server/__init__.py
Expand Up @@ -5,7 +5,7 @@
from .. import ConnectionItem, DatasourceItem, DatabaseItem, JobItem, BackgroundJobItem, \
GroupItem, PaginationItem, ProjectItem, ScheduleItem, SiteItem, TableauAuth,\
UserItem, ViewItem, WorkbookItem, TableItem, TaskItem, SubscriptionItem, \
PermissionsRule, Permission, ColumnItem, FlowItem
PermissionsRule, Permission, ColumnItem, FlowItem, WebhookItem
from .endpoint import Auth, Datasources, Endpoint, Groups, Projects, Schedules, \
Sites, Tables, Users, Views, Workbooks, Subscriptions, ServerResponseError, \
MissingRequiredFieldError, Flows
Expand Down
3 changes: 2 additions & 1 deletion tableauserverclient/server/endpoint/__init__.py
Expand Up @@ -11,9 +11,10 @@
from .schedules_endpoint import Schedules
from .server_info_endpoint import ServerInfo
from .sites_endpoint import Sites
from .subscriptions_endpoint import Subscriptions
from .tables_endpoint import Tables
from .tasks_endpoint import Tasks
from .users_endpoint import Users
from .views_endpoint import Views
from .webhooks_endpoint import Webhooks
from .workbooks_endpoint import Workbooks
from .subscriptions_endpoint import Subscriptions
53 changes: 53 additions & 0 deletions tableauserverclient/server/endpoint/webhooks_endpoint.py
@@ -0,0 +1,53 @@
from .endpoint import Endpoint, api, parameter_added_in
from ...models import WebhookItem, PaginationItem
from .. import RequestFactory

import logging
logger = logging.getLogger('tableau.endpoint.webhooks')


class Webhooks(Endpoint):
def __init__(self, parent_srv):
super(Webhooks, self).__init__(parent_srv)

@property
def baseurl(self):
return "{0}/sites/{1}/webhooks".format(self.parent_srv.baseurl, self.parent_srv.site_id)

@api(version="3.6")
def get(self, req_options=None):
logger.info('Querying all Webhooks on site')
url = self.baseurl
server_response = self.get_request(url, req_options)
all_workbook_items = WebhookItem.from_response(server_response.content, self.parent_srv.namespace)
graysonarts marked this conversation as resolved.
Show resolved Hide resolved
pagination_item = PaginationItem.from_single_page_list(all_workbook_items)
return all_workbook_items, pagination_item

@api(version="3.6")
def get_by_id(self, webhook_id):
if not webhook_id:
error = "Webhook ID undefined."
raise ValueError(error)
logger.info('Querying single webhook (ID: {0})'.format(webhook_id))
url = "{0}/{1}".format(self.baseurl, webhook_id)
server_response = self.get_request(url)
return WebhookItem.from_response(server_response.content, self.parent_srv.namespace)[0]

@api(version="3.6")
def delete(self, webhook_id):
if not webhook_id:
error = "Webhook ID undefined."
raise ValueError(error)
url = "{0}/{1}".format(self.baseurl, webhook_id)
self.delete_request(url)
logger.info('Deleted single webhook (ID: {0})'.format(webhook_id))

@api(version="3.6")
def create(self, webhook_item):
url = self.baseurl
create_req = RequestFactory.Webhook.create_req(webhook_item)
server_response = self.post_request(url, create_req)
new_webhook = WebhookItem.from_response(server_response.content, self.parent_srv.namespace)[0]

logger.info('Created new webhook (ID: {0})'.format(new_webhook.id))
return new_webhook
1 change: 0 additions & 1 deletion tableauserverclient/server/endpoint/workbooks_endpoint.py
@@ -1,6 +1,5 @@
from .endpoint import Endpoint, api, parameter_added_in
from .exceptions import InternalServerError, MissingRequiredFieldError
from .endpoint import api, parameter_added_in, Endpoint
from .permissions_endpoint import _PermissionsEndpoint
from .exceptions import MissingRequiredFieldError
from .fileuploads_endpoint import Fileuploads
Expand Down
20 changes: 19 additions & 1 deletion tableauserverclient/server/request_factory.py
Expand Up @@ -555,6 +555,23 @@ def empty_req(self, xml_request):
pass


class WebhookRequest(object):
@_tsrequest_wrapped
def create_req(self, xml_request, webhook_item):
webhook = ET.SubElement(xml_request, 'webhook')
webhook.attrib['name'] = webhook_item.name

source = ET.SubElement(webhook, 'webhook-source')
event = ET.SubElement(source, webhook_item._event)

destination = ET.SubElement(webhook, 'webhook-destination')
post = ET.SubElement(destination, 'webhook-destination-http')
post.attrib['method'] = 'POST'
graysonarts marked this conversation as resolved.
Show resolved Hide resolved
post.attrib['url'] = webhook_item.url

return ET.tostring(xml_request)


class RequestFactory(object):
Auth = AuthRequest()
Connection = Connection()
Expand All @@ -569,9 +586,10 @@ class RequestFactory(object):
Project = ProjectRequest()
Schedule = ScheduleRequest()
Site = SiteRequest()
Subscription = SubscriptionRequest()
Table = TableRequest()
Tag = TagRequest()
Task = TaskRequest()
User = UserRequest()
Workbook = WorkbookRequest()
Subscription = SubscriptionRequest()
Webhook = WebhookRequest()
3 changes: 2 additions & 1 deletion tableauserverclient/server/server.py
Expand Up @@ -4,7 +4,7 @@
from ..namespace import Namespace
from .endpoint import Sites, Views, Users, Groups, Workbooks, Datasources, Projects, Auth, \
Schedules, ServerInfo, Tasks, ServerInfoEndpointNotFoundError, Subscriptions, Jobs, Metadata,\
Databases, Tables, Flows
Databases, Tables, Flows, Webhooks
from .endpoint.exceptions import EndpointUnavailableError, ServerInfoEndpointNotFoundError

import requests
Expand Down Expand Up @@ -55,6 +55,7 @@ def __init__(self, server_address, use_server_version=False):
self.metadata = Metadata(self)
self.databases = Databases(self)
self.tables = Tables(self)
self.webhooks = Webhooks(self)
self._namespace = Namespace()

if use_server_version:
Expand Down
12 changes: 12 additions & 0 deletions test/assets/webhook_create.xml
@@ -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">
<webhook-source>
<webhook-source-api-event-name/>
</webhook-source>
<webhook-destination>
<webhook-destination-http method="POST" url="url"/>
</webhook-destination>
<owner id="webhook_owner_luid" name="webhook_owner_name"/>
</webhook>
</tsResponse>
1 change: 1 addition & 0 deletions test/assets/webhook_create_request.xml
@@ -0,0 +1 @@
<tsRequest><webhook name="webhook-name"><webhook-source><webhook-source-event-api-event-name /></webhook-source><webhook-destination><webhook-destination-http method="POST" url="url" /></webhook-destination></webhook></tsRequest>
14 changes: 14 additions & 0 deletions test/assets/webhook_get.xml
@@ -0,0 +1,14 @@
<?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">
<webhooks>
<webhook id="webhook-id" name="webhook-name">
<webhook-source>
<webhook-source-event-datasource-created />
</webhook-source>
<webhook-destination>
<webhook-destination-http method="POST" url="url"/>
</webhook-destination>
<owner id="webhook_owner_luid" name="webhook_owner_name"/>
</webhook>
</webhooks>
</tsResponse>
14 changes: 8 additions & 6 deletions test/test_datasource.py
Expand Up @@ -178,8 +178,8 @@ def test_update_connection(self):
new_connection = self.server.datasources.update_connection(single_datasource, connection)
self.assertEqual(connection.id, new_connection.id)
self.assertEqual(connection.connection_type, new_connection.connection_type)
self.assertEquals('bar', new_connection.server_address)
self.assertEquals('9876', new_connection.server_port)
self.assertEqual('bar', new_connection.server_address)
self.assertEqual('9876', new_connection.server_port)
self.assertEqual('foo', new_connection.username)

def test_populate_permissions(self):
Expand Down Expand Up @@ -230,9 +230,11 @@ def test_publish(self):
self.assertEqual('5de011f8-5aa9-4d5b-b991-f462c8dd6bb7', new_datasource.owner_id)

def test_publish_async(self):
self.server.version = "3.0"
baseurl = self.server.datasources.baseurl
response_xml = read_xml_asset(PUBLISH_XML_ASYNC)
with requests_mock.mock() as m:
m.post(self.baseurl, text=response_xml)
m.post(baseurl, text=response_xml)
new_datasource = TSC.DatasourceItem('SampleDS', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760')
publish_mode = self.server.PublishMode.CreateNew

Expand Down Expand Up @@ -355,6 +357,6 @@ def test_synchronous_publish_timeout_error(self):
new_datasource = TSC.DatasourceItem(project_id='')
publish_mode = self.server.PublishMode.CreateNew

self.assertRaisesRegexp(InternalServerError, 'Please use asynchronous publishing to avoid timeouts.',
self.server.datasources.publish, new_datasource,
asset('SampleDS.tds'), publish_mode)
self.assertRaisesRegex(InternalServerError, 'Please use asynchronous publishing to avoid timeouts.',
self.server.datasources.publish, new_datasource,
asset('SampleDS.tds'), publish_mode)