Skip to content

Commit c7d0ba5

Browse files
authored
Implement custom view objects (#1195)
* create custom views item and endpoint with get/update/delete methods Also added custom view operations to the workbook sample
1 parent 2d0e4e3 commit c7d0ba5

15 files changed

+505
-35
lines changed

samples/explore_workbook.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ def main():
7474
if all_workbooks:
7575
# Pick one workbook from the list
7676
sample_workbook = all_workbooks[0]
77+
sample_workbook.name = "Name me something cooler"
78+
sample_workbook.description = "That doesn't work"
79+
updated: TSC.WorkbookItem = server.workbooks.update(sample_workbook)
80+
print(updated.name, updated.description)
7781

7882
# Populate views
7983
server.workbooks.populate_views(sample_workbook)
@@ -127,6 +131,31 @@ def main():
127131
f.write(sample_workbook.preview_image)
128132
print("\nDownloaded preview image of workbook to {}".format(os.path.abspath(args.preview_image)))
129133

134+
# get custom views
135+
cvs, _ = server.custom_views.get()
136+
for c in cvs:
137+
print(c)
138+
139+
# for the last custom view in the list
140+
141+
# update the name
142+
# note that this will fail if the name is already changed to this value
143+
changed: TSC.CustomViewItem(id=c.id, name="I was updated by tsc")
144+
verified_change = server.custom_views.update(changed)
145+
print(verified_change)
146+
147+
# export as image. Filters etc could be added here as usual
148+
server.custom_views.populate_image(c)
149+
filename = c.id + "-image-export.png"
150+
with open(filename, "wb") as f:
151+
f.write(c.image)
152+
print("saved to " + filename)
153+
154+
if args.delete:
155+
print("deleting {}".format(c.id))
156+
unlucky = TSC.CustomViewItem(c.id)
157+
server.custom_views.delete(unlucky.id)
158+
130159

131160
if __name__ == "__main__":
132161
main()

tableauserverclient/models/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from .column_item import ColumnItem
22
from .connection_credentials import ConnectionCredentials
33
from .connection_item import ConnectionItem
4+
from .custom_view_item import CustomViewItem
45
from .data_acceleration_report_item import DataAccelerationReportItem
56
from .data_alert_item import DataAlertItem
67
from .database_item import DatabaseItem
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
from datetime import datetime
2+
3+
from defusedxml import ElementTree
4+
from defusedxml.ElementTree import fromstring, tostring
5+
from typing import Callable, List, Optional
6+
7+
from .exceptions import UnpopulatedPropertyError
8+
from .user_item import UserItem
9+
from .view_item import ViewItem
10+
from .workbook_item import WorkbookItem
11+
from ..datetime_helpers import parse_datetime
12+
13+
14+
class CustomViewItem(object):
15+
def __init__(self, id: Optional[str] = None, name: Optional[str] = None) -> None:
16+
self._content_url: Optional[str] = None # ?
17+
self._created_at: Optional["datetime"] = None
18+
self._id: Optional[str] = id
19+
self._image: Optional[Callable[[], bytes]] = None
20+
self._name: Optional[str] = name
21+
self._shared: Optional[bool] = False
22+
self._updated_at: Optional["datetime"] = None
23+
24+
self._owner: Optional[UserItem] = None
25+
self._view: Optional[ViewItem] = None
26+
self._workbook: Optional[WorkbookItem] = None
27+
28+
def __repr__(self: "CustomViewItem"):
29+
view_info = ""
30+
if self._view:
31+
view_info = " view='{}'".format(self._view.name or self._view.id or "unknown")
32+
wb_info = ""
33+
if self._workbook:
34+
wb_info = " workbook='{}'".format(self._workbook.name or self._workbook.id or "unknown")
35+
owner_info = ""
36+
if self._owner:
37+
owner_info = " owner='{}'".format(self._owner.name or self._owner.id or "unknown")
38+
return "<CustomViewItem id={} name=`{}`{}{}{}>".format(self.id, self.name, view_info, wb_info, owner_info)
39+
40+
def _set_image(self, image):
41+
self._image = image
42+
43+
@property
44+
def content_url(self) -> Optional[str]:
45+
return self._content_url
46+
47+
@property
48+
def created_at(self) -> Optional["datetime"]:
49+
return self._created_at
50+
51+
@property
52+
def id(self) -> Optional[str]:
53+
return self._id
54+
55+
@property
56+
def image(self) -> bytes:
57+
if self._image is None:
58+
error = "View item must be populated with its png image first."
59+
raise UnpopulatedPropertyError(error)
60+
return self._image()
61+
62+
@property
63+
def name(self) -> Optional[str]:
64+
return self._name
65+
66+
@name.setter
67+
def name(self, value: str):
68+
self._name = value
69+
70+
@property
71+
def shared(self) -> Optional[bool]:
72+
return self._shared
73+
74+
@shared.setter
75+
def shared(self, value: bool):
76+
self._shared = value
77+
78+
@property
79+
def updated_at(self) -> Optional["datetime"]:
80+
return self._updated_at
81+
82+
@property
83+
def owner(self) -> Optional[UserItem]:
84+
return self._owner
85+
86+
@owner.setter
87+
def owner(self, value: UserItem):
88+
self._owner = value
89+
90+
@property
91+
def workbook(self) -> Optional[WorkbookItem]:
92+
return self._workbook
93+
94+
@property
95+
def view(self) -> Optional[ViewItem]:
96+
return self._view
97+
98+
@classmethod
99+
def from_response(cls, resp, ns, workbook_id="") -> Optional["CustomViewItem"]:
100+
item = cls.list_from_response(resp, ns, workbook_id)
101+
if not item or len(item) == 0:
102+
return None
103+
else:
104+
return item[0]
105+
106+
@classmethod
107+
def list_from_response(cls, resp, ns, workbook_id="") -> List["CustomViewItem"]:
108+
return cls.from_xml_element(fromstring(resp), ns, workbook_id)
109+
110+
"""
111+
<customView
112+
id="37d015c6-bc28-4c88-989c-72c0a171f7aa"
113+
name="New name 2"
114+
createdAt="2016-02-03T23:35:09Z"
115+
updatedAt="2022-09-28T23:56:01Z"
116+
shared="false">
117+
<view id="8e33ff19-a7a4-4aa5-9dd8-a171e2b9c29f" name="circle"/>
118+
<workbook id="2fbe87c9-a7d8-45bf-b2b3-877a26ec9af5" name="marks and viz types 2"/>
119+
<owner id="cdfe8548-84c8-418e-9b33-2c0728b2398a" name="workgroupuser"/>
120+
</customView>
121+
"""
122+
123+
@classmethod
124+
def from_xml_element(cls, parsed_response, ns, workbook_id="") -> List["CustomViewItem"]:
125+
all_view_items = list()
126+
all_view_xml = parsed_response.findall(".//t:customView", namespaces=ns)
127+
for custom_view_xml in all_view_xml:
128+
cv_item = cls()
129+
view_elem: ElementTree = custom_view_xml.find(".//t:view", namespaces=ns)
130+
workbook_elem: str = custom_view_xml.find(".//t:workbook", namespaces=ns)
131+
owner_elem: str = custom_view_xml.find(".//t:owner", namespaces=ns)
132+
cv_item._created_at = parse_datetime(custom_view_xml.get("createdAt", None))
133+
cv_item._updated_at = parse_datetime(custom_view_xml.get("updatedAt", None))
134+
cv_item._content_url = custom_view_xml.get("contentUrl", None)
135+
cv_item._id = custom_view_xml.get("id", None)
136+
cv_item._name = custom_view_xml.get("name", None)
137+
138+
if owner_elem is not None:
139+
parsed_owners = UserItem.from_response_as_owner(tostring(custom_view_xml), ns)
140+
if parsed_owners and len(parsed_owners) > 0:
141+
cv_item._owner = parsed_owners[0]
142+
143+
if view_elem is not None:
144+
parsed_views = ViewItem.from_response(tostring(custom_view_xml), ns)
145+
if parsed_views and len(parsed_views) > 0:
146+
cv_item._view = parsed_views[0]
147+
148+
if workbook_id:
149+
cv_item._workbook = WorkbookItem(workbook_id)
150+
elif workbook_elem is not None:
151+
parsed_workbooks = WorkbookItem.from_response(tostring(custom_view_xml), ns)
152+
if parsed_workbooks and len(parsed_workbooks) > 0:
153+
cv_item._workbook = parsed_workbooks[0]
154+
155+
all_view_items.append(cv_item)
156+
return all_view_items

tableauserverclient/models/user_item.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,10 @@ def external_auth_user_id(self) -> Optional[str]:
9292
def id(self) -> Optional[str]:
9393
return self._id
9494

95+
@id.setter
96+
def id(self, value: str) -> None:
97+
self._id = value
98+
9599
@property
96100
def last_login(self) -> Optional[datetime]:
97101
return self._last_login
@@ -101,7 +105,6 @@ def name(self) -> Optional[str]:
101105
return self._name
102106

103107
@name.setter
104-
@property_not_empty
105108
def name(self, value: str):
106109
self._name = value
107110

@@ -205,9 +208,19 @@ def _set_values(
205208

206209
@classmethod
207210
def from_response(cls, resp, ns) -> List["UserItem"]:
211+
element_name = ".//t:user"
212+
return cls._parse_xml(element_name, resp, ns)
213+
214+
@classmethod
215+
def from_response_as_owner(cls, resp, ns) -> List["UserItem"]:
216+
element_name = ".//t:owner"
217+
return cls._parse_xml(element_name, resp, ns)
218+
219+
@classmethod
220+
def _parse_xml(cls, element_name, resp, ns):
208221
all_user_items = []
209222
parsed_response = fromstring(resp)
210-
all_user_xml = parsed_response.findall(".//t:user", namespaces=ns)
223+
all_user_xml = parsed_response.findall(element_name, namespaces=ns)
211224
for user_xml in all_user_xml:
212225
(
213226
id,

tableauserverclient/server/endpoint/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from .auth_endpoint import Auth
2+
from .custom_views_endpoint import CustomViews
23
from .data_acceleration_report_endpoint import DataAccelerationReport
34
from .data_alert_endpoint import DataAlerts
45
from .databases_endpoint import Databases
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import logging
2+
from typing import List, Optional, Tuple
3+
4+
from .endpoint import QuerysetEndpoint, api
5+
from .exceptions import MissingRequiredFieldError
6+
from tableauserverclient.models import CustomViewItem, PaginationItem
7+
from tableauserverclient.server import RequestFactory, RequestOptions, ImageRequestOptions
8+
9+
logger = logging.getLogger("tableau.endpoint.custom_views")
10+
11+
"""
12+
Get a list of custom views on a site
13+
get the details of a custom view
14+
download an image of a custom view.
15+
Delete a custom view
16+
update the name or owner of a custom view.
17+
"""
18+
19+
20+
class CustomViews(QuerysetEndpoint):
21+
def __init__(self, parent_srv):
22+
super(CustomViews, self).__init__(parent_srv)
23+
24+
@property
25+
def baseurl(self) -> str:
26+
return "{0}/sites/{1}/customviews".format(self.parent_srv.baseurl, self.parent_srv.site_id)
27+
28+
"""
29+
If the request has no filter parameters: Administrators will see all custom views.
30+
Other users will see only custom views that they own.
31+
If the filter parameters include ownerId: Users will see only custom views that they own.
32+
If the filter parameters include viewId and/or workbookId, and don't include ownerId:
33+
Users will see those custom views that they have Write and WebAuthoring permissions for.
34+
If site user visibility is not set to Limited, the Users will see those custom views that are "public",
35+
meaning the value of their shared attribute is true.
36+
If site user visibility is set to Limited, ????
37+
"""
38+
39+
@api(version="3.18")
40+
def get(self, req_options: Optional["RequestOptions"] = None) -> Tuple[List[CustomViewItem], PaginationItem]:
41+
logger.info("Querying all custom views on site")
42+
url = self.baseurl
43+
server_response = self.get_request(url, req_options)
44+
pagination_item = PaginationItem.from_response(server_response.content, self.parent_srv.namespace)
45+
all_view_items = CustomViewItem.list_from_response(server_response.content, self.parent_srv.namespace)
46+
return all_view_items, pagination_item
47+
48+
@api(version="3.18")
49+
def get_by_id(self, view_id: str) -> Optional[CustomViewItem]:
50+
if not view_id:
51+
error = "Custom view item missing ID."
52+
raise MissingRequiredFieldError(error)
53+
logger.info("Querying custom view (ID: {0})".format(view_id))
54+
url = "{0}/{1}".format(self.baseurl, view_id)
55+
server_response = self.get_request(url)
56+
return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace)
57+
58+
@api(version="3.18")
59+
def populate_image(self, view_item: CustomViewItem, req_options: Optional["ImageRequestOptions"] = None) -> None:
60+
if not view_item.id:
61+
error = "Custom View item missing ID."
62+
raise MissingRequiredFieldError(error)
63+
64+
def image_fetcher():
65+
return self._get_view_image(view_item, req_options)
66+
67+
view_item._set_image(image_fetcher)
68+
logger.info("Populated image for custom view (ID: {0})".format(view_item.id))
69+
70+
def _get_view_image(self, view_item: CustomViewItem, req_options: Optional["ImageRequestOptions"]) -> bytes:
71+
url = "{0}/{1}/image".format(self.baseurl, view_item.id)
72+
server_response = self.get_request(url, req_options)
73+
image = server_response.content
74+
return image
75+
76+
"""
77+
Not yet implemented: pdf or csv exports
78+
"""
79+
80+
@api(version="3.18")
81+
def update(self, view_item: CustomViewItem) -> Optional[CustomViewItem]:
82+
if not view_item.id:
83+
error = "Custom view item missing ID."
84+
raise MissingRequiredFieldError(error)
85+
if not (view_item.owner or view_item.name or view_item.shared):
86+
logger.debug("No changes to make")
87+
return view_item
88+
89+
# Update the custom view owner or name
90+
url = "{0}/{1}".format(self.baseurl, view_item.id)
91+
update_req = RequestFactory.CustomView.update_req(view_item)
92+
server_response = self.put_request(url, update_req)
93+
logger.info("Updated custom view (ID: {0})".format(view_item.id))
94+
return CustomViewItem.from_response(server_response.content, self.parent_srv.namespace)
95+
96+
# Delete 1 view by id
97+
@api(version="3.19")
98+
def delete(self, view_id: str) -> None:
99+
if not view_id:
100+
error = "Custom View ID undefined."
101+
raise ValueError(error)
102+
url = "{0}/{1}".format(self.baseurl, view_id)
103+
self.delete_request(url)
104+
logger.info("Deleted single custom view (ID: {0})".format(view_id))

tableauserverclient/server/request_factory.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1127,10 +1127,21 @@ def update_req(self, xml_request: ET.Element, metric_item: MetricItem) -> bytes:
11271127
return ET.tostring(xml_request)
11281128

11291129

1130+
class CustomViewRequest(object):
1131+
@_tsrequest_wrapped
1132+
def update_req(self, xml_request: ET.Element, custom_view_item: CustomViewItem):
1133+
updating_element = ET.SubElement(xml_request, "customView")
1134+
if custom_view_item.owner is not None and custom_view_item.owner.id is not None:
1135+
ET.SubElement(updating_element, "owner", {"id": custom_view_item.owner.id})
1136+
if custom_view_item.name is not None:
1137+
updating_element.attrib["name"] = custom_view_item.name
1138+
1139+
11301140
class RequestFactory(object):
11311141
Auth = AuthRequest()
11321142
Connection = Connection()
11331143
Column = ColumnRequest()
1144+
CustomView = CustomViewRequest()
11341145
DataAlert = DataAlertRequest()
11351146
Datasource = DatasourceRequest()
11361147
Database = DatabaseRequest()

0 commit comments

Comments
 (0)