diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index 11c403764..18c0516d1 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -11,6 +11,7 @@ class Type: Extract = "Extract" Flow = "Flow" Subscription = "Subscription" + MaterializeViews = "MaterializeViews" class ExecutionOrder: Parallel = "Parallel" @@ -199,7 +200,7 @@ def _parse_interval_item(parsed_response, frequency, ns): # We use fractional hours for the two minute-based intervals. # Need to convert to hours from minutes here if interval_occurrence == IntervalItem.Occurrence.Minutes: - interval_value = float(interval_value / 60) + interval_value = float(interval_value) / 60 return HourlyInterval(start_time, end_time, interval_value) diff --git a/tableauserverclient/models/task_item.py b/tableauserverclient/models/task_item.py index d9d7f2cd0..2b08eee05 100644 --- a/tableauserverclient/models/task_item.py +++ b/tableauserverclient/models/task_item.py @@ -1,14 +1,23 @@ import xml.etree.ElementTree as ET from .target import Target +from .schedule_item import ScheduleItem +from ..datetime_helpers import parse_datetime class TaskItem(object): - def __init__(self, id_, task_type, priority, consecutive_failed_count=0, schedule_id=None, target=None): + class Type: + ExtractRefresh = "extractRefresh" + MaterializeViews = "materializeViews" + + def __init__(self, id_, task_type, priority, consecutive_failed_count=0, schedule_id=None, + schedule_item=None, last_run_at=None, target=None): self.id = id_ self.task_type = task_type self.priority = priority self.consecutive_failed_count = consecutive_failed_count self.schedule_id = schedule_id + self.schedule_item = schedule_item + self.last_run_at = last_run_at self.target = target def __repr__(self): @@ -16,10 +25,10 @@ def __repr__(self): "schedule_id}) target({target})>".format(**self.__dict__) @classmethod - def from_response(cls, xml, ns): + def from_response(cls, xml, ns, task_type=Type.ExtractRefresh): parsed_response = ET.fromstring(xml) all_tasks_xml = parsed_response.findall( - './/t:task/t:extractRefresh', namespaces=ns) + './/t:task/t:{}'.format(task_type), namespaces=ns) all_tasks = (TaskItem._parse_element(x, ns) for x in all_tasks_xml) @@ -27,13 +36,17 @@ def from_response(cls, xml, ns): @classmethod def _parse_element(cls, element, ns): - schedule = None + schedule_id = None + schedule_item = None target = None - schedule_element = element.find('.//t:schedule', namespaces=ns) + last_run_at = None workbook_element = element.find('.//t:workbook', namespaces=ns) datasource_element = element.find('.//t:datasource', namespaces=ns) - if schedule_element is not None: - schedule = schedule_element.get('id', None) + last_run_at_element = element.find('.//t:lastRunAt', namespaces=ns) + + schedule_item_list = ScheduleItem.from_element(element, ns) + if len(schedule_item_list) >= 1: + schedule_item = schedule_item_list[0] # according to the Tableau Server REST API documentation, # there should be only one of workbook or datasource @@ -43,9 +56,12 @@ def _parse_element(cls, element, ns): if datasource_element is not None: datasource_id = datasource_element.get('id', None) target = Target(datasource_id, "datasource") + if last_run_at_element is not None: + last_run_at = parse_datetime(last_run_at_element.text) task_type = element.get('type', None) priority = int(element.get('priority', -1)) consecutive_failed_count = int(element.get('consecutiveFailedCount', 0)) id_ = element.get('id', None) - return cls(id_, task_type, priority, consecutive_failed_count, schedule, target) + return cls(id_, task_type, priority, consecutive_failed_count, schedule_item.id, + schedule_item, last_run_at, target) diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index 2de488bdb..ccba83565 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -1,6 +1,6 @@ from .endpoint import Endpoint, api from .exceptions import MissingRequiredFieldError -from .. import RequestFactory, PaginationItem, ScheduleItem, WorkbookItem, DatasourceItem +from .. import RequestFactory, PaginationItem, ScheduleItem, WorkbookItem, DatasourceItem, TaskItem import logging import copy from collections import namedtuple @@ -68,12 +68,13 @@ def create(self, schedule_item): return new_schedule @api(version="2.8") - def add_to_schedule(self, schedule_id, workbook=None, datasource=None): + def add_to_schedule(self, schedule_id, workbook=None, datasource=None, + task_type=TaskItem.Type.ExtractRefresh): def add_to(resource, type_, req_factory): id_ = resource.id url = "{0}/{1}/{2}s".format(self.siteurl, schedule_id, type_) - add_req = req_factory(id_) + add_req = req_factory(id_, task_type=task_type) response = self.put_request(url, add_req) if response.status_code < 200 or response.status_code >= 300: return AddResponse(result=False, diff --git a/tableauserverclient/server/endpoint/tasks_endpoint.py b/tableauserverclient/server/endpoint/tasks_endpoint.py index 7df5bc0ad..2abe87104 100644 --- a/tableauserverclient/server/endpoint/tasks_endpoint.py +++ b/tableauserverclient/server/endpoint/tasks_endpoint.py @@ -9,18 +9,35 @@ class Tasks(Endpoint): @property def baseurl(self): - return "{0}/sites/{1}/tasks/extractRefreshes".format(self.parent_srv.baseurl, - self.parent_srv.site_id) + return "{0}/sites/{1}/tasks".format(self.parent_srv.baseurl, + self.parent_srv.site_id) + + def __normalize_task_type(self, task_type): + """ + The word for extract refresh used in API URL is "extractRefreshes". + It is different than the tag "extractRefresh" used in the request body. + """ + if task_type == TaskItem.Type.ExtractRefresh: + return '{}es'.format(task_type) + else: + return task_type @api(version='2.6') - def get(self, req_options=None): - logger.info('Querying all tasks for the site') - url = self.baseurl + def get(self, req_options=None, task_type=TaskItem.Type.ExtractRefresh): + if task_type == TaskItem.Type.MaterializeViews: + self.parent_srv.assert_at_least_version("3.8") + + logger.info('Querying all {} tasks for the site'.format(task_type)) + + url = "{0}/{1}".format(self.baseurl, self.__normalize_task_type(task_type)) server_response = self.get_request(url, req_options) - pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace) - all_extract_tasks = TaskItem.from_response(server_response.content, self.parent_srv.namespace) - return all_extract_tasks, pagination_item + pagination_item = PaginationItem.from_response(server_response.content, + self.parent_srv.namespace) + all_tasks = TaskItem.from_response(server_response.content, + self.parent_srv.namespace, + task_type) + return all_tasks, pagination_item @api(version='2.6') def get_by_id(self, task_id): @@ -28,7 +45,8 @@ def get_by_id(self, task_id): error = "No Task ID provided" raise ValueError(error) logger.info("Querying a single task by id ({})".format(task_id)) - url = "{}/{}".format(self.baseurl, task_id) + url = "{}/{}/{}".format(self.baseurl, + self.__normalize_task_type(TaskItem.Type.ExtractRefresh), task_id) server_response = self.get_request(url) return TaskItem.from_response(server_response.content, self.parent_srv.namespace)[0] @@ -38,17 +56,22 @@ def run(self, task_item): error = "User item missing ID." raise MissingRequiredFieldError(error) - url = "{0}/{1}/runNow".format(self.baseurl, task_item.id) + url = "{0}/{1}/{2}/runNow".format(self.baseurl, + self.__normalize_task_type(TaskItem.Type.ExtractRefresh), task_item.id) run_req = RequestFactory.Task.run_req(task_item) server_response = self.post_request(url, run_req) return server_response.content # Delete 1 task by id @api(version="3.6") - def delete(self, task_id): + def delete(self, task_id, task_type=TaskItem.Type.ExtractRefresh): + if task_type == TaskItem.Type.MaterializeViews: + self.parent_srv.assert_at_least_version("3.8") + if not task_id: error = "No Task ID provided" raise ValueError(error) - url = "{0}/{1}".format(self.baseurl, task_id) + url = "{0}/{1}/{2}".format(self.baseurl, + self.__normalize_task_type(task_type), task_id) self.delete_request(url) logger.info('Deleted single task (ID: {0})'.format(task_id)) diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 8001a1e6c..90bda676c 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -5,7 +5,7 @@ from requests.packages.urllib3.fields import RequestField from requests.packages.urllib3.filepost import encode_multipart_formdata -from ..models import UserItem, GroupItem, PermissionsRule +from ..models import TaskItem, UserItem, GroupItem, PermissionsRule def _add_multipart(parts): @@ -24,6 +24,7 @@ def wrapper(self, *args, **kwargs): xml_request = ET.Element('tsRequest') func(self, xml_request, *args, **kwargs) return ET.tostring(xml_request) + return wrapper @@ -311,28 +312,28 @@ def update_req(self, schedule_item): single_interval_element.attrib[expression] = value return ET.tostring(xml_request) - def _add_to_req(self, id_, type_): + def _add_to_req(self, id_, target_type, task_type=TaskItem.Type.ExtractRefresh): """ - + - + """ xml_request = ET.Element('tsRequest') task_element = ET.SubElement(xml_request, 'task') - refresh = ET.SubElement(task_element, 'extractRefresh') - workbook = ET.SubElement(refresh, type_) + task = ET.SubElement(task_element, task_type) + workbook = ET.SubElement(task, target_type) workbook.attrib['id'] = id_ return ET.tostring(xml_request) - def add_workbook_req(self, id_): - return self._add_to_req(id_, "workbook") + def add_workbook_req(self, id_, task_type=TaskItem.Type.ExtractRefresh): + return self._add_to_req(id_, "workbook", task_type) - def add_datasource_req(self, id_): - return self._add_to_req(id_, "datasource") + def add_datasource_req(self, id_, task_type=TaskItem.Type.ExtractRefresh): + return self._add_to_req(id_, "datasource", task_type) class SiteRequest(object): @@ -479,7 +480,7 @@ def update_req(self, workbook_item): if workbook_item.owner_id: owner_element = ET.SubElement(workbook_element, 'owner') owner_element.attrib['id'] = workbook_item.owner_id - if workbook_item.materialized_views_config['materialized_views_enabled']\ + if workbook_item.materialized_views_config['materialized_views_enabled'] \ and workbook_item.materialized_views_config['run_materialization_now']: materialized_views_config = workbook_item.materialized_views_config materialized_views_element = ET.SubElement(workbook_element, 'materializedViewsEnablementConfig') diff --git a/test/assets/tasks_run_now_response.xml b/test/assets/tasks_run_now_response.xml new file mode 100644 index 000000000..6a8860cd7 --- /dev/null +++ b/test/assets/tasks_run_now_response.xml @@ -0,0 +1,6 @@ + + + + diff --git a/test/assets/tasks_with_materializeviews_task.xml b/test/assets/tasks_with_materializeviews_task.xml new file mode 100644 index 000000000..e586b6bb1 --- /dev/null +++ b/test/assets/tasks_with_materializeviews_task.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + 2019-12-09T20:45:04Z + + + + \ No newline at end of file diff --git a/test/test_task.py b/test/test_task.py index ea22a24c7..a0699bc49 100644 --- a/test/test_task.py +++ b/test/test_task.py @@ -2,6 +2,8 @@ import os import requests_mock import tableauserverclient as TSC +from tableauserverclient.models.task_item import TaskItem +from tableauserverclient.datetime_helpers import parse_datetime TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") @@ -9,18 +11,21 @@ GET_XML_WITH_WORKBOOK = os.path.join(TEST_ASSET_DIR, "tasks_with_workbook.xml") GET_XML_WITH_DATASOURCE = os.path.join(TEST_ASSET_DIR, "tasks_with_datasource.xml") GET_XML_WITH_WORKBOOK_AND_DATASOURCE = os.path.join(TEST_ASSET_DIR, "tasks_with_workbook_and_datasource.xml") +GET_XML_MATERIALIZEVIEWS_TASK = os.path.join(TEST_ASSET_DIR, "tasks_with_materializeviews_task.xml") +GET_XML_RUN_NOW_RESPONSE = os.path.join(TEST_ASSET_DIR, "tasks_run_now_response.xml") class TaskTests(unittest.TestCase): def setUp(self): self.server = TSC.Server("http://test") - self.server.version = '3.6' + self.server.version = '3.8' # Fake Signin self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" - self.baseurl = self.server.tasks.baseurl + # default task type is extractRefreshes + self.baseurl = "{}/{}".format(self.server.tasks.baseurl, "extractRefreshes") def test_get_tasks_with_no_workbook(self): with open(GET_XML_NO_WORKBOOK, "rb") as f: @@ -84,3 +89,50 @@ def test_delete(self): def test_delete_missing_id(self): self.assertRaises(ValueError, self.server.tasks.delete, '') + + def test_get_materializeviews_tasks(self): + with open(GET_XML_MATERIALIZEVIEWS_TASK, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get('{}/{}'.format( + self.server.tasks.baseurl, TaskItem.Type.MaterializeViews), text=response_xml) + all_tasks, pagination_item = self.server.tasks.get(task_type=TaskItem.Type.MaterializeViews) + + task = all_tasks[0] + self.assertEqual('a462c148-fc40-4670-a8e4-39b7f0c58c7f', task.target.id) + self.assertEqual('workbook', task.target.type) + self.assertEqual('b22190b4-6ac2-4eed-9563-4afc03444413', task.schedule_id) + self.assertEqual(parse_datetime('2019-12-09T22:30:00Z'), task.schedule_item.next_run_at) + self.assertEqual(parse_datetime('2019-12-09T20:45:04Z'), task.last_run_at) + + def test_delete(self): + with requests_mock.mock() as m: + m.delete('{}/{}/{}'.format( + self.server.tasks.baseurl, TaskItem.Type.MaterializeViews, + 'c9cff7f9-309c-4361-99ff-d4ba8c9f5467'), status_code=204) + self.server.tasks.delete('c9cff7f9-309c-4361-99ff-d4ba8c9f5467', + TaskItem.Type.MaterializeViews) + + def test_get_by_id(self): + with open(GET_XML_WITH_WORKBOOK, "rb") as f: + response_xml = f.read().decode("utf-8") + task_id = 'f84901ac-72ad-4f9b-a87e-7a3500402ad6' + with requests_mock.mock() as m: + m.get('{}/{}'.format(self.baseurl, task_id), text=response_xml) + task = self.server.tasks.get_by_id(task_id) + + self.assertEqual('c7a9327e-1cda-4504-b026-ddb43b976d1d', task.target.id) + self.assertEqual('workbook', task.target.type) + self.assertEqual('b60b4efd-a6f7-4599-beb3-cb677e7abac1', task.schedule_id) + + def test_run_now(self): + task_id = 'f84901ac-72ad-4f9b-a87e-7a3500402ad6' + task = TaskItem(task_id, TaskItem.Type.ExtractRefresh, 100) + with open(GET_XML_RUN_NOW_RESPONSE, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.post('{}/{}/runNow'.format(self.baseurl, task_id), text=response_xml) + job_response_content = self.server.tasks.run(task).decode("utf-8") + + self.assertTrue('7b6b59a8-ac3c-4d1d-2e9e-0b5b4ba8a7b6' in job_response_content) + self.assertTrue('RefreshExtract' in job_response_content)