8000 Feature: export custom views #999 (#1506) · TrimPeachu/server-client-python@c361f8f · GitHub
[go: up one dir, main page]

Skip to content

Commit c361f8f

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

File tree

5 files changed

+194
-40
lines changed
  • test
  • 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("- 8000 -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+ 10000
    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)
    0