Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

added support for schedules #30

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion tableauserverclient/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions tableauserverclient/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
79 changes: 79 additions & 0 deletions tableauserverclient/models/schedule_item.py
Original file line number Diff line number Diff line change
@@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here (what is frequency)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Frequency is a string (or, I guess, an enum of a few potential values)

"Weekly", "Daily", "Hourly" there might be another one

self._id = None
self._name = None
self._next_run_at = None
self._priority = None
self._state = None
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here (what is state)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the schedule is 'on' or not.

"Active" or "Suspended"

self._type = None
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is type. This doesn't feel like it should be just an arbitrary string

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Extract" or "Subscription" -- maybe more in the future

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()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: We seem to use the literal in most places []

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this supposed to be a date?

Copy link
Collaborator

@t8y8 t8y8 Sep 14, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a date in the format "YYYY-MM-DDTHH:MM:SSZ"

Looks like the python strptime string would be %Y-%m-%dT%H:%M:%SZ

We could make these native datetime objects, that seems reasonable


priority = schedule_xml.get('priority', None)
if priority:
schedule_item._priority = int(priority)
all_schedule_items.append(schedule_item)
return all_schedule_items
6 changes: 3 additions & 3 deletions tableauserverclient/server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions tableauserverclient/server/endpoint/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions tableauserverclient/server/endpoint/schedules_endpoint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from .endpoint import Endpoint
from .. import PaginationItem, ScheduleItem
import logging

logger = logging.getLogger('tableau.endpoint.schedules')


class Schedules(Endpoint):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can't update a schedule right now?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No :'(

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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The exception should get logged as an error -- or do we not do that on other endpoints?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed the XSD schema only allows nonNegInts

url = "{0}/{1}".format(self._construct_url(), schedule_id)
self.delete_request(url)
logger.info("Deleted single schedule (ID: {0})".format(schedule_id))
3 changes: 2 additions & 1 deletion tableauserverclient/server/server.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions test/assets/schedule_get.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version='1.0' encoding='UTF-8'?>
<tsResponse xmlns="http://tableau.com/api" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://tableau.com/api http://tableau.com/api/ts-api-2.3.xsd">
<pagination pageNumber="1" pageSize="100" totalAvailable="2" />
<schedules>
<schedule id="c9cff7f9-309c-4361-99ff-d4ba8c9f5467" name="Weekday early mornings" state="Active" priority="50" createdAt="2016-07-06T20:19:00Z" updatedAt="2016-09-13T11:00:32Z" type="Extract" frequency="Weekly" nextRunAt="2016-09-14T11:00:00Z" />
<schedule id="bcb79d07-6e47-472f-8a65-d7f51f40c36c" name="Saturday night" state="Active" priority="80" createdAt="2016-07-07T20:19:00Z" updatedAt="2016-09-12T16:39:38Z" type="Subscription" frequency="Weekly" nextRunAt="2016-09-18T06:00:00Z" />
</schedules>
</tsResponse>
5 changes: 5 additions & 0 deletions test/assets/schedule_get_empty.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='UTF-8'?>
<tsResponse xmlns="http://tableau.com/api" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://tableau.com/api http://tableau.com/api/ts-api-2.3.xsd">
<pagination pageNumber="1" pageSize="100" totalAvailable="0" />
<schedules />
</tsResponse>
63 changes: 63 additions & 0 deletions test/test_schedule.py
Original file line number Diff line number Diff line change
@@ -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):
Copy link
Collaborator

@t8y8 t8y8 Sep 13, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we test this for every content type? I'm not sure it's a super valuable test but it's harmless.

Is it just testing that the Schedule.from_response doesn't choke on an empty input?

If that's the case the mocked request might be unnecessary.

This is minor though, I bet this follows the other test's and that's cool.

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")