Skip to content

Commit c361f8f

Browse files
authored
Feature: export custom views #999 (#1506)
Adding custom views PDF & CSV export endpoints
1 parent e623511 commit c361f8f

File tree

5 files changed

+194
-40
lines changed

5 files changed

+194
-40
lines changed

samples/export.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ def main():
4141
"--language", help="Text such as 'Average' will appear in this language. Use values like fr, de, es, en"
4242
)
4343
parser.add_argument("--workbook", action="store_true")
44+
parser.add_argument("--custom_view", action="store_true")
4445

4546
parser.add_argument("--file", "-f", help="filename to store the exported data")
4647
parser.add_argument("--filter", "-vf", metavar="COLUMN:VALUE", help="View filter to apply to the view")
@@ -58,6 +59,8 @@ def main():
5859
print("Connected")
5960
if args.workbook:
6061
item = server.workbooks.get_by_id(args.resource_id)
62+
elif args.custom_view:
63+
item = server.custom_views.get_by_id(args.resource_id)
6164
else:
6265
item = server.views.get_by_id(args.resource_id)
6366

@@ -74,6 +77,8 @@ def main():
7477
populate = getattr(server.views, populate_func_name)
7578
if args.workbook:
7679
populate = getattr(server.workbooks, populate_func_name)
80+
elif args.custom_view:
81+
populate = getattr(server.custom_views, populate_func_name)
7782

7883
option_factory = getattr(TSC, option_factory_name)
7984
options: TSC.PDFRequestOptions = option_factory()

tableauserverclient/models/custom_view_item.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from defusedxml import ElementTree
44
from defusedxml.ElementTree import fromstring, tostring
55
from typing import Callable, Optional
6+
from collections.abc import Iterator
67

78
from .exceptions import UnpopulatedPropertyError
89
from .user_item import UserItem
@@ -17,6 +18,8 @@ def __init__(self, id: Optional[str] = None, name: Optional[str] = None) -> None
1718
self._created_at: Optional["datetime"] = None
1819
self._id: Optional[str] = id
1920
self._image: Optional[Callable[[], bytes]] = None
21+
self._pdf: Optional[Callable[[], bytes]] = None
22+
self._csv: Optional[Callable[[], Iterator[bytes]]] = None
2023
self._name: Optional[str] = name
2124
self._shared: Optional[bool] = False
2225
self._updated_at: Optional["datetime"] = None
@@ -40,6 +43,12 @@ def __repr__(self: "CustomViewItem"):
4043
def _set_image(self, image):
4144
self._image = image
4245

46+
def _set_pdf(self, pdf):
47+
self._pdf = pdf
48+
49+
def _set_csv(self, csv):
50+
self._csv = csv
51+
4352
@property
4453
def content_url(self) -> Optional[str]:
4554
return self._content_url
@@ -55,10 +64,24 @@ def id(self) -> Optional[str]:
5564
@property
5665
def image(self) -> bytes:
5766
if self._image is None:
58-
error = "View item must be populated with its png image first."
67+
error = "Custom View item must be populated with its png image first."
5968
raise UnpopulatedPropertyError(error)
6069
return self._image()
6170

71+
@property
72+
def pdf(self) -> bytes:
73+
if self._pdf is None:
74+
error = "Custom View item must be populated with its pdf first."
75+
raise UnpopulatedPropertyError(error)
76+
return self._pdf()
77+
78+
@property
79+
def csv(self) -> Iterator[bytes]:
80+
if self._csv is None:
81+
error = "Custom View item must be populated with its csv first."
82+
raise UnpopulatedPropertyError(error)
83+
return self._csv()
84+
6285
@property
6386
def name(self) -> Optional[str]:
6487
return self._name

tableauserverclient/server/endpoint/custom_views_endpoint.py

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,23 @@
11
import io
22
import logging
33
import os
4+
from contextlib import closing
45
from pathlib import Path
56
from typing import Optional, Union
7+
from collections.abc import Iterator
68

79
from tableauserverclient.config import BYTES_PER_MB, config
810
from tableauserverclient.filesys_helpers import get_file_object_size
911
from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api
1012
from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError
1113
from tableauserverclient.models import CustomViewItem, PaginationItem
12-
from tableauserverclient.server import RequestFactory, RequestOptions, ImageRequestOptions
14+
from tableauserverclient.server import (
15+
RequestFactory,
16+
RequestOptions,
17+
ImageRequestOptions,
18+
PDFRequestOptions,
19+
CSVRequestOptions,
20+
)
1321

1422
from tableauserverclient.helpers.logging import logger
1523

@@ -91,9 +99,45 @@ def _get_view_image(self, view_item: CustomViewItem, req_options: Optional["Imag
9199
image = server_response.content
92100
return image
93101

94-
"""
95-
Not yet implemented: pdf or csv exports
96-
"""
102+
@api(version="3.23")
103+
def populate_pdf(self, custom_view_item: CustomViewItem, req_options: Optional["PDFRequestOptions"] = None) -> None:
104+
if not custom_view_item.id:
105+
error = "Custom View item missing ID."
106+
raise MissingRequiredFieldError(error)
107+
108+
def pdf_fetcher():
109+
return self._get_custom_view_pdf(custom_view_item, req_options)
110+
111+
custom_view_item._set_pdf(pdf_fetcher)
112+
logger.info(f"Populated pdf for custom view (ID: {custom_view_item.id})")
113+
114+
def _get_custom_view_pdf(
115+
self, custom_view_item: CustomViewItem, req_options: Optional["PDFRequestOptions"]
116+
) -> bytes:
117+
url = f"{self.baseurl}/{custom_view_item.id}/pdf"
118+
server_response = self.get_request(url, req_options)
119+
pdf = server_response.content
120+
return pdf
121+
122+
@api(version="3.23")
123+
def populate_csv(self, custom_view_item: CustomViewItem, req_options: Optional["CSVRequestOptions"] = None) -> None:
124+
if not custom_view_item.id:
125+
error = "Custom View item missing ID."
126+
raise MissingRequiredFieldError(error)
127+
128+
def csv_fetcher():
129+
return self._get_custom_view_csv(custom_view_item, req_options)
130+
131+
custom_view_item._set_csv(csv_fetcher)
132+
logger.info(f"Populated csv for custom view (ID: {custom_view_item.id})")
133+
134+
def _get_custom_view_csv(
135+
self, custom_view_item: CustomViewItem, req_options: Optional["CSVRequestOptions"]
136+
) -> Iterator[bytes]:
137+
url = f"{self.baseurl}/{custom_view_item.id}/data"
138+
139+
with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response:
140+
yield from server_response.iter_content(1024)
97141

98142
@api(version="3.18")
99143
def update(self, view_item: CustomViewItem) -> Optional[CustomViewItem]:

tableauserverclient/server/request_options.py

Lines changed: 45 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,46 @@ def _append_view_filters(self, params) -> None:
213213
params[name] = value
214214

215215

216+
class _ImagePDFCommonExportOptions(_DataExportOptions):
217+
def __init__(self, maxage=-1, viz_height=None, viz_width=None):
218+
super().__init__(maxage=maxage)
219+
self.viz_height = viz_height
220+
self.viz_width = viz_width
221+
222+
@property
223+
def viz_height(self):
224+
return self._viz_height
225+
226+
@viz_height.setter
227+
@property_is_int(range=(0, sys.maxsize), allowed=(None,))
228+
def viz_height(self, value):
229+
self._viz_height = value
230+
231+
@property
232+
def viz_width(self):
233+
return self._viz_width
234+
235+
@viz_width.setter
236+
@property_is_int(range=(0, sys.maxsize), allowed=(None,))
237+
def viz_width(self, value):
238+
self._viz_width = value
239+
240+
def get_query_params(self) -> dict:
241+
params = super().get_query_params()
242+
243+
# XOR. Either both are None or both are not None.
244+
if (self.viz_height is None) ^ (self.viz_width is None):
245+
raise ValueError("viz_height and viz_width must be specified together")
246+
247+
if self.viz_height is not None:
248+
params["vizHeight"] = self.viz_height
249+
250+
if self.viz_width is not None:
251+
params["vizWidth"] = self.viz_width
252+
253+
return params
254+
255+
216256
class CSVRequestOptions(_DataExportOptions):
217257
extension = "csv"
218258

@@ -221,15 +261,15 @@ class ExcelRequestOptions(_DataExportOptions):
221261
extension = "xlsx"
222262

223263

224-
class ImageRequestOptions(_DataExportOptions):
264+
class ImageRequestOptions(_ImagePDFCommonExportOptions):
225265
extension = "png"
226266

227267
# if 'high' isn't specified, the REST API endpoint returns an image with standard resolution
228268
class Resolution:
229269
High = "high"
230270

231-
def __init__(self, imageresolution=None, maxage=-1):
232-
super().__init__(maxage=maxage)
271+
def __init__(self, imageresolution=None, maxage=-1, viz_height=None, viz_width=None):
272+
super().__init__(maxage=maxage, viz_height=viz_height, viz_width=viz_width)
233273
self.image_resolution = imageresolution
234274

235275
def get_query_params(self):
@@ -239,7 +279,7 @@ def get_query_params(self):
239279
return params
240280

241281

242-
class PDFRequestOptions(_DataExportOptions):
282+
class PDFRequestOptions(_ImagePDFCommonExportOptions):
243283
class PageType:
244284
A3 = "a3"
245285
A4 = "a4"
@@ -261,29 +301,9 @@ class Orientation:
261301
Landscape = "landscape"
262302

263303
def __init__(self, page_type=None, orientation=None, maxage=-1, viz_height=None, viz_width=None):
264-
super().__init__(maxage=maxage)
304+
super().__init__(maxage=maxage, viz_height=viz_height, viz_width=viz_width)
265305
self.page_type = page_type
266306
self.orientation = orientation
267-
self.viz_height = viz_height
268-
self.viz_width = viz_width
269-
270-
@property
271-
def viz_height(self):
272-
return self._viz_height
273-
274-
@viz_height.setter
275-
@property_is_int(range=(0, sys.maxsize), allowed=(None,))
276-
def viz_height(self, value):
277-
self._viz_height = value
278-
279-
@property
280-
def viz_width(self):
281-
return self._viz_width
282-
283-
@viz_width.setter
284-
@property_is_int(range=(0, sys.maxsize), allowed=(None,))
285-
def viz_width(self, value):
286-
self._viz_width = value
287307

288308
def get_query_params(self) -> dict:
289309
params = super().get_query_params()
@@ -293,14 +313,4 @@ def get_query_params(self) -> dict:
293313
if self.orientation:
294314
params["orientation"] = self.orientation
295315

296-
# XOR. Either both are None or both are not None.
297-
if (self.viz_height is None) ^ (self.viz_width is None):
298-
raise ValueError("viz_height and viz_width must be specified together")
299-
300-
if self.viz_height is not None:
301-
params["vizHeight"] = self.viz_height
302-
303-
if self.viz_width is not None:
304-
params["vizWidth"] = self.viz_width
305-
306316
return params

test/test_custom_view.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
GET_XML_ID = os.path.join(TEST_ASSET_DIR, "custom_view_get_id.xml")
1919
POPULATE_PREVIEW_IMAGE = os.path.join(TEST_ASSET_DIR, "Sample View Image.png")
2020
CUSTOM_VIEW_UPDATE_XML = os.path.join(TEST_ASSET_DIR, "custom_view_update.xml")
21+
CUSTOM_VIEW_POPULATE_PDF = os.path.join(TEST_ASSET_DIR, "populate_pdf.pdf")
22+
CUSTOM_VIEW_POPULATE_CSV = os.path.join(TEST_ASSET_DIR, "populate_csv.csv")
2123
CUSTOM_VIEW_DOWNLOAD = TEST_ASSET_DIR / "custom_view_download.json"
2224
FILE_UPLOAD_INIT = TEST_ASSET_DIR / "fileupload_initialize.xml"
2325
FILE_UPLOAD_APPEND = TEST_ASSET_DIR / "fileupload_append.xml"
@@ -246,3 +248,73 @@ def test_large_publish(self):
246248
assert isinstance(view, TSC.CustomViewItem)
247249
assert view.id is not None
248250
assert view.name is not None
251+
252+
def test_populate_pdf(self) -> None:
253+
self.server.version = "3.23"
254+
self.baseurl = self.server.custom_views.baseurl
255+
with open(CUSTOM_VIEW_POPULATE_PDF, "rb") as f:
256+
response = f.read()
257+
with requests_mock.mock() as m:
258+
m.get(
259+
self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/pdf?type=letter&orientation=portrait&maxAge=5",
260+
content=response,
261+
)
262+
custom_view = TSC.CustomViewItem()
263+
custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5"
264+
265+
size = TSC.PDFRequestOptions.PageType.Letter
266+
orientation = TSC.PDFRequestOptions.Orientation.Portrait
267+
req_option = TSC.PDFRequestOptions(size, orientation, 5)
268+
269+
self.server.custom_views.populate_pdf(custom_view, req_option)
270+
self.assertEqual(response, custom_view.pdf)
271+
272+
def test_populate_csv(self) -> None:
273+
self.server.version = "3.23"
274+
self.baseurl = self.server.custom_views.baseurl
275+
with open(CUSTOM_VIEW_POPULATE_CSV, "rb") as f:
276+
response = f.read()
277+
with requests_mock.mock() as m:
278+
m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/data?maxAge=1", content=response)
279+
custom_view = TSC.CustomViewItem()
280+
custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5"
281+
request_option = TSC.CSVRequestOptions(maxage=1)
282+
self.server.custom_views.populate_csv(custom_view, request_option)
283+
284+
csv_file = b"".join(custom_view.csv)
285+
self.assertEqual(response, csv_file)
286+
287+
def test_populate_csv_default_maxage(self) -> None:
288+
self.server.version = "3.23"
289+
self.baseurl = self.server.custom_views.baseurl
290+
with open(CUSTOM_VIEW_POPULATE_CSV, "rb") as f:
291+
response = f.read()
292+
with requests_mock.mock() as m:
293+
m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/data", content=response)
294+
custom_view = TSC.CustomViewItem()
295+
custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5"
296+
self.server.custom_views.populate_csv(custom_view)
297+
298+
csv_file = b"".join(custom_view.csv)
299+
self.assertEqual(response, csv_file)
300+
301+
def test_pdf_height(self) -> None:
302+
self.server.version = "3.23"
303+
self.baseurl = self.server.custom_views.baseurl
304+
with open(CUSTOM_VIEW_POPULATE_PDF, "rb") as f:
305+
response = f.read()
306+
with requests_mock.mock() as m:
307+
m.get(
308+
self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/pdf?vizHeight=1080&vizWidth=1920",
309+
content=response,
310+
)
311+
custom_view = TSC.CustomViewItem()
312+
custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5"
313+
314+
req_option = TSC.PDFRequestOptions(
315+
viz_height=1080,
316+
viz_width=1920,
317+
)
318+
319+
self.server.custom_views.populate_pdf(custom_view, req_option)
320+
self.assertEqual(response, custom_view.pdf)

0 commit comments

Comments
 (0)