8000 Add webhooks (#523) · SnarkyPapi/server-client-python@9c19aa3 · GitHub
[go: up one dir, main page]

Skip to content

Commit 9c19aa3

Browse files
author
Russell Hay
authored
Add webhooks (tableau#523)
* Fix a decreated warning in the tests * Dropping 2.7 support since it's EOLEOY * fixes deprecated warning in test_requests.py * fixes deprecated warning in test_datasource.py * fixed deprecated warning in test_workbook * Fixing incorrect version for publish_async * Fix deprecrated warning in test_regression_tests.py * initial working version * removing print statements * pep8 fixes in test * fixing pep8 failures in tableauserverclient * Make token optional if it's set in the environment * read events properly * read events properly * fix request generation * fix pep8 error * Tyler's feedback
1 parent c366694 commit 9c19aa3

22 files changed

+324
-39
lines changed

.travis.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
dist: xenial
22
language: python
33
python:
4-
- "2.7"
54
- "3.5"
65
- "3.6"
76
- "3.7"

samples/list.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,35 +7,36 @@
77
import argparse
88
import getpass
99
import logging
10+
import os
11+
import sys
1012

1113
import tableauserverclient as TSC
1214

1315

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

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

24-
parser.add_argument('resource_type', choices=['workbook', 'datasource', 'project', 'view', 'job'])
26+
parser.add_argument('resource_type', choices=['workbook', 'datasource', 'project', 'view', 'job', 'webhooks'])
2527

2628
args = parser.parse_args()
27-
28-
if args.password is None:
29-
password = getpass.getpass("Password: ")
30-
else:
31-
password = args.password
29+
token = os.environ.get('TOKEN', args.token)
30+
if not token:
31+
print("--token or TOKEN environment variable needs to be set")
32+
sys.exit(1)
3233

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

3738
# SIGN IN
38-
tableau_auth = TSC.TableauAuth(args.username, password, args.site)
39+
tableau_auth = TSC.PersonalAccessTokenAuth(args.token_name, token, site_id=args.site)
3940
server = TSC.Server(args.server, use_server_version=True)
4041
with server.auth.sign_in(tableau_auth):
4142
endpoint = {
@@ -44,6 +45,7 @@ def main():
4445
'view': server.views,
4546
'job': server.jobs,
4647
'project': server.projects,
48+
'webhooks': server.webhooks,
4749
}.get(args.resource_type)
4850

4951
for resource in TSC.Pager(endpoint.get):

tableauserverclient/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
GroupItem, JobItem, BackgroundJobItem, PaginationItem, ProjectItem, ScheduleItem,\
44
SiteItem, TableauAuth, PersonalAccessTokenAuth, UserItem, ViewItem, WorkbookItem, UnpopulatedPropertyError,\
55
HourlyInterval, DailyInterval, WeeklyInterval, MonthlyInterval, IntervalItem, TaskItem,\
6-
SubscriptionItem, Target, PermissionsRule, Permission, DatabaseItem, TableItem, ColumnItem, FlowItem
6+
SubscriptionItem, Target, PermissionsRule, Permission, DatabaseItem, TableItem, ColumnItem, FlowItem, \
7+
WebhookItem, PersonalAccessTokenAuth
78
from .server import RequestOptions, CSVRequestOptions, ImageRequestOptions, PDFRequestOptions, Filter, Sort, \
89
Server, ServerResponseError, MissingRequiredFieldError, NotSignedInError, Pager
910
from ._version import get_versions

tableauserverclient/models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,5 @@
2323
from .workbook_item import WorkbookItem
2424
from .subscription_item import SubscriptionItem
2525
from .permissions_item import PermissionsRule, Permission
26+
from .webhook_item import WebhookItem
27+
from .personal_access_token_auth import PersonalAccessTokenAuth

tableauserverclient/models/pagination_item.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,12 @@ def from_response(cls, resp, ns):
2929
pagination_item._page_size = int(pagination_xml.get('pageSize', '-1'))
3030
pagination_item._total_available = int(pagination_xml.get('totalAvailable', '-1'))
3131
return pagination_item
32+
33+
@classmethod
34+
def from_single_page_list(cls, l):
35+
item = cls()
36+
item._page_number = 1
37+
item._page_size = len(l)
38+
item._total_available = len(l)
39+
40+
return item

tableauserverclient/models/personal_access_token_auth.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,6 @@ def __init__(self, token_name, personal_access_token, site_id=''):
99
@property
1010
def credentials(self):
1111
return {'personalAccessTokenName': self.token_name, 'personalAccessTokenSecret': self.personal_access_token}
12+
13+
def __repr__(self):
14+
return "<PersonalAccessToken name={} token={}>".format(self.token_name, self.personal_access_token)
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import xml.etree.ElementTree as ET
2+
from .exceptions import UnpopulatedPropertyError
3+
from .property_decorators import property_not_nullable, property_is_boolean, property_is_materialized_views_config
4+
from .tag_item import TagItem
5+
from .view_item import ViewItem
6+
from .permissions_item import PermissionsRule
7+
from ..datetime_helpers import parse_datetime
8+
import re
9+
10+
11+
NAMESPACE_RE = re.compile(r'^{.*}')
12+
13+
14+
def _parse_event(events):
15+
event = events[0]
16+
# Strip out the namespace from the tag name
17+
return NAMESPACE_RE.sub('', event.tag)
18+
19+
20+
class WebhookItem(object):
21+
def __init__(self):
22+
self._id = None
23+
self.name = None
24+
self.url = None
25+
self._event = None
26+
self.owner_id = None
27+
28+
def _set_values(self, id, name, url, event, owner_id):
29+
if id is not None:
30+
self._id = id
31+
if name:
32+
self.name = name
33+
if url:
34+
self.url = url
35+
if event:
36+
self.event = event
37+
if owner_id:
38+
self.owner_id = owner_id
39+
40+
@property
41+
def id(self):
42+
return self._id
43+
44+
@property
45+
def event(self):
46+
if self._event:
47+
return self._event.replace("webhook-source-event-", "")
48+
return None
49+
50+
@event.setter
51+
def event(self, value):
52+
self._event = "webhook-source-event-{}".format(value)
53+
54+
@classmethod
55+
def from_response(cls, resp, ns):
56+
all_webhooks_items = list()
57+
parsed_response = ET.fromstring(resp)
58+
all_webhooks_xml = parsed_response.findall('.//t:webhook', namespaces=ns)
59+
for webhook_xml in all_webhooks_xml:
60+
values = cls._parse_element(webhook_xml, ns)
61+
62+
webhook_item = cls()
63+
webhook_item._set_values(*values)
64+
all_webhooks_items.append(webhook_item)
65+
return all_webhooks_items
66+
67+
@staticmethod
68+
def _parse_element(webhook_xml, ns):
69+
id = webhook_xml.get('id', None)
70+
name = webhook_xml.get('name', None)
71+
72+
url = None
73+
url_tag = webhook_xml.find('.//t:webhook-destination-http', namespaces=ns)
74+
if url_tag is not None:
75+
url = url_tag.get('url', None)
76+
77+
event = webhook_xml.findall('.//t:webhook-source/*', namespaces=ns)
78+
if event is not None and len(event) > 0:
79+
event = _parse_event(event)
80+
81+
owner_id = None
82+
owner_tag = webhook_xml.find('.//t:owner', namespaces=ns)
83+
if owner_tag is not None:
84+
owner_id = owner_tag.get('id', None)
85+
86+
return id, name, url, event, owner_id
87+
88+
def __repr__(self):
89+
return "<Webhook id={} name={} url={} event={}>".format(self.id, self.name, self.url, self.event)

tableauserverclient/server/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from .. import ConnectionItem, DatasourceItem, DatabaseItem, JobItem, BackgroundJobItem, \
66
GroupItem, PaginationItem, ProjectItem, ScheduleItem, SiteItem, TableauAuth,\
77
UserItem, ViewItem, WorkbookItem, TableItem, TaskItem, SubscriptionItem, \
8-
PermissionsRule, Permission, ColumnItem, FlowItem
8+
PermissionsRule, Permission, ColumnItem, FlowItem, WebhookItem
99
from .endpoint import Auth, Datasources, Endpoint, Groups, Projects, Schedules, \
1010
Sites, Tables, Users, Views, Workbooks, Subscriptions, ServerResponseError, \
1111
MissingRequiredFieldError, Flows

tableauserverclient/server/endpoint/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@
1111
from .schedules_endpoint import Schedules
1212
from .server_info_endpoint import ServerInfo
1313
from .sites_endpoint import Sites
14+
from .subscriptions_endpoint import Subscriptions
1415
from .tables_endpoint import Tables
1516
from .tasks_endpoint import Tasks
1617
from .users_endpoint import Users
1718
from .views_endpoint import Views
19+
from .webhooks_endpoint import Webhooks
1820
from .workbooks_endpoint import Workbooks
19-
from .subscriptions_endpoint import Subscriptions
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from .endpoint import Endpoint, api, parameter_added_in
2+
from ...models import WebhookItem, PaginationItem
3+
from .. import RequestFactory
4+
5+
import logging
6+
logger = logging.getLogger('tableau.endpoint.webhooks')
7+
8+
9+
class Webhooks(Endpoint):
10+
def __init__(self, parent_srv):
11+
super(Webhooks, self).__init__(parent_srv)
12+
13+
@property
14+
def baseurl(self):
15+
return "{0}/sites/{1}/webhooks".format(self.parent_srv.baseurl, self.parent_srv.site_id)
16+
17+
@api(version="3.6")
18+
def get(self, req_options=None):
19+
logger.info('Querying all Webhooks on site')
20+
url = self.baseurl
21+
server_response = self.get_request(url, req_options)
22+
all_webhook_items = WebhookItem.from_response(server_response.content, self.parent_srv.namespace)
23+
pagination_item = PaginationItem.from_single_page_list(all_webhook_items)
24+
return all_webhook_items, pagination_item
25+
26+
@api(version="3.6")
27+
def get_by_id(self, webhook_id):
28+
if not webhook_id:
29+
error = "Webhook ID undefined."
30+
raise ValueError(error)
31+
logger.info('Querying single webhook (ID: {0})'.format(webhook_id))
32+
url = "{0}/{1}".format(self.baseurl, webhook_id)
33+
server_response = self.get_request(url)
34+
return WebhookItem.from_response(server_response.content, self.parent_srv.namespace)[0]
35+
36+
@api(version="3.6")
37+
def delete(self, webhook_id):
38+
if not webhook_id:
39+
error = "Webhook ID undefined."
40+
raise ValueError(error)
41+
url = "{0}/{1}".format(self.baseurl, webhook_id)
42+
self.delete_request(url)
43+
logger.info('Deleted single webhook (ID: {0})'.format(webhook_id))
44+
45+
@api(version="3.6")
46+
def create(self, webhook_item):
47+
url = self.baseurl
48+
create_req = RequestFactory.Webhook.create_req(webhook_item)
49+
server_response = self.post_request(url, create_req)
50+
new_webhook = WebhookItem.from_response(server_response.content, self.parent_srv.namespace)[0]
51+
52+
logger.info('Created new webhook (ID: {0})'.format(new_webhook.id))
53+
return new_webhook

tableauserverclient/server/endpoint/workbooks_endpoint.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
from .endpoint import Endpoint, api, parameter_added_in
22
from .exceptions import InternalServerError, MissingRequiredFieldError
3-
from .endpoint import api, parameter_added_in, Endpoint
43
from .permissions_endpoint import _PermissionsEndpoint
54
from .exceptions import MissingRequiredFieldError
65
from .fileuploads_endpoint import Fileuploads

tableauserverclient/server/request_factory.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,23 @@ def empty_req(self, xml_request):
555555
pass
556556

557557

558+
class WebhookRequest(object):
559+
@_tsrequest_wrapped
560+
def create_req(self, xml_request, webhook_item):
561+
webhook = ET.SubElement(xml_request, 'webhook')
562+
webhook.attrib['name'] = webhook_item.name
563+
564+
source = ET.SubElement(webhook, 'webhook-source')
565+
event = ET.SubElement(source, webhook_item._event)
566+
567+
destination = ET.SubElement(webhook, 'webhook-destination')
568+
post = ET.SubElement(destination, 'webhook-destination-http')
569+
post.attrib['method'] = 'POST'
570+
post.attrib['url'] = webhook_item.url
571+
572+
return ET.tostring(xml_request)
573+
574+
558575
class RequestFactory(object):
559576
Auth = AuthRequest()
560577
Connection = Connection()
@@ -569,9 +586,10 @@ class RequestFactory(object):
569586
Project = ProjectRequest()
570587
Schedule = ScheduleRequest()
571588
Site = SiteRequest()
589+
Subscription = SubscriptionRequest()
572590
Table = TableRequest()
573591
Tag = TagRequest()
574592
Task = TaskRequest()
575593
User = UserRequest()
576594
Workbook = WorkbookRequest()
577-
Subscription = SubscriptionRequest()
595+
Webhook = WebhookRequest()

tableauserverclient/server/server.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from ..namespace import Namespace
55
from .endpoint import Sites, Views, Users, Groups, Workbooks, Datasources, Projects, Auth, \
66
Schedules, ServerInfo, Tasks, ServerInfoEndpointNotFoundError, Subscriptions, Jobs, Metadata,\
7-
Databases, Tables, Flows
7+
Databases, Tables, Flows, Webhooks
88
from .endpoint.exceptions import EndpointUnavailableError, ServerInfoEndpointNotFoundError
99

1010
import requests
@@ -55,6 +55,7 @@ def __init__(self, server_address, use_server_version=False):
5555
self.metadata = Metadata(self)
5656
self.databases = Databases(self)
5757
self.tables = Tables(self)
58+
self.webhooks = Webhooks(self)
5859
self._namespace = Namespace()
5960

6061
if use_server_version:

test/assets/webhook_create.xml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?xml version='1.0' encoding='UTF-8'?>
2+
<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">
3+
<webhook id="webhook-id" name="webhook-name">
4+
<webhook-source>
5+
<webhook-source-api-event-name/>
6+
</webhook-source>
7+
<webhook-destination>
8+
<webhook-destination-http method="POST" url="url"/>
9+
</webhook-destination>
10+
<owner id="webhook_owner_luid" name="webhook_owner_name"/>
11+
</webhook>
12+
</tsResponse>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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>

test/assets/webhook_get.xml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?xml version='1.0' encoding='UTF-8'?>
2+
<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">
3+
<webhooks>
4+
<webhook id="webhook-id" name="webhook-name">
5+
<webhook-source>
6+
<webhook-source-event-datasource-created />
7+
</webhook-source>
8+
<webhook-destination>
9+
<webhook-destination-http method="POST" url="url"/>
10+
</webhook-destination>
11+
<owner id="webhook_owner_luid" name="webhook_owner_name"/>
12+
</webhook>
13+
</webhooks>
14+
</tsResponse>

test/test_datasource.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,8 @@ def test_update_connection(self):
178178
new_connection = self.server.datasources.update_connection(single_datasource, connection)
179179
self.assertEqual(connection.id, new_connection.id)
180180
self.assertEqual(connection.connection_type, new_connection.connection_type)
181-
self.assertEquals('bar', new_connection.server_address)
182-
self.assertEquals('9876', new_connection.server_port)
181+
self.assertEqual('bar', new_connection.server_address)
182+
self.assertEqual('9876', new_connection.server_port)
183183
self.assertEqual('foo', new_connection.username)
184184

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

232232
def test_publish_async(self):
233+
self.server.version = "3.0"
234+
baseurl = self.server.datasources.baseurl
233235
response_xml = read_xml_asset(PUBLISH_XML_ASYNC)
234236
with requests_mock.mock() as m:
235-
m.post(self.baseurl, text=response_xml)
237+
m.post(baseurl, text=response_xml)
236238
new_datasource = TSC.DatasourceItem('SampleDS', 'ee8c6e70-43b6-11e6-af4f-f7b0d8e20760')
237239
publish_mode = self.server.PublishMode.CreateNew
238240

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

358-
self.assertRaisesRegexp(InternalServerError, 'Please use asynchronous publishing to avoid timeouts.',
359-
self.server.datasources.publish, new_datasource,
360-
asset('SampleDS.tds'), publish_mode)
360+
self.assertRaisesRegex(InternalServerError, 'Please use asynchronous publishing to avoid timeouts.',
361+
self.server.datasources.publish, new_datasource,
362+
asset('SampleDS.tds'), publish_mode)

0 commit comments

Comments
 (0)
0