8000 Merge pull request #1 from tableau/development · guodah/server-client-python@d2e76ca · GitHub
[go: up one dir, main page]

Skip to content

Commit d2e76ca

Browse files
authored
Merge pull request #1 from tableau/development
Add support for materializeViews as schedule and task type (tableau#542)
2 parents 308705d + 90343ac commit d2e76ca

File tree

8 files changed

+155
-37
lines changed

8 files changed

+155
-37
lines changed

tableauserverclient/models/schedule_item.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ class Type:
1111
Extract = "Extract"
1212
Flow = "Flow"
1313
Subscription = "Subscription"
14+
MaterializeViews = "MaterializeViews"
1415

1516
class ExecutionOrder:
1617
Parallel = "Parallel"
@@ -199,7 +200,7 @@ def _parse_interval_item(parsed_response, frequency, ns):
199200
# We use fractional hours for the two minute-based intervals.
200201
# Need to convert to hours from minutes here
201202
if interval_occurrence == IntervalItem.Occurrence.Minutes:
202-
interval_value = float(interval_value / 60)
203+
interval_value = float(interval_value) / 60
203204

204205
return HourlyInterval(start_time, end_time, interval_value)
205206

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,52 @@
11
import xml.etree.ElementTree as ET
22
from .target import Target
3+
from .schedule_item import ScheduleItem
4+
from ..datetime_helpers import parse_datetime
35

46

57
class TaskItem(object):
6-
def __init__(self, id_, task_type, priority, consecutive_failed_count=0, schedule_id=None, target=None):
8+
class Type:
9< F438 /td>+
ExtractRefresh = "extractRefresh"
10+
MaterializeViews = "materializeViews"
11+
12+
def __init__(self, id_, task_type, priority, consecutive_failed_count=0, schedule_id=None,
13+
schedule_item=None, last_run_at=None, target=None):
714
self.id = id_
815
self.task_type = task_type
916
self.priority = priority
1017
self.consecutive_failed_count = consecutive_failed_count
1118
self.schedule_id = schedule_id
19+
self.schedule_item = schedule_item
20+
self.last_run_at = last_run_at
1221
self.target = target
1322

1423
def __repr__(self):
1524
return "<Task#{id} {task_type} pri({priority}) failed({consecutive_failed_count}) schedule_id({" \
1625
"schedule_id}) target({target})>".format(**self.__dict__)
1726

1827
@classmethod
19-
def from_response(cls, xml, ns):
28+
def from_response(cls, xml, ns, task_type=Type.ExtractRefresh):
2029
parsed_response = ET.fromstring(xml)
2130
all_tasks_xml = parsed_response.findall(
22-
'.//t:task/t:extractRefresh', namespaces=ns)
31+
'.//t:task/t:{}'.format(task_type), namespaces=ns)
2332

2433
all_tasks = (TaskItem._parse_element(x, ns) for x in all_tasks_xml)
2534

2635
return list(all_tasks)
2736

2837
@classmethod
2938
def _parse_element(cls, element, ns):
30-
schedule = None
39+
schedule_id = None
40+
schedule_item = None
3141
target = None
32-
schedule_element = element.find('.//t:schedule', namespaces=ns)
42+
last_run_at = None
3343
workbook_element = element.find('.//t:workbook', namespaces=ns)
3444
datasource_element = element.find('.//t:datasource', namespaces=ns)
35-
if schedule_element is not None:
36-
schedule = schedule_element.get('id', None)
45+
last_run_at_element = element.find('.//t:lastRunAt', namespaces=ns)
46+
47+
schedule_item_list = ScheduleItem.from_element(element, ns)
48+
if len(schedule_item_list) >= 1:
49+
schedule_item = schedule_item_list[0]
3750

3851
# according to the Tableau Server REST API documentation,
3952
# there should be only one of workbook or datasource
@@ -43,9 +56,12 @@ def _parse_element(cls, element, ns):
4356
if datasource_element is not None:
4457
datasource_id = datasource_element.get('id', None)
4558
target = Target(datasource_id, "datasource")
59+
if last_run_at_element is not None:
60+
last_run_at = parse_datetime(last_run_at_element.text)
4661

4762
task_type = element.get('type', None)
4863
priority = int(element.get('priority', -1))
4964
consecutive_failed_count = int(element.get('consecutiveFailedCount', 0))
5065
id_ = element.get('id', None)
51-
return cls(id_, task_type, priority, consecutive_failed_count, schedule, target)
66+
return cls(id_, task_type, priority, consecutive_failed_count, schedule_item.id,
67+
schedule_item, last_run_at, target)

tableauserverclient/server/endpoint/schedules_endpoint.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from .endpoint import Endpoint, api
22
from .exceptions import MissingRequiredFieldError
3-
from .. import RequestFactory, PaginationItem, ScheduleItem, WorkbookItem, DatasourceItem
3+
from .. import RequestFactory, PaginationItem, ScheduleItem, WorkbookItem, DatasourceItem, TaskItem
44
import logging
55
import copy
66
from collections import namedtuple
@@ -68,12 +68,13 @@ def create(self, schedule_item):
6868
return new_schedule
6969

7070
@api(version="2.8")
71-
def add_to_schedule(self, schedule_id, workbook=None, datasource=None):
71+
def add_to_schedule(self, schedule_id, workbook=None, datasource=None,
72+
task_type=TaskItem.Type.ExtractRefresh):
7273

7374
def add_to(resource, type_, req_factory):
7475
id_ = resource.id
7576
url = "{0}/{1}/{2}s".format(self.siteurl, schedule_id, type_)
76-
add_req = req_factory(id_)
77+
add_req = req_factory(id_, task_type=task_type)
7778
response = self.put_request(url, add_req)
7879
if response.status_code < 200 or response.status_code >= 300:
7980
return AddResponse(result=False,

tableauserverclient/server/endpoint/tasks_endpoint.py

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,26 +9,44 @@
99
class Tasks(Endpoint):
1010
@property
1111
def baseurl(self):
12-
return "{0}/sites/{1}/tasks/extractRefreshes".format(self.parent_srv.baseurl,
13-
self.parent_srv.site_id)
12+
return "{0}/sites/{1}/tasks".format(self.parent_srv.baseurl,
13+
self.parent_srv.site_id)
14+
15+
def __normalize_task_type(self, task_type):
16+
"""
17+
The word for extract refresh used in API URL is "extractRefreshes".
18+
It is different than the tag "extractRefresh" used in the request body.
19+
"""
20+
if task_type == TaskItem.Type.ExtractRefresh:
21+
return '{}es'.format(task_type)
22+
else:
23+
return task_type
1424

1525
@api(version='2.6')
16-
def get(self, req_options=None):
17-
logger.info('Querying all tasks for the site')
18-
url = self.baseurl
26+
def get(self, req_options=None, task_type=TaskItem.Type.ExtractRefresh):
27+
if task_type == TaskItem.Type.MaterializeViews:
28+
self.parent_srv.assert_at_least_version("3.8")
29+
30+
logger.info('Querying all {} tasks for the site'.format(task_type))
31+
32+
url = "{0}/{1}".format(self.baseurl, self.__normalize_task_type(task_type))
1933
server_response = self.get_request(url, req_options)
2034

21-
pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace)
22-
all_extract_tasks = TaskItem.from_response(server_response.content, self.parent_srv.namespace)
23-
return all_extract_tasks, pagination_item
35+
pagination_item = PaginationItem.from_response(server_response.content,
36+
self.parent_srv.namespace)
37+
all_tasks = TaskItem.from_response(server_response.content,
38+
self.parent_srv.namespace,
39+
task_type)
40+
return all_tasks, pagination_item
2441

2542
@api(version='2.6')
2643
def get_by_id(self, task_id):
2744
if not task_id:
2845
error = "No Task ID provided"
2946
raise ValueError(error)
3047
logger.info("Querying a single task by id ({})".format(task_id))
31-
url = "{}/{}".format(self.baseurl, task_id)
48+
url = "{}/{}/{}".format(self.baseurl,
49+
self.__normalize_task_type(TaskItem.Type.ExtractRefresh), task_id)
3250
server_response = self.get_request(url)
3351
return TaskItem.from_response(server_response.content, self.parent_srv.namespace)[0]
3452

@@ -38,17 +56,22 @@ def run(self, task_item):
3856
error = "User item missing ID."
3957
raise MissingRequiredFieldError(error)
4058

41-
url = "{0}/{1}/runNow".format(self.baseurl, task_item.id)
59+
url = "{0}/{1}/{2}/runNow".format(self.baseurl,
60+
self.__normalize_task_type(TaskItem.Type.ExtractRefresh), task_item.id)
4261
run_req = RequestFactory.Task.run_req(task_item)
4362
server_response = self.post_request(url, run_req)
4463
return server_response.content
4564

4665
# Delete 1 task by id
4766
@api(version="3.6")
48-
def delete(self, task_id):
67+
def delete(self, task_id, task_type=TaskItem.Type.ExtractRefresh):
68+
if task_type == TaskItem.Type.MaterializeViews:
69+
self.parent_srv.assert_at_least_version("3.8")
70+
4971
if not task_id:
5072
error = "No Task ID provided"
5173
raise ValueError(error)
52-
url = "{0}/{1}".format(self.baseurl, task_id)
74+
url = "{0}/{1}/{2}".format(self.baseurl,
75+
self.__normalize_task_type(task_type), task_id)
5376
self.delete_request(url)
5477
logger.info('Deleted single task (ID: {0})'.format(task_id))

tableauserverclient/server/request_factory.py

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from requests.packages.urllib3.fields import RequestField
66
from requests.packages.urllib3.filepost import encode_multipart_formdata
77

8-
from ..models import UserItem, GroupItem, PermissionsRule
8+
from ..models import TaskItem, UserItem, GroupItem, PermissionsRule
99

1010

1111
def _add_multipart(parts):
@@ -24,6 +24,7 @@ def wrapper(self, *args, **kwargs):
2424
xml_request = ET.Element('tsRequest')
2525
func(self, xml_request, *args, **kwargs)
2626
return ET.tostring(xml_request)
27+
2728
return wrapper
2829

2930

@@ -311,28 +312,28 @@ def update_req(self, schedule_item):
311312
single_interval_element.attrib[expression] = value
312313
return ET.tostring(xml_request)
313314

314-
def _add_to_req(self, id_, type_):
315+
def _add_to_req(self, id_, target_type, task_type=TaskItem.Type.ExtractRefresh):
315316
"""
316317
<task>
317-
<extractRefresh>
318+
<target_type>
318319
<workbook/datasource id="..."/>
319-
</extractRefresh>
320+
</target_type>
320321
</task>
321322
322323
"""
323324
xml_request = ET.Element('tsRequest')
324325
task_element = ET.SubElement(xml_request, 'task')
325-
refresh = ET.SubElement(task_element, 'extractRefresh')
326-
workbook = ET.SubElement(refresh, type_)
326+
task = ET.SubElement(task_element, task_type)
327+
workbook = ET.SubElement(task, target_type)
327328
workbook.attrib['id'] = id_
328329

329330
return ET.tostring(xml_request)
330331

331-
def add_workbook_req(self, id_):
332-
return self._add_to_req(id_, "workbook")
332+
def add_workbook_req(self, id_, task_type=TaskItem.Type.ExtractRefresh):
333+
return self._add_to_req(id_, "workbook", task_type)
333334

334-
def add_datasource_req(self, id_):
335-
return self._add_to_req(id_, "datasource")
335+
def add_datasource_req(self, id_, task_type=TaskItem.Type.ExtractRefresh):
336+
return self._add_to_req(id_, "datasource", task_type)
336337

337338

338339
class SiteRequest(object):
@@ -479,7 +480,7 @@ def update_req(self, workbook_item):
479480
if workbook_item.owner_id:
480481
owner_element = ET.SubElement(workbook_element, 'owner')
481482
owner_element.attrib['id'] = workbook_item.owner_id
482-
if workbook_item.materialized_views_config['materialized_views_enabled']\
483+
if workbook_item.materialized_views_config['materialized_views_enabled'] \
483484
and workbook_item.materialized_views_config['run_materialization_now']:
484485
materialized_views_config = workbook_item.materialized_views_config
485486
materialized_views_element = ET.SubElement(workbook_element, 'materializedViewsEnablementConfig')
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<?xml version='1.0' encoding='UTF-8'?>
2+
<tsResponse
3+
xmlns="http://tableau.com/api"
4+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://tableau.com/api http://tableau.com/api/ts-api-2.6.xsd">
5+
<job id="7b6b59a8-ac3c-4d1d-2e9e-0b5b4ba8a7b6" mode="Asynchronous" type="RefreshExtract" />
6+
</tsResponse>
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
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-3.8.xsd">
3+
<tasks>
4+
<task>
5+
<materializeViews id="2b217acb-194a-4291-ae90-7d5ec360395d" consecutiveFailedCount="0" type="MaterializeViewsTask">
6+
<schedule id="b22190b4-6ac2-4eed-9563-4afc03444413" name="Hourly4-Schedule" state="Active" priority="75" createdAt="2019-12-06T03:27:35Z" updatedAt="2019-12-09T20:30:59Z" type="MaterializeViews" frequency="Hourly" nextRunAt="2019-12-09T22:30:00Z">
7+
<frequencyDetails start="02:30:00" end="23:00:00">
8+
<intervals>
9+
<interval hours="2"/>
10+
</intervals>
11+
</frequencyDetails>
12+
</schedule>
13+
<workbook id="a462c148-fc40-4670-a8e4-39b7f0c58c7f"/>
14+
<lastRunAt>2019-12-09T20:45:04Z</lastRunAt>
15+
</materializeViews>
16+
</task>
17+
</tasks>
18+
</tsResponse>

test/test_task.py

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,30 @@
22
import os
33
import requests_mock
44
import tableauserverclient as TSC
5+
from tableauserverclient.models.task_item import TaskItem
6+
from tableauserverclient.datetime_helpers import parse_datetime
57

68
TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets")
79

810
GET_XML_NO_WORKBOOK = os.path.join(TEST_ASSET_DIR, "tasks_no_workbook_or_datasource.xml")
911
GET_XML_WITH_WORKBOOK = os.path.join(TEST_ASSET_DIR, "tasks_with_workbook.xml")
1012
GET_XML_WITH_DATASOURCE = os.path.join(TEST_ASSET_DIR, "tasks_with_datasource.xml")
1113
GET_XML_WITH_WORKBOOK_AND_DATASOURCE = os.path.join(TEST_ASSET_DIR, "tasks_with_workbook_and_datasource.xml")
14+
GET_XML_MATERIALIZEVIEWS_TASK = os.path.join(TEST_ASSET_DIR, "tasks_with_materializeviews_task.xml")
15+
GET_XML_RUN_NOW_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_run_now_response.xml")
1216

1317

1418
class TaskTests(unittest.TestCase):
1519
def setUp(self):
1620
self.server = TSC.Server("http://test")
17-
self.server.version = '3.6'
21+
self.server.version = '3.8'
1822

1923
# Fake Signin
2024
self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67"
2125
self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM"
2226

23-
self.baseurl = self.server.tasks.baseurl
27+
# default task type is extractRefreshes
28+
self.baseurl = "{}/{}".format(self.server.tasks.baseurl, "extractRefreshes")
2429

2530
def test_get_tasks_with_no_workbook(self):
2631
with open(GET_XML_NO_WORKBOOK, "rb") as f:
@@ -84,3 +89,50 @@ def test_delete(self):
8489

8590
def test_delete_missing_id(self):
8691
self.assertRaises(ValueError, self.server.tasks.delete, '')
92+
93+
def test_get_materializeviews_tasks(self):
94+
with open(GET_XML_MATERIALIZEVIEWS_TASK, "rb") as f:
95+
response_xml = f.read().decode("utf-8")
96+
with requests_mock.mock() as m:
97+
m.get('{}/{}'.format(
98+
self.server.tasks.baseurl, TaskItem.Type.MaterializeViews), text=response_xml)
99+
all_tasks, pagination_item = self.server.tasks.get(task_type=TaskItem.Type.MaterializeViews)
100+
101+
task = all_tasks[0]
102+
self.assertEqual('a462c148-fc40-4670-a8e4-39b7f0c58c7f', task.target.id)
103+
self.assertEqual('workbook', task.target.type)
104+
self.assertEqual('b22190b4-6ac2-4eed-9563-4afc03444413', task.schedule_id)
105+
self.assertEqual(parse_datetime('2019-12-09T22:30:00Z'), task.schedule_item.next_run_at)
106+
self.assertEqual(parse_datetime('2019-12-09T20:45:04Z'), task.last_run_at)
107+
108+
def test_delete(self):
109+
with requests_mock.mock() as m:
110+
m.delete('{}/{}/{}'.format(
111+
self.server.tasks.baseurl, TaskItem.Type.MaterializeViews,
112+
'c9cff7f9-309c-4361-99ff-d4ba8c9f5467'), status_code=204)
113+
self.server.tasks.delete('c9cff7f9-309c-4361-99ff-d4ba8c9f5467',
114+
TaskItem.Type.MaterializeViews)
115+
116+
def test_get_by_id(self):
117+
with open(GET_XML_WITH_WORKBOOK, "rb") as f:
118+
response_xml = f.read().decode("utf-8")
119+
task_id = 'f84901ac-72ad-4f9b-a87e-7a3500402ad6'
120+
with requests_mock.mock() as m:
121+
m.get('{}/{}'.format(self.baseurl, task_id), text=response_xml)
122+
t 57AE ask = self.server.tasks.get_by_id(task_id)
123+
124+
self.assertEqual('c7a9327e-1cda-4504-b026-ddb43b976d1d', task.target.id)
125+
self.assertEqual('workbook', task.target.type)
126+
self.assertEqual('b60b4efd-a6f7-4599-beb3-cb677e7abac1', task.schedule_id)
127+
128+
def test_run_now(self):
129+
task_id = 'f84901ac-72ad-4f9b-a87e-7a3500402ad6'
130+
task = TaskItem(task_id, TaskItem.Type.ExtractRefresh, 100)
131+
with open(GET_XML_RUN_NOW_RESPONSE, "rb") as f:
132+
response_xml = f.read().decode("utf-8")
133+
with requests_mock.mock() as m:
134+
m.post('{}/{}/runNow'.format(self.baseurl, task_id), text=response_xml)
135+
job_response_content = self.server.tasks.run(task).decode("utf-8")
136+
137+
self.assertTrue('7b6b59a8-ac3c-4d1d-2e9e-0b5b4ba8a7b6' in job_response_content)
138+
self.assertTrue('RefreshExtract' in job_response_content)

0 commit comments

Comments
 (0)
0