Skip to content

Feature: export custom views #999 #1506

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

Merged
merged 2 commits into from
Oct 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
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
5 changes: 5 additions & 0 deletions samples/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ def main():
"--language", help="Text such as 'Average' will appear in this language. Use values like fr, de, es, en"
)
parser.add_argument("--workbook", action="store_true")
parser.add_argument("--custom_view", action="store_true")

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

Expand All @@ -74,6 +77,8 @@ def main():
populate = getattr(server.views, populate_func_name)
if args.workbook:
populate = getattr(server.workbooks, populate_func_name)
elif args.custom_view:
populate = getattr(server.custom_views, populate_func_name)

option_factory = getattr(TSC, option_factory_name)
options: TSC.PDFRequestOptions = option_factory()
Expand Down
25 changes: 24 additions & 1 deletion tableauserverclient/models/custom_view_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from defusedxml import ElementTree
from defusedxml.ElementTree import fromstring, tostring
from typing import Callable, Optional
from collections.abc import Iterator

from .exceptions import UnpopulatedPropertyError
from .user_item import UserItem
Expand All @@ -17,6 +18,8 @@ def __init__(self, id: Optional[str] = None, name: Optional[str] = None) -> None
self._created_at: Optional["datetime"] = None
self._id: Optional[str] = id
self._image: Optional[Callable[[], bytes]] = None
self._pdf: Optional[Callable[[], bytes]] = None
self._csv: Optional[Callable[[], Iterator[bytes]]] = None
self._name: Optional[str] = name
self._shared: Optional[bool] = False
self._updated_at: Optional["datetime"] = None
Expand All @@ -40,6 +43,12 @@ def __repr__(self: "CustomViewItem"):
def _set_image(self, image):
self._image = image

def _set_pdf(self, pdf):
self._pdf = pdf

def _set_csv(self, csv):
self._csv = csv

@property
def content_url(self) -> Optional[str]:
return self._content_url
Expand All @@ -55,10 +64,24 @@ def id(self) -> Optional[str]:
@property
def image(self) -> bytes:
if self._image is None:
error = "View item must be populated with its png image first."
error = "Custom View item must be populated with its png image first."
raise UnpopulatedPropertyError(error)
return self._image()

@property
def pdf(self) -> bytes:
if self._pdf is None:
error = "Custom View item must be populated with its pdf first."
raise UnpopulatedPropertyError(error)
return self._pdf()

@property
def csv(self) -> Iterator[bytes]:
if self._csv is None:
error = "Custom View item must be populated with its csv first."
raise UnpopulatedPropertyError(error)
return self._csv()

@property
def name(self) -> Optional[str]:
return self._name
Expand Down
52 changes: 48 additions & 4 deletions tableauserverclient/server/endpoint/custom_views_endpoint.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
import io
import logging
import os
from contextlib import closing
from pathlib import Path
from typing import Optional, Union
from collections.abc import Iterator

from tableauserverclient.config import BYTES_PER_MB, config
from tableauserverclient.filesys_helpers import get_file_object_size
from tableauserverclient.server.endpoint.endpoint import QuerysetEndpoint, api
from tableauserverclient.server.endpoint.exceptions import MissingRequiredFieldError
from tableauserverclient.models import CustomViewItem, PaginationItem
from tableauserverclient.server import RequestFactory, RequestOptions, ImageRequestOptions
from tableauserverclient.server import (
RequestFactory,
RequestOptions,
ImageRequestOptions,
PDFRequestOptions,
CSVRequestOptions,
)

from tableauserverclient.helpers.logging import logger

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

"""
Not yet implemented: pdf or csv exports
"""
@api(version="3.23")
def populate_pdf(self, custom_view_item: CustomViewItem, req_options: Optional["PDFRequestOptions"] = None) -> None:
if not custom_view_item.id:
error = "Custom View item missing ID."
raise MissingRequiredFieldError(error)

def pdf_fetcher():
return self._get_custom_view_pdf(custom_view_item, req_options)

custom_view_item._set_pdf(pdf_fetcher)
logger.info(f"Populated pdf for custom view (ID: {custom_view_item.id})")

def _get_custom_view_pdf(
self, custom_view_item: CustomViewItem, req_options: Optional["PDFRequestOptions"]
) -> bytes:
url = f"{self.baseurl}/{custom_view_item.id}/pdf"
server_response = self.get_request(url, req_options)
pdf = server_response.content
return pdf

@api(version="3.23")
def populate_csv(self, custom_view_item: CustomViewItem, req_options: Optional["CSVRequestOptions"] = None) -> None:
if not custom_view_item.id:
error = "Custom View item missing ID."
raise MissingRequiredFieldError(error)

def csv_fetcher():
return self._get_custom_view_csv(custom_view_item, req_options)

custom_view_item._set_csv(csv_fetcher)
logger.info(f"Populated csv for custom view (ID: {custom_view_item.id})")

def _get_custom_view_csv(
self, custom_view_item: CustomViewItem, req_options: Optional["CSVRequestOptions"]
) -> Iterator[bytes]:
url = f"{self.baseurl}/{custom_view_item.id}/data"

with closing(self.get_request(url, request_object=req_options, parameters={"stream": True})) as server_response:
yield from server_response.iter_content(1024)

@api(version="3.18")
def update(self, view_item: CustomViewItem) -> Optional[CustomViewItem]:
Expand Down
80 changes: 45 additions & 35 deletions tableauserverclient/server/request_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,46 @@ def _append_view_filters(self, params) -> None:
params[name] = value


class _ImagePDFCommonExportOptions(_DataExportOptions):
def __init__(self, maxage=-1, viz_height=None, viz_width=None):
super().__init__(maxage=maxage)
self.viz_height = viz_height
self.viz_width = viz_width

@property
def viz_height(self):
return self._viz_height

@viz_height.setter
@property_is_int(range=(0, sys.maxsize), allowed=(None,))
def viz_height(self, value):
self._viz_height = value

@property
def viz_width(self):
return self._viz_width

@viz_width.setter
@property_is_int(range=(0, sys.maxsize), allowed=(None,))
def viz_width(self, value):
self._viz_width = value

def get_query_params(self) -> dict:
params = super().get_query_params()

# XOR. Either both are None or both are not None.
if (self.viz_height is None) ^ (self.viz_width is None):
raise ValueError("viz_height and viz_width must be specified together")

if self.viz_height is not None:
params["vizHeight"] = self.viz_height

if self.viz_width is not None:
params["vizWidth"] = self.viz_width

return params


class CSVRequestOptions(_DataExportOptions):
extension = "csv"

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


class ImageRequestOptions(_DataExportOptions):
class ImageRequestOptions(_ImagePDFCommonExportOptions):
extension = "png"

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

def __init__(self, imageresolution=None, maxage=-1):
super().__init__(maxage=maxage)
def __init__(self, imageresolution=None, maxage=-1, viz_height=None, viz_width=None):
super().__init__(maxage=maxage, viz_height=viz_height, viz_width=viz_width)
self.image_resolution = imageresolution

def get_query_params(self):
Expand All @@ -239,7 +279,7 @@ def get_query_params(self):
return params


class PDFRequestOptions(_DataExportOptions):
class PDFRequestOptions(_ImagePDFCommonExportOptions):
class PageType:
A3 = "a3"
A4 = "a4"
Expand All @@ -261,29 +301,9 @@ class Orientation:
Landscape = "landscape"

def __init__(self, page_type=None, orientation=None, maxage=-1, viz_height=None, viz_width=None):
super().__init__(maxage=maxage)
super().__init__(maxage=maxage, viz_height=viz_height, viz_width=viz_width)
self.page_type = page_type
self.orientation = orientation
self.viz_height = viz_height
self.viz_width = viz_width

@property
def viz_height(self):
return self._viz_height

@viz_height.setter
@property_is_int(range=(0, sys.maxsize), allowed=(None,))
def viz_height(self, value):
self._viz_height = value

@property
def viz_width(self):
return self._viz_width

@viz_width.setter
@property_is_int(range=(0, sys.maxsize), allowed=(None,))
def viz_width(self, value):
self._viz_width = value

def get_query_params(self) -> dict:
params = super().get_query_params()
Expand All @@ -293,14 +313,4 @@ def get_query_params(self) -> dict:
if self.orientation:
params["orientation"] = self.orientation

# XOR. Either both are None or both are not None.
if (self.viz_height is None) ^ (self.viz_width is None):
raise ValueError("viz_height and viz_width must be specified together")

if self.viz_height is not None:
params["vizHeight"] = self.viz_height

if self.viz_width is not None:
params["vizWidth"] = self.viz_width

return params
72 changes: 72 additions & 0 deletions test/test_custom_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
GET_XML_ID = os.path.join(TEST_ASSET_DIR, "custom_view_get_id.xml")
POPULATE_PREVIEW_IMAGE = os.path.join(TEST_ASSET_DIR, "Sample View Image.png")
CUSTOM_VIEW_UPDATE_XML = os.path.join(TEST_ASSET_DIR, "custom_view_update.xml")
CUSTOM_VIEW_POPULATE_PDF = os.path.join(TEST_ASSET_DIR, "populate_pdf.pdf")
CUSTOM_VIEW_POPULATE_CSV = os.path.join(TEST_ASSET_DIR, "populate_csv.csv")
CUSTOM_VIEW_DOWNLOAD = TEST_ASSET_DIR / "custom_view_download.json"
FILE_UPLOAD_INIT = TEST_ASSET_DIR / "fileupload_initialize.xml"
FILE_UPLOAD_APPEND = TEST_ASSET_DIR / "fileupload_append.xml"
Expand Down Expand Up @@ -246,3 +248,73 @@ def test_large_publish(self):
assert isinstance(view, TSC.CustomViewItem)
assert view.id is not None
assert view.name is not None

def test_populate_pdf(self) -> None:
self.server.version = "3.23"
self.baseurl = self.server.custom_views.baseurl
with open(CUSTOM_VIEW_POPULATE_PDF, "rb") as f:
response = f.read()
with requests_mock.mock() as m:
m.get(
self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/pdf?type=letter&orientation=portrait&maxAge=5",
content=response,
)
custom_view = TSC.CustomViewItem()
custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5"

size = TSC.PDFRequestOptions.PageType.Letter
orientation = TSC.PDFRequestOptions.Orientation.Portrait
req_option = TSC.PDFRequestOptions(size, orientation, 5)

self.server.custom_views.populate_pdf(custom_view, req_option)
self.assertEqual(response, custom_view.pdf)

def test_populate_csv(self) -> None:
self.server.version = "3.23"
self.baseurl = self.server.custom_views.baseurl
with open(CUSTOM_VIEW_POPULATE_CSV, "rb") as f:
response = f.read()
with requests_mock.mock() as m:
m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/data?maxAge=1", content=response)
custom_view = TSC.CustomViewItem()
custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5"
request_option = TSC.CSVRequestOptions(maxage=1)
self.server.custom_views.populate_csv(custom_view, request_option)

csv_file = b"".join(custom_view.csv)
self.assertEqual(response, csv_file)

def test_populate_csv_default_maxage(self) -> None:
self.server.version = "3.23"
self.baseurl = self.server.custom_views.baseurl
with open(CUSTOM_VIEW_POPULATE_CSV, "rb") as f:
response = f.read()
with requests_mock.mock() as m:
m.get(self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/data", content=response)
custom_view = TSC.CustomViewItem()
custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5"
self.server.custom_views.populate_csv(custom_view)

csv_file = b"".join(custom_view.csv)
self.assertEqual(response, csv_file)

def test_pdf_height(self) -> None:
self.server.version = "3.23"
self.baseurl = self.server.custom_views.baseurl
with open(CUSTOM_VIEW_POPULATE_PDF, "rb") as f:
response = f.read()
with requests_mock.mock() as m:
m.get(
self.baseurl + "/d79634e1-6063-4ec9-95ff-50acbf609ff5/pdf?vizHeight=1080&vizWidth=1920",
content=response,
)
custom_view = TSC.CustomViewItem()
custom_view._id = "d79634e1-6063-4ec9-95ff-50acbf609ff5"

req_option = TSC.PDFRequestOptions(
viz_height=1080,
viz_width=1920,
)

self.server.custom_views.populate_pdf(custom_view, req_option)
self.assertEqual(response, custom_view.pdf)
Loading