From 03e8ce38d4ee10955b6e13bdcd491acda28b966e Mon Sep 17 00:00:00 2001 From: shinchris Date: Tue, 13 Sep 2016 15:27:33 -0700 Subject: [PATCH 1/2] added ability to query and delete schedules --- tableauserverclient/__init__.py | 2 +- tableauserverclient/models/__init__.py | 1 + tableauserverclient/models/schedule_item.py | 79 +++++++++++++++++++ tableauserverclient/server/__init__.py | 6 +- .../server/endpoint/__init__.py | 1 + .../server/endpoint/schedules_endpoint.py | 31 ++++++++ tableauserverclient/server/server.py | 3 +- test/assets/schedule_get.xml | 8 ++ test/assets/schedule_get_empty.xml | 5 ++ test/test_schedule.py | 63 +++++++++++++++ 10 files changed, 194 insertions(+), 5 deletions(-) create mode 100644 tableauserverclient/models/schedule_item.py create mode 100644 tableauserverclient/server/endpoint/schedules_endpoint.py create mode 100644 test/assets/schedule_get.xml create mode 100644 test/assets/schedule_get_empty.xml create mode 100644 test/test_schedule.py diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index 7ac613556..aa49f8eee 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -1,6 +1,6 @@ from .namespace import NAMESPACE from .models import ConnectionItem, DatasourceItem,\ - GroupItem, PaginationItem, ProjectItem, \ + GroupItem, PaginationItem, ProjectItem, ScheduleItem, \ SiteItem, TableauAuth, UserItem, ViewItem, WorkbookItem, UnpopulatedPropertyError from .server import RequestOptions, Filter, Sort, Server, ServerResponseError,\ MissingRequiredFieldError, NotSignedInError diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 594252f8f..4331f406e 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -4,6 +4,7 @@ from .group_item import GroupItem from .pagination_item import PaginationItem from .project_item import ProjectItem +from .schedule_item import ScheduleItem from .site_item import SiteItem from .tableau_auth import TableauAuth from .user_item import UserItem diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py new file mode 100644 index 000000000..33d8b8f98 --- /dev/null +++ b/tableauserverclient/models/schedule_item.py @@ -0,0 +1,79 @@ +import xml.etree.ElementTree as ET +from .. import NAMESPACE + + +class ScheduleItem(object): + def __init__(self): + self._created_at = None + self._end_schedule_at = None + self._frequency = None + self._id = None + self._name = None + self._next_run_at = None + self._priority = None + self._state = None + self._type = None + self._updated_at = None + + @property + def created_at(self): + return self._created_at + + @property + def end_schedule_at(self): + return self._end_schedule_at + + @property + def frequency(self): + return self._frequency + + @property + def id(self): + return self._id + + @property + def name(self): + return self._name + + @property + def next_run_at(self): + return self._next_run_at + + @property + def priority(self): + return self._priority + + @property + def state(self): + return self._state + + @property + def type(self): + return self._type + + @property + def updated_at(self): + return self._updated_at + + @classmethod + def from_response(cls, resp): + all_schedule_items = list() + parsed_response = ET.fromstring(resp) + all_schedule_xml = parsed_response.findall('.//t:schedule', namespaces=NAMESPACE) + for schedule_xml in all_schedule_xml: + schedule_item = cls() + schedule_item._id = schedule_xml.get('id', None) + schedule_item._name = schedule_xml.get('name', None) + schedule_item._state = schedule_xml.get('state', None) + schedule_item._created_at = schedule_xml.get('createdAt', None) + schedule_item._updated_at = schedule_xml.get('updatedAt', None) + schedule_item._type = schedule_xml.get('type', None) + schedule_item._frequency = schedule_xml.get('frequency', None) + schedule_item._next_run_at = schedule_xml.get('nextRunAt', None) + schedule_item._end_schedule_at = schedule_xml.get('endScheduleAt', None) + + priority = schedule_xml.get('priority', None) + if priority: + schedule_item._priority = int(priority) + all_schedule_items.append(schedule_item) + return all_schedule_items diff --git a/tableauserverclient/server/__init__.py b/tableauserverclient/server/__init__.py index 909705052..a8b78b7fc 100644 --- a/tableauserverclient/server/__init__.py +++ b/tableauserverclient/server/__init__.py @@ -3,9 +3,9 @@ from .filter import Filter from .sort import Sort from .. import ConnectionItem, DatasourceItem,\ - GroupItem, PaginationItem, ProjectItem, SiteItem, TableauAuth,\ + GroupItem, PaginationItem, ProjectItem, ScheduleItem, SiteItem, TableauAuth,\ UserItem, ViewItem, WorkbookItem, NAMESPACE -from .endpoint import Auth, Datasources, Endpoint, \ - Groups, Projects, Sites, Users, Views, Workbooks, ServerResponseError, MissingRequiredFieldError +from .endpoint import Auth, Datasources, Endpoint, Groups, Projects, Schedules, \ + Sites, Users, Views, Workbooks, ServerResponseError, MissingRequiredFieldError from .server import Server from .exceptions import NotSignedInError diff --git a/tableauserverclient/server/endpoint/__init__.py b/tableauserverclient/server/endpoint/__init__.py index 30adf2549..65e15c683 100644 --- a/tableauserverclient/server/endpoint/__init__.py +++ b/tableauserverclient/server/endpoint/__init__.py @@ -4,6 +4,7 @@ from .exceptions import ServerResponseError, MissingRequiredFieldError from .groups_endpoint import Groups from .projects_endpoint import Projects +from .schedules_endpoint import Schedules from .sites_endpoint import Sites from .users_endpoint import Users from .views_endpoint import Views diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py new file mode 100644 index 000000000..b5d4154c9 --- /dev/null +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -0,0 +1,31 @@ +from .endpoint import Endpoint +from .. import PaginationItem, ScheduleItem +import logging + +logger = logging.getLogger('tableau.endpoint.schedules') + + +class Schedules(Endpoint): + def __init__(self, parent_srv): + super(Endpoint, self).__init__() + self.baseurl = "{0}/schedules" + self.parent_srv = parent_srv + + def _construct_url(self): + return self.baseurl.format(self.parent_srv.baseurl) + + def get(self, req_options=None): + logger.info("Querying all schedules") + url = self._construct_url() + server_response = self.get_request(url, req_options) + pagination_item = PaginationItem.from_response(server_response.content) + all_schedule_items = ScheduleItem.from_response(server_response.content) + return all_schedule_items, pagination_item + + def delete(self, schedule_id): + if not schedule_id: + error = "Schedule ID undefined" + raise ValueError(error) + url = "{0}/{1}".format(self._construct_url(), schedule_id) + self.delete_request(url) + logger.info("Deleted single schedule (ID: {0})".format(schedule_id)) diff --git a/tableauserverclient/server/server.py b/tableauserverclient/server/server.py index 3458e0644..24d56e1fe 100644 --- a/tableauserverclient/server/server.py +++ b/tableauserverclient/server/server.py @@ -1,5 +1,5 @@ from .exceptions import NotSignedInError -from .endpoint import Sites, Views, Users, Groups, Workbooks, Datasources, Projects, Auth +from .endpoint import Sites, Views, Users, Groups, Workbooks, Datasources, Projects, Auth, Schedules import requests @@ -26,6 +26,7 @@ def __init__(self, server_address): self.workbooks = Workbooks(self) self.datasources = Datasources(self) self.projects = Projects(self) + self.schedules = Schedules(self) def add_http_options(self, options_dict): self._http_options.update(options_dict) diff --git a/test/assets/schedule_get.xml b/test/assets/schedule_get.xml new file mode 100644 index 000000000..3d8578ede --- /dev/null +++ b/test/assets/schedule_get.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/test/assets/schedule_get_empty.xml b/test/assets/schedule_get_empty.xml new file mode 100644 index 000000000..c40943303 --- /dev/null +++ b/test/assets/schedule_get_empty.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/test/test_schedule.py b/test/test_schedule.py new file mode 100644 index 000000000..9a52fc992 --- /dev/null +++ b/test/test_schedule.py @@ -0,0 +1,63 @@ +import unittest +import os +import requests_mock +import tableauserverclient as TSC + +TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") + +GET_XML = os.path.join(TEST_ASSET_DIR, "schedule_get.xml") +GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_empty.xml") + + +class ScheduleTests(unittest.TestCase): + def setUp(self): + self.server = TSC.Server("http://test") + + # Fake Signin + self.server._site_id = "dad65087-b08b-4603-af4e-2887b8aafc67" + self.server._auth_token = "j80k54ll2lfMZ0tv97mlPvvSCRyD0DOM" + + self.baseurl = self.server.schedules._construct_url() + + def test_get(self): + with open(GET_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get(self.baseurl, text=response_xml) + all_schedules, pagination_item = self.server.schedules.get() + + self.assertEqual(2, pagination_item.total_available) + self.assertEqual("c9cff7f9-309c-4361-99ff-d4ba8c9f5467", all_schedules[0].id) + self.assertEqual("Weekday early mornings", all_schedules[0].name) + self.assertEqual("Active", all_schedules[0].state) + self.assertEqual(50, all_schedules[0].priority) + self.assertEqual("2016-07-06T20:19:00Z", all_schedules[0].created_at) + self.assertEqual("2016-09-13T11:00:32Z", all_schedules[0].updated_at) + self.assertEqual("Extract", all_schedules[0].type) + self.assertEqual("Weekly", all_schedules[0].frequency) + self.assertEqual("2016-09-14T11:00:00Z", all_schedules[0].next_run_at) + + self.assertEqual("bcb79d07-6e47-472f-8a65-d7f51f40c36c", all_schedules[1].id) + self.assertEqual("Saturday night", all_schedules[1].name) + self.assertEqual("Active", all_schedules[1].state) + self.assertEqual(80, all_schedules[1].priority) + self.assertEqual("2016-07-07T20:19:00Z", all_schedules[1].created_at) + self.assertEqual("2016-09-12T16:39:38Z", all_schedules[1].updated_at) + self.assertEqual("Subscription", all_schedules[1].type) + self.assertEqual("Weekly", all_schedules[1].frequency) + self.assertEqual("2016-09-18T06:00:00Z", all_schedules[1].next_run_at) + + def test_get_empty(self): + with open(GET_EMPTY_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.get(self.baseurl, text=response_xml) + all_schedules, pagination_item = self.server.schedules.get() + + self.assertEqual(0, pagination_item.total_available) + self.assertEqual([], all_schedules) + + def test_delete(self): + with requests_mock.mock() as m: + m.delete(self.baseurl + "/c9cff7f9-309c-4361-99ff-d4ba8c9f5467", status_code=204) + self.server.schedules.delete("c9cff7f9-309c-4361-99ff-d4ba8c9f5467") From 312a1c7da9cb7c0efd1e6e74eef136cf86146c56 Mon Sep 17 00:00:00 2001 From: shinchris Date: Thu, 15 Sep 2016 17:00:44 -0700 Subject: [PATCH 2/2] added ability to create and update schedules --- samples/create_schedules.py | 57 ++++++ tableauserverclient/__init__.py | 2 +- tableauserverclient/models/__init__.py | 1 + tableauserverclient/models/interval_item.py | 99 +++++++++++ tableauserverclient/models/schedule_item.py | 162 ++++++++++++++++-- .../server/endpoint/schedules_endpoint.py | 31 +++- tableauserverclient/server/request_factory.py | 49 ++++++ test/assets/schedule_create_daily.xml | 6 + test/assets/schedule_create_hourly.xml | 10 ++ test/assets/schedule_create_monthly.xml | 10 ++ test/assets/schedule_create_weekly.xml | 12 ++ test/assets/schedule_update.xml | 11 ++ test/test_schedule.py | 130 +++++++++++++- 13 files changed, 557 insertions(+), 23 deletions(-) create mode 100644 samples/create_schedules.py create mode 100644 tableauserverclient/models/interval_item.py create mode 100644 test/assets/schedule_create_daily.xml create mode 100644 test/assets/schedule_create_hourly.xml create mode 100644 test/assets/schedule_create_monthly.xml create mode 100644 test/assets/schedule_create_weekly.xml create mode 100644 test/assets/schedule_update.xml diff --git a/samples/create_schedules.py b/samples/create_schedules.py new file mode 100644 index 000000000..a48b67907 --- /dev/null +++ b/samples/create_schedules.py @@ -0,0 +1,57 @@ +#### +# This script demonstrates how to create schedules using the Tableau +# Server Client. +# +# To run the script, you must have installed Python 2.7.9 or later. +#### + +import tableauserverclient as TSC +import argparse +import getpass +import logging +from datetime import time + +parser = argparse.ArgumentParser(description='Creates sample schedules for each type of frequency.') +parser.add_argument('--server', '-s', required=True, help='server address') +parser.add_argument('--username', '-u', required=True, help='username to sign into server') +parser.add_argument('--logging-level', '-l', choices=['debug', 'info', 'error'], default='error', + help='desired logging level (set to error by default)') +args = parser.parse_args() + +password = getpass.getpass("Password: ") + +# Set logging level based on user input, or error by default +logging_level = getattr(logging, args.logging_level.upper()) +logging.basicConfig(level=logging_level) + +tableau_auth = TSC.TableauAuth(args.username, password) +server = TSC.Server(args.server) +with server.auth.sign_in(tableau_auth): + # Hourly Schedule + hourly_interval = TSC.IntervalItem.create_hourly(time(2, 30), time(23, 0), TSC.IntervalItem.Occurrence.Hours, 2) + hourly_schedule = TSC.ScheduleItem("Hourly-Schedule", 50, TSC.ScheduleItem.Type.Extract, + TSC.ScheduleItem.ExecutionOrder.Parallel, hourly_interval) + hourly_schedule = server.schedules.create(hourly_schedule) + print("Hourly schedule created (ID: {}).".format(hourly_schedule.id)) + + # Daily Schedule + daily_interval = TSC.IntervalItem.create_daily(time(5)) + daily_schedule = TSC.ScheduleItem("Daily-Schedule", 60, TSC.ScheduleItem.Type.Subscription, + TSC.ScheduleItem.ExecutionOrder.Serial, daily_interval) + daily_schedule = server.schedules.create(daily_schedule) + print("Daily schedule created (ID: {}).".format(daily_schedule.id)) + + # Weekly Schedule + weekly_interval = TSC.IntervalItem.create_weekly(time(19, 15), TSC.IntervalItem.Day.Monday, + TSC.IntervalItem.Day.Wednesday, TSC.IntervalItem.Day.Friday) + weekly_schedule = TSC.ScheduleItem("Weekly-Schedule", 70, TSC.ScheduleItem.Type.Extract, + TSC.ScheduleItem.ExecutionOrder.Serial, weekly_interval) + weekly_schedule = server.schedules.create(weekly_schedule) + print("Weekly schedule created (ID: {}).".format(weekly_schedule.id)) + + # Monthly Schedule + monthly_interval = TSC.IntervalItem.create_monthly(time(23, 30), 15) + monthly_schedule = TSC.ScheduleItem("Monthly-Schedule", 80, TSC.ScheduleItem.Type.Subscription, + TSC.ScheduleItem.ExecutionOrder.Parallel, monthly_interval) + monthly_schedule = server.schedules.create(monthly_schedule) + print("Monthly schedule created (ID: {}).".format(monthly_schedule.id)) diff --git a/tableauserverclient/__init__.py b/tableauserverclient/__init__.py index aa49f8eee..080937c64 100644 --- a/tableauserverclient/__init__.py +++ b/tableauserverclient/__init__.py @@ -1,6 +1,6 @@ from .namespace import NAMESPACE from .models import ConnectionItem, DatasourceItem,\ - GroupItem, PaginationItem, ProjectItem, ScheduleItem, \ + GroupItem, IntervalItem, PaginationItem, ProjectItem, ScheduleItem, \ SiteItem, TableauAuth, UserItem, ViewItem, WorkbookItem, UnpopulatedPropertyError from .server import RequestOptions, Filter, Sort, Server, ServerResponseError,\ MissingRequiredFieldError, NotSignedInError diff --git a/tableauserverclient/models/__init__.py b/tableauserverclient/models/__init__.py index 4331f406e..54757d48c 100644 --- a/tableauserverclient/models/__init__.py +++ b/tableauserverclient/models/__init__.py @@ -2,6 +2,7 @@ from .datasource_item import DatasourceItem from .exceptions import UnpopulatedPropertyError from .group_item import GroupItem +from .interval_item import IntervalItem from .pagination_item import PaginationItem from .project_item import ProjectItem from .schedule_item import ScheduleItem diff --git a/tableauserverclient/models/interval_item.py b/tableauserverclient/models/interval_item.py new file mode 100644 index 000000000..ddce4f039 --- /dev/null +++ b/tableauserverclient/models/interval_item.py @@ -0,0 +1,99 @@ +import xml.etree.ElementTree as ET +from datetime import datetime, time +from .. import NAMESPACE + + +class IntervalItem(object): + class Frequency: + Hourly = "Hourly" + Daily = "Daily" + Weekly = "Weekly" + Monthly = "Monthly" + + class Occurrence: + Hours = "Hours" + Minutes = "Minutes" + WeekDay = "weekDay" + MonthDay = "monthDay" + + class Day: + Sunday = "Sunday" + Monday = "Monday" + Tuesday = "Tuesday" + Wednesday = "Wednesday" + Thursday = "Thursday" + Friday = "Friday" + Saturday = "Saturday" + LastDay = "LastDay" + + def __init__(self, frequency, interval, start_time, end_time=None): + self.end_time = end_time + self.frequency = frequency + self.interval = interval + self.start_time = start_time + + @staticmethod + def _validate_time(t): + if not hasattr(t, "hour") or not hasattr(t, "minute") or not hasattr(t, "second"): + error = "Invalid time object defined." + raise ValueError(error) + + @classmethod + def create_hourly(cls, start_time, end_time, interval_occurrence, interval_value): + if interval_occurrence != IntervalItem.Occurrence.Hours and \ + interval_occurrence != IntervalItem.Occurrence.Minutes: + error = "Invalid interval type defined: {}.".format(interval_occurrence) + raise ValueError(error) + elif interval_occurrence == IntervalItem.Occurrence.Hours and interval_value not in [1, 2, 4, 6, 8, 12]: + error = "Invalid hour value defined: {}.".format(interval_value) + raise ValueError(error) + elif interval_occurrence == IntervalItem.Occurrence.Minutes and interval_value not in [15, 30]: + error = "Invalid minute value defined: {}".format(interval_value) + raise ValueError(error) + + cls._validate_time(start_time) + cls._validate_time(end_time) + interval = [(interval_occurrence.lower(), str(interval_value))] + return cls(IntervalItem.Frequency.Hourly, interval, start_time, end_time) + + @classmethod + def create_daily(cls, start_time): + cls._validate_time(start_time) + return cls(IntervalItem.Frequency.Daily, None, start_time) + + @classmethod + def create_weekly(cls, start_time, *interval_value): + interval = [] + for day in interval_value: + if not hasattr(IntervalItem.Day, day): + error = "Invalid week day defined: {}.".format(day) + raise ValueError(error) + interval.append((IntervalItem.Occurrence.WeekDay, day)) + cls._validate_time(start_time) + return cls(IntervalItem.Frequency.Weekly, interval, start_time) + + @classmethod + def create_monthly(cls, start_time, interval_value): + if (interval_value < 1 or interval_value > 31) and interval_value != IntervalItem.Day.LastDay: + error = "Invalid interval value defined for a monthly frequency: {}.".format(interval_value) + raise ValueError(error) + interval = [(IntervalItem.Occurrence.MonthDay, str(interval_value))] + cls._validate_time(start_time) + return cls(IntervalItem.Frequency.Monthly, interval, start_time) + + @classmethod + def from_response(cls, resp, frequency): + cls.from_xml_element(ET.fromstring(resp), frequency) + + @classmethod + def from_xml_element(cls, parsed_response, frequency): + start_time = parsed_response.get("start", None) + start_time = datetime.strptime(start_time, "%H:%M:%S").time() + end_time = parsed_response.get("end", None) + if end_time is not None: + end_time = datetime.strptime(end_time, "%H:%M:%S").time() + interval_elems = parsed_response.findall(".//t:intervals/t:interval", namespaces=NAMESPACE) + interval = [] + for interval_elem in interval_elems: + interval.extend(interval_elem.attrib.items()) + return cls(frequency, interval, start_time, end_time) diff --git a/tableauserverclient/models/schedule_item.py b/tableauserverclient/models/schedule_item.py index 33d8b8f98..3a3ad9aaf 100644 --- a/tableauserverclient/models/schedule_item.py +++ b/tableauserverclient/models/schedule_item.py @@ -1,19 +1,40 @@ import xml.etree.ElementTree as ET +from .interval_item import IntervalItem from .. import NAMESPACE class ScheduleItem(object): - def __init__(self): + class Type: + Extract = "Extract" + Subscription = "Subscription" + + class ExecutionOrder: + Parallel = "Parallel" + Serial = "Serial" + + class State: + Active = "Active" + Suspended = "Suspended" + + def __init__(self, name, priority, schedule_type, execution_order, interval_item): self._created_at = None self._end_schedule_at = None + self._execution_order = None self._frequency = None self._id = None self._name = None self._next_run_at = None self._priority = None + self._schedule_type = None self._state = None - self._type = None self._updated_at = None + self.interval_item = interval_item + + # Invoke setter + self.execution_order = execution_order + self.name = name + self.priority = priority + self.schedule_type = schedule_type @property def created_at(self): @@ -23,6 +44,18 @@ def created_at(self): def end_schedule_at(self): return self._end_schedule_at + @property + def execution_order(self): + return self._execution_order + + @execution_order.setter + def execution_order(self, value): + if value and not hasattr(ScheduleItem.ExecutionOrder, value): + error = "Invalid execution order defined: {}.".format(value) + raise ValueError(error) + else: + self._execution_order = value + @property def frequency(self): return self._frequency @@ -35,6 +68,14 @@ def id(self): def name(self): return self._name + @name.setter + def name(self, value): + if not value: + error = "Name must be defined." + raise ValueError(error) + else: + self._name = value + @property def next_run_at(self): return self._next_run_at @@ -43,37 +84,120 @@ def next_run_at(self): def priority(self): return self._priority + @priority.setter + def priority(self, value): + if value < 1 or value > 100: + error = "Invalid priority defined: {}.".format(value) + raise ValueError(error) + else: + self._priority = value + + @property + def schedule_type(self): + return self._schedule_type + + @schedule_type.setter + def schedule_type(self, value): + if not value: + error = "Schedule type must be defined." + raise ValueError(error) + elif not hasattr(ScheduleItem.Type, value): + error = "Invalid schedule type defined: {}.".format(value) + raise ValueError(error) + else: + self._schedule_type = value + @property def state(self): return self._state - @property - def type(self): - return self._type + @state.setter + def state(self, value): + if not hasattr(ScheduleItem.State, value): + error = "Invalid state defined." + raise ValueError(error) + else: + self._state = value @property def updated_at(self): return self._updated_at + def _parse_common_tags(self, schedule_xml): + if not isinstance(schedule_xml, ET.Element): + schedule_xml = ET.fromstring(schedule_xml).find('.//t:schedule', namespaces=NAMESPACE) + if schedule_xml is not None: + (_, name, _, _, updated_at, _, frequency, next_run_at, end_schedule_at, execution_order, + priority, interval_item) = self._parse_element(schedule_xml) + + self._set_values(None, name, None, None, updated_at, None, frequency, next_run_at, end_schedule_at, + execution_order, priority, interval_item) + + return self + + def _set_values(self, id, name, state, created_at, updated_at, schedule_type, frequency, + next_run_at, end_schedule_at, execution_order, priority, interval_item): + if id is not None: + self._id = id + if name: + self._name = name + if state: + self._state = state + if created_at: + self._created_at = created_at + if updated_at: + self._updated_at = updated_at + if schedule_type: + self._schedule_type = schedule_type + if frequency: + self._frequency = frequency + if next_run_at: + self._next_run_at = next_run_at + if end_schedule_at: + self._end_schedule_at = end_schedule_at + if execution_order: + self._execution_order = execution_order + if priority: + self._priority = priority + if interval_item: + self._interval_item = interval_item + @classmethod def from_response(cls, resp): all_schedule_items = list() parsed_response = ET.fromstring(resp) all_schedule_xml = parsed_response.findall('.//t:schedule', namespaces=NAMESPACE) for schedule_xml in all_schedule_xml: - schedule_item = cls() - schedule_item._id = schedule_xml.get('id', None) - schedule_item._name = schedule_xml.get('name', None) - schedule_item._state = schedule_xml.get('state', None) - schedule_item._created_at = schedule_xml.get('createdAt', None) - schedule_item._updated_at = schedule_xml.get('updatedAt', None) - schedule_item._type = schedule_xml.get('type', None) - schedule_item._frequency = schedule_xml.get('frequency', None) - schedule_item._next_run_at = schedule_xml.get('nextRunAt', None) - schedule_item._end_schedule_at = schedule_xml.get('endScheduleAt', None) - - priority = schedule_xml.get('priority', None) - if priority: - schedule_item._priority = int(priority) + (id, name, state, created_at, updated_at, schedule_type, frequency, next_run_at, + end_schedule_at, execution_order, priority, interval_item) = cls._parse_element(schedule_xml) + + schedule_item = cls(name, priority, schedule_type, execution_order, interval_item) + schedule_item._set_values(id, None, state, created_at, updated_at, None, frequency, next_run_at, + end_schedule_at, None, None, None) all_schedule_items.append(schedule_item) return all_schedule_items + + @staticmethod + def _parse_element(schedule_xml): + id = schedule_xml.get('id', None) + name = schedule_xml.get('name', None) + state = schedule_xml.get('state', None) + created_at = schedule_xml.get('createdAt', None) + updated_at = schedule_xml.get('updatedAt', None) + schedule_type = schedule_xml.get('type', None) + frequency = schedule_xml.get('frequency', None) + next_run_at = schedule_xml.get('nextRunAt', None) + end_schedule_at = schedule_xml.get('endScheduleAt', None) + execution_order = schedule_xml.get('executionOrder', None) + + priority = schedule_xml.get('priority', None) + if priority: + priority = int(priority) + + interval_item = None + frequency_detail_elem = schedule_xml.find('.//t:frequencyDetails', namespaces=NAMESPACE) + if frequency_detail_elem is not None: + interval_item = IntervalItem.from_xml_element(frequency_detail_elem, frequency) + + return id, name, state, created_at, updated_at, schedule_type,\ + frequency, next_run_at, end_schedule_at, execution_order, priority, interval_item diff --git a/tableauserverclient/server/endpoint/schedules_endpoint.py b/tableauserverclient/server/endpoint/schedules_endpoint.py index b5d4154c9..816d48ab7 100644 --- a/tableauserverclient/server/endpoint/schedules_endpoint.py +++ b/tableauserverclient/server/endpoint/schedules_endpoint.py @@ -1,6 +1,8 @@ from .endpoint import Endpoint -from .. import PaginationItem, ScheduleItem +from .exceptions import MissingRequiredFieldError +from .. import RequestFactory, PaginationItem, ScheduleItem import logging +import copy logger = logging.getLogger('tableau.endpoint.schedules') @@ -29,3 +31,30 @@ def delete(self, schedule_id): url = "{0}/{1}".format(self._construct_url(), schedule_id) self.delete_request(url) logger.info("Deleted single schedule (ID: {0})".format(schedule_id)) + + def update(self, schedule_item): + if not schedule_item.id: + error = "Schedule item missing ID." + raise MissingRequiredFieldError(error) + if schedule_item.interval_item is None: + error = "Interval item must be defined." + raise MissingRequiredFieldError(error) + + url = "{0}/{1}".format(self._construct_url(), schedule_item.id) + update_req = RequestFactory.Schedule.update_req(schedule_item) + server_response = self.put_request(url, update_req) + logger.info("Updated schedule item (ID: {})".format(schedule_item.id)) + updated_schedule = copy.copy(schedule_item) + return updated_schedule._parse_common_tags(server_response.content) + + def create(self, schedule_item): + if schedule_item.interval_item is None: + error = "Interval item must be defined." + raise MissingRequiredFieldError(error) + + url = self._construct_url() + create_req = RequestFactory.Schedule.create_req(schedule_item) + server_response = self.post_request(url, create_req) + new_schedule = ScheduleItem.from_response(server_response.content)[0] + logger.info("Created new schedule (ID: {})".format(new_schedule.id)) + return new_schedule diff --git a/tableauserverclient/server/request_factory.py b/tableauserverclient/server/request_factory.py index 3439cfa43..2a55439ac 100644 --- a/tableauserverclient/server/request_factory.py +++ b/tableauserverclient/server/request_factory.py @@ -129,6 +129,54 @@ def create_req(self, project_item): return ET.tostring(xml_request) +class ScheduleRequest(object): + def create_req(self, schedule_item): + xml_request = ET.Element('tsRequest') + schedule_element = ET.SubElement(xml_request, 'schedule') + schedule_element.attrib['name'] = schedule_item.name + schedule_element.attrib['priority'] = str(schedule_item.priority) + schedule_element.attrib['type'] = schedule_item.schedule_type + schedule_element.attrib['executionOrder'] = schedule_item.execution_order + interval_item = schedule_item.interval_item + schedule_element.attrib['frequency'] = interval_item.frequency + frequency_element = ET.SubElement(schedule_element, 'frequencyDetails') + frequency_element.attrib['start'] = str(interval_item.start_time) + if interval_item.end_time: + frequency_element.attrib['end'] = str(interval_item.end_time) + if interval_item.interval: + intervals_element = ET.SubElement(frequency_element, 'intervals') + for interval in interval_item.interval: + (expression, value) = interval + single_interval_element = ET.SubElement(intervals_element, 'interval') + single_interval_element.attrib[expression] = value + return ET.tostring(xml_request) + + def update_req(self, schedule_item): + xml_request = ET.Element('tsRequest') + schedule_element = ET.SubElement(xml_request, 'schedule') + if schedule_item.name: + schedule_element.attrib['name'] = schedule_item.name + if schedule_item.priority: + schedule_element.attrib['priority'] = str(schedule_item.priority) + if schedule_item.execution_order: + schedule_element.attrib['executionOrder'] = schedule_item.execution_order + if schedule_item.state: + schedule_element.attrib['state'] = schedule_item.state + interval_item = schedule_item.interval_item + if interval_item.frequency: + schedule_element.attrib['frequency'] = interval_item.frequency + frequency_element = ET.SubElement(schedule_element, 'frequencyDetails') + frequency_element.attrib['start'] = str(interval_item.start_time) + if interval_item.end_time: + frequency_element.attrib['end'] = str(interval_item.end_time) + intervals_element = ET.SubElement(frequency_element, 'intervals') + for interval in interval_item.interval: + (expression, value) = interval + single_interval_element = ET.SubElement(intervals_element, 'interval') + single_interval_element.attrib[expression] = value + return ET.tostring(xml_request) + + class SiteRequest(object): def update_req(self, site_item): xml_request = ET.Element('tsRequest') @@ -249,6 +297,7 @@ class RequestFactory(object): Group = GroupRequest() Permission = PermissionRequest() Project = ProjectRequest() + Schedule = ScheduleRequest() Site = SiteRequest() Tag = TagRequest() User = UserRequest() diff --git a/test/assets/schedule_create_daily.xml b/test/assets/schedule_create_daily.xml new file mode 100644 index 000000000..fe1eda485 --- /dev/null +++ b/test/assets/schedule_create_daily.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/test/assets/schedule_create_hourly.xml b/test/assets/schedule_create_hourly.xml new file mode 100644 index 000000000..b1c3b73c3 --- /dev/null +++ b/test/assets/schedule_create_hourly.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/schedule_create_monthly.xml b/test/assets/schedule_create_monthly.xml new file mode 100644 index 000000000..408ff428d --- /dev/null +++ b/test/assets/schedule_create_monthly.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/schedule_create_weekly.xml b/test/assets/schedule_create_weekly.xml new file mode 100644 index 000000000..624a56e25 --- /dev/null +++ b/test/assets/schedule_create_weekly.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/test/assets/schedule_update.xml b/test/assets/schedule_update.xml new file mode 100644 index 000000000..314925377 --- /dev/null +++ b/test/assets/schedule_update.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/test/test_schedule.py b/test/test_schedule.py index 9a52fc992..5dcbcba0d 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -2,11 +2,17 @@ import os import requests_mock import tableauserverclient as TSC +from datetime import time TEST_ASSET_DIR = os.path.join(os.path.dirname(__file__), "assets") GET_XML = os.path.join(TEST_ASSET_DIR, "schedule_get.xml") GET_EMPTY_XML = os.path.join(TEST_ASSET_DIR, "schedule_get_empty.xml") +CREATE_HOURLY_XML = os.path.join(TEST_ASSET_DIR, "schedule_create_hourly.xml") +CREATE_DAILY_XML = os.path.join(TEST_ASSET_DIR, "schedule_create_daily.xml") +CREATE_WEEKLY_XML = os.path.join(TEST_ASSET_DIR, "schedule_create_weekly.xml") +CREATE_MONTHLY_XML = os.path.join(TEST_ASSET_DIR, "schedule_create_monthly.xml") +UPDATE_XML = os.path.join(TEST_ASSET_DIR, "schedule_update.xml") class ScheduleTests(unittest.TestCase): @@ -33,7 +39,7 @@ def test_get(self): self.assertEqual(50, all_schedules[0].priority) self.assertEqual("2016-07-06T20:19:00Z", all_schedules[0].created_at) self.assertEqual("2016-09-13T11:00:32Z", all_schedules[0].updated_at) - self.assertEqual("Extract", all_schedules[0].type) + self.assertEqual("Extract", all_schedules[0].schedule_type) self.assertEqual("Weekly", all_schedules[0].frequency) self.assertEqual("2016-09-14T11:00:00Z", all_schedules[0].next_run_at) @@ -43,7 +49,7 @@ def test_get(self): self.assertEqual(80, all_schedules[1].priority) self.assertEqual("2016-07-07T20:19:00Z", all_schedules[1].created_at) self.assertEqual("2016-09-12T16:39:38Z", all_schedules[1].updated_at) - self.assertEqual("Subscription", all_schedules[1].type) + self.assertEqual("Subscription", all_schedules[1].schedule_type) self.assertEqual("Weekly", all_schedules[1].frequency) self.assertEqual("2016-09-18T06:00:00Z", all_schedules[1].next_run_at) @@ -61,3 +67,123 @@ def test_delete(self): with requests_mock.mock() as m: m.delete(self.baseurl + "/c9cff7f9-309c-4361-99ff-d4ba8c9f5467", status_code=204) self.server.schedules.delete("c9cff7f9-309c-4361-99ff-d4ba8c9f5467") + + def test_create_hourly(self): + with open(CREATE_HOURLY_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.post(self.baseurl, text=response_xml) + hourly_interval = TSC.IntervalItem.create_hourly(time(2, 30), time(23, 0), + TSC.IntervalItem.Occurrence.Hours, 2) + new_schedule = TSC.ScheduleItem("hourly-schedule-1", 50, TSC.ScheduleItem.Type.Extract, + TSC.ScheduleItem.ExecutionOrder.Parallel, hourly_interval) + new_schedule = self.server.schedules.create(new_schedule) + + self.assertEqual("5f42be25-8a43-47ba-971a-63f2d4e7029c", new_schedule.id) + self.assertEqual("hourly-schedule-1", new_schedule.name) + self.assertEqual("Active", new_schedule.state) + self.assertEqual(50, new_schedule.priority) + self.assertEqual("2016-09-15T20:47:33Z", new_schedule.created_at) + self.assertEqual("2016-09-15T20:47:33Z", new_schedule.updated_at) + self.assertEqual(TSC.ScheduleItem.Type.Extract, new_schedule.schedule_type) + self.assertEqual(TSC.IntervalItem.Frequency.Hourly, new_schedule.frequency) + self.assertEqual("2016-09-16T01:30:00Z", new_schedule.next_run_at) + self.assertEqual(TSC.ScheduleItem.ExecutionOrder.Parallel, new_schedule.execution_order) + self.assertEqual(time(2, 30), new_schedule.interval_item.start_time) + self.assertEqual(time(23), new_schedule.interval_item.end_time) + self.assertEqual([('hours', "8")], new_schedule.interval_item.interval) + + def test_create_daily(self): + with open(CREATE_DAILY_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.post(self.baseurl, text=response_xml) + daily_interval = TSC.IntervalItem.create_daily(time(4, 50)) + new_schedule = TSC.ScheduleItem("daily-schedule-1", 90, TSC.ScheduleItem.Type.Subscription, + TSC.ScheduleItem.ExecutionOrder.Serial, daily_interval) + new_schedule = self.server.schedules.create(new_schedule) + + self.assertEqual("907cae38-72fd-417c-892a-95540c4664cd", new_schedule.id) + self.assertEqual("daily-schedule-1", new_schedule.name) + self.assertEqual("Active", new_schedule.state) + self.assertEqual(90, new_schedule.priority) + self.assertEqual("2016-09-15T21:01:09Z", new_schedule.created_at) + self.assertEqual("2016-09-15T21:01:09Z", new_schedule.updated_at) + self.assertEqual(TSC.ScheduleItem.Type.Subscription, new_schedule.schedule_type) + self.assertEqual(TSC.IntervalItem.Frequency.Daily, new_schedule.frequency) + self.assertEqual("2016-09-16T11:45:00Z", new_schedule.next_run_at) + self.assertEqual(TSC.ScheduleItem.ExecutionOrder.Serial, new_schedule.execution_order) + self.assertEqual(time(4, 45), new_schedule.interval_item.start_time) + + def test_create_weekly(self): + with open(CREATE_WEEKLY_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.post(self.baseurl, text=response_xml) + weekly_interval = TSC.IntervalItem.create_weekly(time(9, 15), TSC.IntervalItem.Day.Monday, + TSC.IntervalItem.Day.Wednesday, + TSC.IntervalItem.Day.Friday) + new_schedule = TSC.ScheduleItem("weekly-schedule-1", 80, TSC.ScheduleItem.Type.Extract, + TSC.ScheduleItem.ExecutionOrder.Parallel, weekly_interval) + new_schedule = self.server.schedules.create(new_schedule) + + self.assertEqual("1adff386-6be0-4958-9f81-a35e676932bf", new_schedule.id) + self.assertEqual("weekly-schedule-1", new_schedule.name) + self.assertEqual("Active", new_schedule.state) + self.assertEqual(80, new_schedule.priority) + self.assertEqual("2016-09-15T21:12:50Z", new_schedule.created_at) + self.assertEqual("2016-09-15T21:12:50Z", new_schedule.updated_at) + self.assertEqual(TSC.ScheduleItem.Type.Extract, new_schedule.schedule_type) + self.assertEqual(TSC.IntervalItem.Frequency.Weekly, new_schedule.frequency) + self.assertEqual("2016-09-16T16:15:00Z", new_schedule.next_run_at) + self.assertEqual(TSC.ScheduleItem.ExecutionOrder.Parallel, new_schedule.execution_order) + self.assertEqual(time(9, 15), new_schedule.interval_item.start_time) + self.assertEqual([("weekDay", "Monday"), ("weekDay", "Wednesday"), ("weekDay", "Friday")], + new_schedule.interval_item.interval) + + def test_create_monthly(self): + with open(CREATE_MONTHLY_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.post(self.baseurl, text=response_xml) + monthly_interval = TSC.IntervalItem.create_monthly(time(7), 12) + new_schedule = TSC.ScheduleItem("monthly-schedule-1", 20, TSC.ScheduleItem.Type.Extract, + TSC.ScheduleItem.ExecutionOrder.Serial, monthly_interval) + new_schedule = self.server.schedules.create(new_schedule) + + self.assertEqual("e06a7c75-5576-4f68-882d-8909d0219326", new_schedule.id) + self.assertEqual("monthly-schedule-1", new_schedule.name) + self.assertEqual("Active", new_schedule.state) + self.assertEqual(20, new_schedule.priority) + self.assertEqual("2016-09-15T21:16:56Z", new_schedule.created_at) + self.assertEqual("2016-09-15T21:16:56Z", new_schedule.updated_at) + self.assertEqual(TSC.ScheduleItem.Type.Extract, new_schedule.schedule_type) + self.assertEqual(TSC.IntervalItem.Frequency.Monthly, new_schedule.frequency) + self.assertEqual("2016-10-12T14:00:00Z", new_schedule.next_run_at) + self.assertEqual(TSC.ScheduleItem.ExecutionOrder.Serial, new_schedule.execution_order) + self.assertEqual(time(7), new_schedule.interval_item.start_time) + self.assertEqual([('monthDay', "12")], new_schedule.interval_item.interval) + + def test_update(self): + with open(UPDATE_XML, "rb") as f: + response_xml = f.read().decode("utf-8") + with requests_mock.mock() as m: + m.put(self.baseurl + '/7bea1766-1543-4052-9753-9d224bc069b5', text=response_xml) + new_interval = TSC.IntervalItem.create_weekly(time(7), TSC.IntervalItem.Day.Monday, + TSC.IntervalItem.Day.Friday) + single_schedule = TSC.ScheduleItem("weekly-schedule-1", 90, TSC.ScheduleItem.Type.Extract, + TSC.ScheduleItem.ExecutionOrder.Parallel, new_interval) + single_schedule._id = "7bea1766-1543-4052-9753-9d224bc069b5" + single_schedule = self.server.schedules.update(single_schedule) + + self.assertEqual("7bea1766-1543-4052-9753-9d224bc069b5", single_schedule.id) + self.assertEqual("weekly-schedule-1", single_schedule.name) + self.assertEqual(90, single_schedule.priority) + self.assertEqual("2016-09-15T23:50:02Z", single_schedule.updated_at) + self.assertEqual(TSC.ScheduleItem.Type.Extract, single_schedule.schedule_type) + self.assertEqual(TSC.IntervalItem.Frequency.Weekly, single_schedule.frequency) + self.assertEqual("2016-09-16T14:00:00Z", single_schedule.next_run_at) + self.assertEqual(TSC.ScheduleItem.ExecutionOrder.Parallel, single_schedule.execution_order) + self.assertEqual(time(7), single_schedule.interval_item.start_time) + self.assertEqual([("weekDay", "Monday"), ("weekDay", "Friday")], + single_schedule.interval_item.interval)